Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
70bab35
next: service-device records and role-0 log tokens for cloud-copy col…
callumalpass Oct 5, 2026
93d1a31
next: refuse weak service-device keys; escrow Noise key must be zero
callumalpass Oct 5, 2026
148310b
next: owner creates a cloud-copy collection (MDBASE_NEXT_CLOUD_COPY_B…
callumalpass Oct 5, 2026
12b2c9d
next: hold the collection row through service-device fetch and token …
callumalpass Oct 5, 2026
725cd15
Merge branch 'next/cloud-copy-bootstrap' into next/cloud-copy-create
callumalpass Oct 5, 2026
4537913
next: cloud-copy create tests with stateless deployments; refuse a re…
callumalpass Oct 5, 2026
4901109
next: cloud-copy create rechecks identity and collection after every …
callumalpass Oct 5, 2026
4173209
next: refuse a nil service-device ID
callumalpass Oct 5, 2026
31d302c
Merge branch 'next/cloud-copy-bootstrap' into next/cloud-copy-create
callumalpass Oct 5, 2026
f3076cd
next: escrow service devices enrol a real Noise key (policy voids all…
callumalpass Oct 5, 2026
e25d2bc
Merge branch 'next/cloud-copy-bootstrap' into next/cloud-copy-create
callumalpass Oct 5, 2026
330c866
next: cloud-copy create tests enrol escrow with a real Noise key
callumalpass Oct 5, 2026
2e1a32b
next: cloud-copy create transactions are lock-bounded (5 s); contenti…
callumalpass Oct 5, 2026
336b474
next: service-created cloud copy (no owner device) and owner-device j…
callumalpass Oct 5, 2026
0e523eb
next: device join locks the current identity before queueing; session…
callumalpass Oct 5, 2026
1da9234
next: private collection create and device enrol (SAS commitment), be…
callumalpass Oct 5, 2026
0940c0c
next: private create rechecks current owner membership before minting…
callumalpass Oct 5, 2026
c498aa6
Merge origin/main (#616/#618 landed) into next/private-collections
callumalpass Oct 6, 2026
a326656
Merge remote-tracking branch 'origin/main' into next/private-collections
callumalpass Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions architecture.d/next-private-collections.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"reason": "Private collection create and private device enrol (coordinator 2026-10-06: control reviews) are one feature module beside the cloud-copy routes. The proof, current-identity, membership, transaction, enrolment-lookup and refusal helpers that both use move out of cloud-copy-bootstrap.ts into bootstrap-common.ts unchanged, so the two route modules share one audited implementation instead of copying it; most new export declarations are those existing helpers becoming module exports. Both routes go through registerNextCollection/queueNextPolicy and the existing challenge table; no new transport, table or credential store. app.ts mounts the private routes only with MDBASE_NEXT_PRIVATE_BOOTSTRAP=1.",
"growth": {
"productionFiles": 2,
"relativeImports": 14,
"typeScriptExportDeclarations": 23,
"services/server": 2
}
}
9 changes: 9 additions & 0 deletions changelog.d/next-private-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## Added

- Create private (end-to-end) collections on the next control plane from an owner's
registered device (`POST /v1/next/collections/private`), behind
`MDBASE_NEXT_PRIVATE_BOOTSTRAP=1`. The genesis enrols only that device, and no
hosted or escrow device is ever enrolled.
- Enrol a registered device of a current member into a private collection with its
SAS commitment (`POST /v1/next/collections/:id/private/devices`). The device holds
no key until an existing keyed device approves it and grants the key.
2 changes: 2 additions & 0 deletions services/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ProviderRevocationWorker } from "./hosted-capability-lifecycle.js";
import { LogServiceClient } from "./features/next/log-service-client.js";
import { PolicyEmitter } from "./features/next/policy-outbox.js";
import { registerCloudCopyRoutes } from "./features/next/cloud-copy-bootstrap.js";
import { registerPrivateCollectionRoutes } from "./features/next/private-collections.js";
import { registerNextHostedRoutes } from "./features/next/hosted-routes.js";
import { registerPolicyRecoveryRoutes } from "./features/next/policy-recovery-routes.js";
import { registerLabFixtureRoutes } from "./features/next/lab-fixture-routes.js";
Expand Down Expand Up @@ -537,6 +538,7 @@ export async function buildApp(options: BuildOptions) {
registerNoisePipeClientRoute(app, { db: options.db, broker: relayBroker });
registerNextHostedRoutes(app, { db: options.db, tokens: options.nextControlPlane.serviceTokens, log: nextLog });
if (options.nextControlPlane.cloudCopyBootstrap) registerCloudCopyRoutes(app, { db: options.db, next: options.nextControlPlane, emitter: nextPolicyEmitter!, log: nextLog, tailscaleAuth: options.tailscaleAuth });
if (options.nextControlPlane.privateBootstrap) registerPrivateCollectionRoutes(app, { db: options.db, next: options.nextControlPlane, emitter: nextPolicyEmitter!, log: nextLog });
registerPolicyRecoveryRoutes(app, options.db, nextPolicyEmitter!);
registerNextRouteRoutes(app, { db: options.db, publicUrl, broker: relayBroker });
if (options.nextControlPlane.labFixtures) registerLabFixtureRoutes(app, {
Expand Down
159 changes: 159 additions & 0 deletions services/server/src/features/next/bootstrap-common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Shared pieces of the next control plane's collection bootstrap routes (cloud copy
// and private): device proofs, current-identity and membership checks under locks,
// bounded transactions, enrolment lookups and refusals.
import { verify } from "node:crypto";
import type { FastifyReply } from "fastify";
import type { DatabaseConnection, DatabasePool } from "../../database-types.js";
import { apiError } from "../../platform/http-errors.js";
import { ed25519PublicKeyObject } from "./policy-keys.js";
import type { PolicyOp } from "./policy-wire.js";

/** Service devices belong to no account (policy.md: hosted and escrow enrol with the zero account). */
export const SERVICE_ACCOUNT = "00000000-0000-0000-0000-000000000000";
export const NIL = SERVICE_ACCOUNT;

export interface Proof { device_id: string; challenge: string; sig: string }
export interface Device { sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer; kind: "desktop" | "cli" }
export type Connector = { id: string; user_id: string };
/** PostgreSQL lock_timeout or statement_timeout: answer busy (fail closed), never the driver error. */
export const isLockTimeout = (error: unknown) => ["55P03", "57014"].includes(String((error as { code?: unknown } | null)?.code));

export class CreateError extends Error {
constructor(readonly status: number, readonly code: string) { super(code); }
}

export const lock = (client: DatabaseConnection, collection: string) =>
client.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text, 20261005))", [collection]);

/** Verify a device's signature over `digest` and consume its challenge. */
export async function authenticate(client: DatabaseConnection, body: Proof, connector: Connector, digest: (challenge: Uint8Array) => Uint8Array): Promise<Device> {
const device = (await client.query<Device>(
"SELECT sign_pk, kem_pk, noise_pk, kind FROM next_devices WHERE id = $1 AND connector_id = $2 AND user_id = $3",
[body.device_id, connector.id, connector.user_id]
)).rows[0];
const challenge = Buffer.from(body.challenge, "hex");
if (!device || !verify(null, digest(challenge), ed25519PublicKeyObject(device.sign_pk), Buffer.from(body.sig, "hex"))) throw new CreateError(403, "invalid_proof");
const used = await client.query(
"UPDATE next_device_challenges SET used_at = now() WHERE challenge = $1 AND connector_id = $2 AND used_at IS NULL AND expires_at > now()",
[challenge, connector.id]
);
if (used.rowCount !== 1) throw new CreateError(403, "invalid_proof");
return device;
}

/**
* The connector, account and device are still current, with the exact keys
* authenticated in phase 1; locked until the transaction ends, so a revocation,
* suspension or device removal either happened before (and is refused here) or waits.
*/
export async function currentIdentity(client: DatabaseConnection, connector: Connector, deviceId: string, device: Device): Promise<void> {
const row = await client.query(
`SELECT 1 FROM connectors c JOIN users u ON u.id = c.user_id
JOIN next_devices d ON d.connector_id = c.id AND d.user_id = u.id
WHERE c.id = $1 AND u.id = $2 AND d.id = $3 AND c.revoked_at IS NULL AND u.suspended_at IS NULL
AND d.sign_pk = $4 AND d.kem_pk = $5 AND d.noise_pk = $6 AND d.kind = $7
FOR SHARE OF c, u, d`,
[connector.id, connector.user_id, deviceId, device.sign_pk, device.kem_pk, device.noise_pk, device.kind]
);
if (!row.rows.length) throw new CreateError(403, "identity_not_current");
}

/**
* The signed-in session is still the account's current credential (not revoked, not
* expired, same session epoch, account not suspended); share-locked until the
* transaction ends, so a sign-out or suspension either happened before or waits.
*/
export async function currentSession(client: DatabaseConnection, session: string, user: string): Promise<void> {
const row = await client.query(
`SELECT 1 FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id = $1 AND u.id = $2 AND s.revoked_at IS NULL AND s.expires_at > now()
AND u.suspended_at IS NULL AND s.account_session_epoch = u.session_epoch
FOR SHARE OF s, u`,
[session, user]
);
if (!row.rows.length) throw new CreateError(403, "identity_not_current");
}

/** The account is still active; share-locked until the transaction ends. */
export async function currentAccount(client: DatabaseConnection, user: string): Promise<void> {
const row = await client.query("SELECT 1 FROM users WHERE id = $1 AND suspended_at IS NULL FOR SHARE", [user]);
if (!row.rows.length) throw new CreateError(403, "identity_not_current");
}

export async function inTransaction<T>(db: DatabasePool, run: (client: DatabaseConnection) => Promise<T>): Promise<T> {
const client = await db.connect();
try {
await client.query("BEGIN");
// Bounded: no request waits on another's locks for long. Network calls never run
// inside these transactions.
await client.query("SET LOCAL lock_timeout = '5s'");
// Every statement is bounded too: no history scan holds share locks for long.
await client.query("SET LOCAL statement_timeout = '5s'");
const result = await run(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}

export const enrolOp = (device: string, account: string, d: { kind: "desktop" | "cli" | "hosted" | "escrow"; sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer }): PolicyOp => ({
op: "device-enrol", device, account, kind: d.kind, signPublicKey: d.sign_pk, kemPublicKey: d.kem_pk, noisePublicKey: d.noise_pk
});

/** The outbox row that enrols `device` in `collection`, if any (any keys, any account). */
export const ENROLMENT = `SELECT o.ops, b.seq, b.item, b.state FROM next_policy_outbox o
LEFT JOIN next_policy_batches b ON b.id = o.batch_id
WHERE o.collection_id = $1 AND o.ops->'ops' @> $2::jsonb ORDER BY o.id LIMIT 1`;
export const enrolmentKey = (device: string) => JSON.stringify([{ op: "device-enrol", device }]);
/** The whole immutable enrolment tuple: device, account, kind and all three keys. */
export const exactEnrolment = (device: string, account: string, d: Device) => JSON.stringify([{
op: "device-enrol", device, account, kind: d.kind,
signPublicKey: { $hex: d.sign_pk.toString("hex") },
kemPublicKey: { $hex: d.kem_pk.toString("hex") },
noisePublicKey: { $hex: d.noise_pk.toString("hex") }
}]);

/** A historical enrolment is never current once the device has been revoked. */
export async function refuseRevoked(client: DatabaseConnection, collection: string, device: string): Promise<void> {
const revoked = await client.query(
"SELECT 1 FROM next_policy_outbox WHERE collection_id = $1 AND ops->'ops' @> $2::jsonb LIMIT 1",
[collection, JSON.stringify([{ op: "device-revoke", device }])]
);
if (revoked.rows.length) throw new CreateError(409, "device_revoked");
}

export function refuse(reply: FastifyReply, error: unknown, message: string) {
if (error instanceof CreateError) {
const status = error.status === 502 ? 503 : error.status;
return reply.code(status).send(apiError(error.code, message));
}
if (isLockTimeout(error)) return reply.code(503).send(apiError("busy", message));
throw error;
}

/**
* The account is a current member of the collection: its latest effective membership
* op (outbox order, then op order within the batch) is a member-set acknowledged by
* the log. A member-remove is effective even while pending; a pending member-set is
* not. One row at most, projected in SQL.
*/
export async function currentMember(client: DatabaseConnection, collection: string, account: string): Promise<void> {
const latest = await client.query<{ op: string }>(
`SELECT e.value->>'op' AS op
FROM next_policy_outbox o
LEFT JOIN next_policy_batches b ON b.id = o.batch_id
CROSS JOIN LATERAL jsonb_array_elements(o.ops->'ops') WITH ORDINALITY AS e(value, ord)
WHERE o.collection_id = $1
AND (o.ops->'ops' @> $2::jsonb OR o.ops->'ops' @> $3::jsonb)
AND e.value->>'account' = $4
AND (e.value->>'op' = 'member-remove' OR (e.value->>'op' = 'member-set' AND b.state = 'appended'))
ORDER BY o.id DESC, e.ord DESC
LIMIT 1`,
[collection, JSON.stringify([{ op: "member-set", account }]), JSON.stringify([{ op: "member-remove", account }]), account]
);
if (latest.rows[0]?.op !== "member-set") throw new CreateError(409, "not_member");
}
Loading
Loading