From 70bab355dfe28e399020081a3c04f69e903bbf4e Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:28:24 +1100 Subject: [PATCH 01/14] next: service-device records and role-0 log tokens for cloud-copy collections Add next_service_devices (0048): hosted/escrow members of cloud_copy collections only, via the (collection_id, sync) composite key. The control plane stores public keys and the deployment's KMS-wrapped keys and never unwraps them. - service-devices.ts: strict record parsing (canonical 32-byte hex keys, canonical base64 wrapped keys <= 64 KiB, arn), idempotent put-first store that refuses a different device, lookup that requires a current cloud copy under a share lock, and a bounded HTTPS-only generate client (no redirects, timeout, 128 KiB response cap) for the C2 bootstrap. - hosted-routes.ts: GET collections/:id/service-devices/:kind and POST service-devices/:device/log-token, kind-matched to the caller's internal token, 409 unless standard. Tokens are role 0, name the device, its sign key and one collection, and last 15 minutes. --- architecture.d/next-service-devices.json | 9 + changelog.d/next-service-devices.md | 7 + .../migrations/0048_next_service_devices.sql | 19 ++ services/server/src/app.ts | 5 +- .../server/src/features/next/hosted-routes.ts | 42 +++- .../next/service-devices.postgres.test.ts | 118 +++++++++++ .../src/features/next/service-devices.test.ts | 54 +++++ .../src/features/next/service-devices.ts | 186 ++++++++++++++++++ 8 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 architecture.d/next-service-devices.json create mode 100644 changelog.d/next-service-devices.md create mode 100644 services/server/migrations/0048_next_service_devices.sql create mode 100644 services/server/src/features/next/service-devices.postgres.test.ts create mode 100644 services/server/src/features/next/service-devices.test.ts create mode 100644 services/server/src/features/next/service-devices.ts diff --git a/architecture.d/next-service-devices.json b/architecture.d/next-service-devices.json new file mode 100644 index 00000000..be662b96 --- /dev/null +++ b/architecture.d/next-service-devices.json @@ -0,0 +1,9 @@ +{ + "reason": "Cloud-copy collections need durable service-device records (hosted replica and escrow) and role-0 log tokens for them. One feature module owns the record type, its validation, the idempotent store and the bounded generate client; the existing hosted-routes module serves fetch and token refresh to the matching deployment, reusing LogServiceClient.mintToken. No new transport or credential store.", + "growth": { + "productionFiles": 1, + "relativeImports": 3, + "typeScriptExportDeclarations": 9, + "services/server": 1 + } +} diff --git a/changelog.d/next-service-devices.md b/changelog.d/next-service-devices.md new file mode 100644 index 00000000..39083224 --- /dev/null +++ b/changelog.d/next-service-devices.md @@ -0,0 +1,7 @@ +## Added + +- Store service-device records (public keys and KMS-wrapped private keys) for + hosted-replica and escrow members of cloud-copy collections, and let each + deployment fetch its own kind's record and refresh a role-0 log token scoped to + one collection. Inactive unless the next control plane and the matching internal + service token are configured. diff --git a/services/server/migrations/0048_next_service_devices.sql b/services/server/migrations/0048_next_service_devices.sql new file mode 100644 index 00000000..a263f932 --- /dev/null +++ b/services/server/migrations/0048_next_service_devices.sql @@ -0,0 +1,19 @@ +-- mdbase-next service devices: the hosted replica and escrow members of a cloud-copy +-- collection (mdbase-next interface note 2026-10-04-control-hosted-replica.md §2). +-- Public keys only. `wrapped_keys` is the deployment's KMS-wrapped private keys, which +-- the control plane stores and returns but can never unwrap. The composite key admits +-- rows only for cloud_copy collections. Lengths are checked by the writer. +CREATE TABLE next_service_devices ( + collection_id uuid NOT NULL, + sync text NOT NULL DEFAULT 'cloud_copy' CHECK (sync = 'cloud_copy'), + kind text NOT NULL CHECK (kind IN ('hosted', 'escrow')), + device_id uuid NOT NULL UNIQUE, + sign_pk bytea NOT NULL, + kem_pk bytea NOT NULL, + noise_pk bytea NOT NULL, + wrapped_keys bytea NOT NULL, + kms_key_arn text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (collection_id, kind), + FOREIGN KEY (collection_id, sync) REFERENCES next_collections(collection_id, sync) ON DELETE CASCADE +); diff --git a/services/server/src/app.ts b/services/server/src/app.ts index 7db9358c..04664fbf 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -519,9 +519,10 @@ export async function buildApp(options: BuildOptions) { }); if (options.nextControlPlane) { relay.useNextDevices(new NextRelayDevices(options.db, noisePipes)); - registerNextDeviceRoutes(app, { db: options.db, log: new LogServiceClient(options.nextControlPlane.logService) }); + const nextLog = new LogServiceClient(options.nextControlPlane.logService); + registerNextDeviceRoutes(app, { db: options.db, log: nextLog }); registerNoisePipeClientRoute(app, { db: options.db, broker: relayBroker }); - registerNextHostedRoutes(app, { db: options.db, tokens: options.nextControlPlane.serviceTokens }); + registerNextHostedRoutes(app, { db: options.db, tokens: options.nextControlPlane.serviceTokens, log: nextLog }); registerPolicyRecoveryRoutes(app, options.db, nextPolicyEmitter!); registerNextRouteRoutes(app, { db: options.db, publicUrl, broker: relayBroker }); if (options.nextControlPlane.labFixtures) registerLabFixtureRoutes(app, { diff --git a/services/server/src/features/next/hosted-routes.ts b/services/server/src/features/next/hosted-routes.ts index e5678ad8..a66d242c 100644 --- a/services/server/src/features/next/hosted-routes.ts +++ b/services/server/src/features/next/hosted-routes.ts @@ -2,14 +2,15 @@ // (mdbase-next interface note 2026-10-04-control-hosted-replica.md). Each deployment // has its own bearer token, accepted only on these routes; neither is the provider's // internal token. -import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import type { DatabaseQueryable } from "../../database-types.js"; import { apiError } from "../../platform/http-errors.js"; import { bearerToken } from "../../platform/request-authentication.js"; import { safeEqual } from "../../security.js"; +import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; +import { loadServiceDevice, serviceDeviceWire, type ServiceKind } from "./service-devices.js"; -export type ServiceKind = "hosted" | "escrow"; export type CollectionDirectoryState = "standard" | "private" | "local" | "unknown"; export interface CollectionDirectoryEntry { @@ -57,9 +58,15 @@ export function serviceKind(request: FastifyRequest, tokens: { hosted?: string; export function registerNextHostedRoutes( app: FastifyInstance, - options: { db: DatabaseQueryable; tokens: { hosted?: string; escrow?: string } } + options: { db: DatabaseQueryable; tokens: { hosted?: string; escrow?: string }; log?: LogServiceClient; now?: () => number } ): void { const authorize = (request: FastifyRequest) => serviceKind(request, options.tokens) !== null; + // The record lookup itself requires a current cloud copy; this only picks the answer + // for a miss: 409 when the collection is not (or no longer) a cloud copy, else 404. + const notCurrent = async (reply: FastifyReply, collection: string, missing: string) => + (await collectionDirectory(options.db, [collection]))[0]!.state !== "standard" + ? reply.code(409).send(apiError("collection_not_standard", "The collection is not a cloud copy.")) + : reply.code(404).send(apiError("service_device_not_found", missing)); app.get("/internal/v1/next/collections/:id/state", async (request, reply) => { if (!authorize(request)) return reply.code(401).send(apiError("invalid_internal_token", "Internal token required.")); const { id } = z.object({ id: z.uuid() }).parse(request.params); @@ -70,4 +77,33 @@ export function registerNextHostedRoutes( const { ids } = z.object({ ids: z.array(z.uuid()).min(1).max(500) }).strict().parse(request.body); return { collections: await collectionDirectory(options.db, [...new Set(ids)]) }; }); + + // A deployment reads only its own kind's record, and only while the collection is + // standard: a collection that left sync, or never was cloud copy, has no service device. + app.get("/internal/v1/next/collections/:id/service-devices/:kind", async (request, reply) => { + const caller = serviceKind(request, options.tokens); + if (!caller) return reply.code(401).send(apiError("invalid_internal_token", "Internal token required.")); + const { id, kind } = z.object({ id: z.uuid(), kind: z.enum(["hosted", "escrow"]) }).parse(request.params); + if (kind !== caller) return reply.code(403).send(apiError("wrong_service_kind", "This token reads only its own kind of service device.")); + const record = await loadServiceDevice(options.db, id, { kind }); + if (!record) return notCurrent(reply, id, "The collection has no service device of this kind."); + return serviceDeviceWire(record); + }); + // Role-0 log token for a service device, narrowed to one collection (claim 5) and + // valid for at most LOG_TOKEN_LIFETIME_MS. The deployment refreshes it by asking again. + const log = options.log; + if (log) app.post("/internal/v1/next/service-devices/:device/log-token", async (request, reply) => { + const caller = serviceKind(request, options.tokens); + if (!caller) return reply.code(401).send(apiError("invalid_internal_token", "Internal token required.")); + const params = z.object({ device: z.uuid() }).safeParse(request.params); + const body = z.object({ collection: z.uuid() }).strict().safeParse(request.body); + if (!params.success || !body.success) return reply.code(400).send(apiError("invalid_request", "A device and collection are required.")); + const { device } = params.data; + const { collection } = body.data; + const record = await loadServiceDevice(options.db, collection, { device: device.toLowerCase() }); + if (!record) return notCurrent(reply, collection, "No such service device in this collection."); + if (record.kind !== caller) return reply.code(403).send(apiError("wrong_service_kind", "This token mints only for its own kind of service device.")); + const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + return { token: log.mintToken({ device: record.device_id, signPublicKey: record.sign_pk, collection, expiresAt }), expires_at: expiresAt }; + }); } diff --git a/services/server/src/features/next/service-devices.postgres.test.ts b/services/server/src/features/next/service-devices.postgres.test.ts new file mode 100644 index 00000000..c4082b0a --- /dev/null +++ b/services/server/src/features/next/service-devices.postgres.test.ts @@ -0,0 +1,118 @@ +import { generateKeyPairSync, verify, createHash } from "node:crypto"; +import { randomUUID } from "node:crypto"; +import Fastify from "fastify"; +import pg from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, type DatabasePool } from "../../db.js"; +import { registerNextHostedRoutes } from "./hosted-routes.js"; +import { LOG_TOKEN_LIFETIME_MS, LogServiceClient } from "./log-service-client.js"; +import { decodeCbor } from "./policy-wire.js"; +import { loadServiceDevice, parseServiceDevice, ServiceDeviceError, storeServiceDevice } from "./service-devices.js"; + +const testUrl = process.env.MDBASE_CONNECT_TEST_DATABASE_URL; +const approved = process.env.MDBASE_CONNECT_DESTRUCTIVE_TEST_APPROVAL === "I APPROVE MDBASE CONNECT DESTRUCTIVE POSTGRES TESTS"; +const describePostgres = testUrl && approved ? describe : describe.skip; + +const hosted = "h".repeat(40); +const escrow = "e".repeat(40); +const NOW = 1_800_000_000_000; +const uuidBytes = (id: string) => Buffer.from(id.replaceAll("-", ""), "hex"); + +function record(kind: "hosted" | "escrow", device = randomUUID(), fill = 1) { + return parseServiceDevice({ + kind, device_id: device, sign_pk: Buffer.alloc(32, fill).toString("hex"), kem_pk: Buffer.alloc(32, fill + 1).toString("hex"), + noise_pk: Buffer.alloc(32, fill + 2).toString("hex"), wrapped_keys: Buffer.from(`sealed-${kind}`).toString("base64"), + kms_key_arn: `arn:aws:kms:eu-west-1:000000000000:key/${kind}` + }); +} + +describePostgres("service devices", () => { + let admin: pg.Pool; + let db: DatabasePool; + let schema: string; + const app = Fastify(); + const issuer = generateKeyPairSync("ed25519"); + const owner = randomUUID(); + const ids = { standard: randomUUID(), private: randomUUID(), left: randomUUID() }; + const devices = { hosted: record("hosted"), escrow: record("escrow", randomUUID(), 7), left: record("hosted", randomUUID(), 11) }; + + beforeAll(async () => { + const url = new URL(testUrl!); + if (!["localhost", "127.0.0.1", "::1"].includes(url.hostname) || !/test/i.test(url.pathname)) throw new Error("Service device tests require a dedicated local test database."); + schema = `mdbase_next_service_devices_test_${randomUUID().replaceAll("-", "")}`; + admin = new pg.Pool({ connectionString: url.toString(), max: 2 }); + await admin.query(`CREATE SCHEMA "${schema}"`); + url.searchParams.set("options", `-csearch_path=${schema}`); + db = await createDatabase(url.toString()); + await db.query("INSERT INTO users(id,email,name) VALUES($1,$2,'Owner')", [owner, `${owner}@example.test`]); + for (const [id, sync] of [[ids.standard, "cloud_copy"], [ids.private, "private"], [ids.left, "cloud_copy"]]) { + await db.query("INSERT INTO next_collections(collection_id, owner_user_id, runtime, sync, root_key_id) VALUES($1,$2,'next',$3,$4)", [id, owner, sync, Buffer.alloc(16)]); + } + await storeServiceDevice(db, ids.standard, devices.hosted); + await storeServiceDevice(db, ids.standard, devices.escrow); + await storeServiceDevice(db, ids.left, devices.left); + await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [ids.left]); + const transport = generateKeyPairSync("ed25519").privateKey; + const log = new LogServiceClient({ url: "https://log.example.test", tokenIssuerKeyPem: issuer.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + transportKeyPem: transport.export({ format: "pem", type: "pkcs8" }).toString() }, async () => { throw new Error("no network"); }); + registerNextHostedRoutes(app, { db, tokens: { hosted, escrow }, log, now: () => NOW }); + }, 60_000); + + afterAll(async () => { + await app.close(); + await db?.end(); + if (admin && schema) await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await admin?.end(); + }, 60_000); + + it("stores idempotently and refuses a different device for the same kind", async () => { + await expect(storeServiceDevice(db, ids.standard, devices.hosted)).resolves.toMatchObject({ device_id: devices.hosted.device_id }); + await expect(storeServiceDevice(db, ids.standard, record("hosted"))).rejects.toMatchObject({ status: 409, code: "service_device_conflict" }); + await expect(storeServiceDevice(db, ids.standard, { ...devices.hosted, kms_key_arn: "arn:aws:kms:other" })).rejects.toBeInstanceOf(ServiceDeviceError); + expect((await loadServiceDevice(db, ids.standard, { kind: "hosted" }))!.kms_key_arn).toBe(devices.hosted.kms_key_arn); + }); + + it("admits service devices only for cloud-copy collections", async () => { + await expect(storeServiceDevice(db, ids.private, record("hosted"))).rejects.toThrow(/foreign key/i); + await expect(storeServiceDevice(db, randomUUID(), record("hosted"))).rejects.toThrow(/foreign key/i); + }); + + it("returns a deployment only its own kind's record while standard", async () => { + const get = (id: string, kind: string, token: string) => app.inject({ method: "GET", url: `/internal/v1/next/collections/${id}/service-devices/${kind}`, headers: { authorization: `Bearer ${token}` } }); + const own = await get(ids.standard, "hosted", hosted); + expect(own.statusCode, own.body).toBe(200); + expect(own.json()).toMatchObject({ kind: "hosted", device_id: devices.hosted.device_id, sign_pk: devices.hosted.sign_pk.toString("hex"), wrapped_keys: devices.hosted.wrapped_keys.toString("base64") }); + expect((await get(ids.standard, "escrow", escrow)).json().device_id).toBe(devices.escrow.device_id); + expect((await get(ids.standard, "escrow", hosted)).statusCode).toBe(403); + expect((await get(ids.standard, "hosted", "x".repeat(40))).statusCode).toBe(401); + expect((await get(ids.private, "hosted", hosted)).statusCode).toBe(409); + expect((await get(ids.left, "hosted", hosted)).statusCode).toBe(409); + expect((await get(randomUUID(), "hosted", hosted)).statusCode).toBe(409); + }); + + it("mints a role-0 collection-scoped log token for the caller's own device", async () => { + const mint = (device: string, collection: string, token: string) => app.inject({ method: "POST", url: `/internal/v1/next/service-devices/${device}/log-token`, headers: { authorization: `Bearer ${token}` }, payload: { collection } }); + const response = await mint(devices.hosted.device_id, ids.standard, hosted); + expect(response.statusCode, response.body).toBe(200); + const { token, expires_at } = response.json() as { token: string; expires_at: number }; + expect(expires_at).toBe(NOW + LOG_TOKEN_LIFETIME_MS); + const [claimsHex, signatureHex] = token.split("."); + const claims = Buffer.from(claimsHex!, "hex"); + const tag = Buffer.from("mdbase/v1/ls-token"); + const digest = createHash("sha256").update(Buffer.concat([Buffer.of(tag.length), tag, claims])).digest(); + expect(verify(null, digest, issuer.publicKey, Buffer.from(signatureHex!, "hex"))).toBe(true); + const decoded = decodeCbor(claims) as Map; + expect(decoded.get(0)).toBe(0); + expect(Buffer.from(decoded.get(1) as Uint8Array)).toEqual(uuidBytes(devices.hosted.device_id)); + expect(Buffer.from(decoded.get(2) as Uint8Array)).toEqual(devices.hosted.sign_pk); + expect(Buffer.from(decoded.get(5) as Uint8Array)).toEqual(uuidBytes(ids.standard)); + + expect((await mint(devices.escrow.device_id, ids.standard, hosted)).statusCode).toBe(403); + expect((await mint(devices.hosted.device_id, ids.standard, "x".repeat(40))).statusCode).toBe(401); + expect((await mint(randomUUID(), ids.standard, hosted)).statusCode).toBe(404); + expect((await mint(devices.left.device_id, ids.left, hosted)).statusCode).toBe(409); + expect((await mint(devices.hosted.device_id, ids.private, hosted)).statusCode).toBe(409); + const extra = await app.inject({ method: "POST", url: `/internal/v1/next/service-devices/${devices.hosted.device_id}/log-token`, headers: { authorization: `Bearer ${hosted}` }, payload: { collection: ids.standard, role: 1 } }); + expect(extra.statusCode).toBe(400); + }); +}); diff --git a/services/server/src/features/next/service-devices.test.ts b/services/server/src/features/next/service-devices.test.ts new file mode 100644 index 00000000..2091327d --- /dev/null +++ b/services/server/src/features/next/service-devices.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { generateServiceDevice, MAX_WRAPPED_KEYS_BYTES, parseServiceDevice, ServiceDeviceError, serviceDeviceWire } from "./service-devices.js"; + +const collection = "0e0e0e0e-0e0e-4e0e-8e0e-0e0e0e0e0e0e"; +const wire = { + kind: "hosted", + device_id: "11111111-1111-4111-8111-111111111111", + sign_pk: "aa".repeat(32), + kem_pk: "bb".repeat(32), + noise_pk: "cc".repeat(32), + wrapped_keys: Buffer.from("sealed").toString("base64"), + kms_key_arn: "arn:aws:kms:eu-west-1:000000000000:key/lab" +}; +const deployment = { url: "https://hosted.example.test/", token: "t".repeat(40) }; + +describe("service device record", () => { + it("round-trips its wire form", () => { + expect(serviceDeviceWire(parseServiceDevice(wire))).toEqual(wire); + }); + + it("rejects malformed or oversized fields", () => { + for (const bad of [ + { ...wire, kind: "owner" }, { ...wire, sign_pk: "aa".repeat(31) }, { ...wire, kem_pk: "AA".repeat(32) }, + { ...wire, wrapped_keys: "" }, { ...wire, wrapped_keys: "not base64!" }, { ...wire, wrapped_keys: "QQ" }, + { ...wire, wrapped_keys: Buffer.alloc(MAX_WRAPPED_KEYS_BYTES + 1).toString("base64") }, + { ...wire, kms_key_arn: "key" }, { ...wire, extra: 1 }, { ...wire, device_id: "nope" } + ]) expect(() => parseServiceDevice(bad), JSON.stringify(bad).slice(0, 80)).toThrow(ServiceDeviceError); + }); +}); + +describe("generateServiceDevice", () => { + it("posts the collection with the deployment token and checks the kind", async () => { + let seen: { url: string; init: RequestInit } | undefined; + const record = await generateServiceDevice(deployment, "hosted", collection, async (url, init) => { + seen = { url: String(url), init: init! }; + return new Response(JSON.stringify(wire)); + }); + expect(seen!.url).toBe("https://hosted.example.test/internal/v1/service-devices"); + expect(new Headers(seen!.init.headers).get("authorization")).toBe(`Bearer ${deployment.token}`); + expect(JSON.parse(String(seen!.init.body))).toEqual({ collection }); + expect(seen!.init.redirect).toBe("error"); + expect(record.device_id).toBe(wire.device_id); + await expect(generateServiceDevice(deployment, "escrow", collection, async () => new Response(JSON.stringify(wire)))).rejects.toMatchObject({ code: "invalid_service_device" }); + }); + + it("maps failures without echoing the response", async () => { + await expect(generateServiceDevice({ ...deployment, url: "http://hosted.example.test" }, "hosted", collection, async () => new Response(JSON.stringify(wire)))).rejects.toMatchObject({ message: "The service deployment must use HTTPS." }); + await expect(generateServiceDevice(deployment, "hosted", collection, async () => { throw new Error("down"); })).rejects.toMatchObject({ status: 503 }); + await expect(generateServiceDevice(deployment, "hosted", collection, async () => new Response("secret detail", { status: 500 }))).rejects.toMatchObject({ status: 503, message: "The service deployment answered 500." }); + await expect(generateServiceDevice(deployment, "hosted", collection, async () => new Response("no", { status: 403 }))).rejects.toMatchObject({ status: 502 }); + await expect(generateServiceDevice(deployment, "hosted", collection, async () => new Response("{"))).rejects.toMatchObject({ code: "invalid_service_device" }); + await expect(generateServiceDevice(deployment, "hosted", collection, async () => new Response("x".repeat(3 * MAX_WRAPPED_KEYS_BYTES)))).rejects.toMatchObject({ message: "The service device record is too large." }); + }); +}); diff --git a/services/server/src/features/next/service-devices.ts b/services/server/src/features/next/service-devices.ts new file mode 100644 index 00000000..2ffec1ea --- /dev/null +++ b/services/server/src/features/next/service-devices.ts @@ -0,0 +1,186 @@ +// Service devices of a cloud-copy collection: the hosted replica and escrow members +// (mdbase-next interface note 2026-10-04-control-hosted-replica.md §2, §3). The deployment +// generates the keys and returns the public halves plus its KMS-wrapped private keys; +// the control plane stores the record, enrols the public keys, and hands the record +// and role-0 log tokens back to the deployment of the same kind. It never unwraps. +import { z } from "zod"; +import type { DatabaseQueryable } from "../../database-types.js"; + +export type ServiceKind = "hosted" | "escrow"; + +export const MAX_WRAPPED_KEYS_BYTES = 64 * 1024; +const MAX_GENERATE_RESPONSE_BYTES = 2 * MAX_WRAPPED_KEYS_BYTES; +const GENERATE_TIMEOUT_MS = 10_000; + +export interface ServiceDeviceRecord { + kind: ServiceKind; + device_id: string; + sign_pk: Buffer; + kem_pk: Buffer; + noise_pk: Buffer; + wrapped_keys: Buffer; + kms_key_arn: string; +} + +export class ServiceDeviceError extends Error { + constructor(readonly status: number, readonly code: string, message: string) { + super(message); + } +} + +const key32 = z.string().regex(/^[0-9a-f]{64}$/u); +const base64 = z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/u).max(Math.ceil(MAX_WRAPPED_KEYS_BYTES / 3) * 4); +const wireRecord = z.object({ + kind: z.enum(["hosted", "escrow"]), + device_id: z.uuid(), + sign_pk: key32, + kem_pk: key32, + noise_pk: key32, + wrapped_keys: base64, + kms_key_arn: z.string().min(1).max(2048).regex(/^arn:[!-~]+$/u) +}).strict(); + +type ServiceDeviceWire = z.infer; + +/** Parse a record from its wire form. Rejects anything malformed or oversized. */ +export function parseServiceDevice(value: unknown): ServiceDeviceRecord { + const parsed = wireRecord.safeParse(value); + if (!parsed.success) throw new ServiceDeviceError(502, "invalid_service_device", "The service device record is malformed."); + const wrapped = Buffer.from(parsed.data.wrapped_keys, "base64"); + if (wrapped.length === 0 || wrapped.length > MAX_WRAPPED_KEYS_BYTES || wrapped.toString("base64") !== parsed.data.wrapped_keys) { + throw new ServiceDeviceError(502, "invalid_service_device", "The wrapped keys are malformed."); + } + return { + kind: parsed.data.kind, + device_id: parsed.data.device_id.toLowerCase(), + sign_pk: Buffer.from(parsed.data.sign_pk, "hex"), + kem_pk: Buffer.from(parsed.data.kem_pk, "hex"), + noise_pk: Buffer.from(parsed.data.noise_pk, "hex"), + wrapped_keys: wrapped, + kms_key_arn: parsed.data.kms_key_arn + }; +} + +export function serviceDeviceWire(record: ServiceDeviceRecord): ServiceDeviceWire { + return { + kind: record.kind, + device_id: record.device_id, + sign_pk: record.sign_pk.toString("hex"), + kem_pk: record.kem_pk.toString("hex"), + noise_pk: record.noise_pk.toString("hex"), + wrapped_keys: record.wrapped_keys.toString("base64"), + kms_key_arn: record.kms_key_arn + }; +} + +function sameRecord(a: ServiceDeviceRecord, b: ServiceDeviceRecord): boolean { + return a.kind === b.kind && a.device_id === b.device_id && a.kms_key_arn === b.kms_key_arn + && a.sign_pk.equals(b.sign_pk) && a.kem_pk.equals(b.kem_pk) && a.noise_pk.equals(b.noise_pk) && a.wrapped_keys.equals(b.wrapped_keys); +} + +type Row = Omit & { kind: ServiceKind }; +const COLUMNS = "device.kind, device.device_id::text AS device_id, device.sign_pk, device.kem_pk, device.noise_pk, device.wrapped_keys, device.kms_key_arn"; + +/** + * Store the record of `collection`'s service device of its kind. Idempotent for the + * same record; a different record for the same collection and kind is refused, so a + * retried bootstrap never replaces an enrolled device. Run inside the caller's transaction. + */ +export async function storeServiceDevice(db: DatabaseQueryable, collection: string, record: ServiceDeviceRecord): Promise { + await db.query( + `INSERT INTO next_service_devices(collection_id, kind, device_id, sign_pk, kem_pk, noise_pk, wrapped_keys, kms_key_arn) + VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`, + [collection, record.kind, record.device_id, record.sign_pk, record.kem_pk, record.noise_pk, record.wrapped_keys, record.kms_key_arn] + ); + const stored = await loadServiceDevice(db, collection, { kind: record.kind }, true); + if (!stored || !sameRecord(stored, record)) throw new ServiceDeviceError(409, "service_device_conflict", "A different service device is already recorded."); + return stored; +} + +/** + * The record of `collection`'s service device of a kind, or with a device id. Unless + * `anyState`, only while the collection is cloud copy and has not left sync, checked in + * the same statement under a share lock on the collection row, so a concurrent leave + * either waits for this read or is seen by it. + */ +export async function loadServiceDevice( + db: DatabaseQueryable, + collection: string, + by: { kind: ServiceKind } | { device: string }, + anyState = false +): Promise { + const [column, value] = "kind" in by ? ["kind", by.kind] : ["device_id", by.device]; + const current = anyState ? "" : "AND parent.sync = 'cloud_copy' AND parent.left_sync_at IS NULL"; + const result = await db.query( + `SELECT ${COLUMNS} FROM next_service_devices device + JOIN next_collections parent ON parent.collection_id = device.collection_id + WHERE device.collection_id = $1 AND device.${column} = $2 ${current} + FOR SHARE OF parent`, + [collection, value] + ); + return result.rows[0] ?? null; +} + +export interface ServiceDeploymentConfig { + url: string; + token: string; +} + +/** + * Ask the deployment of `kind` to generate a service device for `collection` + * (`POST /internal/v1/service-devices`). The response is bounded and must + * name the requested kind. The deployment returns the same device for a retried request. + */ +export async function generateServiceDevice( + deployment: ServiceDeploymentConfig, + kind: ServiceKind, + collection: string, + fetchImpl: typeof fetch = fetch +): Promise { + const url = new URL("internal/v1/service-devices", `${deployment.url.replace(/\/+$/u, "")}/`); + if (url.protocol !== "https:") throw new ServiceDeviceError(503, "service_deployment_unavailable", "The service deployment must use HTTPS."); + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + headers: { authorization: `Bearer ${deployment.token}`, "content-type": "application/json" }, + body: JSON.stringify({ collection }), + redirect: "error", + signal: AbortSignal.timeout(GENERATE_TIMEOUT_MS) + }); + } catch { + throw new ServiceDeviceError(503, "service_deployment_unavailable", "The service deployment is unavailable."); + } + if (!response.ok) { + await response.body?.cancel(); + throw new ServiceDeviceError(response.status >= 500 ? 503 : 502, "service_deployment_refused", `The service deployment answered ${response.status}.`); + } + const text = await readBounded(response, MAX_GENERATE_RESPONSE_BYTES); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new ServiceDeviceError(502, "invalid_service_device", "The service device record is malformed."); + } + const record = parseServiceDevice(body); + if (record.kind !== kind) throw new ServiceDeviceError(502, "invalid_service_device", "The deployment returned another kind of device."); + return record; +} + +async function readBounded(response: Response, limit: number): Promise { + const reader = response.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel(); + throw new ServiceDeviceError(502, "invalid_service_device", "The service device record is too large."); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} From 93d1a317dc0e0cfa0094e366cfcf7e19408632bc Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:31:27 +1100 Subject: [PATCH 02/14] next: refuse weak service-device keys; escrow Noise key must be zero --- architecture.d/next-service-devices.json | 2 +- .../next/service-devices.postgres.test.ts | 2 +- .../src/features/next/service-devices.test.ts | 8 +++++++- .../server/src/features/next/service-devices.ts | 15 ++++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/architecture.d/next-service-devices.json b/architecture.d/next-service-devices.json index be662b96..8eec49a1 100644 --- a/architecture.d/next-service-devices.json +++ b/architecture.d/next-service-devices.json @@ -2,7 +2,7 @@ "reason": "Cloud-copy collections need durable service-device records (hosted replica and escrow) and role-0 log tokens for them. One feature module owns the record type, its validation, the idempotent store and the bounded generate client; the existing hosted-routes module serves fetch and token refresh to the matching deployment, reusing LogServiceClient.mintToken. No new transport or credential store.", "growth": { "productionFiles": 1, - "relativeImports": 3, + "relativeImports": 4, "typeScriptExportDeclarations": 9, "services/server": 1 } diff --git a/services/server/src/features/next/service-devices.postgres.test.ts b/services/server/src/features/next/service-devices.postgres.test.ts index c4082b0a..95922b5a 100644 --- a/services/server/src/features/next/service-devices.postgres.test.ts +++ b/services/server/src/features/next/service-devices.postgres.test.ts @@ -21,7 +21,7 @@ const uuidBytes = (id: string) => Buffer.from(id.replaceAll("-", ""), "hex"); function record(kind: "hosted" | "escrow", device = randomUUID(), fill = 1) { return parseServiceDevice({ kind, device_id: device, sign_pk: Buffer.alloc(32, fill).toString("hex"), kem_pk: Buffer.alloc(32, fill + 1).toString("hex"), - noise_pk: Buffer.alloc(32, fill + 2).toString("hex"), wrapped_keys: Buffer.from(`sealed-${kind}`).toString("base64"), + noise_pk: Buffer.alloc(32, kind === "escrow" ? 0 : fill + 2).toString("hex"), wrapped_keys: Buffer.from(`sealed-${kind}`).toString("base64"), kms_key_arn: `arn:aws:kms:eu-west-1:000000000000:key/${kind}` }); } diff --git a/services/server/src/features/next/service-devices.test.ts b/services/server/src/features/next/service-devices.test.ts index 2091327d..a9c2da75 100644 --- a/services/server/src/features/next/service-devices.test.ts +++ b/services/server/src/features/next/service-devices.test.ts @@ -18,12 +18,18 @@ describe("service device record", () => { expect(serviceDeviceWire(parseServiceDevice(wire))).toEqual(wire); }); + it("requires escrow's Noise key to be all zero", () => { + expect(parseServiceDevice({ ...wire, kind: "escrow", noise_pk: "00".repeat(32) }).noise_pk).toEqual(Buffer.alloc(32)); + }); + it("rejects malformed or oversized fields", () => { for (const bad of [ { ...wire, kind: "owner" }, { ...wire, sign_pk: "aa".repeat(31) }, { ...wire, kem_pk: "AA".repeat(32) }, { ...wire, wrapped_keys: "" }, { ...wire, wrapped_keys: "not base64!" }, { ...wire, wrapped_keys: "QQ" }, { ...wire, wrapped_keys: Buffer.alloc(MAX_WRAPPED_KEYS_BYTES + 1).toString("base64") }, - { ...wire, kms_key_arn: "key" }, { ...wire, extra: 1 }, { ...wire, device_id: "nope" } + { ...wire, kms_key_arn: "key" }, { ...wire, extra: 1 }, { ...wire, device_id: "nope" }, + { ...wire, sign_pk: "00".repeat(32) }, { ...wire, kem_pk: "01" + "00".repeat(31) }, { ...wire, noise_pk: "00".repeat(32) }, + { ...wire, kind: "escrow" } ]) expect(() => parseServiceDevice(bad), JSON.stringify(bad).slice(0, 80)).toThrow(ServiceDeviceError); }); }); diff --git a/services/server/src/features/next/service-devices.ts b/services/server/src/features/next/service-devices.ts index 2ffec1ea..743a642b 100644 --- a/services/server/src/features/next/service-devices.ts +++ b/services/server/src/features/next/service-devices.ts @@ -5,6 +5,7 @@ // and role-0 log tokens back to the deployment of the same kind. It never unwraps. import { z } from "zod"; import type { DatabaseQueryable } from "../../database-types.js"; +import { weakAgreementKey, weakSigningKey } from "./devices.js"; export type ServiceKind = "hosted" | "escrow"; @@ -50,12 +51,20 @@ export function parseServiceDevice(value: unknown): ServiceDeviceRecord { if (wrapped.length === 0 || wrapped.length > MAX_WRAPPED_KEYS_BYTES || wrapped.toString("base64") !== parsed.data.wrapped_keys) { throw new ServiceDeviceError(502, "invalid_service_device", "The wrapped keys are malformed."); } + const sign = Buffer.from(parsed.data.sign_pk, "hex"); + const kem = Buffer.from(parsed.data.kem_pk, "hex"); + const noise = Buffer.from(parsed.data.noise_pk, "hex"); + // Escrow never holds a Noise session, so its Noise key is all zero; hosted's must be a real key. + const noiseOk = parsed.data.kind === "escrow" ? noise.equals(Buffer.alloc(32)) : !weakAgreementKey(noise); + if (weakSigningKey(sign) || weakAgreementKey(kem) || !noiseOk) { + throw new ServiceDeviceError(502, "invalid_service_device", "A service device key is weak or misplaced."); + } return { kind: parsed.data.kind, device_id: parsed.data.device_id.toLowerCase(), - sign_pk: Buffer.from(parsed.data.sign_pk, "hex"), - kem_pk: Buffer.from(parsed.data.kem_pk, "hex"), - noise_pk: Buffer.from(parsed.data.noise_pk, "hex"), + sign_pk: sign, + kem_pk: kem, + noise_pk: noise, wrapped_keys: wrapped, kms_key_arn: parsed.data.kms_key_arn }; From 148310b30205b6e55121a81fefed25c2338f008e Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:36:17 +1100 Subject: [PATCH 03/14] next: owner creates a cloud-copy collection (MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP) Per the coordinator's sealed-envelope 7.1 decision: the owner's device signs a fresh challenge; the control plane asks the hosted and escrow deployments to generate their service devices (outbound tokens distinct from inbound), then registers a cloud-copy genesis that enrols the owner's device and both service devices (zero account) and stores the records. The desktop performs the initial rekey with wraps for desktop, hosted and escrow; the CP never holds a collection key and makes no escrow-to-hosted wrap. Retries must come from the enrolled device and reuse stored records; other owners, other devices and existing private collections get 409. A failed generation registers nothing. Success is reported only after the log returns the exact genesis bytes. --- architecture.d/next-cloud-copy-create.json | 9 + changelog.d/next-cloud-copy-create.md | 8 + services/server/src/app.ts | 2 + .../cloud-copy-bootstrap.postgres.test.ts | 252 ++++++++++++++++++ .../src/features/next/cloud-copy-bootstrap.ts | 189 +++++++++++++ .../server/src/features/next/policy-keys.ts | 25 ++ 6 files changed, 485 insertions(+) create mode 100644 architecture.d/next-cloud-copy-create.json create mode 100644 changelog.d/next-cloud-copy-create.md create mode 100644 services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts create mode 100644 services/server/src/features/next/cloud-copy-bootstrap.ts diff --git a/architecture.d/next-cloud-copy-create.json b/architecture.d/next-cloud-copy-create.json new file mode 100644 index 00000000..d46230c5 --- /dev/null +++ b/architecture.d/next-cloud-copy-create.json @@ -0,0 +1,9 @@ +{ + "reason": "The ordinary owner path to create a cloud-copy collection is one feature module: device-signed proof against the existing challenge table, service-device generation through the C1 client, and genesis through registerNextCollection. It reuses the policy outbox, emitter and log client and adds no transport or credential store; app.ts mounts it only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1.", + "growth": { + "productionFiles": 1, + "relativeImports": 9, + "typeScriptExportDeclarations": 2, + "services/server": 1 + } +} diff --git a/changelog.d/next-cloud-copy-create.md b/changelog.d/next-cloud-copy-create.md new file mode 100644 index 00000000..2e0f8bbb --- /dev/null +++ b/changelog.d/next-cloud-copy-create.md @@ -0,0 +1,8 @@ +## Added + +- Let an owner's registered device create a cloud-copy collection on the next + control plane (`POST /v1/next/collections/cloud-copy`, signed with a fresh + device challenge). The hosted and escrow deployments generate their own + service devices, and the collection's genesis enrols the owner's device and + both service devices; the owner's desktop then performs the initial rekey. + Off unless `MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1`. diff --git a/services/server/src/app.ts b/services/server/src/app.ts index 04664fbf..a6f63ba1 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -23,6 +23,7 @@ import { HostedAuthorityRegistry } from "./hosted.js"; 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 { 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"; @@ -523,6 +524,7 @@ export async function buildApp(options: BuildOptions) { registerNextDeviceRoutes(app, { db: options.db, log: nextLog }); 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 }); registerPolicyRecoveryRoutes(app, options.db, nextPolicyEmitter!); registerNextRouteRoutes(app, { db: options.db, publicUrl, broker: relayBroker }); if (options.nextControlPlane.labFixtures) registerLabFixtureRoutes(app, { diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts new file mode 100644 index 00000000..c0a59c08 --- /dev/null +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -0,0 +1,252 @@ +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import Fastify from "fastify"; +import pg from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, type DatabasePool } from "../../db.js"; +import { tokenHash } from "../../security.js"; +import { cloudCopyCreateDigest, registerCloudCopyRoutes } from "./cloud-copy-bootstrap.js"; +import { deviceRegistrationDigest, issueDeviceChallenge, registerDevice } from "./devices.js"; +import { collectionDirectory } from "./hosted-routes.js"; +import { LogServiceClient } from "./log-service-client.js"; +import { certToJson, ed25519RawPublicKey, loadPolicySigner, parseNextControlPlaneEnv, type NextControlPlaneConfig } from "./policy-keys.js"; +import { PolicyEmitter } from "./policy-outbox.js"; +import { certDigest, chainHash, decodeCbor, encodeCbor, keyId, type Cbor, type Decoded } from "./policy-wire.js"; + +const testUrl = process.env.MDBASE_CONNECT_TEST_DATABASE_URL; +const approved = process.env.MDBASE_CONNECT_DESTRUCTIVE_TEST_APPROVAL === "I APPROVE MDBASE CONNECT DESTRUCTIVE POSTGRES TESTS"; +const describePg = testUrl && approved ? describe : describe.skip; +const field = (value: Decoded, key: number) => value instanceof Map ? value.get(key) : undefined; +const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"); +const rawX = () => (generateKeyPairSync("x25519").publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32); +const ZERO_ACCOUNT = "00".repeat(16); +const tokens = { hosted: "h".repeat(40), escrow: "e".repeat(40), hostedOut: "H".repeat(40), escrowOut: "E".repeat(40) }; + +describe("cloud-copy bootstrap configuration", () => { + const base = { + MDBASE_NEXT_CONTROL_PLANE: "1", MDBASE_NEXT_ROOT_PUBLIC_KEY: "00".repeat(32), MDBASE_NEXT_POLICY_SIGNING_KEY: "pem", MDBASE_NEXT_POLICY_KEY_CERT: "{}", + MDBASE_NEXT_LOG_SERVICE_URL: "https://log.example", MDBASE_NEXT_LOG_TOKEN_SIGNING_KEY: "pem", MDBASE_NEXT_LOG_TRANSPORT_KEY: "pem", + MDBASE_NEXT_HOSTED_INTERNAL_TOKEN: tokens.hosted, MDBASE_NEXT_ESCROW_INTERNAL_TOKEN: tokens.escrow + }; + const on = { + ...base, MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP: "1", + MDBASE_NEXT_HOSTED_SERVICE_URL: "https://hosted.example", MDBASE_NEXT_HOSTED_SERVICE_TOKEN: tokens.hostedOut, + MDBASE_NEXT_ESCROW_SERVICE_URL: "https://escrow.example", MDBASE_NEXT_ESCROW_SERVICE_TOKEN: tokens.escrowOut + }; + it("is off by default and complete when on", () => { + expect(parseNextControlPlaneEnv(base)?.cloudCopyBootstrap).toBeUndefined(); + expect(parseNextControlPlaneEnv(on)?.cloudCopyBootstrap).toEqual({ + hosted: { url: "https://hosted.example", token: tokens.hostedOut }, escrow: { url: "https://escrow.example", token: tokens.escrowOut } + }); + }); + it("refuses partial, insecure or shared-token configuration", () => { + expect(() => parseNextControlPlaneEnv({ ...on, MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP: "yes" })).toThrow(/0 or 1/); + expect(() => parseNextControlPlaneEnv({ ...on, MDBASE_NEXT_ESCROW_SERVICE_URL: "" })).toThrow(/ESCROW_SERVICE_URL/); + expect(() => parseNextControlPlaneEnv({ ...on, MDBASE_NEXT_HOSTED_SERVICE_URL: "http://hosted.example" })).toThrow(/https/); + expect(() => parseNextControlPlaneEnv({ ...on, MDBASE_NEXT_HOSTED_SERVICE_TOKEN: tokens.hosted })).toThrow(/all differ/); + expect(() => parseNextControlPlaneEnv({ ...on, MDBASE_NEXT_ESCROW_SERVICE_TOKEN: tokens.hostedOut })).toThrow(/all differ/); + expect(() => parseNextControlPlaneEnv({ ...on, MDBASE_NEXT_ESCROW_INTERNAL_TOKEN: "" })).toThrow(/INTERNAL_TOKEN/); + }); +}); + +function configuration(): NextControlPlaneConfig { + const root = generateKeyPairSync("ed25519").privateKey; + const policy = generateKeyPairSync("ed25519").privateKey; + const cert = { policyPublicKey: ed25519RawPublicKey(policy), notBefore: Date.now() - 60_000, notAfter: Date.now() + 30 * 86_400_000, root: keyId(ed25519RawPublicKey(root)) }; + const pem = (key: typeof root) => key.export({ type: "pkcs8", format: "pem" }).toString(); + return { + rootPublicKey: ed25519RawPublicKey(root), policyPrivateKeyPem: pem(policy), policyCert: certToJson({ ...cert, signature: sign(null, certDigest(cert), root) }), + serviceTokens: { hosted: tokens.hosted, escrow: tokens.escrow }, + cloudCopyBootstrap: { hosted: { url: "https://hosted.test", token: tokens.hostedOut }, escrow: { url: "https://escrow.test", token: tokens.escrowOut } }, + logService: { url: "http://log.test", tokenIssuerKeyPem: pem(generateKeyPairSync("ed25519").privateKey), transportKeyPem: pem(generateKeyPairSync("ed25519").privateKey) } + }; +} + +/** The log service, holding genesis only. */ +class Log { + readonly logs = new Map(); + readonly fetch: typeof fetch = async (input, init) => { + if (String(input).endsWith("/v1/nonce")) return new Response("ab".repeat(32)); + const frame = decodeCbor(Buffer.from(init!.body as Uint8Array)); + const method = field(frame, 2); + const params = field(frame, 3)!; + const id = hex(field(params, 0) as Uint8Array); + let result: Cbor; + if (method === "create_log") { + this.logs.set(id, Buffer.from(field(params, 1) as Uint8Array)); + result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)!)]] }; + } else if (method === "head") { + result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)!)], [2, 1]] }; + } else if (method === "read") { + const genesis = this.logs.get(id); + if (!genesis) return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [3, { struct: [[0, "not_found"]] }]] })); + result = { struct: [[0, [[1, genesis]]]] }; + } else throw new Error("unexpected log operation"); + return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [2, result]] }), { headers: { "content-type": "application/vnd.mdbase.v1+cbor" } }); + }; +} + +/** Both deployments: one device per collection and kind, returned again on retry. */ +class Deployments { + readonly devices = new Map>(); + calls = 0; + failing: "down" | "wrong-kind" | undefined; + readonly fetch: typeof fetch = async (input, init) => { + this.calls += 1; + const url = new URL(String(input)); + const kind = url.hostname === "hosted.test" ? "hosted" : "escrow"; + expect(url.pathname).toBe("/internal/v1/service-devices"); + expect(new Headers(init!.headers).get("authorization")).toBe(`Bearer ${kind === "hosted" ? tokens.hostedOut : tokens.escrowOut}`); + if (this.failing === "down") throw new TypeError("unavailable"); + const { collection } = JSON.parse(String(init!.body)) as { collection: string }; + const key = `${kind}/${collection}`; + if (!this.devices.has(key)) { + this.devices.set(key, { + kind, device_id: randomUUID(), sign_pk: hex(ed25519RawPublicKey(generateKeyPairSync("ed25519").privateKey)), kem_pk: hex(rawX()), + noise_pk: kind === "escrow" ? "00".repeat(32) : hex(rawX()), wrapped_keys: Buffer.from(`sealed ${key}`).toString("base64"), + kms_key_arn: `arn:aws:kms:eu-west-1:000000000000:key/${kind}` + }); + } + const device = this.devices.get(key)!; + return new Response(JSON.stringify(this.failing === "wrong-kind" ? { ...device, kind: kind === "hosted" ? "escrow" : "hosted" } : device)); + }; +} + +describePg("cloud-copy bootstrap", () => { + let db: DatabasePool; + let admin: pg.Pool; + let schema: string; + const config = configuration(); + const log = new Log(); + const deployments = new Deployments(); + const client = new LogServiceClient(config.logService, log.fetch); + const app = Fastify(); + + beforeAll(async () => { + const url = new URL(testUrl!); + if (!["localhost", "127.0.0.1", "::1"].includes(url.hostname) || !/test/i.test(url.pathname)) throw new Error("Bootstrap tests require dedicated local test Postgres."); + schema = `cloud_copy_${randomUUID().replaceAll("-", "")}`; + admin = new pg.Pool({ connectionString: url.toString(), max: 2 }); + await admin.query(`CREATE SCHEMA "${schema}"`); + url.searchParams.set("options", `-csearch_path=${schema}`); + db = await createDatabase(url.toString()); + const emitter = new PolicyEmitter(db, client, loadPolicySigner(config, Date.now())); + registerCloudCopyRoutes(app, { db, next: config, emitter, log: client, fetchImpl: deployments.fetch }); + }, 60_000); + afterAll(async () => { + await app.close(); await db?.end(); + if (admin && schema) await admin.query(`DROP SCHEMA "${schema}" CASCADE`); + await admin?.end(); + }); + + async function identity(user = randomUUID()) { + const connector = { id: randomUUID(), user_id: user }; + const token = randomUUID(); + const device = randomUUID(); + const key = generateKeyPairSync("ed25519").privateKey; + const signPk = ed25519RawPublicKey(key); + const kemPk = rawX(); const noisePk = rawX(); + await db.query("INSERT INTO users(id,email,name) VALUES($1,$2,'Owner') ON CONFLICT DO NOTHING", [user, `${user}@example.test`]); + await db.query("INSERT INTO connectors(id,user_id,name,token_hash) VALUES($1,$2,'Daemon',$3)", [connector.id, user, tokenHash(token)]); + const registration = await issueDeviceChallenge(db, connector.id); + await registerDevice(db, connector, { + device_id: device, kind: "desktop", sign_pk: hex(signPk), kem_pk: hex(kemPk), noise_pk: hex(noisePk), challenge: registration.challenge, + sig: hex(sign(null, deviceRegistrationDigest({ challenge: Buffer.from(registration.challenge, "hex"), connectorId: connector.id, deviceId: device, signPk, kemPk, noisePk }), key)) + }); + return { connector, device, key, signPk, headers: { authorization: `Bearer ${token}` } }; + } + type Who = Awaited>; + async function proof(who: Who, collection: string) { + const { challenge } = await issueDeviceChallenge(db, who.connector.id); + const digest = cloudCopyCreateDigest({ challenge: Buffer.from(challenge, "hex"), connector: who.connector.id, device: who.device, collection }); + return { collection_id: collection, device_id: who.device, challenge, sig: hex(sign(null, digest, who.key)) }; + } + const create = (who: Who, payload: unknown) => app.inject({ method: "POST", url: "/v1/next/collections/cloud-copy", headers: who.headers, payload }); + const registered = async (collection: string) => (await db.query("SELECT 1 FROM next_collections WHERE collection_id = $1", [collection])).rows.length === 1; + + it("enrols the owner's device and both service devices in a cloud-copy genesis", async () => { + const who = await identity(); const collection = randomUUID(); + const response = await create(who, await proof(who, collection)); + expect(response.statusCode, response.body).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + const result = response.json(); + expect(result).toMatchObject({ collection_id: collection, state: "cloud-copy", owner_account: who.connector.user_id, head: { seq: 1 } }); + const hosted = deployments.devices.get(`hosted/${collection}`)!; + const escrow = deployments.devices.get(`escrow/${collection}`)!; + expect(result.rekey_recipients).toEqual([who.device, hosted.device_id, escrow.device_id]); + expect(result.service_devices).toEqual([hosted, escrow].map(({ wrapped_keys: _w, kms_key_arn: _k, ...visible }) => visible)); + expect(response.body).not.toContain(hosted.wrapped_keys); + + const payload = decodeCbor(field(decodeCbor(Buffer.from(result.genesis.item, "hex")), 11) as Uint8Array); + const ops = field(payload, 3) as Decoded[]; + expect(ops.map((op) => field(op, 0))).toEqual([1, 4, 2, 2, 2]); + expect(field(ops[0]!, 3)).toBe(1); // cloud-copy + const enrols = ops.slice(2).map((op) => ({ device: hex(field(op, 1) as Uint8Array), account: hex(field(op, 2) as Uint8Array), kind: field(op, 3), sign: hex(field(op, 4) as Uint8Array), noise: hex(field(op, 6) as Uint8Array) })); + expect(enrols).toEqual([ + { device: who.device.replaceAll("-", ""), account: who.connector.user_id.replaceAll("-", ""), kind: 0, sign: hex(who.signPk), noise: expect.any(String) }, + { device: hosted.device_id!.replaceAll("-", ""), account: ZERO_ACCOUNT, kind: 4, sign: hosted.sign_pk, noise: hosted.noise_pk }, + { device: escrow.device_id!.replaceAll("-", ""), account: ZERO_ACCOUNT, kind: 5, sign: escrow.sign_pk, noise: "00".repeat(32) } + ]); + const claims = decodeCbor(Buffer.from(result.device.token.split(".")[0], "hex")); + expect(field(claims, 0)).toBe(0); + expect(hex(field(claims, 1) as Uint8Array)).toBe(who.device.replaceAll("-", "")); + expect(hex(field(claims, 5) as Uint8Array)).toBe(collection.replaceAll("-", "")); + expect((await collectionDirectory(db, [collection]))[0]!.state).toBe("standard"); + const stored = await db.query<{ kind: string; wrapped_keys: Buffer }>("SELECT kind, wrapped_keys FROM next_service_devices WHERE collection_id = $1 ORDER BY kind", [collection]); + expect(stored.rows.map((row) => [row.kind, row.wrapped_keys.toString("base64")])).toEqual([["escrow", escrow.wrapped_keys], ["hosted", hosted.wrapped_keys]]); + }); + + it("answers a retry from the enrolled device without generating again", async () => { + const who = await identity(); const collection = randomUUID(); + const first = (await create(who, await proof(who, collection))).json(); + const calls = deployments.calls; + const again = await create(who, await proof(who, collection)); + expect(again.statusCode, again.body).toBe(200); + expect(deployments.calls).toBe(calls); + expect(again.json().service_devices).toEqual(first.service_devices); + expect(again.json().genesis).toEqual(first.genesis); + }); + + it("refuses other devices, other owners and existing private collections", async () => { + const who = await identity(); const collection = randomUUID(); + expect((await create(who, await proof(who, collection))).statusCode).toBe(200); + const sibling = await identity(who.connector.user_id); + expect((await create(sibling, await proof(sibling, collection))).statusCode).toBe(409); + const stranger = await identity(); + expect((await create(stranger, await proof(stranger, collection))).statusCode).toBe(409); + const priv = randomUUID(); + await db.query("INSERT INTO next_collections(collection_id, owner_user_id, runtime, sync, root_key_id) VALUES($1,$2,'next','private',$3)", [priv, who.connector.user_id, Buffer.from(config.policyCert.root_key_id, "hex")]); + const calls = deployments.calls; + expect((await create(who, await proof(who, priv))).statusCode).toBe(409); + expect(deployments.calls).toBe(calls); + }); + + it("refuses a local collection that belongs to someone else", async () => { + const owner = await identity(); const who = await identity(); const collection = randomUUID(); + await db.query(`INSERT INTO collections(id,user_id,connector_id,local_id,display_name,spec_version) VALUES($1,$2,$3,$4,'Theirs','0.3.0')`, + [randomUUID(), owner.connector.user_id, owner.connector.id, collection]); + expect((await create(who, await proof(who, collection))).statusCode).toBe(409); + expect(await registered(collection)).toBe(false); + }); + + it("needs a fresh signed proof", async () => { + const who = await identity(); const collection = randomUUID(); + const payload = await proof(who, collection); + expect((await app.inject({ method: "POST", url: "/v1/next/collections/cloud-copy", payload })).statusCode).toBe(401); + expect((await create(who, { ...payload, collection_id: randomUUID() })).statusCode).toBe(403); + expect((await create(who, { ...payload, sig: "00" })).statusCode).toBe(400); + expect((await create(who, payload)).statusCode).toBe(200); + expect((await create(who, payload)).statusCode).toBe(403); + }); + + it("registers nothing when a deployment fails, and a later retry succeeds", async () => { + const who = await identity(); const collection = randomUUID(); + for (const failing of ["down", "wrong-kind"] as const) { + deployments.failing = failing; + const response = await create(who, await proof(who, collection)); + expect(response.statusCode).toBe(503); + expect(await registered(collection)).toBe(false); + } + deployments.failing = undefined; + expect((await create(who, await proof(who, collection))).statusCode).toBe(200); + }); +}); diff --git a/services/server/src/features/next/cloud-copy-bootstrap.ts b/services/server/src/features/next/cloud-copy-bootstrap.ts new file mode 100644 index 00000000..fc7ecf55 --- /dev/null +++ b/services/server/src/features/next/cloud-copy-bootstrap.ts @@ -0,0 +1,189 @@ +// An owner creates a cloud-copy collection (coordinator decision on sealed-envelope §7.1): +// the control plane asks the hosted and escrow deployments to generate their service +// devices, then registers a cloud-copy genesis that enrols the owner's device and both +// service devices. The owner's desktop then appends the initial rekey itself, with wraps +// for desktop + hosted + escrow. The control plane never holds a collection key, and +// there is no escrow-to-hosted wrap. Mounted only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1. +// +// A collection is created as a cloud copy here; converting an existing private +// collection is not supported. +import { verify } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import type { DatabaseConnection, DatabasePool } from "../../database-types.js"; +import { apiError } from "../../platform/http-errors.js"; +import { requireConnector } from "../../platform/request-authentication.js"; +import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; +import { ed25519PublicKeyObject, type NextControlPlaneConfig } from "./policy-keys.js"; +import { registerNextCollection, type PolicyEmitter } from "./policy-outbox.js"; +import { domainHash, encodeCbor, uuidBytes } from "./policy-wire.js"; +import { generateServiceDevice, loadServiceDevice, ServiceDeviceError, storeServiceDevice, type ServiceDeviceRecord } from "./service-devices.js"; + +/** Service devices belong to no account (policy.md: hosted and escrow enrol with the zero account). */ +const SERVICE_ACCOUNT = "00000000-0000-0000-0000-000000000000"; + +interface Body { collection_id: string; device_id: string; challenge: string; sig: string } +interface Device { sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer; kind: "desktop" | "cli" } +class CreateError extends Error { + constructor(readonly status: number, readonly code: string) { super(code); } +} + +/** `H("mdbase/v1/cloud-copy-create", cbor[challenge, connector, device, collection])`, signed by the owner's device. */ +export function cloudCopyCreateDigest(input: { challenge: Uint8Array; connector: string; device: string; collection: string }): Uint8Array { + return domainHash("mdbase/v1/cloud-copy-create", encodeCbor([input.challenge, uuidBytes(input.connector), uuidBytes(input.device), uuidBytes(input.collection)])); +} + +const lock = (client: DatabaseConnection, collection: string) => + client.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text, 20261005))", [collection]); + +/** Whether the collection already exists: false when free, true when this owner's cloud copy, else refused. */ +async function existing(client: DatabaseConnection, collection: string, owner: string): Promise { + const row = (await client.query<{ owner_user_id: string; sync: string; left: boolean }>( + "SELECT owner_user_id, sync, left_sync_at IS NOT NULL AS left FROM next_collections WHERE collection_id = $1 FOR UPDATE", [collection] + )).rows[0]; + if (row && (row.owner_user_id !== owner || row.sync !== "cloud_copy" || row.left)) throw new CreateError(409, "collection_exists"); + if (!row) { + // A local collection with this logical ID that belongs to someone else is never adopted. + const other = await client.query("SELECT 1 FROM collections WHERE local_id = $1 AND user_id <> $2 AND removed_at IS NULL", [collection, owner]); + if (other.rows.length) throw new CreateError(409, "collection_exists"); + } + return Boolean(row); +} + +async function authenticate(client: DatabaseConnection, body: Body, connector: { id: string; user_id: string }): Promise { + const device = (await client.query( + "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"); + const digest = cloudCopyCreateDigest({ challenge, connector: connector.id, device: body.device_id, collection: body.collection_id }); + if (!device || !verify(null, digest, 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; +} + +async function inTransaction(db: DatabasePool, run: (client: DatabaseConnection) => Promise): Promise { + const client = await db.connect(); + try { + await client.query("BEGIN"); + const result = await run(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +const publicRecord = (record: ServiceDeviceRecord) => ({ + kind: record.kind, device_id: record.device_id, + sign_pk: record.sign_pk.toString("hex"), kem_pk: record.kem_pk.toString("hex"), noise_pk: record.noise_pk.toString("hex") +}); + +export function registerCloudCopyRoutes(app: FastifyInstance, options: { + db: DatabasePool; next: NextControlPlaneConfig; emitter: PolicyEmitter; + log: Pick; fetchImpl?: typeof fetch; now?: () => number; +}): void { + const deployments = options.next.cloudCopyBootstrap; + if (!deployments) throw new Error("cloud-copy routes need MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1"); + const rootKeyId = Buffer.from(options.next.policyCert.root_key_id, "hex"); + const uuid = { type: "string", pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }; + app.post<{ Body: Body }>("/v1/next/collections/cloud-copy", { + bodyLimit: 4096, + config: { rateLimit: { max: 6, timeWindow: "1 minute" } }, + schema: { body: { + type: "object", additionalProperties: false, required: ["collection_id", "device_id", "challenge", "sig"], + properties: { collection_id: uuid, device_id: uuid, challenge: { type: "string", pattern: "^[0-9a-f]{64}$" }, sig: { type: "string", pattern: "^[0-9a-f]{128}$" } } + } } + }, async (request, reply) => { + reply.header("cache-control", "no-store"); + const connector = await requireConnector(request, reply, options.db); + if (!connector) return reply; + const body = { ...request.body, collection_id: request.body.collection_id.toLowerCase(), device_id: request.body.device_id.toLowerCase() }; + const collection = body.collection_id; + let device: Device; + let records: ServiceDeviceRecord[]; + try { + // 1. Proof and ownership, consuming the challenge. No network call holds a lock. + let created: boolean; + ({ device, created } = await inTransaction(options.db, async (client) => { + await lock(client, collection); + const owner = await authenticate(client, body, connector); + return { device: owner, created: await existing(client, collection, connector.user_id) }; + })); + if (created) { + // A retry must come from the device the genesis enrolled, with the same keys. + const enrolled = await options.db.query( + `SELECT 1 FROM next_policy_outbox WHERE id = (SELECT min(id) FROM next_policy_outbox WHERE collection_id = $1) + AND ops->'ops' @> $2::jsonb`, + [collection, JSON.stringify([{ op: "device-enrol", device: body.device_id, account: connector.user_id, signPublicKey: { $hex: device.sign_pk.toString("hex") } }])] + ); + if (!enrolled.rows.length) throw new CreateError(409, "collection_exists"); + records = await Promise.all((["hosted", "escrow"] as const).map(async (kind) => { + const record = await loadServiceDevice(options.db, collection, { kind }, true); + if (!record) throw new CreateError(503, "not_ready"); + return record; + })); + } else { + // 2. Each deployment generates its own keys; a retry returns the same device. + const generated = await Promise.all((["hosted", "escrow"] as const).map((kind) => + generateServiceDevice(deployments[kind], kind, collection, options.fetchImpl))); + // 3. Register genesis and store the records together, rechecking ownership. + records = await inTransaction(options.db, async (client) => { + await lock(client, collection); + // Created concurrently: the retry path rechecks the enrolled device. + if (await existing(client, collection, connector.user_id)) throw new CreateError(503, "not_ready"); + { + await registerNextCollection(client, { + collectionId: collection, ownerUserId: connector.user_id, runtime: "next", sync: "cloud_copy", rootKeyId, + ops: [ + { op: "genesis", owner: connector.user_id, root: rootKeyId, state: "cloud-copy" }, + { op: "member-set", account: connector.user_id, role: "owner" }, + { op: "device-enrol", device: body.device_id, account: connector.user_id, kind: device.kind, signPublicKey: device.sign_pk, kemPublicKey: device.kem_pk, noisePublicKey: device.noise_pk }, + ...generated.map((record) => ({ + op: "device-enrol" as const, device: record.device_id, account: SERVICE_ACCOUNT, kind: record.kind, + signPublicKey: record.sign_pk, kemPublicKey: record.kem_pk, noisePublicKey: record.noise_pk + })) + ] + }); + } + return Promise.all(generated.map((record) => storeServiceDevice(client, collection, record))); + }); + } + } catch (error) { + if (error instanceof CreateError || error instanceof ServiceDeviceError) { + const status = error.status === 502 ? 503 : error.status; + return reply.code(status).send(apiError(error.code, "The cloud copy was not created; retry with a fresh proof.")); + } + throw error; + } + // 4. Only an appended genesis whose exact bytes the log returns counts as created. + try { + await options.emitter.drainCollection(collection); + const genesis = (await options.db.query<{ item: Buffer; state: string }>( + "SELECT item, state FROM next_policy_batches WHERE collection_id = $1 AND seq = 1 ORDER BY id LIMIT 1", [collection] + )).rows[0]; + const external = genesis?.state === "appended" ? await options.log.controlItemAt(collection, 1) : null; + if (!genesis || !external || !genesis.item.equals(Buffer.from(external))) throw new CreateError(503, "not_ready"); + const head = await options.log.head(collection); + const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + return { + collection_id: collection, state: "cloud-copy", owner_account: connector.user_id, + log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, + root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, + genesis: { seq: 1, item: genesis.item.toString("hex") }, + // The desktop's initial rekey wraps for exactly these devices; it checks them against the genesis it verifies. + rekey_recipients: [body.device_id, ...records.map((record) => record.device_id)], + service_devices: records.map(publicRecord), + device: { device_id: body.device_id, token: options.log.mintToken({ device: body.device_id, signPublicKey: device.sign_pk, collection, expiresAt }), expires_at: expiresAt } + }; + } catch { + return reply.code(503).send(apiError("not_ready", "The cloud copy outcome is not verified; retry with a fresh proof.")); + } + }); +} diff --git a/services/server/src/features/next/policy-keys.ts b/services/server/src/features/next/policy-keys.ts index 8f23a886..af503f11 100644 --- a/services/server/src/features/next/policy-keys.ts +++ b/services/server/src/features/next/policy-keys.ts @@ -32,6 +32,13 @@ export interface NextControlPlaneConfig { logService: LogServiceConfig; /** Bearer tokens of the hosted replica and escrow deployments, per kind; absent until deployed. */ serviceTokens: { hosted?: string; escrow?: string }; + /** + * Outbound: where the control plane asks each deployment to generate its service + * device when an owner creates a cloud copy. Set only by MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1; + * these tokens authenticate the control plane to the deployment and differ from the + * inbound `serviceTokens`. + */ + cloudCopyBootstrap?: { hosted: { url: string; token: string }; escrow: { url: string; token: string } }; labFixtures?: LabFixtureConfig; } @@ -111,12 +118,30 @@ export function parseNextControlPlaneEnv(env: NodeJS.ProcessEnv): NextControlPla const hosted = serviceToken("MDBASE_NEXT_HOSTED_INTERNAL_TOKEN"); const escrow = serviceToken("MDBASE_NEXT_ESCROW_INTERNAL_TOKEN"); if (hosted && hosted === escrow) throw new Error("The hosted and escrow internal tokens must differ."); + const bootstrap = env.MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP?.trim() ?? ""; + if (bootstrap !== "" && bootstrap !== "0" && bootstrap !== "1") throw new Error("MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP must be 0 or 1."); + let cloudCopyBootstrap: NextControlPlaneConfig["cloudCopyBootstrap"]; + if (bootstrap === "1") { + const deployment = (kind: "HOSTED" | "ESCROW") => { + const url = env[`MDBASE_NEXT_${kind}_SERVICE_URL`]?.trim() ?? ""; + const token = serviceToken(`MDBASE_NEXT_${kind}_SERVICE_TOKEN`); + if (!url || !token) throw new Error(`MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1 requires MDBASE_NEXT_${kind}_SERVICE_URL and MDBASE_NEXT_${kind}_SERVICE_TOKEN.`); + if (new URL(url).protocol !== "https:") throw new Error(`MDBASE_NEXT_${kind}_SERVICE_URL must use https.`); + return { url, token }; + }; + if (!hosted || !escrow) throw new Error("MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1 requires MDBASE_NEXT_HOSTED_INTERNAL_TOKEN and MDBASE_NEXT_ESCROW_INTERNAL_TOKEN."); + cloudCopyBootstrap = { hosted: deployment("HOSTED"), escrow: deployment("ESCROW") }; + if (new Set([hosted, escrow, cloudCopyBootstrap.hosted.token, cloudCopyBootstrap.escrow.token]).size !== 4) { + throw new Error("Inbound and outbound service tokens must all differ."); + } + } return { rootPublicKey: hexBytes(root, 32, "MDBASE_NEXT_ROOT_PUBLIC_KEY"), policyPrivateKeyPem: pem, policyCert: parsedCert, logService: { url: logServiceUrl, tokenIssuerKeyPem, transportKeyPem }, serviceTokens: { ...(hosted ? { hosted } : {}), ...(escrow ? { escrow } : {}) }, + ...(cloudCopyBootstrap ? { cloudCopyBootstrap } : {}), ...(labFixtures ? { labFixtures } : {}), }; } From 12b2c9d94f8219e287ebed4e1262a59600e5ba6c Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:50:10 +1100 Subject: [PATCH 04/14] next: hold the collection row through service-device fetch and token mint; CHECK key and wrapped sizes; validate every writer Review (control): fetch and mint now run in one transaction under FOR SHARE of the collection row, so a concurrent leave waits for the mint and a later fetch/mint sees it. 0048 CHECKs 32-byte keys and 1..64 KiB wrapped keys (pg-mem gains octet_length); storeServiceDevice validates records from any writer. Deployments need not be idempotent: the CP's first stored record wins. --- .../migrations/0048_next_service_devices.sql | 10 +-- services/server/src/db.ts | 6 ++ .../server/src/features/next/hosted-routes.ts | 50 ++++++++--- .../next/service-devices.postgres.test.ts | 89 ++++++++++++++++++- .../src/features/next/service-devices.ts | 9 +- 5 files changed, 143 insertions(+), 21 deletions(-) diff --git a/services/server/migrations/0048_next_service_devices.sql b/services/server/migrations/0048_next_service_devices.sql index a263f932..35327f6a 100644 --- a/services/server/migrations/0048_next_service_devices.sql +++ b/services/server/migrations/0048_next_service_devices.sql @@ -2,16 +2,16 @@ -- collection (mdbase-next interface note 2026-10-04-control-hosted-replica.md §2). -- Public keys only. `wrapped_keys` is the deployment's KMS-wrapped private keys, which -- the control plane stores and returns but can never unwrap. The composite key admits --- rows only for cloud_copy collections. Lengths are checked by the writer. +-- rows only for cloud_copy collections. The writer validates the record as well. CREATE TABLE next_service_devices ( collection_id uuid NOT NULL, sync text NOT NULL DEFAULT 'cloud_copy' CHECK (sync = 'cloud_copy'), kind text NOT NULL CHECK (kind IN ('hosted', 'escrow')), device_id uuid NOT NULL UNIQUE, - sign_pk bytea NOT NULL, - kem_pk bytea NOT NULL, - noise_pk bytea NOT NULL, - wrapped_keys bytea NOT NULL, + sign_pk bytea NOT NULL CHECK (octet_length(sign_pk) = 32), + kem_pk bytea NOT NULL CHECK (octet_length(kem_pk) = 32), + noise_pk bytea NOT NULL CHECK (octet_length(noise_pk) = 32), + wrapped_keys bytea NOT NULL CHECK (octet_length(wrapped_keys) BETWEEN 1 AND 65536), kms_key_arn text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (collection_id, kind), diff --git a/services/server/src/db.ts b/services/server/src/db.ts index 9b439f63..50b13319 100644 --- a/services/server/src/db.ts +++ b/services/server/src/db.ts @@ -37,6 +37,12 @@ export async function openDatabase( returns: DataType.bool, implementation: () => true }); + memory.public.registerFunction({ + name: "octet_length", + args: [DataType.bytea], + returns: DataType.integer, + implementation: (value: Uint8Array) => value.length + }); memory.public.registerFunction({ name: "gen_random_uuid", returns: DataType.uuid, diff --git a/services/server/src/features/next/hosted-routes.ts b/services/server/src/features/next/hosted-routes.ts index a66d242c..274ce00f 100644 --- a/services/server/src/features/next/hosted-routes.ts +++ b/services/server/src/features/next/hosted-routes.ts @@ -4,12 +4,12 @@ // internal token. import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; -import type { DatabaseQueryable } from "../../database-types.js"; +import type { DatabasePool, DatabaseQueryable } from "../../database-types.js"; import { apiError } from "../../platform/http-errors.js"; import { bearerToken } from "../../platform/request-authentication.js"; import { safeEqual } from "../../security.js"; import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; -import { loadServiceDevice, serviceDeviceWire, type ServiceKind } from "./service-devices.js"; +import { loadServiceDevice, serviceDeviceWire, type ServiceDeviceRecord, type ServiceKind } from "./service-devices.js"; export type CollectionDirectoryState = "standard" | "private" | "local" | "unknown"; @@ -56,9 +56,34 @@ export function serviceKind(request: FastifyRequest, tokens: { hosted?: string; return null; } +/** + * Run `use` on the current record while its collection row is share-locked, in one + * transaction. Leaving sync updates that row, so it waits until `use` has finished: + * a record served or a token minted here was served while the collection was current. + * Null when there is no current record. + */ +async function withCurrentRecord( + db: DatabasePool, collection: string, by: { kind: ServiceKind } | { device: string }, use: (record: ServiceDeviceRecord) => T +): Promise { + const client = await db.connect(); + try { + await client.query("BEGIN"); + await client.query("SET LOCAL lock_timeout = '5s'"); + const record = await loadServiceDevice(client, collection, by); + const result = record ? use(record) : null; + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + export function registerNextHostedRoutes( app: FastifyInstance, - options: { db: DatabaseQueryable; tokens: { hosted?: string; escrow?: string }; log?: LogServiceClient; now?: () => number } + options: { db: DatabasePool; tokens: { hosted?: string; escrow?: string }; log?: LogServiceClient; now?: () => number } ): void { const authorize = (request: FastifyRequest) => serviceKind(request, options.tokens) !== null; // The record lookup itself requires a current cloud copy; this only picks the answer @@ -85,9 +110,9 @@ export function registerNextHostedRoutes( if (!caller) return reply.code(401).send(apiError("invalid_internal_token", "Internal token required.")); const { id, kind } = z.object({ id: z.uuid(), kind: z.enum(["hosted", "escrow"]) }).parse(request.params); if (kind !== caller) return reply.code(403).send(apiError("wrong_service_kind", "This token reads only its own kind of service device.")); - const record = await loadServiceDevice(options.db, id, { kind }); - if (!record) return notCurrent(reply, id, "The collection has no service device of this kind."); - return serviceDeviceWire(record); + const wire = await withCurrentRecord(options.db, id, { kind }, serviceDeviceWire); + if (!wire) return notCurrent(reply, id, "The collection has no service device of this kind."); + return wire; }); // Role-0 log token for a service device, narrowed to one collection (claim 5) and // valid for at most LOG_TOKEN_LIFETIME_MS. The deployment refreshes it by asking again. @@ -100,10 +125,13 @@ export function registerNextHostedRoutes( if (!params.success || !body.success) return reply.code(400).send(apiError("invalid_request", "A device and collection are required.")); const { device } = params.data; const { collection } = body.data; - const record = await loadServiceDevice(options.db, collection, { device: device.toLowerCase() }); - if (!record) return notCurrent(reply, collection, "No such service device in this collection."); - if (record.kind !== caller) return reply.code(403).send(apiError("wrong_service_kind", "This token mints only for its own kind of service device.")); - const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; - return { token: log.mintToken({ device: record.device_id, signPublicKey: record.sign_pk, collection, expiresAt }), expires_at: expiresAt }; + const minted = await withCurrentRecord(options.db, collection, { device: device.toLowerCase() }, (record) => { + if (record.kind !== caller) return "wrong_kind" as const; + const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + return { token: log.mintToken({ device: record.device_id, signPublicKey: record.sign_pk, collection, expiresAt }), expires_at: expiresAt }; + }); + if (!minted) return notCurrent(reply, collection, "No such service device in this collection."); + if (minted === "wrong_kind") return reply.code(403).send(apiError("wrong_service_kind", "This token mints only for its own kind of service device.")); + return minted; }); } diff --git a/services/server/src/features/next/service-devices.postgres.test.ts b/services/server/src/features/next/service-devices.postgres.test.ts index 95922b5a..d22403f9 100644 --- a/services/server/src/features/next/service-devices.postgres.test.ts +++ b/services/server/src/features/next/service-devices.postgres.test.ts @@ -33,8 +33,8 @@ describePostgres("service devices", () => { const app = Fastify(); const issuer = generateKeyPairSync("ed25519"); const owner = randomUUID(); - const ids = { standard: randomUUID(), private: randomUUID(), left: randomUUID() }; - const devices = { hosted: record("hosted"), escrow: record("escrow", randomUUID(), 7), left: record("hosted", randomUUID(), 11) }; + const ids = { standard: randomUUID(), private: randomUUID(), left: randomUUID(), barrier: randomUUID() }; + const devices = { hosted: record("hosted"), escrow: record("escrow", randomUUID(), 7), left: record("hosted", randomUUID(), 11), barrier: record("hosted", randomUUID(), 21) }; beforeAll(async () => { const url = new URL(testUrl!); @@ -45,12 +45,13 @@ describePostgres("service devices", () => { url.searchParams.set("options", `-csearch_path=${schema}`); db = await createDatabase(url.toString()); await db.query("INSERT INTO users(id,email,name) VALUES($1,$2,'Owner')", [owner, `${owner}@example.test`]); - for (const [id, sync] of [[ids.standard, "cloud_copy"], [ids.private, "private"], [ids.left, "cloud_copy"]]) { + for (const [id, sync] of [[ids.standard, "cloud_copy"], [ids.private, "private"], [ids.left, "cloud_copy"], [ids.barrier, "cloud_copy"]]) { await db.query("INSERT INTO next_collections(collection_id, owner_user_id, runtime, sync, root_key_id) VALUES($1,$2,'next',$3,$4)", [id, owner, sync, Buffer.alloc(16)]); } await storeServiceDevice(db, ids.standard, devices.hosted); await storeServiceDevice(db, ids.standard, devices.escrow); await storeServiceDevice(db, ids.left, devices.left); + await storeServiceDevice(db, ids.barrier, devices.barrier); await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [ids.left]); const transport = generateKeyPairSync("ed25519").privateKey; const log = new LogServiceClient({ url: "https://log.example.test", tokenIssuerKeyPem: issuer.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), @@ -72,6 +73,88 @@ describePostgres("service devices", () => { expect((await loadServiceDevice(db, ids.standard, { kind: "hosted" }))!.kms_key_arn).toBe(devices.hosted.kms_key_arn); }); + it("validates direct writes, and the table refuses malformed rows", async () => { + const fresh = randomUUID(); + await db.query("INSERT INTO next_collections(collection_id, owner_user_id, runtime, sync, root_key_id) VALUES($1,$2,'next','cloud_copy',$3)", [fresh, owner, Buffer.alloc(16)]); + const good = record("hosted"); + for (const bad of [ + { ...good, sign_pk: good.sign_pk.subarray(0, 31) }, { ...good, kem_pk: Buffer.alloc(32) }, { ...good, wrapped_keys: Buffer.alloc(0) }, + { ...good, wrapped_keys: Buffer.alloc(65 * 1024) }, { ...good, kms_key_arn: "nope" }, { ...good, kind: "owner" as "hosted" }, + { ...good, kind: "escrow" as const } + ]) await expect(storeServiceDevice(db, fresh, bad)).rejects.toMatchObject({ code: "invalid_service_device" }); + const insert = (sign: Buffer, wrapped: Buffer) => db.query( + "INSERT INTO next_service_devices(collection_id, kind, device_id, sign_pk, kem_pk, noise_pk, wrapped_keys, kms_key_arn) VALUES($1,'hosted',$2,$3,$4,$4,$5,'arn:x')", + [fresh, randomUUID(), sign, Buffer.alloc(32, 9), wrapped] + ); + await expect(insert(Buffer.alloc(31, 1), Buffer.alloc(1))).rejects.toThrow(/check constraint/i); + await expect(insert(Buffer.alloc(32, 1), Buffer.alloc(0))).rejects.toThrow(/check constraint/i); + await expect(insert(Buffer.alloc(32, 1), Buffer.alloc(65537))).rejects.toThrow(/check constraint/i); + expect(await loadServiceDevice(db, fresh, { kind: "hosted" })).toBeNull(); + }); + + it("serves nothing once a concurrent leave commits: the leave and the mint are ordered by the row lock", async () => { + const leaving = await admin.connect(); + try { + await leaving.query(`SET search_path = "${schema}"`); + await leaving.query("BEGIN"); + await leaving.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [ids.barrier]); + let settled = false; + const mint = app.inject({ method: "POST", url: `/internal/v1/next/service-devices/${devices.barrier.device_id}/log-token`, headers: { authorization: `Bearer ${hosted}` }, payload: { collection: ids.barrier } }) + .finally(() => { settled = true; }); + const fetch = app.inject({ method: "GET", url: `/internal/v1/next/collections/${ids.barrier}/service-devices/hosted`, headers: { authorization: `Bearer ${hosted}` } }); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(settled).toBe(false); + await leaving.query("COMMIT"); + expect((await mint).statusCode).toBe(409); + expect((await fetch).statusCode).toBe(409); + } finally { + leaving.release(); + } + }); + + it("holds the collection row from the currentness check through the mint", async () => { + // A pool whose transactions, just before COMMIT, check that a leave cannot take the row. + const events: string[] = []; + const probe: DatabasePool = { + query: db.query.bind(db), end: async () => undefined, + async connect() { + const inner = await db.connect(); + return { + async query(text: string, values?: unknown[]) { + if (text === "COMMIT") { + const other = await admin.connect(); + try { + await other.query(`SET search_path = "${schema}"`); + await other.query("BEGIN"); + await other.query("SET LOCAL lock_timeout = '100ms'"); + await other.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [ids.standard]); + events.push("leave-acquired"); + } catch (error) { + events.push(/lock timeout/i.test(String(error)) ? "leave-blocked" : String(error)); + } finally { + await other.query("ROLLBACK").catch(() => undefined); + other.release(); + } + } + return inner.query(text, values as never); + }, + release: () => inner.release() + } as Awaited>; + } + }; + const probed = Fastify(); + const transport = generateKeyPairSync("ed25519").privateKey; + const log = new LogServiceClient({ url: "https://log.example.test", tokenIssuerKeyPem: issuer.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + transportKeyPem: transport.export({ format: "pem", type: "pkcs8" }).toString() }, async () => { throw new Error("no network"); }); + const mint = log.mintToken.bind(log); + log.mintToken = (claims) => { events.push("minted"); return mint(claims); }; + registerNextHostedRoutes(probed, { db: probe, tokens: { hosted, escrow }, log, now: () => NOW }); + const response = await probed.inject({ method: "POST", url: `/internal/v1/next/service-devices/${devices.hosted.device_id}/log-token`, headers: { authorization: `Bearer ${hosted}` }, payload: { collection: ids.standard } }); + expect(response.statusCode, response.body).toBe(200); + expect(events).toEqual(["minted", "leave-blocked"]); + await probed.close(); + }); + it("admits service devices only for cloud-copy collections", async () => { await expect(storeServiceDevice(db, ids.private, record("hosted"))).rejects.toThrow(/foreign key/i); await expect(storeServiceDevice(db, randomUUID(), record("hosted"))).rejects.toThrow(/foreign key/i); diff --git a/services/server/src/features/next/service-devices.ts b/services/server/src/features/next/service-devices.ts index 743a642b..fb4d38db 100644 --- a/services/server/src/features/next/service-devices.ts +++ b/services/server/src/features/next/service-devices.ts @@ -95,7 +95,9 @@ const COLUMNS = "device.kind, device.device_id::text AS device_id, device.sign_p * same record; a different record for the same collection and kind is refused, so a * retried bootstrap never replaces an enrolled device. Run inside the caller's transaction. */ -export async function storeServiceDevice(db: DatabaseQueryable, collection: string, record: ServiceDeviceRecord): Promise { +export async function storeServiceDevice(db: DatabaseQueryable, collection: string, given: ServiceDeviceRecord): Promise { + // Every writer gets the parser's checks, not only records that came over HTTP. + const record = parseServiceDevice(serviceDeviceWire(given)); await db.query( `INSERT INTO next_service_devices(collection_id, kind, device_id, sign_pk, kem_pk, noise_pk, wrapped_keys, kms_key_arn) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`, @@ -138,7 +140,10 @@ export interface ServiceDeploymentConfig { /** * Ask the deployment of `kind` to generate a service device for `collection` * (`POST /internal/v1/service-devices`). The response is bounded and must - * name the requested kind. The deployment returns the same device for a retried request. + * name the requested kind. The deployment need not be idempotent or keep state: the + * control plane's first stored record wins (`storeServiceDevice`), a retry after a + * committed store reuses that record without calling the deployment again, and a + * device generated for a store that never committed is discarded unused. */ export async function generateServiceDevice( deployment: ServiceDeploymentConfig, From 45379135ed339f63ff8aca734dad70737d7d49f3 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:52:30 +1100 Subject: [PATCH 05/14] next: cloud-copy create tests with stateless deployments; refuse a retry after leaving sync --- .../next/cloud-copy-bootstrap.postgres.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts index c0a59c08..537b5c5d 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -85,7 +85,7 @@ class Log { }; } -/** Both deployments: one device per collection and kind, returned again on retry. */ +/** Both deployments, stateless: every call generates a new device; the CP's first stored record wins. */ class Deployments { readonly devices = new Map>(); calls = 0; @@ -99,7 +99,7 @@ class Deployments { if (this.failing === "down") throw new TypeError("unavailable"); const { collection } = JSON.parse(String(init!.body)) as { collection: string }; const key = `${kind}/${collection}`; - if (!this.devices.has(key)) { + { this.devices.set(key, { kind, device_id: randomUUID(), sign_pk: hex(ed25519RawPublicKey(generateKeyPairSync("ed25519").privateKey)), kem_pk: hex(rawX()), noise_pk: kind === "escrow" ? "00".repeat(32) : hex(rawX()), wrapped_keys: Buffer.from(`sealed ${key}`).toString("base64"), @@ -220,6 +220,13 @@ describePg("cloud-copy bootstrap", () => { expect(deployments.calls).toBe(calls); }); + it("refuses a retry once the collection has left sync", async () => { + const who = await identity(); const collection = randomUUID(); + expect((await create(who, await proof(who, collection))).statusCode).toBe(200); + await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [collection]); + expect((await create(who, await proof(who, collection))).statusCode).toBe(409); + }); + it("refuses a local collection that belongs to someone else", async () => { const owner = await identity(); const who = await identity(); const collection = randomUUID(); await db.query(`INSERT INTO collections(id,user_id,connector_id,local_id,display_name,spec_version) VALUES($1,$2,$3,$4,'Theirs','0.3.0')`, From 490110934624573dbc4b6dd6c230cbc34532caa7 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:59:58 +1100 Subject: [PATCH 06/14] next: cloud-copy create rechecks identity and collection after every await; refuse nil IDs Review (control): after generation and after the log read-back, the owner's connector (not revoked), account (not suspended) and device (same keys) are re-read under FOR SHARE, and the final transaction checks the collection is still this owner's current cloud copy and the device the genesis enrolled before minting. PG tests for revoke, suspend and device removal during generation, and leave/revoke during the read-back. Nil collection/device IDs are refused. --- .../cloud-copy-bootstrap.postgres.test.ts | 42 ++++++ .../src/features/next/cloud-copy-bootstrap.ts | 126 +++++++++++------- 2 files changed, 122 insertions(+), 46 deletions(-) diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts index 537b5c5d..02329b59 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -64,6 +64,8 @@ function configuration(): NextControlPlaneConfig { /** The log service, holding genesis only. */ class Log { readonly logs = new Map(); + /** Runs once, while the route awaits the log's read-back of genesis. */ + onRead: (() => Promise) | undefined; readonly fetch: typeof fetch = async (input, init) => { if (String(input).endsWith("/v1/nonce")) return new Response("ab".repeat(32)); const frame = decodeCbor(Buffer.from(init!.body as Uint8Array)); @@ -77,6 +79,9 @@ class Log { } else if (method === "head") { result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)!)], [2, 1]] }; } else if (method === "read") { + const hook = this.onRead; + this.onRead = undefined; + await hook?.(); const genesis = this.logs.get(id); if (!genesis) return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [3, { struct: [[0, "not_found"]] }]] })); result = { struct: [[0, [[1, genesis]]]] }; @@ -90,8 +95,13 @@ class Deployments { readonly devices = new Map>(); calls = 0; failing: "down" | "wrong-kind" | undefined; + /** Runs once, while the route awaits generation. */ + during: (() => Promise) | undefined; readonly fetch: typeof fetch = async (input, init) => { this.calls += 1; + const hook = this.during; + this.during = undefined; + await hook?.(); const url = new URL(String(input)); const kind = url.hostname === "hosted.test" ? "hosted" : "escrow"; expect(url.pathname).toBe("/internal/v1/service-devices"); @@ -256,4 +266,36 @@ describePg("cloud-copy bootstrap", () => { deployments.failing = undefined; expect((await create(who, await proof(who, collection))).statusCode).toBe(200); }); + + it("refuses nil identifiers", async () => { + const who = await identity(); + const nil = "00000000-0000-0000-0000-000000000000"; + expect((await create(who, await proof(who, nil))).statusCode).toBe(400); + expect((await create(who, { ...(await proof(who, randomUUID())), device_id: nil })).statusCode).toBe(400); + }); + + it.each([ + ["the connector is revoked", (who: Who) => db.query("UPDATE connectors SET revoked_at = now() WHERE id = $1", [who.connector.id])], + ["the account is suspended", (who: Who) => db.query("UPDATE users SET suspended_at = now() WHERE id = $1", [who.connector.user_id])], + ["the device is removed", (who: Who) => db.query("DELETE FROM next_devices WHERE id = $1", [who.device])] + ])("registers nothing when %s during generation", async (_case, change) => { + const who = await identity(); const collection = randomUUID(); + const payload = await proof(who, collection); + deployments.during = async () => { await change(who); }; + expect((await create(who, payload)).statusCode).toBe(403); + expect(await registered(collection)).toBe(false); + }); + + it("mints nothing when the collection leaves sync or the connector is revoked while the log is read back", async () => { + const left = await identity(); const leaving = randomUUID(); + log.onRead = async () => { await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [leaving]); }; + const a = await create(left, await proof(left, leaving)); + expect(a.statusCode).toBe(409); + expect(a.body).not.toContain("token"); + const revoked = await identity(); const collection = randomUUID(); + log.onRead = async () => { await db.query("UPDATE connectors SET revoked_at = now() WHERE id = $1", [revoked.connector.id]); }; + const b = await create(revoked, await proof(revoked, collection)); + expect(b.statusCode).toBe(403); + expect(b.body).not.toContain("token"); + }); }); diff --git a/services/server/src/features/next/cloud-copy-bootstrap.ts b/services/server/src/features/next/cloud-copy-bootstrap.ts index fc7ecf55..c5c98b98 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.ts @@ -20,6 +20,7 @@ import { generateServiceDevice, loadServiceDevice, ServiceDeviceError, storeServ /** Service devices belong to no account (policy.md: hosted and escrow enrol with the zero account). */ const SERVICE_ACCOUNT = "00000000-0000-0000-0000-000000000000"; +const NIL = SERVICE_ACCOUNT; interface Body { collection_id: string; device_id: string; challenge: string; sig: string } interface Device { sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer; kind: "desktop" | "cli" } @@ -65,6 +66,23 @@ async function authenticate(client: DatabaseConnection, body: Body, connector: { return device; } +/** + * The owner's 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. + */ +async function currentIdentity(client: DatabaseConnection, connector: { id: string; user_id: string }, deviceId: string, device: Device): Promise { + 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"); +} + async function inTransaction(db: DatabasePool, run: (client: DatabaseConnection) => Promise): Promise { const client = await db.connect(); try { @@ -106,8 +124,8 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { if (!connector) return reply; const body = { ...request.body, collection_id: request.body.collection_id.toLowerCase(), device_id: request.body.device_id.toLowerCase() }; const collection = body.collection_id; + if (collection === NIL || body.device_id === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); let device: Device; - let records: ServiceDeviceRecord[]; try { // 1. Proof and ownership, consuming the challenge. No network call holds a lock. let created: boolean; @@ -116,43 +134,31 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { const owner = await authenticate(client, body, connector); return { device: owner, created: await existing(client, collection, connector.user_id) }; })); - if (created) { - // A retry must come from the device the genesis enrolled, with the same keys. - const enrolled = await options.db.query( - `SELECT 1 FROM next_policy_outbox WHERE id = (SELECT min(id) FROM next_policy_outbox WHERE collection_id = $1) - AND ops->'ops' @> $2::jsonb`, - [collection, JSON.stringify([{ op: "device-enrol", device: body.device_id, account: connector.user_id, signPublicKey: { $hex: device.sign_pk.toString("hex") } }])] - ); - if (!enrolled.rows.length) throw new CreateError(409, "collection_exists"); - records = await Promise.all((["hosted", "escrow"] as const).map(async (kind) => { - const record = await loadServiceDevice(options.db, collection, { kind }, true); - if (!record) throw new CreateError(503, "not_ready"); - return record; - })); - } else { - // 2. Each deployment generates its own keys; a retry returns the same device. + if (!created) { + // 2. Each deployment generates its own keys. Nothing is locked while they work. const generated = await Promise.all((["hosted", "escrow"] as const).map((kind) => generateServiceDevice(deployments[kind], kind, collection, options.fetchImpl))); - // 3. Register genesis and store the records together, rechecking ownership. - records = await inTransaction(options.db, async (client) => { + // 3. Recheck the owner's identity (revocation, suspension, device removal or + // key change may have happened meanwhile) and the collection, then register + // genesis and store the records together. The first committed record wins. + await inTransaction(options.db, async (client) => { await lock(client, collection); + await currentIdentity(client, connector, body.device_id, device); // Created concurrently: the retry path rechecks the enrolled device. if (await existing(client, collection, connector.user_id)) throw new CreateError(503, "not_ready"); - { - await registerNextCollection(client, { - collectionId: collection, ownerUserId: connector.user_id, runtime: "next", sync: "cloud_copy", rootKeyId, - ops: [ - { op: "genesis", owner: connector.user_id, root: rootKeyId, state: "cloud-copy" }, - { op: "member-set", account: connector.user_id, role: "owner" }, - { op: "device-enrol", device: body.device_id, account: connector.user_id, kind: device.kind, signPublicKey: device.sign_pk, kemPublicKey: device.kem_pk, noisePublicKey: device.noise_pk }, - ...generated.map((record) => ({ - op: "device-enrol" as const, device: record.device_id, account: SERVICE_ACCOUNT, kind: record.kind, - signPublicKey: record.sign_pk, kemPublicKey: record.kem_pk, noisePublicKey: record.noise_pk - })) - ] - }); - } - return Promise.all(generated.map((record) => storeServiceDevice(client, collection, record))); + await registerNextCollection(client, { + collectionId: collection, ownerUserId: connector.user_id, runtime: "next", sync: "cloud_copy", rootKeyId, + ops: [ + { op: "genesis", owner: connector.user_id, root: rootKeyId, state: "cloud-copy" }, + { op: "member-set", account: connector.user_id, role: "owner" }, + { op: "device-enrol", device: body.device_id, account: connector.user_id, kind: device.kind, signPublicKey: device.sign_pk, kemPublicKey: device.kem_pk, noisePublicKey: device.noise_pk }, + ...generated.map((record) => ({ + op: "device-enrol" as const, device: record.device_id, account: SERVICE_ACCOUNT, kind: record.kind, + signPublicKey: record.sign_pk, kemPublicKey: record.kem_pk, noisePublicKey: record.noise_pk + })) + ] + }); + for (const record of generated) await storeServiceDevice(client, collection, record); }); } } catch (error) { @@ -162,8 +168,8 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { } throw error; } - // 4. Only an appended genesis whose exact bytes the log returns counts as created. try { + // 4. Only an appended genesis whose exact bytes the log returns counts as created. await options.emitter.drainCollection(collection); const genesis = (await options.db.query<{ item: Buffer; state: string }>( "SELECT item, state FROM next_policy_batches WHERE collection_id = $1 AND seq = 1 ORDER BY id LIMIT 1", [collection] @@ -171,18 +177,46 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { const external = genesis?.state === "appended" ? await options.log.controlItemAt(collection, 1) : null; if (!genesis || !external || !genesis.item.equals(Buffer.from(external))) throw new CreateError(503, "not_ready"); const head = await options.log.head(collection); - const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; - return { - collection_id: collection, state: "cloud-copy", owner_account: connector.user_id, - log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, - root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, - genesis: { seq: 1, item: genesis.item.toString("hex") }, - // The desktop's initial rekey wraps for exactly these devices; it checks them against the genesis it verifies. - rekey_recipients: [body.device_id, ...records.map((record) => record.device_id)], - service_devices: records.map(publicRecord), - device: { device_id: body.device_id, token: options.log.mintToken({ device: body.device_id, signPublicKey: device.sign_pk, collection, expiresAt }), expires_at: expiresAt } - }; - } catch { + // 5. After every await: the identity, the collection and the enrolment are + // current, and stay locked until the token is minted. + const answer = await inTransaction(options.db, async (client) => { + await currentIdentity(client, connector, body.device_id, device); + const current = await client.query( + `SELECT 1 FROM next_collections WHERE collection_id = $1 AND owner_user_id = $2 AND sync = 'cloud_copy' AND left_sync_at IS NULL FOR SHARE`, + [collection, connector.user_id] + ); + if (!current.rows.length) throw new CreateError(409, "collection_exists"); + // The requesting device must be the one the genesis enrolled, with the same keys. + const enrolled = await client.query( + `SELECT 1 FROM next_policy_outbox WHERE id = (SELECT min(id) FROM next_policy_outbox WHERE collection_id = $1) + AND ops->'ops' @> $2::jsonb`, + [collection, JSON.stringify([{ op: "device-enrol", device: body.device_id, account: connector.user_id, signPublicKey: { $hex: device.sign_pk.toString("hex") } }])] + ); + if (!enrolled.rows.length) throw new CreateError(409, "collection_exists"); + const records: ServiceDeviceRecord[] = []; + for (const kind of ["hosted", "escrow"] as const) { + const record = await loadServiceDevice(client, collection, { kind }); + if (!record) throw new CreateError(503, "not_ready"); + records.push(record); + } + const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + return { + collection_id: collection, state: "cloud-copy", owner_account: connector.user_id, + log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, + root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, + genesis: { seq: 1, item: genesis.item.toString("hex") }, + // Public identities enrolled by the genesis. Keying them is the owner + // desktop's signed initial rekey; this answer is identity provisioning only. + rekey_recipients: [body.device_id, ...records.map((record) => record.device_id)], + service_devices: records.map(publicRecord), + device: { device_id: body.device_id, token: options.log.mintToken({ device: body.device_id, signPublicKey: device.sign_pk, collection, expiresAt }), expires_at: expiresAt } + }; + }); + return answer; + } catch (error) { + if (error instanceof CreateError && error.status !== 503) { + return reply.code(error.status).send(apiError(error.code, "The cloud copy is not current for this device.")); + } return reply.code(503).send(apiError("not_ready", "The cloud copy outcome is not verified; retry with a fresh proof.")); } }); From 41732094416596896eb8add9d30b7c063499f143 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 01:59:59 +1100 Subject: [PATCH 07/14] next: refuse a nil service-device ID --- services/server/src/features/next/service-devices.test.ts | 2 +- services/server/src/features/next/service-devices.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/services/server/src/features/next/service-devices.test.ts b/services/server/src/features/next/service-devices.test.ts index a9c2da75..ee318be4 100644 --- a/services/server/src/features/next/service-devices.test.ts +++ b/services/server/src/features/next/service-devices.test.ts @@ -29,7 +29,7 @@ describe("service device record", () => { { ...wire, wrapped_keys: Buffer.alloc(MAX_WRAPPED_KEYS_BYTES + 1).toString("base64") }, { ...wire, kms_key_arn: "key" }, { ...wire, extra: 1 }, { ...wire, device_id: "nope" }, { ...wire, sign_pk: "00".repeat(32) }, { ...wire, kem_pk: "01" + "00".repeat(31) }, { ...wire, noise_pk: "00".repeat(32) }, - { ...wire, kind: "escrow" } + { ...wire, kind: "escrow" }, { ...wire, device_id: "00000000-0000-0000-0000-000000000000" } ]) expect(() => parseServiceDevice(bad), JSON.stringify(bad).slice(0, 80)).toThrow(ServiceDeviceError); }); }); diff --git a/services/server/src/features/next/service-devices.ts b/services/server/src/features/next/service-devices.ts index fb4d38db..46d0113e 100644 --- a/services/server/src/features/next/service-devices.ts +++ b/services/server/src/features/next/service-devices.ts @@ -47,6 +47,9 @@ type ServiceDeviceWire = z.infer; export function parseServiceDevice(value: unknown): ServiceDeviceRecord { const parsed = wireRecord.safeParse(value); if (!parsed.success) throw new ServiceDeviceError(502, "invalid_service_device", "The service device record is malformed."); + if (/^0{8}-0{4}-0{4}-0{4}-0{12}$/u.test(parsed.data.device_id)) { + throw new ServiceDeviceError(502, "invalid_service_device", "A service device needs a non-nil ID."); + } const wrapped = Buffer.from(parsed.data.wrapped_keys, "base64"); if (wrapped.length === 0 || wrapped.length > MAX_WRAPPED_KEYS_BYTES || wrapped.toString("base64") !== parsed.data.wrapped_keys) { throw new ServiceDeviceError(502, "invalid_service_device", "The wrapped keys are malformed."); From f3076cde32f6ed60adf47591877d1b990cf1da85 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 02:01:59 +1100 Subject: [PATCH 08/14] next: escrow service devices enrol a real Noise key (policy voids all-zero noise_pk except for recovery) --- .../src/features/next/service-devices.postgres.test.ts | 4 ++-- services/server/src/features/next/service-devices.test.ts | 8 ++++---- services/server/src/features/next/service-devices.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/services/server/src/features/next/service-devices.postgres.test.ts b/services/server/src/features/next/service-devices.postgres.test.ts index d22403f9..60d4e5a6 100644 --- a/services/server/src/features/next/service-devices.postgres.test.ts +++ b/services/server/src/features/next/service-devices.postgres.test.ts @@ -21,7 +21,7 @@ const uuidBytes = (id: string) => Buffer.from(id.replaceAll("-", ""), "hex"); function record(kind: "hosted" | "escrow", device = randomUUID(), fill = 1) { return parseServiceDevice({ kind, device_id: device, sign_pk: Buffer.alloc(32, fill).toString("hex"), kem_pk: Buffer.alloc(32, fill + 1).toString("hex"), - noise_pk: Buffer.alloc(32, kind === "escrow" ? 0 : fill + 2).toString("hex"), wrapped_keys: Buffer.from(`sealed-${kind}`).toString("base64"), + noise_pk: Buffer.alloc(32, fill + 2).toString("hex"), wrapped_keys: Buffer.from(`sealed-${kind}`).toString("base64"), kms_key_arn: `arn:aws:kms:eu-west-1:000000000000:key/${kind}` }); } @@ -80,7 +80,7 @@ describePostgres("service devices", () => { for (const bad of [ { ...good, sign_pk: good.sign_pk.subarray(0, 31) }, { ...good, kem_pk: Buffer.alloc(32) }, { ...good, wrapped_keys: Buffer.alloc(0) }, { ...good, wrapped_keys: Buffer.alloc(65 * 1024) }, { ...good, kms_key_arn: "nope" }, { ...good, kind: "owner" as "hosted" }, - { ...good, kind: "escrow" as const } + { ...good, noise_pk: Buffer.alloc(32) } ]) await expect(storeServiceDevice(db, fresh, bad)).rejects.toMatchObject({ code: "invalid_service_device" }); const insert = (sign: Buffer, wrapped: Buffer) => db.query( "INSERT INTO next_service_devices(collection_id, kind, device_id, sign_pk, kem_pk, noise_pk, wrapped_keys, kms_key_arn) VALUES($1,'hosted',$2,$3,$4,$4,$5,'arn:x')", diff --git a/services/server/src/features/next/service-devices.test.ts b/services/server/src/features/next/service-devices.test.ts index ee318be4..2c422a3b 100644 --- a/services/server/src/features/next/service-devices.test.ts +++ b/services/server/src/features/next/service-devices.test.ts @@ -18,8 +18,9 @@ describe("service device record", () => { expect(serviceDeviceWire(parseServiceDevice(wire))).toEqual(wire); }); - it("requires escrow's Noise key to be all zero", () => { - expect(parseServiceDevice({ ...wire, kind: "escrow", noise_pk: "00".repeat(32) }).noise_pk).toEqual(Buffer.alloc(32)); + it("refuses an all-zero Noise key for escrow too (policy voids it)", () => { + expect(() => parseServiceDevice({ ...wire, kind: "escrow", noise_pk: "00".repeat(32) })).toThrow(ServiceDeviceError); + expect(parseServiceDevice({ ...wire, kind: "escrow" }).kind).toBe("escrow"); }); it("rejects malformed or oversized fields", () => { @@ -28,8 +29,7 @@ describe("service device record", () => { { ...wire, wrapped_keys: "" }, { ...wire, wrapped_keys: "not base64!" }, { ...wire, wrapped_keys: "QQ" }, { ...wire, wrapped_keys: Buffer.alloc(MAX_WRAPPED_KEYS_BYTES + 1).toString("base64") }, { ...wire, kms_key_arn: "key" }, { ...wire, extra: 1 }, { ...wire, device_id: "nope" }, - { ...wire, sign_pk: "00".repeat(32) }, { ...wire, kem_pk: "01" + "00".repeat(31) }, { ...wire, noise_pk: "00".repeat(32) }, - { ...wire, kind: "escrow" }, { ...wire, device_id: "00000000-0000-0000-0000-000000000000" } + { ...wire, sign_pk: "00".repeat(32) }, { ...wire, kem_pk: "01" + "00".repeat(31) }, { ...wire, noise_pk: "00".repeat(32) }, { ...wire, device_id: "00000000-0000-0000-0000-000000000000" } ]) expect(() => parseServiceDevice(bad), JSON.stringify(bad).slice(0, 80)).toThrow(ServiceDeviceError); }); }); diff --git a/services/server/src/features/next/service-devices.ts b/services/server/src/features/next/service-devices.ts index 46d0113e..50212091 100644 --- a/services/server/src/features/next/service-devices.ts +++ b/services/server/src/features/next/service-devices.ts @@ -57,9 +57,9 @@ export function parseServiceDevice(value: unknown): ServiceDeviceRecord { const sign = Buffer.from(parsed.data.sign_pk, "hex"); const kem = Buffer.from(parsed.data.kem_pk, "hex"); const noise = Buffer.from(parsed.data.noise_pk, "hex"); - // Escrow never holds a Noise session, so its Noise key is all zero; hosted's must be a real key. - const noiseOk = parsed.data.kind === "escrow" ? noise.equals(Buffer.alloc(32)) : !weakAgreementKey(noise); - if (weakSigningKey(sign) || weakAgreementKey(kem) || !noiseOk) { + // Policy (replica policy.rs, policy.md §6.4) voids an all-zero noise_pk for every + // kind but recovery, so escrow too enrols a real X25519 key, which it never serves. + if (weakSigningKey(sign) || weakAgreementKey(kem) || weakAgreementKey(noise)) { throw new ServiceDeviceError(502, "invalid_service_device", "A service device key is weak or misplaced."); } return { From 330c86649449106823f7dffd81be197fdbbe1ae8 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 02:02:43 +1100 Subject: [PATCH 09/14] next: cloud-copy create tests enrol escrow with a real Noise key --- .../src/features/next/cloud-copy-bootstrap.postgres.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts index 02329b59..cb52c897 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -112,7 +112,7 @@ class Deployments { { this.devices.set(key, { kind, device_id: randomUUID(), sign_pk: hex(ed25519RawPublicKey(generateKeyPairSync("ed25519").privateKey)), kem_pk: hex(rawX()), - noise_pk: kind === "escrow" ? "00".repeat(32) : hex(rawX()), wrapped_keys: Buffer.from(`sealed ${key}`).toString("base64"), + noise_pk: hex(rawX()), wrapped_keys: Buffer.from(`sealed ${key}`).toString("base64"), kms_key_arn: `arn:aws:kms:eu-west-1:000000000000:key/${kind}` }); } @@ -194,7 +194,7 @@ describePg("cloud-copy bootstrap", () => { expect(enrols).toEqual([ { device: who.device.replaceAll("-", ""), account: who.connector.user_id.replaceAll("-", ""), kind: 0, sign: hex(who.signPk), noise: expect.any(String) }, { device: hosted.device_id!.replaceAll("-", ""), account: ZERO_ACCOUNT, kind: 4, sign: hosted.sign_pk, noise: hosted.noise_pk }, - { device: escrow.device_id!.replaceAll("-", ""), account: ZERO_ACCOUNT, kind: 5, sign: escrow.sign_pk, noise: "00".repeat(32) } + { device: escrow.device_id!.replaceAll("-", ""), account: ZERO_ACCOUNT, kind: 5, sign: escrow.sign_pk, noise: escrow.noise_pk } ]); const claims = decodeCbor(Buffer.from(result.device.token.split(".")[0], "hex")); expect(field(claims, 0)).toBe(0); From 2e1a32b4fcc163bd39300108a733c87e9f441834 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 02:35:44 +1100 Subject: [PATCH 10/14] next: cloud-copy create transactions are lock-bounded (5 s); contention answers busy Review (control): every transaction sets lock_timeout 5s like C1; a timeout answers 503 busy with no driver detail, and registers nothing. PG regression holds the collection lock from another session. --- .../cloud-copy-bootstrap.postgres.test.ts | 20 +++++++++++++++++++ .../src/features/next/cloud-copy-bootstrap.ts | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts index cb52c897..8c10ac5b 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -298,4 +298,24 @@ describePg("cloud-copy bootstrap", () => { expect(b.statusCode).toBe(403); expect(b.body).not.toContain("token"); }); + + it("answers busy, registering nothing, when another request holds the collection lock", async () => { + const who = await identity(); const collection = randomUUID(); + const holder = await admin.connect(); + try { + await holder.query(`SET search_path = "${schema}"`); + await holder.query("BEGIN"); + await holder.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text, 20261005))", [collection]); + const started = Date.now(); + const response = await create(who, await proof(who, collection)); + expect(response.statusCode).toBe(503); + expect(response.json().error.code).toBe("busy"); + expect(Date.now() - started).toBeLessThan(9_000); + expect(response.body).not.toMatch(/lock timeout|canceling statement/i); + } finally { + await holder.query("ROLLBACK").catch(() => undefined); + holder.release(); + } + expect(await registered(collection)).toBe(false); + }, 20_000); }); diff --git a/services/server/src/features/next/cloud-copy-bootstrap.ts b/services/server/src/features/next/cloud-copy-bootstrap.ts index c5c98b98..22ddc047 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.ts @@ -24,6 +24,9 @@ const NIL = SERVICE_ACCOUNT; interface Body { collection_id: string; device_id: string; challenge: string; sig: string } interface Device { sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer; kind: "desktop" | "cli" } +/** PostgreSQL lock_timeout: another request holds the rows; answer busy, never the driver error. */ +const isLockTimeout = (error: unknown) => (error as { code?: unknown } | null)?.code === "55P03"; + class CreateError extends Error { constructor(readonly status: number, readonly code: string) { super(code); } } @@ -87,6 +90,9 @@ async function inTransaction(db: DatabasePool, run: (client: DatabaseConnecti 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'"); const result = await run(client); await client.query("COMMIT"); return result; @@ -166,6 +172,7 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { const status = error.status === 502 ? 503 : error.status; return reply.code(status).send(apiError(error.code, "The cloud copy was not created; retry with a fresh proof.")); } + if (isLockTimeout(error)) return reply.code(503).send(apiError("busy", "The cloud copy was not created; retry with a fresh proof.")); throw error; } try { From 336b4745d8e78e940bcef7823fd1b359e7d3e8f9 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 07:31:45 +1100 Subject: [PATCH 11/14] next: service-created cloud copy (no owner device) and owner-device join, per Callum's 2026-10-06 decision - POST /v1/next/collections/cloud-copy/service: account session; genesis [genesis cloud-copy, member-set owner, enrol hosted, enrol escrow]; hosted is the first member and keys the collection. Same phases as the owner path (generate with no lock held, recheck account and collection under lock, exact genesis read-back), no token. - POST /v1/next/collections/:id/devices: a device registered under the owner's connector, with a join-domain proof, is enrolled via queueNextPolicy only into a current cloud copy (private, foreign or left collections refuse before any op); idempotent for the same keys; the token is minted after the enrol batch reads back exactly. - The owner-device create path stays. Tests cover genesis ops, retries, session/suspension/foreign refusals, private exclusion (no outbox row), join proof binding and revocation. --- architecture.d/next-cloud-copy-create.json | 4 +- changelog.d/next-cloud-copy-create.md | 17 +- services/server/src/app.ts | 2 +- .../cloud-copy-bootstrap.postgres.test.ts | 137 +++++++- .../src/features/next/cloud-copy-bootstrap.ts | 327 +++++++++++++----- 5 files changed, 390 insertions(+), 97 deletions(-) diff --git a/architecture.d/next-cloud-copy-create.json b/architecture.d/next-cloud-copy-create.json index d46230c5..afef0f24 100644 --- a/architecture.d/next-cloud-copy-create.json +++ b/architecture.d/next-cloud-copy-create.json @@ -1,9 +1,9 @@ { - "reason": "The ordinary owner path to create a cloud-copy collection is one feature module: device-signed proof against the existing challenge table, service-device generation through the C1 client, and genesis through registerNextCollection. It reuses the policy outbox, emitter and log client and adds no transport or credential store; app.ts mounts it only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1.", + "reason": "Cloud-copy collections (Callum's 2026-10-06 decision) are one feature module: service-created (account session, hosted first member), owner-device creation, and owner-device join, all through registerNextCollection/queueNextPolicy, the existing challenge table and the C1 service-device client. Exports are the route registrar and the two device-proof digests. No new transport or credential store; app.ts mounts it only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1.", "growth": { "productionFiles": 1, "relativeImports": 9, - "typeScriptExportDeclarations": 2, + "typeScriptExportDeclarations": 3, "services/server": 1 } } diff --git a/changelog.d/next-cloud-copy-create.md b/changelog.d/next-cloud-copy-create.md index 2e0f8bbb..38bf97f3 100644 --- a/changelog.d/next-cloud-copy-create.md +++ b/changelog.d/next-cloud-copy-create.md @@ -1,8 +1,13 @@ ## Added -- Let an owner's registered device create a cloud-copy collection on the next - control plane (`POST /v1/next/collections/cloud-copy`, signed with a fresh - device challenge). The hosted and escrow deployments generate their own - service devices, and the collection's genesis enrols the owner's device and - both service devices; the owner's desktop then performs the initial rekey. - Off unless `MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1`. +- Create cloud-copy collections on the next control plane, behind + `MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1`: + - for a signed-in account with no device + (`POST /v1/next/collections/cloud-copy/service`), where the hosted replica is the + first member; + - or from an owner's registered device (`POST /v1/next/collections/cloud-copy`). + In both cases the hosted and escrow deployments generate their own service + devices, and the genesis enrols them. +- Enrol an owner's registered device into a cloud copy + (`POST /v1/next/collections/:id/devices`, signed with a fresh device challenge). + Private collections refuse this. diff --git a/services/server/src/app.ts b/services/server/src/app.ts index a6f63ba1..9ae3cb24 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -524,7 +524,7 @@ export async function buildApp(options: BuildOptions) { registerNextDeviceRoutes(app, { db: options.db, log: nextLog }); 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 }); + if (options.nextControlPlane.cloudCopyBootstrap) registerCloudCopyRoutes(app, { db: options.db, next: options.nextControlPlane, emitter: nextPolicyEmitter!, log: nextLog, tailscaleAuth: options.tailscaleAuth }); registerPolicyRecoveryRoutes(app, options.db, nextPolicyEmitter!); registerNextRouteRoutes(app, { db: options.db, publicUrl, broker: relayBroker }); if (options.nextControlPlane.labFixtures) registerLabFixtureRoutes(app, { diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts index 8c10ac5b..dd672dd7 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -1,10 +1,11 @@ import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import cookie from "@fastify/cookie"; import Fastify from "fastify"; import pg from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createDatabase, type DatabasePool } from "../../db.js"; import { tokenHash } from "../../security.js"; -import { cloudCopyCreateDigest, registerCloudCopyRoutes } from "./cloud-copy-bootstrap.js"; +import { cloudCopyCreateDigest, cloudCopyJoinDigest, registerCloudCopyRoutes } from "./cloud-copy-bootstrap.js"; import { deviceRegistrationDigest, issueDeviceChallenge, registerDevice } from "./devices.js"; import { collectionDirectory } from "./hosted-routes.js"; import { LogServiceClient } from "./log-service-client.js"; @@ -61,10 +62,10 @@ function configuration(): NextControlPlaneConfig { }; } -/** The log service, holding genesis only. */ +/** The log service: control items per collection, with create, append, head and read. */ class Log { - readonly logs = new Map(); - /** Runs once, while the route awaits the log's read-back of genesis. */ + readonly logs = new Map(); + /** Runs once, while the route awaits a read-back. */ onRead: (() => Promise) | undefined; readonly fetch: typeof fetch = async (input, init) => { if (String(input).endsWith("/v1/nonce")) return new Response("ab".repeat(32)); @@ -72,19 +73,32 @@ class Log { const method = field(frame, 2); const params = field(frame, 3)!; const id = hex(field(params, 0) as Uint8Array); + const items = this.logs.get(id); + const chain = () => chainHash(items![items!.length - 1]!); let result: Cbor; if (method === "create_log") { - this.logs.set(id, Buffer.from(field(params, 1) as Uint8Array)); - result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)!)]] }; + if (!items) this.logs.set(id, [Buffer.from(field(params, 1) as Uint8Array)]); + result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)![0]!)]] }; } else if (method === "head") { - result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)!)], [2, 1]] }; + result = { struct: [[0, items!.length], [1, chain()], [2, 1]] }; + } else if (method === "append") { + const expect = field(params, 1) as number; + const prev = Buffer.from(field(params, 2) as Uint8Array); + if (expect !== items!.length + 1 || !prev.equals(Buffer.from(chain()))) { + result = { struct: [[0, 1], [1, items!.length], [2, chain()]] }; + } else { + const added = (field(params, 3) as Uint8Array[]).map((b) => Buffer.from(b)); + items!.push(...added); + result = { struct: [[0, 0], [1, expect], [2, items!.length]] }; + } } else if (method === "read") { const hook = this.onRead; this.onRead = undefined; await hook?.(); - const genesis = this.logs.get(id); - if (!genesis) return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [3, { struct: [[0, "not_found"]] }]] })); - result = { struct: [[0, [[1, genesis]]]] }; + if (!items) return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [3, { struct: [[0, "not_found"]] }]] })); + const after = field(params, 1) as number; + const limit = field(params, 2) as number; + result = { struct: [[0, items.slice(after, after + limit).map((item, i) => [after + i + 1, item])]] }; } else throw new Error("unexpected log operation"); return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [2, result]] }), { headers: { "content-type": "application/vnd.mdbase.v1+cbor" } }); }; @@ -140,6 +154,7 @@ describePg("cloud-copy bootstrap", () => { url.searchParams.set("options", `-csearch_path=${schema}`); db = await createDatabase(url.toString()); const emitter = new PolicyEmitter(db, client, loadPolicySigner(config, Date.now())); + await app.register(cookie); registerCloudCopyRoutes(app, { db, next: config, emitter, log: client, fetchImpl: deployments.fetch }); }, 60_000); afterAll(async () => { @@ -318,4 +333,106 @@ describePg("cloud-copy bootstrap", () => { } expect(await registered(collection)).toBe(false); }, 20_000); + + // ---- Service-created cloud copy and device join (Callum, 2026-10-06) ---- + + async function session(user: string) { + const token = randomUUID(); + await db.query( + `INSERT INTO sessions (id, user_id, token_hash, provider, account_session_epoch, expires_at) + VALUES ($1, $2, $3, 'password', COALESCE((SELECT session_epoch FROM users WHERE id = $2), 1), now() + interval '1 day')`, + [randomUUID(), user, tokenHash(token)] + ); + await db.query("UPDATE users SET session_epoch = COALESCE(session_epoch, 1) WHERE id = $1", [user]); + return { cookie: `mdbase_session=${token}` }; + } + const serviceCreate = (headers: Record, collection: string) => + app.inject({ method: "POST", url: "/v1/next/collections/cloud-copy/service", headers, payload: { collection_id: collection } }); + async function joinProof(who: Who, collection: string) { + const { challenge } = await issueDeviceChallenge(db, who.connector.id); + const digest = cloudCopyJoinDigest({ challenge: Buffer.from(challenge, "hex"), connector: who.connector.id, device: who.device, collection }); + return { device_id: who.device, challenge, sig: hex(sign(null, digest, who.key)) }; + } + const join = (who: Who, collection: string, payload: unknown) => + app.inject({ method: "POST", url: `/v1/next/collections/${collection}/devices`, headers: who.headers, payload }); + const genesisOps = (item: string) => field(decodeCbor(field(decodeCbor(Buffer.from(item, "hex")), 11) as Uint8Array), 3) as Decoded[]; + + it("service-creates a cloud copy for an account with no device: genesis enrols hosted and escrow only", async () => { + const who = await identity(); const collection = randomUUID(); + const headers = await session(who.connector.user_id); + const response = await serviceCreate(headers, collection); + expect(response.statusCode, response.body).toBe(200); + const result = response.json(); + expect(result).toMatchObject({ collection_id: collection, state: "cloud-copy", owner_account: who.connector.user_id, first_member: "hosted" }); + expect(result.device).toBeUndefined(); + const ops = genesisOps(result.genesis.item); + expect(ops.map((op) => field(op, 0))).toEqual([1, 4, 2, 2]); + expect(field(ops[0]!, 3)).toBe(1); + expect(ops.slice(2).map((op) => [hex(field(op, 2) as Uint8Array), field(op, 3)])).toEqual([[ZERO_ACCOUNT, 4], [ZERO_ACCOUNT, 5]]); + expect(response.body).not.toContain(deployments.devices.get(`hosted/${collection}`)!.wrapped_keys); + const calls = deployments.calls; + expect((await serviceCreate(headers, collection)).statusCode).toBe(200); + expect(deployments.calls).toBe(calls); + }); + + it("refuses service-creation without a session, for a suspended account, or over someone else's collection", async () => { + const who = await identity(); const other = await identity(); + expect((await serviceCreate({}, randomUUID())).statusCode).toBe(401); + const taken = randomUUID(); + expect((await serviceCreate(await session(other.connector.user_id), taken)).statusCode).toBe(200); + expect((await serviceCreate(await session(who.connector.user_id), taken)).statusCode).toBe(409); + const headers = await session(who.connector.user_id); + const collection = randomUUID(); + deployments.during = async () => { await db.query("UPDATE users SET suspended_at = now() WHERE id = $1", [who.connector.user_id]); }; + expect((await serviceCreate(headers, collection)).statusCode).toBe(403); + expect(await registered(collection)).toBe(false); + }); + + it("enrols the owner's registered device into a cloud copy and mints its token", async () => { + const who = await identity(); const collection = randomUUID(); + expect((await serviceCreate(await session(who.connector.user_id), collection)).statusCode).toBe(200); + const response = await join(who, collection, await joinProof(who, collection)); + expect(response.statusCode, response.body).toBe(200); + const result = response.json(); + expect(result.enrolled_at).toBe(2); + const claims = decodeCbor(Buffer.from(result.device.token.split(".")[0], "hex")); + expect(hex(field(claims, 1) as Uint8Array)).toBe(who.device.replaceAll("-", "")); + expect(hex(field(claims, 5) as Uint8Array)).toBe(collection.replaceAll("-", "")); + const item = await client.controlItemAt(collection, 2); + const ops = field(decodeCbor(field(decodeCbor(item!), 11) as Uint8Array), 3) as Decoded[]; + expect(ops.map((op) => [field(op, 0), hex(field(op, 1) as Uint8Array), hex(field(op, 2) as Uint8Array), field(op, 3)])) + .toEqual([[2, who.device.replaceAll("-", ""), who.connector.user_id.replaceAll("-", ""), 0]]); + // Idempotent for the same device and keys: no second enrolment. + const again = await join(who, collection, await joinProof(who, collection)); + expect(again.statusCode, again.body).toBe(200); + expect(again.json().enrolled_at).toBe(2); + }); + + it("never enrols a device into a private collection, someone else's, or one that left sync", async () => { + const who = await identity(); + const priv = randomUUID(); + await db.query("INSERT INTO next_collections(collection_id, owner_user_id, runtime, sync, root_key_id) VALUES($1,$2,'next','private',$3)", [priv, who.connector.user_id, Buffer.from(config.policyCert.root_key_id, "hex")]); + expect((await join(who, priv, await joinProof(who, priv))).statusCode).toBe(409); + expect((await db.query("SELECT 1 FROM next_policy_outbox WHERE collection_id = $1", [priv])).rows).toHaveLength(0); + const theirs = randomUUID(); const owner = await identity(); + expect((await serviceCreate(await session(owner.connector.user_id), theirs)).statusCode).toBe(200); + expect((await join(who, theirs, await joinProof(who, theirs))).statusCode).toBe(409); + const left = randomUUID(); + expect((await serviceCreate(await session(who.connector.user_id), left)).statusCode).toBe(200); + await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [left]); + expect((await join(who, left, await joinProof(who, left))).statusCode).toBe(409); + }); + + it("needs a fresh join proof bound to the collection, and mints nothing once revoked", async () => { + const who = await identity(); const collection = randomUUID(); const other = randomUUID(); + expect((await serviceCreate(await session(who.connector.user_id), collection)).statusCode).toBe(200); + const proofFor = await joinProof(who, other); + expect((await join(who, collection, proofFor)).statusCode).toBe(403); + const createProof = await proof(who, collection); + expect((await join(who, collection, { device_id: createProof.device_id, challenge: createProof.challenge, sig: createProof.sig })).statusCode).toBe(403); + log.onRead = async () => { await db.query("UPDATE connectors SET revoked_at = now() WHERE id = $1", [who.connector.id]); }; + const revoked = await join(who, collection, await joinProof(who, collection)); + expect(revoked.statusCode).toBe(403); + expect(revoked.body).not.toContain("token"); + }); }); diff --git a/services/server/src/features/next/cloud-copy-bootstrap.ts b/services/server/src/features/next/cloud-copy-bootstrap.ts index 22ddc047..9b241526 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.ts @@ -1,29 +1,40 @@ -// An owner creates a cloud-copy collection (coordinator decision on sealed-envelope §7.1): -// the control plane asks the hosted and escrow deployments to generate their service -// devices, then registers a cloud-copy genesis that enrols the owner's device and both -// service devices. The owner's desktop then appends the initial rekey itself, with wraps -// for desktop + hosted + escrow. The control plane never holds a collection key, and -// there is no escrow-to-hosted wrap. Mounted only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1. +// Cloud-copy collections on the next control plane (Callum's decision of 2026-10-06, +// which replaces the owner-only §7.1 keying for CLOUD COPY only; private collections +// are unchanged and never get here). Mounted only with MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1. // -// A collection is created as a cloud copy here; converting an existing private -// collection is not supported. +// - Service-created (`POST /v1/next/collections/cloud-copy/service`): an account with +// no user device gets a cloud copy. Genesis enrols the hosted and escrow service +// devices only; hosted, the first member, generates the epoch key and wraps it for +// hosted and escrow. +// - Owner-device (`POST /v1/next/collections/cloud-copy`): an owner's registered device +// creates it; genesis also enrols that device, whose initial rekey keys everyone. +// - Device join (`POST /v1/next/collections/:id/devices`): a device registered under the +// owner's authenticated connector is enrolled (control-signed); hosted, or escrow, +// then wraps the current epoch key to it. Refused for anything but a current cloud +// copy, so a private collection never enrols a device this way. +// +// The control plane never holds a collection key. Each deployment generates its own +// service device; the first committed record wins, and nothing is ever deleted on a +// failure path. import { verify } from "node:crypto"; -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply } from "fastify"; import type { DatabaseConnection, DatabasePool } from "../../database-types.js"; import { apiError } from "../../platform/http-errors.js"; -import { requireConnector } from "../../platform/request-authentication.js"; +import { requireConnector, requireUser } from "../../platform/request-authentication.js"; import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; import { ed25519PublicKeyObject, type NextControlPlaneConfig } from "./policy-keys.js"; -import { registerNextCollection, type PolicyEmitter } from "./policy-outbox.js"; -import { domainHash, encodeCbor, uuidBytes } from "./policy-wire.js"; +import { queueNextPolicy, registerNextCollection, type PolicyEmitter } from "./policy-outbox.js"; +import { domainHash, encodeCbor, uuidBytes, type PolicyOp } from "./policy-wire.js"; import { generateServiceDevice, loadServiceDevice, ServiceDeviceError, storeServiceDevice, type ServiceDeviceRecord } from "./service-devices.js"; /** Service devices belong to no account (policy.md: hosted and escrow enrol with the zero account). */ const SERVICE_ACCOUNT = "00000000-0000-0000-0000-000000000000"; const NIL = SERVICE_ACCOUNT; +const KINDS = ["hosted", "escrow"] as const; -interface Body { collection_id: string; device_id: string; challenge: string; sig: string } +interface Proof { device_id: string; challenge: string; sig: string } interface Device { sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer; kind: "desktop" | "cli" } +type Connector = { id: string; user_id: string }; /** PostgreSQL lock_timeout: another request holds the rows; answer busy, never the driver error. */ const isLockTimeout = (error: unknown) => (error as { code?: unknown } | null)?.code === "55P03"; @@ -36,6 +47,11 @@ export function cloudCopyCreateDigest(input: { challenge: Uint8Array; connector: return domainHash("mdbase/v1/cloud-copy-create", encodeCbor([input.challenge, uuidBytes(input.connector), uuidBytes(input.device), uuidBytes(input.collection)])); } +/** `H("mdbase/v1/cloud-copy-join", cbor[challenge, connector, device, collection])`, signed by the joining device. */ +export function cloudCopyJoinDigest(input: { challenge: Uint8Array; connector: string; device: string; collection: string }): Uint8Array { + return domainHash("mdbase/v1/cloud-copy-join", encodeCbor([input.challenge, uuidBytes(input.connector), uuidBytes(input.device), uuidBytes(input.collection)])); +} + const lock = (client: DatabaseConnection, collection: string) => client.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text, 20261005))", [collection]); @@ -53,14 +69,23 @@ async function existing(client: DatabaseConnection, collection: string, owner: s return Boolean(row); } -async function authenticate(client: DatabaseConnection, body: Body, connector: { id: string; user_id: string }): Promise { +/** The collection is still `owner`'s current cloud copy; share-locked until the transaction ends. */ +async function currentCloudCopy(client: DatabaseConnection, collection: string, owner: string): Promise { + const current = await client.query( + `SELECT 1 FROM next_collections WHERE collection_id = $1 AND owner_user_id = $2 AND sync = 'cloud_copy' AND left_sync_at IS NULL FOR SHARE`, + [collection, owner] + ); + if (!current.rows.length) throw new CreateError(409, "not_current_cloud_copy"); +} + +/** Verify a device's signature over `digest` and consume its challenge. */ +async function authenticate(client: DatabaseConnection, body: Proof, connector: Connector, digest: (challenge: Uint8Array) => Uint8Array): Promise { const device = (await client.query( "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"); - const digest = cloudCopyCreateDigest({ challenge, connector: connector.id, device: body.device_id, collection: body.collection_id }); - if (!device || !verify(null, digest, ed25519PublicKeyObject(device.sign_pk), Buffer.from(body.sig, "hex"))) throw new CreateError(403, "invalid_proof"); + 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] @@ -70,11 +95,11 @@ async function authenticate(client: DatabaseConnection, body: Body, connector: { } /** - * The owner's connector, account and device are still current, with the exact keys + * 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. */ -async function currentIdentity(client: DatabaseConnection, connector: { id: string; user_id: string }, deviceId: string, device: Device): Promise { +async function currentIdentity(client: DatabaseConnection, connector: Connector, deviceId: string, device: Device): Promise { 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 @@ -86,6 +111,12 @@ async function currentIdentity(client: DatabaseConnection, connector: { id: stri if (!row.rows.length) throw new CreateError(403, "identity_not_current"); } +/** The account is still active; share-locked until the transaction ends. */ +async function currentAccount(client: DatabaseConnection, user: string): Promise { + 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"); +} + async function inTransaction(db: DatabasePool, run: (client: DatabaseConnection) => Promise): Promise { const client = await db.connect(); try { @@ -109,20 +140,139 @@ const publicRecord = (record: ServiceDeviceRecord) => ({ sign_pk: record.sign_pk.toString("hex"), kem_pk: record.kem_pk.toString("hex"), noise_pk: record.noise_pk.toString("hex") }); +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). */ +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`; +const enrolmentKey = (device: string) => JSON.stringify([{ op: "device-enrol", device }]); +const exactEnrolment = (device: string, account: string, signPk: Buffer) => + JSON.stringify([{ op: "device-enrol", device, account, signPublicKey: { $hex: signPk.toString("hex") } }]); + +function refuse(reply: FastifyReply, error: unknown, message: string) { + if (error instanceof CreateError || error instanceof ServiceDeviceError) { + 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; +} + export function registerCloudCopyRoutes(app: FastifyInstance, options: { db: DatabasePool; next: NextControlPlaneConfig; emitter: PolicyEmitter; log: Pick; fetchImpl?: typeof fetch; now?: () => number; + tailscaleAuth?: boolean; }): void { const deployments = options.next.cloudCopyBootstrap; if (!deployments) throw new Error("cloud-copy routes need MDBASE_NEXT_CLOUD_COPY_BOOTSTRAP=1"); const rootKeyId = Buffer.from(options.next.policyCert.root_key_id, "hex"); const uuid = { type: "string", pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }; - app.post<{ Body: Body }>("/v1/next/collections/cloud-copy", { - bodyLimit: 4096, - config: { rateLimit: { max: 6, timeWindow: "1 minute" } }, + const proof = { device_id: uuid, challenge: { type: "string", pattern: "^[0-9a-f]{64}$" }, sig: { type: "string", pattern: "^[0-9a-f]{128}$" } }; + const limited = { bodyLimit: 4096, config: { rateLimit: { max: 6, timeWindow: "1 minute" } } }; + /** Each deployment generates its own keys. Nothing is locked while they work. */ + const generateFor = (collection: string) => + Promise.all(KINDS.map((kind) => generateServiceDevice(deployments[kind], kind, collection, options.fetchImpl))); + + /** The exact bytes of a policy batch, as the log returns them at its position. */ + async function appendedBatch(collection: string, batch: { seq: string | number | null; item: Buffer | null; state: string | null } | undefined) { + const seq = batch?.seq === null || batch?.seq === undefined ? null : Number(batch.seq); + const external = seq !== null && batch?.state === "appended" ? await options.log.controlItemAt(collection, seq) : null; + if (seq === null || !batch?.item || !external || !batch.item.equals(Buffer.from(external))) throw new CreateError(503, "not_ready"); + return { seq, item: batch.item }; + } + + async function appendedGenesis(collection: string) { + await options.emitter.drainCollection(collection); + const genesis = (await options.db.query<{ seq: string; item: Buffer; state: string }>( + "SELECT seq, item, state FROM next_policy_batches WHERE collection_id = $1 AND seq = 1 ORDER BY id LIMIT 1", [collection] + )).rows[0]; + return appendedBatch(collection, genesis); + } + + async function loadRecords(client: DatabaseConnection, collection: string): Promise { + const records: ServiceDeviceRecord[] = []; + for (const kind of KINDS) { + const record = await loadServiceDevice(client, collection, { kind }); + if (!record) throw new CreateError(503, "not_ready"); + records.push(record); + } + return records; + } + + const created = (collection: string, owner: string, head: { seq: number; chain: Uint8Array }, genesis: Buffer, records: ServiceDeviceRecord[]) => ({ + collection_id: collection, state: "cloud-copy", owner_account: owner, + log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, + root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, + genesis: { seq: 1, item: genesis.toString("hex") }, + service_devices: records.map(publicRecord) + }); + + const mint = (device: string, signPk: Buffer, collection: string) => { + const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + return { device_id: device, token: options.log.mintToken({ device, signPublicKey: signPk, collection, expiresAt }), expires_at: expiresAt }; + }; + + // ---- Service-created: the account, no device. ---- + app.post<{ Body: { collection_id: string } }>("/v1/next/collections/cloud-copy/service", { + ...limited, + schema: { body: { type: "object", additionalProperties: false, required: ["collection_id"], properties: { collection_id: uuid } } } + }, async (request, reply) => { + reply.header("cache-control", "no-store"); + const user = await requireUser(request, reply, options.db, options.tailscaleAuth); + if (!user) return reply; + const collection = request.body.collection_id.toLowerCase(); + if (collection === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); + try { + const exists = await inTransaction(options.db, async (client) => { + await lock(client, collection); + await currentAccount(client, user.id); + return existing(client, collection, user.id); + }); + if (!exists) { + const generated = await generateFor(collection); + await inTransaction(options.db, async (client) => { + await lock(client, collection); + await currentAccount(client, user.id); + if (await existing(client, collection, user.id)) throw new CreateError(503, "not_ready"); + await registerNextCollection(client, { + collectionId: collection, ownerUserId: user.id, runtime: "next", sync: "cloud_copy", rootKeyId, + ops: [ + { op: "genesis", owner: user.id, root: rootKeyId, state: "cloud-copy" }, + { op: "member-set", account: user.id, role: "owner" }, + ...generated.map((record) => enrolOp(record.device_id, SERVICE_ACCOUNT, record)) + ] + }); + for (const record of generated) await storeServiceDevice(client, collection, record); + }); + } + } catch (error) { + return refuse(reply, error, "The cloud copy was not created; retry."); + } + try { + const genesis = await appendedGenesis(collection); + const head = await options.log.head(collection); + return await inTransaction(options.db, async (client) => { + await currentAccount(client, user.id); + await currentCloudCopy(client, collection, user.id); + // Hosted is the first member: it generates the epoch key and wraps it for + // hosted and escrow. No user device is enrolled here. + return { ...created(collection, user.id, head, genesis.item, await loadRecords(client, collection)), first_member: "hosted" }; + }); + } catch (error) { + if (error instanceof CreateError && error.status !== 503) return refuse(reply, error, "The cloud copy is not current for this account."); + return reply.code(503).send(apiError("not_ready", "The cloud copy outcome is not verified; retry.")); + } + }); + + // ---- Owner-device: a registered device of the owner creates it. ---- + app.post<{ Body: Proof & { collection_id: string } }>("/v1/next/collections/cloud-copy", { + ...limited, schema: { body: { type: "object", additionalProperties: false, required: ["collection_id", "device_id", "challenge", "sig"], - properties: { collection_id: uuid, device_id: uuid, challenge: { type: "string", pattern: "^[0-9a-f]{64}$" }, sig: { type: "string", pattern: "^[0-9a-f]{128}$" } } + properties: { collection_id: uuid, ...proof } } } }, async (request, reply) => { reply.header("cache-control", "no-store"); @@ -131,22 +281,20 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { const body = { ...request.body, collection_id: request.body.collection_id.toLowerCase(), device_id: request.body.device_id.toLowerCase() }; const collection = body.collection_id; if (collection === NIL || body.device_id === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); + const digest = (challenge: Uint8Array) => cloudCopyCreateDigest({ challenge, connector: connector.id, device: body.device_id, collection }); let device: Device; try { // 1. Proof and ownership, consuming the challenge. No network call holds a lock. - let created: boolean; - ({ device, created } = await inTransaction(options.db, async (client) => { + let exists: boolean; + ({ device, exists } = await inTransaction(options.db, async (client) => { await lock(client, collection); - const owner = await authenticate(client, body, connector); - return { device: owner, created: await existing(client, collection, connector.user_id) }; + const owner = await authenticate(client, body, connector, digest); + return { device: owner, exists: await existing(client, collection, connector.user_id) }; })); - if (!created) { - // 2. Each deployment generates its own keys. Nothing is locked while they work. - const generated = await Promise.all((["hosted", "escrow"] as const).map((kind) => - generateServiceDevice(deployments[kind], kind, collection, options.fetchImpl))); - // 3. Recheck the owner's identity (revocation, suspension, device removal or - // key change may have happened meanwhile) and the collection, then register - // genesis and store the records together. The first committed record wins. + if (!exists) { + const generated = await generateFor(collection); + // 2. Recheck the identity and the collection, then register genesis and store + // the records together. The first committed record wins. await inTransaction(options.db, async (client) => { await lock(client, collection); await currentIdentity(client, connector, body.device_id, device); @@ -157,74 +305,97 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { ops: [ { op: "genesis", owner: connector.user_id, root: rootKeyId, state: "cloud-copy" }, { op: "member-set", account: connector.user_id, role: "owner" }, - { op: "device-enrol", device: body.device_id, account: connector.user_id, kind: device.kind, signPublicKey: device.sign_pk, kemPublicKey: device.kem_pk, noisePublicKey: device.noise_pk }, - ...generated.map((record) => ({ - op: "device-enrol" as const, device: record.device_id, account: SERVICE_ACCOUNT, kind: record.kind, - signPublicKey: record.sign_pk, kemPublicKey: record.kem_pk, noisePublicKey: record.noise_pk - })) + enrolOp(body.device_id, connector.user_id, device), + ...generated.map((record) => enrolOp(record.device_id, SERVICE_ACCOUNT, record)) ] }); for (const record of generated) await storeServiceDevice(client, collection, record); }); } } catch (error) { - if (error instanceof CreateError || error instanceof ServiceDeviceError) { - const status = error.status === 502 ? 503 : error.status; - return reply.code(status).send(apiError(error.code, "The cloud copy was not created; retry with a fresh proof.")); - } - if (isLockTimeout(error)) return reply.code(503).send(apiError("busy", "The cloud copy was not created; retry with a fresh proof.")); - throw error; + return refuse(reply, error, "The cloud copy was not created; retry with a fresh proof."); } try { - // 4. Only an appended genesis whose exact bytes the log returns counts as created. - await options.emitter.drainCollection(collection); - const genesis = (await options.db.query<{ item: Buffer; state: string }>( - "SELECT item, state FROM next_policy_batches WHERE collection_id = $1 AND seq = 1 ORDER BY id LIMIT 1", [collection] - )).rows[0]; - const external = genesis?.state === "appended" ? await options.log.controlItemAt(collection, 1) : null; - if (!genesis || !external || !genesis.item.equals(Buffer.from(external))) throw new CreateError(503, "not_ready"); + // 3. Only an appended genesis whose exact bytes the log returns counts as created. + const genesis = await appendedGenesis(collection); const head = await options.log.head(collection); - // 5. After every await: the identity, the collection and the enrolment are + // 4. After every await: the identity, the collection and the enrolment are // current, and stay locked until the token is minted. - const answer = await inTransaction(options.db, async (client) => { + return await inTransaction(options.db, async (client) => { await currentIdentity(client, connector, body.device_id, device); - const current = await client.query( - `SELECT 1 FROM next_collections WHERE collection_id = $1 AND owner_user_id = $2 AND sync = 'cloud_copy' AND left_sync_at IS NULL FOR SHARE`, - [collection, connector.user_id] - ); - if (!current.rows.length) throw new CreateError(409, "collection_exists"); + await currentCloudCopy(client, collection, connector.user_id); // The requesting device must be the one the genesis enrolled, with the same keys. const enrolled = await client.query( `SELECT 1 FROM next_policy_outbox WHERE id = (SELECT min(id) FROM next_policy_outbox WHERE collection_id = $1) AND ops->'ops' @> $2::jsonb`, - [collection, JSON.stringify([{ op: "device-enrol", device: body.device_id, account: connector.user_id, signPublicKey: { $hex: device.sign_pk.toString("hex") } }])] + [collection, exactEnrolment(body.device_id, connector.user_id, device.sign_pk)] ); if (!enrolled.rows.length) throw new CreateError(409, "collection_exists"); - const records: ServiceDeviceRecord[] = []; - for (const kind of ["hosted", "escrow"] as const) { - const record = await loadServiceDevice(client, collection, { kind }); - if (!record) throw new CreateError(503, "not_ready"); - records.push(record); - } - const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + const records = await loadRecords(client, collection); return { - collection_id: collection, state: "cloud-copy", owner_account: connector.user_id, - log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, - root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, - genesis: { seq: 1, item: genesis.item.toString("hex") }, - // Public identities enrolled by the genesis. Keying them is the owner - // desktop's signed initial rekey; this answer is identity provisioning only. + ...created(collection, connector.user_id, head, genesis.item, records), + // Public identities enrolled by the genesis; advisory. Consumers compute the + // legal recipient set from the verified log. rekey_recipients: [body.device_id, ...records.map((record) => record.device_id)], - service_devices: records.map(publicRecord), - device: { device_id: body.device_id, token: options.log.mintToken({ device: body.device_id, signPublicKey: device.sign_pk, collection, expiresAt }), expires_at: expiresAt } + device: mint(body.device_id, device.sign_pk, collection) }; }); - return answer; } catch (error) { - if (error instanceof CreateError && error.status !== 503) { - return reply.code(error.status).send(apiError(error.code, "The cloud copy is not current for this device.")); - } + if (error instanceof CreateError && error.status !== 503) return refuse(reply, error, "The cloud copy is not current for this device."); return reply.code(503).send(apiError("not_ready", "The cloud copy outcome is not verified; retry with a fresh proof.")); } }); + + // ---- Device join: the owner's registered device, approved by the authenticated account. ---- + app.post<{ Params: { id: string }; Body: Proof }>("/v1/next/collections/:id/devices", { + ...limited, + schema: { + params: { type: "object", required: ["id"], properties: { id: uuid } }, + body: { type: "object", additionalProperties: false, required: ["device_id", "challenge", "sig"], properties: proof } + } + }, async (request, reply) => { + reply.header("cache-control", "no-store"); + const connector = await requireConnector(request, reply, options.db); + if (!connector) return reply; + const collection = request.params.id.toLowerCase(); + const body = { ...request.body, device_id: request.body.device_id.toLowerCase() }; + if (collection === NIL || body.device_id === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); + const digest = (challenge: Uint8Array) => cloudCopyJoinDigest({ challenge, connector: connector.id, device: body.device_id, collection }); + let device: Device; + try { + device = await inTransaction(options.db, async (client) => { + await lock(client, collection); + const joining = await authenticate(client, body, connector, digest); + // Cloud copy only: a private collection, or one that has left sync, refuses + // before any policy op exists. + await currentCloudCopy(client, collection, connector.user_id); + const prior = (await client.query<{ ops: { ops: Array> } }>(ENROLMENT, [collection, enrolmentKey(body.device_id)])).rows[0]; + if (prior) { + const same = (await client.query(ENROLMENT, [collection, exactEnrolment(body.device_id, connector.user_id, joining.sign_pk)])).rows.length > 0; + if (!same) throw new CreateError(409, "device_enrolled_differently"); + } else if (!(await queueNextPolicy(client, collection, [enrolOp(body.device_id, connector.user_id, joining)]))) { + throw new CreateError(409, "not_current_cloud_copy"); + } + return joining; + }); + } catch (error) { + return refuse(reply, error, "The device was not enrolled; retry with a fresh proof."); + } + try { + await options.emitter.drainCollection(collection); + const row = (await options.db.query<{ seq: string | null; item: Buffer | null; state: string | null }>( + ENROLMENT, [collection, exactEnrolment(body.device_id, connector.user_id, device.sign_pk)] + )).rows[0]; + const batch = await appendedBatch(collection, row); + return await inTransaction(options.db, async (client) => { + await currentIdentity(client, connector, body.device_id, device); + await currentCloudCopy(client, collection, connector.user_id); + // Hosted (or escrow) wraps the current epoch key to this device next. + return { collection_id: collection, enrolled_at: batch.seq, device: mint(body.device_id, device.sign_pk, collection) }; + }); + } catch (error) { + if (error instanceof CreateError && error.status !== 503) return refuse(reply, error, "The cloud copy is not current for this device."); + return reply.code(503).send(apiError("not_ready", "The enrolment is not verified; retry with a fresh proof.")); + } + }); } From 0e523eb94674f3e6c6d922c5a553c0b4355e1c70 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 07:51:31 +1100 Subject: [PATCH 12/14] next: device join locks the current identity before queueing; session rechecked after awaits; full enrolment tuple; revoked devices never revive Review (control, security-2) on 336b4745: - join: currentIdentity (connector/account/device, exact keys) under FOR SHARE before the enrolment is queued and through its commit; PG barrier: a revocation racing the request wins and nothing is queued. - service-created: the session itself (not revoked/expired, same session epoch, account active) is rechecked under lock after every await (Tailscale identities: the account). - idempotent re-join and the owner-path check compare the whole immutable tuple (device, account, kind, sign/KEM/Noise keys). - a device with a later device-revoke is never accepted again. --- .../cloud-copy-bootstrap.postgres.test.ts | 64 ++++++++++++++++- .../src/features/next/cloud-copy-bootstrap.ts | 71 ++++++++++++++++--- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts index dd672dd7..e19fdf00 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.postgres.test.ts @@ -10,7 +10,7 @@ import { deviceRegistrationDigest, issueDeviceChallenge, registerDevice } from " import { collectionDirectory } from "./hosted-routes.js"; import { LogServiceClient } from "./log-service-client.js"; import { certToJson, ed25519RawPublicKey, loadPolicySigner, parseNextControlPlaneEnv, type NextControlPlaneConfig } from "./policy-keys.js"; -import { PolicyEmitter } from "./policy-outbox.js"; +import { PolicyEmitter, queueNextPolicy } from "./policy-outbox.js"; import { certDigest, chainHash, decodeCbor, encodeCbor, keyId, type Cbor, type Decoded } from "./policy-wire.js"; const testUrl = process.env.MDBASE_CONNECT_TEST_DATABASE_URL; @@ -435,4 +435,66 @@ describePg("cloud-copy bootstrap", () => { expect(revoked.statusCode).toBe(403); expect(revoked.body).not.toContain("token"); }); + + // ---- Review fixes (control, security-2) ---- + + it("locks the joining identity before the enrolment is queued: a racing revocation wins", async () => { + const who = await identity(); const collection = randomUUID(); + expect((await serviceCreate(await session(who.connector.user_id), collection)).statusCode).toBe(200); + const payload = await joinProof(who, collection); + const revoking = await admin.connect(); + try { + await revoking.query(`SET search_path = "${schema}"`); + await revoking.query("BEGIN"); + await revoking.query("UPDATE connectors SET revoked_at = now() WHERE id = $1", [who.connector.id]); + // The request authenticates against the committed (unrevoked) connector, then + // waits on the row lock inside its enrolment transaction. + const pending = join(who, collection, payload); + await new Promise((resolve) => setTimeout(resolve, 300)); + await revoking.query("COMMIT"); + expect((await pending).statusCode).toBe(403); + } finally { + revoking.release(); + } + const enrolled = await db.query("SELECT 1 FROM next_policy_outbox WHERE collection_id = $1 AND ops->'ops' @> $2::jsonb", + [collection, JSON.stringify([{ op: "device-enrol", device: who.device }])]); + expect(enrolled.rows).toHaveLength(0); + }); + + it("compares the whole enrolment tuple and never revives a revoked device", async () => { + const who = await identity(); const collection = randomUUID(); + expect((await serviceCreate(await session(who.connector.user_id), collection)).statusCode).toBe(200); + expect((await join(who, collection, await joinProof(who, collection))).statusCode).toBe(200); + // Same device and signing key, another KEM key: not the enrolment on record. + await db.query("UPDATE next_devices SET kem_pk = $2 WHERE id = $1", [who.device, rawX()]); + expect((await join(who, collection, await joinProof(who, collection))).statusCode).toBe(409); + const other = await identity(who.connector.user_id); const c2 = randomUUID(); + expect((await serviceCreate(await session(who.connector.user_id), c2)).statusCode).toBe(200); + expect((await join(other, c2, await joinProof(other, c2))).statusCode).toBe(200); + const client = await db.connect(); + try { + await client.query("BEGIN"); + await queueNextPolicy(client, c2, [{ op: "device-revoke", device: other.device }]); + await client.query("COMMIT"); + } finally { + client.release(); + } + const again = await join(other, c2, await joinProof(other, c2)); + expect(again.statusCode).toBe(409); + expect(again.json().error.code).toBe("device_revoked"); + }); + + it("rechecks the session after generation: a sign-out or session-epoch bump refuses", async () => { + const who = await identity(); + const headers = await session(who.connector.user_id); + const signedOut = randomUUID(); + deployments.during = async () => { await db.query("UPDATE sessions SET revoked_at = now() WHERE user_id = $1", [who.connector.user_id]); }; + expect((await serviceCreate(headers, signedOut)).statusCode).toBe(403); + expect(await registered(signedOut)).toBe(false); + const fresh = await session(who.connector.user_id); + const bumped = randomUUID(); + deployments.during = async () => { await db.query("UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1", [who.connector.user_id]); }; + expect((await serviceCreate(fresh, bumped)).statusCode).toBe(403); + expect(await registered(bumped)).toBe(false); + }); }); diff --git a/services/server/src/features/next/cloud-copy-bootstrap.ts b/services/server/src/features/next/cloud-copy-bootstrap.ts index 9b241526..f039cb0a 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.ts @@ -20,7 +20,7 @@ import { verify } from "node:crypto"; import type { FastifyInstance, FastifyReply } from "fastify"; import type { DatabaseConnection, DatabasePool } from "../../database-types.js"; import { apiError } from "../../platform/http-errors.js"; -import { requireConnector, requireUser } from "../../platform/request-authentication.js"; +import { requireConnector, requireSessionContext, requireUser } from "../../platform/request-authentication.js"; import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; import { ed25519PublicKeyObject, type NextControlPlaneConfig } from "./policy-keys.js"; import { queueNextPolicy, registerNextCollection, type PolicyEmitter } from "./policy-outbox.js"; @@ -111,6 +111,22 @@ async function currentIdentity(client: DatabaseConnection, connector: Connector, 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. + */ +async function currentSession(client: DatabaseConnection, session: string, user: string): Promise { + 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. */ async function currentAccount(client: DatabaseConnection, user: string): Promise { const row = await client.query("SELECT 1 FROM users WHERE id = $1 AND suspended_at IS NULL FOR SHARE", [user]); @@ -149,8 +165,22 @@ const ENROLMENT = `SELECT o.ops, b.seq, b.item, b.state FROM next_policy_outbox 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`; const enrolmentKey = (device: string) => JSON.stringify([{ op: "device-enrol", device }]); -const exactEnrolment = (device: string, account: string, signPk: Buffer) => - JSON.stringify([{ op: "device-enrol", device, account, signPublicKey: { $hex: signPk.toString("hex") } }]); +/** The whole immutable enrolment tuple: device, account, kind and all three keys. */ +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. */ +async function refuseRevoked(client: DatabaseConnection, collection: string, device: string): Promise { + 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"); +} function refuse(reply: FastifyReply, error: unknown, message: string) { if (error instanceof CreateError || error instanceof ServiceDeviceError) { @@ -221,21 +251,34 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { schema: { body: { type: "object", additionalProperties: false, required: ["collection_id"], properties: { collection_id: uuid } } } }, async (request, reply) => { reply.header("cache-control", "no-store"); - const user = await requireUser(request, reply, options.db, options.tailscaleAuth); - if (!user) return reply; + // Session credentials are rechecked after every await; Tailscale identities have + // no session row, so for them the account itself is. + let user: { id: string }; + let current: (client: DatabaseConnection) => Promise; + if (options.tailscaleAuth) { + const u = await requireUser(request, reply, options.db, true); + if (!u) return reply; + user = u; + current = (client) => currentAccount(client, u.id); + } else { + const context = await requireSessionContext(request, reply, options.db); + if (!context) return reply; + user = context.user; + current = (client) => currentSession(client, context.sessionId, context.user.id); + } const collection = request.body.collection_id.toLowerCase(); if (collection === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); try { const exists = await inTransaction(options.db, async (client) => { await lock(client, collection); - await currentAccount(client, user.id); + await current(client); return existing(client, collection, user.id); }); if (!exists) { const generated = await generateFor(collection); await inTransaction(options.db, async (client) => { await lock(client, collection); - await currentAccount(client, user.id); + await current(client); if (await existing(client, collection, user.id)) throw new CreateError(503, "not_ready"); await registerNextCollection(client, { collectionId: collection, ownerUserId: user.id, runtime: "next", sync: "cloud_copy", rootKeyId, @@ -255,7 +298,7 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { const genesis = await appendedGenesis(collection); const head = await options.log.head(collection); return await inTransaction(options.db, async (client) => { - await currentAccount(client, user.id); + await current(client); await currentCloudCopy(client, collection, user.id); // Hosted is the first member: it generates the epoch key and wraps it for // hosted and escrow. No user device is enrolled here. @@ -324,11 +367,12 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { return await inTransaction(options.db, async (client) => { await currentIdentity(client, connector, body.device_id, device); await currentCloudCopy(client, collection, connector.user_id); + await refuseRevoked(client, collection, body.device_id); // The requesting device must be the one the genesis enrolled, with the same keys. const enrolled = await client.query( `SELECT 1 FROM next_policy_outbox WHERE id = (SELECT min(id) FROM next_policy_outbox WHERE collection_id = $1) AND ops->'ops' @> $2::jsonb`, - [collection, exactEnrolment(body.device_id, connector.user_id, device.sign_pk)] + [collection, exactEnrolment(body.device_id, connector.user_id, device)] ); if (!enrolled.rows.length) throw new CreateError(409, "collection_exists"); const records = await loadRecords(client, collection); @@ -366,12 +410,16 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { device = await inTransaction(options.db, async (client) => { await lock(client, collection); const joining = await authenticate(client, body, connector, digest); + // The connector, account and device are current and stay locked until the + // enrolment commits: a revocation racing this request waits or wins. + await currentIdentity(client, connector, body.device_id, joining); // Cloud copy only: a private collection, or one that has left sync, refuses // before any policy op exists. await currentCloudCopy(client, collection, connector.user_id); + await refuseRevoked(client, collection, body.device_id); const prior = (await client.query<{ ops: { ops: Array> } }>(ENROLMENT, [collection, enrolmentKey(body.device_id)])).rows[0]; if (prior) { - const same = (await client.query(ENROLMENT, [collection, exactEnrolment(body.device_id, connector.user_id, joining.sign_pk)])).rows.length > 0; + const same = (await client.query(ENROLMENT, [collection, exactEnrolment(body.device_id, connector.user_id, joining)])).rows.length > 0; if (!same) throw new CreateError(409, "device_enrolled_differently"); } else if (!(await queueNextPolicy(client, collection, [enrolOp(body.device_id, connector.user_id, joining)]))) { throw new CreateError(409, "not_current_cloud_copy"); @@ -384,12 +432,13 @@ export function registerCloudCopyRoutes(app: FastifyInstance, options: { try { await options.emitter.drainCollection(collection); const row = (await options.db.query<{ seq: string | null; item: Buffer | null; state: string | null }>( - ENROLMENT, [collection, exactEnrolment(body.device_id, connector.user_id, device.sign_pk)] + ENROLMENT, [collection, exactEnrolment(body.device_id, connector.user_id, device)] )).rows[0]; const batch = await appendedBatch(collection, row); return await inTransaction(options.db, async (client) => { await currentIdentity(client, connector, body.device_id, device); await currentCloudCopy(client, collection, connector.user_id); + await refuseRevoked(client, collection, body.device_id); // Hosted (or escrow) wraps the current epoch key to this device next. return { collection_id: collection, enrolled_at: batch.seq, device: mint(body.device_id, device.sign_pk, collection) }; }); From 1da92341a5ebf35215554d9e7e16f8fc46d3ed10 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 09:30:31 +1100 Subject: [PATCH 13/14] next: private collection create and device enrol (SAS commitment), behind MDBASE_NEXT_PRIVATE_BOOTSTRAP=1 Create: owner's registered device; e2e genesis enrolling only that device. Enrol: a current member's registered device with its sas_commit (key 7); no key until an existing keyed device approves and grants. Shared proof/identity/ membership helpers move from cloud-copy-bootstrap.ts to bootstrap-common.ts. --- architecture.d/next-private-collections.json | 9 + changelog.d/next-private-collections.md | 9 + services/server/src/app.ts | 2 + .../src/features/next/bootstrap-common.ts | 157 +++++++++ .../src/features/next/cloud-copy-bootstrap.ts | 137 +------- .../server/src/features/next/policy-keys.ts | 5 + .../next/private-collections.postgres.test.ts | 308 ++++++++++++++++++ .../src/features/next/private-collections.ts | 234 +++++++++++++ 8 files changed, 736 insertions(+), 125 deletions(-) create mode 100644 architecture.d/next-private-collections.json create mode 100644 changelog.d/next-private-collections.md create mode 100644 services/server/src/features/next/bootstrap-common.ts create mode 100644 services/server/src/features/next/private-collections.postgres.test.ts create mode 100644 services/server/src/features/next/private-collections.ts diff --git a/architecture.d/next-private-collections.json b/architecture.d/next-private-collections.json new file mode 100644 index 00000000..91201848 --- /dev/null +++ b/architecture.d/next-private-collections.json @@ -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 + } +} diff --git a/changelog.d/next-private-collections.md b/changelog.d/next-private-collections.md new file mode 100644 index 00000000..992aab62 --- /dev/null +++ b/changelog.d/next-private-collections.md @@ -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. diff --git a/services/server/src/app.ts b/services/server/src/app.ts index 9ae3cb24..114c5b87 100644 --- a/services/server/src/app.ts +++ b/services/server/src/app.ts @@ -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"; @@ -525,6 +526,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, { diff --git a/services/server/src/features/next/bootstrap-common.ts b/services/server/src/features/next/bootstrap-common.ts new file mode 100644 index 00000000..8677de0a --- /dev/null +++ b/services/server/src/features/next/bootstrap-common.ts @@ -0,0 +1,157 @@ +// 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: another request holds the rows; answer busy, never the driver error. */ +export const isLockTimeout = (error: unknown) => (error as { code?: unknown } | null)?.code === "55P03"; + +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 { + const device = (await client.query( + "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 { + 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 { + 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 { + 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(db: DatabasePool, run: (client: DatabaseConnection) => Promise): Promise { + 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'"); + 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 { + 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 { + 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"); +} diff --git a/services/server/src/features/next/cloud-copy-bootstrap.ts b/services/server/src/features/next/cloud-copy-bootstrap.ts index f039cb0a..364aac4a 100644 --- a/services/server/src/features/next/cloud-copy-bootstrap.ts +++ b/services/server/src/features/next/cloud-copy-bootstrap.ts @@ -16,30 +16,28 @@ // The control plane never holds a collection key. Each deployment generates its own // service device; the first committed record wins, and nothing is ever deleted on a // failure path. -import { verify } from "node:crypto"; import type { FastifyInstance, FastifyReply } from "fastify"; import type { DatabaseConnection, DatabasePool } from "../../database-types.js"; import { apiError } from "../../platform/http-errors.js"; import { requireConnector, requireSessionContext, requireUser } from "../../platform/request-authentication.js"; import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; -import { ed25519PublicKeyObject, type NextControlPlaneConfig } from "./policy-keys.js"; +import { + authenticate, CreateError, currentAccount, currentIdentity, currentSession, ENROLMENT, enrolmentKey, enrolOp, exactEnrolment, + inTransaction, lock, NIL, refuse as refuseCommon, refuseRevoked, SERVICE_ACCOUNT, type Device, type Proof +} from "./bootstrap-common.js"; +import { type NextControlPlaneConfig } from "./policy-keys.js"; import { queueNextPolicy, registerNextCollection, type PolicyEmitter } from "./policy-outbox.js"; -import { domainHash, encodeCbor, uuidBytes, type PolicyOp } from "./policy-wire.js"; +import { domainHash, encodeCbor, uuidBytes } from "./policy-wire.js"; import { generateServiceDevice, loadServiceDevice, ServiceDeviceError, storeServiceDevice, type ServiceDeviceRecord } from "./service-devices.js"; -/** Service devices belong to no account (policy.md: hosted and escrow enrol with the zero account). */ -const SERVICE_ACCOUNT = "00000000-0000-0000-0000-000000000000"; -const NIL = SERVICE_ACCOUNT; const KINDS = ["hosted", "escrow"] as const; -interface Proof { device_id: string; challenge: string; sig: string } -interface Device { sign_pk: Buffer; kem_pk: Buffer; noise_pk: Buffer; kind: "desktop" | "cli" } -type Connector = { id: string; user_id: string }; -/** PostgreSQL lock_timeout: another request holds the rows; answer busy, never the driver error. */ -const isLockTimeout = (error: unknown) => (error as { code?: unknown } | null)?.code === "55P03"; - -class CreateError extends Error { - constructor(readonly status: number, readonly code: string) { super(code); } +function refuse(reply: FastifyReply, error: unknown, message: string) { + if (error instanceof ServiceDeviceError) { + const status = error.status === 502 ? 503 : error.status; + return reply.code(status).send(apiError(error.code, message)); + } + return refuseCommon(reply, error, message); } /** `H("mdbase/v1/cloud-copy-create", cbor[challenge, connector, device, collection])`, signed by the owner's device. */ @@ -52,9 +50,6 @@ export function cloudCopyJoinDigest(input: { challenge: Uint8Array; connector: s return domainHash("mdbase/v1/cloud-copy-join", encodeCbor([input.challenge, uuidBytes(input.connector), uuidBytes(input.device), uuidBytes(input.collection)])); } -const lock = (client: DatabaseConnection, collection: string) => - client.query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text, 20261005))", [collection]); - /** Whether the collection already exists: false when free, true when this owner's cloud copy, else refused. */ async function existing(client: DatabaseConnection, collection: string, owner: string): Promise { const row = (await client.query<{ owner_user_id: string; sync: string; left: boolean }>( @@ -78,119 +73,11 @@ async function currentCloudCopy(client: DatabaseConnection, collection: string, if (!current.rows.length) throw new CreateError(409, "not_current_cloud_copy"); } -/** Verify a device's signature over `digest` and consume its challenge. */ -async function authenticate(client: DatabaseConnection, body: Proof, connector: Connector, digest: (challenge: Uint8Array) => Uint8Array): Promise { - const device = (await client.query( - "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. - */ -async function currentIdentity(client: DatabaseConnection, connector: Connector, deviceId: string, device: Device): Promise { - 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. - */ -async function currentSession(client: DatabaseConnection, session: string, user: string): Promise { - 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. */ -async function currentAccount(client: DatabaseConnection, user: string): Promise { - 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"); -} - -async function inTransaction(db: DatabasePool, run: (client: DatabaseConnection) => Promise): Promise { - 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'"); - const result = await run(client); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK").catch(() => undefined); - throw error; - } finally { - client.release(); - } -} - const publicRecord = (record: ServiceDeviceRecord) => ({ kind: record.kind, device_id: record.device_id, sign_pk: record.sign_pk.toString("hex"), kem_pk: record.kem_pk.toString("hex"), noise_pk: record.noise_pk.toString("hex") }); -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). */ -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`; -const enrolmentKey = (device: string) => JSON.stringify([{ op: "device-enrol", device }]); -/** The whole immutable enrolment tuple: device, account, kind and all three keys. */ -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. */ -async function refuseRevoked(client: DatabaseConnection, collection: string, device: string): Promise { - 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"); -} - -function refuse(reply: FastifyReply, error: unknown, message: string) { - if (error instanceof CreateError || error instanceof ServiceDeviceError) { - 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; -} - export function registerCloudCopyRoutes(app: FastifyInstance, options: { db: DatabasePool; next: NextControlPlaneConfig; emitter: PolicyEmitter; log: Pick; fetchImpl?: typeof fetch; now?: () => number; diff --git a/services/server/src/features/next/policy-keys.ts b/services/server/src/features/next/policy-keys.ts index af503f11..b4e0d673 100644 --- a/services/server/src/features/next/policy-keys.ts +++ b/services/server/src/features/next/policy-keys.ts @@ -39,6 +39,8 @@ export interface NextControlPlaneConfig { * inbound `serviceTokens`. */ cloudCopyBootstrap?: { hosted: { url: string; token: string }; escrow: { url: string; token: string } }; + /** Private collection create and device enrol routes; set only by MDBASE_NEXT_PRIVATE_BOOTSTRAP=1. */ + privateBootstrap?: true; labFixtures?: LabFixtureConfig; } @@ -135,6 +137,8 @@ export function parseNextControlPlaneEnv(env: NodeJS.ProcessEnv): NextControlPla throw new Error("Inbound and outbound service tokens must all differ."); } } + const privateBootstrap = env.MDBASE_NEXT_PRIVATE_BOOTSTRAP?.trim() ?? ""; + if (privateBootstrap !== "" && privateBootstrap !== "0" && privateBootstrap !== "1") throw new Error("MDBASE_NEXT_PRIVATE_BOOTSTRAP must be 0 or 1."); return { rootPublicKey: hexBytes(root, 32, "MDBASE_NEXT_ROOT_PUBLIC_KEY"), policyPrivateKeyPem: pem, @@ -142,6 +146,7 @@ export function parseNextControlPlaneEnv(env: NodeJS.ProcessEnv): NextControlPla logService: { url: logServiceUrl, tokenIssuerKeyPem, transportKeyPem }, serviceTokens: { ...(hosted ? { hosted } : {}), ...(escrow ? { escrow } : {}) }, ...(cloudCopyBootstrap ? { cloudCopyBootstrap } : {}), + ...(privateBootstrap === "1" ? { privateBootstrap: true as const } : {}), ...(labFixtures ? { labFixtures } : {}), }; } diff --git a/services/server/src/features/next/private-collections.postgres.test.ts b/services/server/src/features/next/private-collections.postgres.test.ts new file mode 100644 index 00000000..a480eb9d --- /dev/null +++ b/services/server/src/features/next/private-collections.postgres.test.ts @@ -0,0 +1,308 @@ +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import cookie from "@fastify/cookie"; +import Fastify from "fastify"; +import pg from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, type DatabasePool } from "../../db.js"; +import { tokenHash } from "../../security.js"; +import { deviceRegistrationDigest, issueDeviceChallenge, registerDevice } from "./devices.js"; +import { LogServiceClient } from "./log-service-client.js"; +import { certToJson, ed25519RawPublicKey, loadPolicySigner, parseNextControlPlaneEnv, type NextControlPlaneConfig } from "./policy-keys.js"; +import { PolicyEmitter, queueNextPolicy, registerNextCollection } from "./policy-outbox.js"; +import { certDigest, chainHash, decodeCbor, encodeCbor, keyId, type Cbor, type Decoded, type PolicyOp } from "./policy-wire.js"; +import { privateCreateDigest, privateDeviceEnrolDigest, registerPrivateCollectionRoutes } from "./private-collections.js"; + +const testUrl = process.env.MDBASE_CONNECT_TEST_DATABASE_URL; +const approved = process.env.MDBASE_CONNECT_DESTRUCTIVE_TEST_APPROVAL === "I APPROVE MDBASE CONNECT DESTRUCTIVE POSTGRES TESTS"; +const describePg = testUrl && approved ? describe : describe.skip; +const field = (value: Decoded, key: number) => value instanceof Map ? value.get(key) : undefined; +const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"); +const rawX = () => (generateKeyPairSync("x25519").publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32); + +describe("private bootstrap configuration", () => { + const base = { + MDBASE_NEXT_CONTROL_PLANE: "1", MDBASE_NEXT_ROOT_PUBLIC_KEY: "00".repeat(32), MDBASE_NEXT_POLICY_SIGNING_KEY: "pem", MDBASE_NEXT_POLICY_KEY_CERT: "{}", + MDBASE_NEXT_LOG_SERVICE_URL: "https://log.example", MDBASE_NEXT_LOG_TOKEN_SIGNING_KEY: "pem", MDBASE_NEXT_LOG_TRANSPORT_KEY: "pem" + }; + it("is off by default, on with 1, and refuses anything else", () => { + expect(parseNextControlPlaneEnv(base)?.privateBootstrap).toBeUndefined(); + expect(parseNextControlPlaneEnv({ ...base, MDBASE_NEXT_PRIVATE_BOOTSTRAP: "0" })?.privateBootstrap).toBeUndefined(); + expect(parseNextControlPlaneEnv({ ...base, MDBASE_NEXT_PRIVATE_BOOTSTRAP: "1" })?.privateBootstrap).toBe(true); + expect(() => parseNextControlPlaneEnv({ ...base, MDBASE_NEXT_PRIVATE_BOOTSTRAP: "yes" })).toThrow(/0 or 1/); + }); +}); + +function configuration(): NextControlPlaneConfig { + const root = generateKeyPairSync("ed25519").privateKey; + const policy = generateKeyPairSync("ed25519").privateKey; + const cert = { policyPublicKey: ed25519RawPublicKey(policy), notBefore: Date.now() - 60_000, notAfter: Date.now() + 30 * 86_400_000, root: keyId(ed25519RawPublicKey(root)) }; + const pem = (key: typeof root) => key.export({ type: "pkcs8", format: "pem" }).toString(); + return { + rootPublicKey: ed25519RawPublicKey(root), policyPrivateKeyPem: pem(policy), policyCert: certToJson({ ...cert, signature: sign(null, certDigest(cert), root) }), + serviceTokens: {}, privateBootstrap: true, + logService: { url: "http://log.test", tokenIssuerKeyPem: pem(generateKeyPairSync("ed25519").privateKey), transportKeyPem: pem(generateKeyPairSync("ed25519").privateKey) } + }; +} + +/** The log service: control items per collection, with create, append, head and read. */ +class Log { + readonly logs = new Map(); + /** Runs once, while the route awaits a read-back. */ + onRead: (() => Promise) | undefined; + readonly fetch: typeof fetch = async (input, init) => { + if (String(input).endsWith("/v1/nonce")) return new Response("ab".repeat(32)); + const frame = decodeCbor(Buffer.from(init!.body as Uint8Array)); + const method = field(frame, 2); + const params = field(frame, 3)!; + const id = hex(field(params, 0) as Uint8Array); + const items = this.logs.get(id); + const chain = () => chainHash(items![items!.length - 1]!); + let result: Cbor; + if (method === "create_log") { + if (!items) this.logs.set(id, [Buffer.from(field(params, 1) as Uint8Array)]); + result = { struct: [[0, 1], [1, chainHash(this.logs.get(id)![0]!)]] }; + } else if (method === "head") { + result = { struct: [[0, items!.length], [1, chain()], [2, 1]] }; + } else if (method === "append") { + const expect = field(params, 1) as number; + const prev = Buffer.from(field(params, 2) as Uint8Array); + if (expect !== items!.length + 1 || !prev.equals(Buffer.from(chain()))) { + result = { struct: [[0, 1], [1, items!.length], [2, chain()]] }; + } else { + const added = (field(params, 3) as Uint8Array[]).map((b) => Buffer.from(b)); + items!.push(...added); + result = { struct: [[0, 0], [1, expect], [2, items!.length]] }; + } + } else if (method === "read") { + const hook = this.onRead; + this.onRead = undefined; + await hook?.(); + if (!items) return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [3, { struct: [[0, "not_found"]] }]] })); + const after = field(params, 1) as number; + const limit = field(params, 2) as number; + result = { struct: [[0, items.slice(after, after + limit).map((item, i) => [after + i + 1, item])]] }; + } else throw new Error("unexpected log operation"); + return new Response(encodeCbor({ struct: [[0, 1], [1, 1], [2, result]] }), { headers: { "content-type": "application/vnd.mdbase.v1+cbor" } }); + }; +} + +describePg("private collections", () => { + let db: DatabasePool; + let admin: pg.Pool; + let schema: string; + const config = configuration(); + const log = new Log(); + const client = new LogServiceClient(config.logService, log.fetch); + const app = Fastify(); + + beforeAll(async () => { + const url = new URL(testUrl!); + if (!["localhost", "127.0.0.1", "::1"].includes(url.hostname) || !/test/i.test(url.pathname)) throw new Error("Private collection tests require dedicated local test Postgres."); + schema = `private_${randomUUID().replaceAll("-", "")}`; + admin = new pg.Pool({ connectionString: url.toString(), max: 2 }); + await admin.query(`CREATE SCHEMA "${schema}"`); + url.searchParams.set("options", `-csearch_path=${schema}`); + db = await createDatabase(url.toString()); + const emitter = new PolicyEmitter(db, client, loadPolicySigner(config, Date.now())); + await app.register(cookie); + registerPrivateCollectionRoutes(app, { db, next: config, emitter, log: client }); + }, 60_000); + afterAll(async () => { + await app.close(); await db?.end(); + if (admin && schema) await admin.query(`DROP SCHEMA "${schema}" CASCADE`); + await admin?.end(); + }); + + async function identity(user = randomUUID()) { + const connector = { id: randomUUID(), user_id: user }; + const token = randomUUID(); + const device = randomUUID(); + const key = generateKeyPairSync("ed25519").privateKey; + const signPk = ed25519RawPublicKey(key); + const kemPk = rawX(); const noisePk = rawX(); + await db.query("INSERT INTO users(id,email,name) VALUES($1,$2,'Owner') ON CONFLICT DO NOTHING", [user, `${user}@example.test`]); + await db.query("INSERT INTO connectors(id,user_id,name,token_hash) VALUES($1,$2,'Daemon',$3)", [connector.id, user, tokenHash(token)]); + const registration = await issueDeviceChallenge(db, connector.id); + await registerDevice(db, connector, { + device_id: device, kind: "desktop", sign_pk: hex(signPk), kem_pk: hex(kemPk), noise_pk: hex(noisePk), challenge: registration.challenge, + sig: hex(sign(null, deviceRegistrationDigest({ challenge: Buffer.from(registration.challenge, "hex"), connectorId: connector.id, deviceId: device, signPk, kemPk, noisePk }), key)) + }); + return { connector, device, key, signPk, headers: { authorization: `Bearer ${token}` } }; + } + type Who = Awaited>; + async function createProof(who: Who, collection: string) { + const { challenge } = await issueDeviceChallenge(db, who.connector.id); + const digest = privateCreateDigest({ challenge: Buffer.from(challenge, "hex"), connector: who.connector.id, device: who.device, collection }); + return { collection_id: collection, device_id: who.device, challenge, sig: hex(sign(null, digest, who.key)) }; + } + async function enrolProof(who: Who, collection: string, commit: string) { + const { challenge } = await issueDeviceChallenge(db, who.connector.id); + const digest = privateDeviceEnrolDigest({ challenge: Buffer.from(challenge, "hex"), connector: who.connector.id, device: who.device, collection, sasCommit: Buffer.from(commit, "hex") }); + return { device_id: who.device, challenge, sig: hex(sign(null, digest, who.key)), sas_commit: commit }; + } + const create = (who: Who, payload: unknown) => app.inject({ method: "POST", url: "/v1/next/collections/private", headers: who.headers, payload }); + const enrol = (who: Who, collection: string, payload: unknown) => + app.inject({ method: "POST", url: `/v1/next/collections/${collection}/private/devices`, headers: who.headers, payload }); + const commit = () => hex(Buffer.from(randomUUID().replaceAll("-", "").repeat(2), "hex")); + const registered = async (collection: string) => (await db.query("SELECT 1 FROM next_collections WHERE collection_id = $1", [collection])).rows.length === 1; + const drain = (collection: string) => new PolicyEmitter(db, client, loadPolicySigner(config, Date.now())).drainCollection(collection); + /** Queue ops without appending them (pending). */ + const queue = async (collection: string, ops: PolicyOp[]) => { + const c = await db.connect(); + try { + await c.query("BEGIN"); + await queueNextPolicy(c, collection, ops); + await c.query("COMMIT"); + } finally { + c.release(); + } + }; + const opsOf = (item: Buffer | Uint8Array) => field(decodeCbor(field(decodeCbor(Buffer.from(item)), 11) as Uint8Array), 3) as Decoded[]; + const created = async (who: Who) => { + const collection = randomUUID(); + const response = await create(who, await createProof(who, collection)); + expect(response.statusCode, response.body).toBe(200); + return collection; + }; + + it("creates an e2e genesis that enrols only the owner's device", async () => { + const who = await identity(); const collection = randomUUID(); + const response = await create(who, await createProof(who, collection)); + expect(response.statusCode, response.body).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + const result = response.json(); + expect(result).toMatchObject({ collection_id: collection, state: "private", owner_account: who.connector.user_id, head: { seq: 1 }, rekey_recipients: [who.device] }); + expect(result.service_devices).toBeUndefined(); + const ops = opsOf(Buffer.from(result.genesis.item, "hex")); + expect(ops.map((op) => field(op, 0))).toEqual([1, 4, 2]); + expect(field(ops[0]!, 3)).toBe(0); // e2e + expect(hex(field(ops[2]!, 1) as Uint8Array)).toBe(who.device.replaceAll("-", "")); + expect(field(ops[2]!, 3)).toBe(0); // desktop + expect(field(ops[2]!, 7)).toBeUndefined(); // the creator needs no approval + expect(result.genesis.item).toBe(hex(log.logs.get(collection.replaceAll("-", ""))![0]!)); + const claims = decodeCbor(Buffer.from(result.device.token.split(".")[0], "hex")); + expect(hex(field(claims, 1) as Uint8Array)).toBe(who.device.replaceAll("-", "")); + expect(hex(field(claims, 5) as Uint8Array)).toBe(collection.replaceAll("-", "")); + const row = (await db.query("SELECT sync, runtime FROM next_collections WHERE collection_id = $1", [collection])).rows[0]; + expect(row).toEqual({ sync: "private", runtime: "next" }); + }); + + it("answers a retry from the creating device with the same genesis", async () => { + const who = await identity(); const collection = randomUUID(); + const first = (await create(who, await createProof(who, collection))).json(); + const again = await create(who, await createProof(who, collection)); + expect(again.statusCode, again.body).toBe(200); + expect(again.json().genesis).toEqual(first.genesis); + expect(log.logs.get(collection.replaceAll("-", ""))!.length).toBe(1); + }); + + it("refuses other devices, other owners, cloud copies and collections that left sync", async () => { + const who = await identity(); + const collection = await created(who); + const sibling = await identity(who.connector.user_id); + expect((await create(sibling, await createProof(sibling, collection))).json().error.code).toBe("collection_exists"); + const stranger = await identity(); + expect((await create(stranger, await createProof(stranger, collection))).json().error.code).toBe("collection_exists"); + const cloud = randomUUID(); + await db.query("INSERT INTO next_collections(collection_id, owner_user_id, runtime, sync, root_key_id) VALUES($1,$2,'next','cloud_copy',$3)", [cloud, who.connector.user_id, Buffer.from(config.policyCert.root_key_id, "hex")]); + expect((await create(who, await createProof(who, cloud))).json().error.code).toBe("collection_exists"); + await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [collection]); + expect((await create(who, await createProof(who, collection))).statusCode).toBe(409); + }); + + it("needs a fresh proof under its own domain, and refuses nil identifiers", async () => { + const who = await identity(); const collection = randomUUID(); + const payload = await createProof(who, collection); + expect((await app.inject({ method: "POST", url: "/v1/next/collections/private", payload })).statusCode).toBe(401); + expect((await create(who, { ...payload, collection_id: randomUUID() })).statusCode).toBe(403); + expect((await create(who, payload)).statusCode).toBe(200); + expect((await create(who, payload)).statusCode).toBe(403); + // An enrolment proof never creates. + const other = randomUUID(); + const e = await enrolProof(who, other, commit()); + expect((await create(who, { collection_id: other, device_id: e.device_id, challenge: e.challenge, sig: e.sig })).statusCode).toBe(403); + expect(await registered(other)).toBe(false); + const nil = "00000000-0000-0000-0000-000000000000"; + expect((await create(who, await createProof(who, nil))).statusCode).toBe(400); + }); + + it("registers nothing when the device is removed before the proof is checked", async () => { + const who = await identity(); const collection = randomUUID(); + const payload = await createProof(who, collection); + await db.query("DELETE FROM next_devices WHERE id = $1", [who.device]); + expect((await create(who, payload)).statusCode).toBe(403); + expect(await registered(collection)).toBe(false); + }); + + it("enrols the owner's second device with its SAS commitment, approval pending", async () => { + const owner = await identity(); + const collection = await created(owner); + const second = await identity(owner.connector.user_id); + const sas = commit(); + const response = await enrol(second, collection, await enrolProof(second, collection, sas)); + expect(response.statusCode, response.body).toBe(200); + expect(response.json()).toMatchObject({ collection_id: collection, enrolled_at: 2, approval: "pending", device: { device_id: second.device } }); + const items = log.logs.get(collection.replaceAll("-", ""))!; + const [op] = opsOf(items[1]!); + expect(field(op!, 0)).toBe(2); + expect(hex(field(op!, 1) as Uint8Array)).toBe(second.device.replaceAll("-", "")); + expect(hex(field(op!, 7) as Uint8Array)).toBe(sas); + // The same device and commitment again: the same enrolment, nothing appended. + const again = await enrol(second, collection, await enrolProof(second, collection, sas)); + expect(again.statusCode, again.body).toBe(200); + expect(again.json().enrolled_at).toBe(2); + expect(items.length).toBe(2); + // A different commitment is the device's own approval-request, never this route. + expect((await enrol(second, collection, await enrolProof(second, collection, commit()))).json().error.code).toBe("device_enrolled_differently"); + }); + + it("enrols another member account's device, and refuses non-members and removed members", async () => { + const owner = await identity(); + const collection = await created(owner); + const editor = await identity(); + expect((await enrol(editor, collection, await enrolProof(editor, collection, commit()))).json().error.code).toBe("not_member"); + await queue(collection, [{ op: "member-set", account: editor.connector.user_id, role: "editor" }]); + expect((await enrol(editor, collection, await enrolProof(editor, collection, commit()))).json().error.code).toBe("not_member"); + await drain(collection); + expect((await enrol(editor, collection, await enrolProof(editor, collection, commit()))).statusCode).toBe(200); + const removed = await identity(editor.connector.user_id); + await queue(collection, [{ op: "member-remove", account: editor.connector.user_id }]); + expect((await enrol(removed, collection, await enrolProof(removed, collection, commit()))).json().error.code).toBe("not_member"); + }); + + it("refuses revoked devices, cloud copies, collections that left sync and other proofs", async () => { + const owner = await identity(); + const collection = await created(owner); + const second = await identity(owner.connector.user_id); + await queue(collection, [{ op: "device-revoke", device: second.device }]); + expect((await enrol(second, collection, await enrolProof(second, collection, commit()))).json().error.code).toBe("device_revoked"); + + const cloud = randomUUID(); + const c = await db.connect(); + try { + await c.query("BEGIN"); + await registerNextCollection(c, { + collectionId: cloud, ownerUserId: owner.connector.user_id, runtime: "next", sync: "cloud_copy", rootKeyId: Buffer.from(config.policyCert.root_key_id, "hex"), + ops: [{ op: "genesis", owner: owner.connector.user_id, root: Buffer.from(config.policyCert.root_key_id, "hex"), state: "cloud-copy" }, { op: "member-set", account: owner.connector.user_id, role: "owner" }] + }); + await c.query("COMMIT"); + } finally { + c.release(); + } + const third = await identity(owner.connector.user_id); + expect((await enrol(third, cloud, await enrolProof(third, cloud, commit()))).json().error.code).toBe("not_current_private"); + + const create2 = await created(owner); + const p = await createProof(third, create2); + expect((await enrol(third, create2, { device_id: p.device_id, challenge: p.challenge, sig: p.sig, sas_commit: commit() })).json().error.code).toBe("invalid_proof"); + const sas = commit(); + const proofed = await enrolProof(third, create2, sas); + expect((await enrol(third, create2, { ...proofed, sas_commit: commit() })).json().error.code).toBe("invalid_proof"); + await db.query("UPDATE next_collections SET runtime = 'shadow' WHERE collection_id = $1", [create2]); + expect((await enrol(third, create2, await enrolProof(third, create2, sas))).json().error.code).toBe("not_current_private"); + expect((await create(owner, await createProof(owner, create2))).json().error.code).toBe("collection_exists"); + await db.query("UPDATE next_collections SET runtime = 'next' WHERE collection_id = $1", [create2]); + await db.query("UPDATE next_collections SET left_sync_at = now() WHERE collection_id = $1", [create2]); + expect((await enrol(third, create2, await enrolProof(third, create2, sas))).json().error.code).toBe("not_current_private"); + }); +}); diff --git a/services/server/src/features/next/private-collections.ts b/services/server/src/features/next/private-collections.ts new file mode 100644 index 00000000..7148f782 --- /dev/null +++ b/services/server/src/features/next/private-collections.ts @@ -0,0 +1,234 @@ +// Private (end-to-end) collections on the next control plane. Mounted only with +// MDBASE_NEXT_PRIVATE_BOOTSTRAP=1. +// +// - Create (`POST /v1/next/collections/private`): an owner's registered device creates +// it. Genesis is e2e, sets the owner and enrols that device only. No service device +// is ever enrolled. The device's own initial rekey keys the collection. +// - Device enrol (`POST /v1/next/collections/:id/private/devices`): a registered device +// of a current member account is enrolled, carrying its SAS commitment +// (`device-enrol` key 7). It holds no key until an existing keyed device approves it +// (SAS commit-then-reveal) and appends a `key_grant`. A changed commitment travels +// as the device's own `approval-request`, never through here. +// +// The control plane never approves, never grants and never holds a collection key. It +// enrols with its policy key and mints log credentials, nothing more. +import type { FastifyInstance } from "fastify"; +import type { DatabaseConnection, DatabasePool } from "../../database-types.js"; +import { apiError } from "../../platform/http-errors.js"; +import { requireConnector } from "../../platform/request-authentication.js"; +import { + authenticate, CreateError, currentIdentity, currentMember, ENROLMENT, enrolmentKey, enrolOp, exactEnrolment, inTransaction, lock, NIL, + refuse, refuseRevoked, type Device, type Proof +} from "./bootstrap-common.js"; +import { LOG_TOKEN_LIFETIME_MS, type LogServiceClient } from "./log-service-client.js"; +import type { NextControlPlaneConfig } from "./policy-keys.js"; +import { queueNextPolicy, registerNextCollection, type PolicyEmitter } from "./policy-outbox.js"; +import { domainHash, encodeCbor, uuidBytes } from "./policy-wire.js"; + +/** `H("mdbase/v1/private-create", cbor[challenge, connector, device, collection])`, signed by the owner's device. */ +export function privateCreateDigest(input: { challenge: Uint8Array; connector: string; device: string; collection: string }): Uint8Array { + return domainHash("mdbase/v1/private-create", encodeCbor([input.challenge, uuidBytes(input.connector), uuidBytes(input.device), uuidBytes(input.collection)])); +} + +/** `H("mdbase/v1/private-device-enrol", cbor[challenge, connector, device, collection, sas_commit])`, signed by the enrolling device. */ +export function privateDeviceEnrolDigest(input: { challenge: Uint8Array; connector: string; device: string; collection: string; sasCommit: Uint8Array }): Uint8Array { + return domainHash("mdbase/v1/private-device-enrol", encodeCbor([ + input.challenge, uuidBytes(input.connector), uuidBytes(input.device), uuidBytes(input.collection), input.sasCommit + ])); +} + +/** Whether the collection already exists: false when free, true when this owner's private collection, else refused. */ +async function existing(client: DatabaseConnection, collection: string, owner: string): Promise { + const row = (await client.query<{ owner_user_id: string; sync: string; runtime: string; left: boolean }>( + "SELECT owner_user_id, sync, runtime, left_sync_at IS NOT NULL AS left FROM next_collections WHERE collection_id = $1 FOR UPDATE", [collection] + )).rows[0]; + if (row && (row.owner_user_id !== owner || row.sync !== "private" || row.runtime !== "next" || row.left)) throw new CreateError(409, "collection_exists"); + if (!row) { + // A local collection with this logical ID that belongs to someone else is never adopted. + const other = await client.query("SELECT 1 FROM collections WHERE local_id = $1 AND user_id <> $2 AND removed_at IS NULL", [collection, owner]); + if (other.rows.length) throw new CreateError(409, "collection_exists"); + } + return Boolean(row); +} + +/** A current private collection on the next runtime (any owner); share-locked until the transaction ends. */ +async function currentPrivate(client: DatabaseConnection, collection: string, owner?: string): Promise { + const current = await client.query( + `SELECT 1 FROM next_collections WHERE collection_id = $1 AND sync = 'private' AND runtime = 'next' AND left_sync_at IS NULL + AND ($2::uuid IS NULL OR owner_user_id = $2) FOR SHARE`, + [collection, owner ?? null] + ); + if (!current.rows.length) throw new CreateError(409, "not_current_private"); +} + +/** The enrolment tuple including the SAS commitment. */ +const exactPrivateEnrolment = (device: string, account: string, d: Device, sasCommit: Buffer) => { + const [op] = JSON.parse(exactEnrolment(device, account, d)) as Array>; + return JSON.stringify([{ ...op, sasCommit: { $hex: sasCommit.toString("hex") } }]); +}; + +export function registerPrivateCollectionRoutes(app: FastifyInstance, options: { + db: DatabasePool; next: NextControlPlaneConfig; emitter: PolicyEmitter; + log: Pick; now?: () => number; +}): void { + if (!options.next.privateBootstrap) throw new Error("private collection routes need MDBASE_NEXT_PRIVATE_BOOTSTRAP=1"); + const rootKeyId = Buffer.from(options.next.policyCert.root_key_id, "hex"); + const uuid = { type: "string", pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }; + const proof = { device_id: uuid, challenge: { type: "string", pattern: "^[0-9a-f]{64}$" }, sig: { type: "string", pattern: "^[0-9a-f]{128}$" } }; + const limited = { bodyLimit: 4096, config: { rateLimit: { max: 6, timeWindow: "1 minute" } } }; + + /** The exact bytes of a policy batch, as the log returns them at its position. */ + async function appendedBatch(collection: string, batch: { seq: string | number | null; item: Buffer | null; state: string | null } | undefined) { + const seq = batch?.seq === null || batch?.seq === undefined ? null : Number(batch.seq); + const external = seq !== null && batch?.state === "appended" ? await options.log.controlItemAt(collection, seq) : null; + if (seq === null || !batch?.item || !external || !batch.item.equals(Buffer.from(external))) throw new CreateError(503, "not_ready"); + return { seq, item: batch.item }; + } + + const mint = (device: string, signPk: Buffer, collection: string) => { + const expiresAt = (options.now ?? Date.now)() + LOG_TOKEN_LIFETIME_MS; + return { device_id: device, token: options.log.mintToken({ device, signPublicKey: signPk, collection, expiresAt }), expires_at: expiresAt }; + }; + + // ---- Create: a registered device of the owner. ---- + app.post<{ Body: Proof & { collection_id: string } }>("/v1/next/collections/private", { + ...limited, + schema: { body: { + type: "object", additionalProperties: false, required: ["collection_id", "device_id", "challenge", "sig"], + properties: { collection_id: uuid, ...proof } + } } + }, async (request, reply) => { + reply.header("cache-control", "no-store"); + const connector = await requireConnector(request, reply, options.db); + if (!connector) return reply; + const body = { ...request.body, collection_id: request.body.collection_id.toLowerCase(), device_id: request.body.device_id.toLowerCase() }; + const collection = body.collection_id; + if (collection === NIL || body.device_id === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); + const digest = (challenge: Uint8Array) => privateCreateDigest({ challenge, connector: connector.id, device: body.device_id, collection }); + let device: Device; + try { + // 1. Proof, current identity and ownership, then genesis, in one transaction. + // Nothing here calls the network. + device = await inTransaction(options.db, async (client) => { + await lock(client, collection); + const owner = await authenticate(client, body, connector, digest); + await currentIdentity(client, connector, body.device_id, owner); + if (!(await existing(client, collection, connector.user_id))) { + await registerNextCollection(client, { + collectionId: collection, ownerUserId: connector.user_id, runtime: "next", sync: "private", rootKeyId, + ops: [ + { op: "genesis", owner: connector.user_id, root: rootKeyId, state: "e2e" }, + { op: "member-set", account: connector.user_id, role: "owner" }, + enrolOp(body.device_id, connector.user_id, owner) + ] + }); + } + return owner; + }); + } catch (error) { + return refuse(reply, error, "The private collection was not created; retry with a fresh proof."); + } + try { + // 2. Only an appended genesis whose exact bytes the log returns counts as created. + await options.emitter.drainCollection(collection); + const row = (await options.db.query<{ seq: string; item: Buffer; state: string }>( + "SELECT seq, item, state FROM next_policy_batches WHERE collection_id = $1 AND seq = 1 ORDER BY id LIMIT 1", [collection] + )).rows[0]; + const genesis = await appendedBatch(collection, row); + const head = await options.log.head(collection); + // 3. After every await: the identity, the collection and the enrolment are + // current, and stay locked until the token is minted. + return await inTransaction(options.db, async (client) => { + await currentIdentity(client, connector, body.device_id, device); + await currentPrivate(client, collection, connector.user_id); + await refuseRevoked(client, collection, body.device_id); + // The requesting device must be the one the genesis enrolled, with the same keys. + const enrolled = await client.query( + `SELECT 1 FROM next_policy_outbox WHERE id = (SELECT min(id) FROM next_policy_outbox WHERE collection_id = $1) + AND ops->'ops' @> $2::jsonb`, + [collection, exactEnrolment(body.device_id, connector.user_id, device)] + ); + if (!enrolled.rows.length) throw new CreateError(409, "collection_exists"); + return { + collection_id: collection, state: "private", owner_account: connector.user_id, + log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, + root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, + genesis: { seq: 1, item: genesis.item.toString("hex") }, + // Advisory: this device signs the initial rekey, wrapped to itself only. + rekey_recipients: [body.device_id], + device: mint(body.device_id, device.sign_pk, collection) + }; + }); + } catch (error) { + if (error instanceof CreateError && error.status !== 503) return refuse(reply, error, "The private collection is not current for this device."); + return reply.code(503).send(apiError("not_ready", "The private collection outcome is not verified; retry with a fresh proof.")); + } + }); + + // ---- Device enrol: a member account's registered device, with its SAS commitment. ---- + app.post<{ Params: { id: string }; Body: Proof & { sas_commit: string } }>("/v1/next/collections/:id/private/devices", { + ...limited, + schema: { + params: { type: "object", required: ["id"], properties: { id: uuid } }, + body: { + type: "object", additionalProperties: false, required: ["device_id", "challenge", "sig", "sas_commit"], + properties: { ...proof, sas_commit: { type: "string", pattern: "^[0-9a-f]{64}$" } } + } + } + }, async (request, reply) => { + reply.header("cache-control", "no-store"); + const connector = await requireConnector(request, reply, options.db); + if (!connector) return reply; + const collection = request.params.id.toLowerCase(); + const body = { ...request.body, device_id: request.body.device_id.toLowerCase() }; + const sasCommit = Buffer.from(body.sas_commit, "hex"); + if (collection === NIL || body.device_id === NIL) return reply.code(400).send(apiError("invalid_request", "Nil identifiers are not accepted.")); + const digest = (challenge: Uint8Array) => privateDeviceEnrolDigest({ challenge, connector: connector.id, device: body.device_id, collection, sasCommit }); + const checks = async (client: DatabaseConnection, d: Device) => { + // The connector, account and device are current and stay locked; the collection + // is a current private one and the account a current member. A cloud copy, or a + // collection that has left sync, refuses before any policy op exists. + await currentIdentity(client, connector, body.device_id, d); + await currentPrivate(client, collection); + await currentMember(client, collection, connector.user_id); + await refuseRevoked(client, collection, body.device_id); + }; + let device: Device; + try { + device = await inTransaction(options.db, async (client) => { + await lock(client, collection); + const enrolling = await authenticate(client, body, connector, digest); + await checks(client, enrolling); + const prior = (await client.query(ENROLMENT, [collection, enrolmentKey(body.device_id)])).rows[0]; + if (prior) { + // Idempotent only for the identical tuple and commitment. + const same = (await client.query(ENROLMENT, [collection, exactPrivateEnrolment(body.device_id, connector.user_id, enrolling, sasCommit)])).rows.length > 0; + if (!same) throw new CreateError(409, "device_enrolled_differently"); + } else if (!(await queueNextPolicy(client, collection, [{ + op: "device-enrol", device: body.device_id, account: connector.user_id, kind: enrolling.kind, + signPublicKey: enrolling.sign_pk, kemPublicKey: enrolling.kem_pk, noisePublicKey: enrolling.noise_pk, sasCommit + }]))) { + throw new CreateError(409, "not_current_private"); + } + return enrolling; + }); + } catch (error) { + return refuse(reply, error, "The device was not enrolled; retry with a fresh proof."); + } + try { + await options.emitter.drainCollection(collection); + const row = (await options.db.query<{ seq: string | null; item: Buffer | null; state: string | null }>( + ENROLMENT, [collection, exactPrivateEnrolment(body.device_id, connector.user_id, device, sasCommit)] + )).rows[0]; + const batch = await appendedBatch(collection, row); + return await inTransaction(options.db, async (client) => { + await checks(client, device); + // An existing keyed device approves this one (SAS) and grants it the key next. + return { collection_id: collection, enrolled_at: batch.seq, approval: "pending", device: mint(body.device_id, device.sign_pk, collection) }; + }); + } catch (error) { + if (error instanceof CreateError && error.status !== 503) return refuse(reply, error, "The private collection is not current for this device."); + return reply.code(503).send(apiError("not_ready", "The enrolment is not verified; retry with a fresh proof.")); + } + }); +} From 0940c0ca989f148c037d9d1c45567d85017e07aa Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 09:49:52 +1100 Subject: [PATCH 14/14] next: private create rechecks current owner membership before minting; bootstrap transactions bound every statement Per control/security-2 on #621: P1's final locked transaction adds currentMember (a pending member-remove refuses); inTransaction sets statement_timeout 5s and 57014 answers 503 busy like lock_timeout (shared by the cloud-copy routes). --- .../src/features/next/bootstrap-common.ts | 6 +++-- .../next/private-collections.postgres.test.ts | 25 +++++++++++++++++++ .../src/features/next/private-collections.ts | 7 +++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/services/server/src/features/next/bootstrap-common.ts b/services/server/src/features/next/bootstrap-common.ts index 8677de0a..544a19ef 100644 --- a/services/server/src/features/next/bootstrap-common.ts +++ b/services/server/src/features/next/bootstrap-common.ts @@ -15,8 +15,8 @@ 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: another request holds the rows; answer busy, never the driver error. */ -export const isLockTimeout = (error: unknown) => (error as { code?: unknown } | null)?.code === "55P03"; +/** 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); } @@ -87,6 +87,8 @@ export async function inTransaction(db: DatabasePool, run: (client: DatabaseC // 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; diff --git a/services/server/src/features/next/private-collections.postgres.test.ts b/services/server/src/features/next/private-collections.postgres.test.ts index a480eb9d..41865e02 100644 --- a/services/server/src/features/next/private-collections.postgres.test.ts +++ b/services/server/src/features/next/private-collections.postgres.test.ts @@ -10,6 +10,7 @@ import { LogServiceClient } from "./log-service-client.js"; import { certToJson, ed25519RawPublicKey, loadPolicySigner, parseNextControlPlaneEnv, type NextControlPlaneConfig } from "./policy-keys.js"; import { PolicyEmitter, queueNextPolicy, registerNextCollection } from "./policy-outbox.js"; import { certDigest, chainHash, decodeCbor, encodeCbor, keyId, type Cbor, type Decoded, type PolicyOp } from "./policy-wire.js"; +import { inTransaction, refuse } from "./bootstrap-common.js"; import { privateCreateDigest, privateDeviceEnrolDigest, registerPrivateCollectionRoutes } from "./private-collections.js"; const testUrl = process.env.MDBASE_CONNECT_TEST_DATABASE_URL; @@ -196,6 +197,30 @@ describePg("private collections", () => { expect(log.logs.get(collection.replaceAll("-", ""))!.length).toBe(1); }); + it("bounds every statement and lock wait in its transactions, and answers busy on either timeout", async () => { + const settings = await inTransaction(db, async (c) => (await c.query<{ s: string; l: string }>( + "SELECT current_setting('statement_timeout') AS s, current_setting('lock_timeout') AS l" + )).rows[0]); + expect(settings).toEqual({ s: "5s", l: "5s" }); + const timedOut = await inTransaction(db, (c) => c.query("SET LOCAL statement_timeout = '10ms'").then(() => c.query("SELECT pg_sleep(1)"))) + .then(() => undefined, (error: unknown) => error); + expect((timedOut as { code?: string }).code).toBe("57014"); + for (const code of ["55P03", "57014"]) { + const reply = Fastify(); + reply.get("/", (_req, r) => refuse(r, Object.assign(new Error("timeout"), { code }), "retry")); + const res = await reply.inject({ method: "GET", url: "/" }); + expect([res.statusCode, res.json().error.code]).toEqual([503, "busy"]); + await reply.close(); + } + }); + + it("a retry mints nothing once the owner account is removed, even while the removal is pending", async () => { + const who = await identity(); + const collection = await created(who); + await queue(collection, [{ op: "member-remove", account: who.connector.user_id }]); + expect((await create(who, await createProof(who, collection))).json().error.code).toBe("not_member"); + }); + it("refuses other devices, other owners, cloud copies and collections that left sync", async () => { const who = await identity(); const collection = await created(who); diff --git a/services/server/src/features/next/private-collections.ts b/services/server/src/features/next/private-collections.ts index 7148f782..00a2eaef 100644 --- a/services/server/src/features/next/private-collections.ts +++ b/services/server/src/features/next/private-collections.ts @@ -141,6 +141,9 @@ export function registerPrivateCollectionRoutes(app: FastifyInstance, options: { return await inTransaction(options.db, async (client) => { await currentIdentity(client, connector, body.device_id, device); await currentPrivate(client, collection, connector.user_id); + // Ownership and a historical genesis are not membership: the owner account + // must still be a current member (a member-remove counts even while pending). + await currentMember(client, collection, connector.user_id); await refuseRevoked(client, collection, body.device_id); // The requesting device must be the one the genesis enrolled, with the same keys. const enrolled = await client.query( @@ -154,7 +157,9 @@ export function registerPrivateCollectionRoutes(app: FastifyInstance, options: { log_url: options.next.logService.url, head: { seq: head.seq, chain: Buffer.from(head.chain).toString("hex") }, root_public_key: Buffer.from(options.next.rootPublicKey).toString("hex"), policy_cert: options.next.policyCert, genesis: { seq: 1, item: genesis.item.toString("hex") }, - // Advisory: this device signs the initial rekey, wrapped to itself only. + // Advisory: the creating owner device is an editor user device, the legal + // initial-rekey signer in e2e (never hosted or escrow); at genesis it is + // the only active device, so it wraps the first epoch key to itself. rekey_recipients: [body.device_id], device: mint(body.device_id, device.sign_pk, collection) };