diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a726f289..49c263a6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -85,6 +85,35 @@ before it becomes the `aud`. `https` rules are unchanged. The flag widens only which http hosts are accepted. It does not make a public http origin acceptable, so turn it on only where History is reached over a trusted private network. +### Machine binding (`meta.machineId`) + +History assertions for courier writes carry the machine they were minted for +as `meta.machineId`, so ingest can check that a manifest POST or status call +comes from the machine named in the manifest key's `m=` component. Read-only +and control-plane History paths may omit the request body field because they do +not represent a courier machine. When `machineId` is supplied it is: + +- 1 to 128 characters of `A-Za-z0-9._-`, the same form as the `m=` component; +- no leading `.`, and no `relay_` prefix (machine ids have no RelayAuth alias); +- exact bytes: values that would need trimming, case folding or prefix + stripping are refused (400 `invalid_machine_id`), never rewritten. + +An omitted `machineId` produces no `meta.machineId` claim and the audit row is +marked `machineIdMissing: "true"`. Consumers of machine-bound operations must +require the claim and match it to their own authenticated machine context; +RelayAuth cannot infer that context for read-only or control-plane callers. + +**Who is trusted to assert it.** RelayAuth validates the form only. It cannot +know which machine a courier runs on, so it signs the `machineId` that the +holder of the dedicated `relayauth:assertion:create:relayhistory` API key sends +(the control plane / Cloud's broker), exactly as it already signs that caller's +`orgId`, `workspaceId` and `sponsorId`. That caller must establish the machine +from its own authenticated session with the courier before asking, and must +never pass through a value the courier chose without that check. The route +accepts only that dedicated key over `x-api-key` (a bearer token carrying the +scope is refused with `assertion_key_required`), so couriers and other callers +cannot choose a `machineId`. Never hand the assertion key to a courier. + ## Session directory search issuer RelayAuth can mint 60-second tokens for one PostgREST RPC, diff --git a/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts b/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts index 0dc533d1..5a6c9882 100644 --- a/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts +++ b/packages/server/src/__tests__/selfhost-keys-issuer-bootstrap.test.ts @@ -205,6 +205,7 @@ test("bootstrap api key is seeded idempotently, hash-only, and mints History ass orgId: "org_a", workspaceId: "ws_a", sponsorId: "user_a", + machineId: "machine-a", scopes: ["rth:sync"], expiresIn: 60, historyOrigin: "http://127.0.0.1:18780", diff --git a/packages/server/src/__tests__/tokens-route.test.ts b/packages/server/src/__tests__/tokens-route.test.ts index 71de8075..973365c3 100644 --- a/packages/server/src/__tests__/tokens-route.test.ts +++ b/packages/server/src/__tests__/tokens-route.test.ts @@ -36,6 +36,7 @@ import { seedPlainApiKey, seedWorkspaceContext, TEST_RS256_PRIVATE_KEY_PEM, + TEST_RS256_PUBLIC_KEY_PEM, } from "./test-helpers.js"; type JwtHeader = { @@ -2271,6 +2272,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:sync", "rth:read"], expiresIn: 45, }, @@ -2315,6 +2317,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { requestedOrgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), + machineId: "machine-a", }); assert.equal( await countStoredTokens(app), @@ -2346,6 +2349,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { actorOrgId: "org_tokens_route", sponsorId: "user_cloud_login", grantedScopes: JSON.stringify(["rth:read", "rth:sync"]), + machineId: "machine-a", }); }, ); @@ -2369,6 +2373,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], }, headers: { "x-api-key": assertionKey.key }, @@ -2399,6 +2404,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], }, headers: authHeaders, @@ -2611,6 +2617,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read", "rth:admin"], }, headers: { @@ -2644,6 +2651,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], expiresIn: 61, }, @@ -2675,6 +2683,7 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { orgId: "org_relayhistory_target", workspaceId: "ws_relayhistory_target", sponsorId: "user_cloud_login", + machineId: "machine-a", scopes: ["rth:read"], historyOrigin, }, @@ -2895,6 +2904,194 @@ test("POST /v1/tokens/relayhistory-assertion", async (t) => { }); }); +function verifyRs256Token(token: string): boolean { + const [header, payload, signature] = token.split("."); + return crypto.verify( + "RSA-SHA256", + Buffer.from(`${header}.${payload}`), + TEST_RS256_PUBLIC_KEY_PEM, + Buffer.from(signature ?? "", "base64url"), + ); +} + +test("POST /v1/tokens/relayhistory-assertion machineId claim", async (t) => { + const assertionScope = "relayauth:assertion:create:relayhistory"; + const baseBody = { + orgId: "org_relayhistory_target", + workspaceId: "ws_relayhistory_target", + sponsorId: "user_cloud_login", + scopes: ["rth:sync"], + }; + + async function harness() { + const { app, authHeaders } = await createHarness({ + authClaims: { + scopes: ["relayauth:api-key:manage:*", assertionScope], + }, + }); + // Broker keys are provisioned out of band. Current main deliberately + // refuses bearer/admin callers that try to delegate this literal scope. + const assertionKey = await seedPlainApiKey(app, { + orgId: "org_tokens_route", + name: "relayhistory-machine-binding-broker", + scopes: [assertionScope], + }); + const mint = (body: Record, headers?: HeadersInit) => + requestRoute(app, "POST", "/v1/tokens/relayhistory-assertion", { + body, + headers: headers ?? { "x-api-key": assertionKey.key }, + }); + return { app, authHeaders, assertionKey, mint }; + } + + async function lastAssertionAudit(app: ReturnType) { + const row = await app.storage.DB.prepare( + `SELECT metadata_json FROM audit_logs + WHERE resource = 'relayhistory-assertion' + ORDER BY timestamp DESC LIMIT 1`, + ).first<{ metadata_json: string }>(); + return JSON.parse(row?.metadata_json ?? "{}") as Record; + } + + for (const machineId of [ + "machine-a", + "M", + "unknown-origin-42", + "host.example_01", + "a".repeat(128), + "019a1f3c-8e2b-7c41-9d3a-5b6f0e2a7c18", + ]) { + await t.test(`signs a canonical machineId (${machineId.slice(0, 24)})`, async () => { + const { app, mint } = await harness(); + const body = await assertJsonResponse( + await mint({ ...baseBody, machineId }), + 201, + ); + assert.equal(verifyRs256Token(body.accessToken), true); + const claims = decodeJwtJsonSegment(body.accessToken, 1); + assert.equal(claims.meta?.machineId, machineId); + assert.equal((await lastAssertionAudit(app)).machineId, machineId); + }); + } + + for (const machineId of [ + "", + " machine-a", + "machine-a ", + "machine a", + "machine/a", + "m=machine-a", + ".hidden", + "..", + ".", + "relay_machine-a", + "a".repeat(129), + "machine-\u00e4", + "machine-a\n", + null, + 42, + true, + ["machine-a"], + { id: "machine-a" }, + ]) { + await t.test(`refuses a malformed machineId (${JSON.stringify(machineId)})`, async () => { + const { app, mint } = await harness(); + await assertJsonResponse( + await mint({ ...baseBody, machineId }), + 400, + (body) => { + assert.equal(body.code, "invalid_machine_id"); + }, + ); + assert.equal(await countStoredTokens(app), 0); + }); + } + + await t.test("allows and audits an omitted machineId for non-machine paths", async () => { + const { app, mint } = await harness(); + const body = await assertJsonResponse( + await mint(baseBody), + 201, + ); + const claims = decodeJwtJsonSegment(body.accessToken, 1); + assert.equal(claims.meta?.machineId, undefined); + assert.equal("machineId" in (claims.meta ?? {}), false); + const audit = await lastAssertionAudit(app); + assert.equal(audit.machineIdMissing, "true"); + assert.equal(audit.machineId, undefined); + + // A supplied machineId is still validated and signed exactly. + const withMachine = await assertJsonResponse( + await mint({ ...baseBody, machineId: "machine-a" }), + 201, + ); + assert.equal( + decodeJwtJsonSegment(withMachine.accessToken, 1).meta?.machineId, + "machine-a", + ); + }); + + await t.test("machineId does not widen who may mint or for which org", async () => { + const { app, authHeaders, assertionKey, mint } = await harness(); + // The claim is only ever minted through the dedicated assertion key. + await assertJsonResponse( + await mint({ ...baseBody, machineId: "machine-a" }, authHeaders), + 403, + (body) => { + assert.equal(body.code, "assertion_key_required"); + }, + ); + const otherKey = await issueApiKey(app, authHeaders, ["relayauth:api-key:manage:*"]); + const wrongScope = await mint( + { ...baseBody, machineId: "machine-a" }, + { "x-api-key": otherKey.key }, + ); + assert.equal(wrongScope.status, 403); + + // A machine id never stands in for, or rewrites, org/workspace/sponsor. + for (const [field, code] of [ + ["orgId", "orgId_required"], + ["workspaceId", "workspaceId_required"], + ["sponsorId", "sponsorId_required"], + ] as const) { + const { [field]: _omitted, ...partial } = baseBody; + await assertJsonResponse( + await mint({ ...partial, machineId: "org_relayhistory_target" }), + 400, + (body) => { + assert.equal(body.code, code); + }, + ); + } + const body = await assertJsonResponse( + await mint({ + ...baseBody, + machineId: "org_other", + meta: { machineId: "injected", org: "org_other" }, + org: "org_other", + }), + 201, + ); + const claims = decodeJwtJsonSegment(body.accessToken, 1); + assert.equal(claims.org, baseBody.orgId); + assert.equal(claims.wks, baseBody.workspaceId); + assert.equal(claims.sponsorId, baseBody.sponsorId); + assert.equal(claims.meta?.requestedOrgId, baseBody.orgId); + assert.equal(claims.meta?.actorOrgId, "org_tokens_route"); + assert.equal(claims.meta?.actorId, "api_key:" + assertionKey.apiKey.id); + assert.equal(claims.meta?.machineId, "org_other"); + assert.deepEqual(Object.keys(claims.meta ?? {}).sort(), [ + "actorId", + "actorOrgId", + "grantedScopes", + "machineId", + "requestedOrgId", + "tokenClass", + "workspaceId", + ]); + }); +}); + test("POST /v1/tokens/refresh", async (t) => { await t.test( "refreshes a RS256 token pair without requiring a bearer token", diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 21f4fd5f..98d24579 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -119,6 +119,18 @@ type RelayhistoryAssertionRequest = { * History. Omitted: the legacy shared `relayhistory` audience. */ historyOrigin?: unknown; + /** + * Canonical id of the courier machine (the writer) the assertion is for, + * signed as `meta.machineId` so ingest can bind the token to the manifest + * key's `m=` component. Same form as that component: 1 to 128 characters of + * `A-Za-z0-9._-`, no leading `.`, no `relay_` alias prefix, exact bytes (no + * trimming or case folding). RelayAuth checks the form only: the caller (the + * holder of the dedicated assertion key, i.e. Cloud's broker) is trusted to + * have authorized the machine before asking. Optional because read-only and + * control-plane History paths do not represent a courier machine; consumers + * of machine-bound writes must require and match the signed claim. + */ + machineId?: unknown; }; type RevokeTokenRequest = { @@ -228,6 +240,9 @@ const RELAYHISTORY_ASSERTION_SCOPE = "relayauth:assertion:create:relayhistory"; const RELAYHISTORY_ASSERTION_AUDIENCE = "relayhistory"; const MAX_RELAYHISTORY_ASSERTION_TTL_SECONDS = 60; const RELAYHISTORY_ASSERTION_SCOPES = ["rth:read", "rth:sync"] as const; +/** Mirrors the manifest key's `m=` component ([A-Za-z0-9._-]{1,128}). */ +const RELAYHISTORY_MACHINE_ID_SHAPE = /^[A-Za-z0-9._-]{1,128}$/u; +const RELAYHISTORY_MACHINE_ID_ALIAS_PREFIX = "relay_"; tokens.post("/", async (c) => { const auth = await authenticateAndAuthorizeFromContext( @@ -812,6 +827,7 @@ tokens.post("/relayhistory-assertion", async (c) => { scopes: request.scopes, expiresIn: request.expiresIn, historyOrigin: request.historyOrigin, + machineId: request.machineId, actorId: auth.claims.sub, actorOrgId: auth.claims.org, }); @@ -1570,6 +1586,7 @@ async function issueRelayhistoryAssertion( scopes: string[]; expiresIn: number; historyOrigin?: string; + machineId?: string; actorId: string; actorOrgId: string; }, @@ -1603,6 +1620,7 @@ async function issueRelayhistoryAssertion( ...(options.historyOrigin ? { historyOrigin: options.historyOrigin } : {}), + ...(options.machineId ? { machineId: options.machineId } : {}), }, }; @@ -1617,6 +1635,7 @@ async function issueRelayhistoryAssertion( sponsorId: options.sponsorId, tokenId: claims.jti, scopes: options.scopes, + machineId: options.machineId, }), }); @@ -1714,6 +1733,7 @@ function createAssertionAuditEntry(options: { sponsorId: string; tokenId: string; scopes: string[]; + machineId?: string; }): AuditLogWriteEntry { return { id: crypto.randomUUID(), @@ -1729,6 +1749,11 @@ function createAssertionAuditEntry(options: { actorOrgId: options.actorOrgId, sponsorId: options.sponsorId, grantedScopes: JSON.stringify(options.scopes), + // Keep omission explicit for review; correlate with grantedScopes and + // the consumer operation because non-machine paths legitimately omit it. + ...(options.machineId + ? { machineId: options.machineId } + : { machineIdMissing: "true" }), }, timestamp: new Date().toISOString(), }; @@ -2129,6 +2154,7 @@ function normalizeRelayhistoryAssertionRequest( scopes: string[]; expiresIn: number; historyOrigin?: string; + machineId?: string; } | { ok: false; error: string; code: string; status: 400 } { const orgId = normalizeOptionalString(value.orgId); @@ -2194,6 +2220,11 @@ function normalizeRelayhistoryAssertionRequest( }; } + const machineId = normalizeRelayhistoryMachineId(value.machineId); + if (!machineId.ok) { + return machineId; + } + return { ok: true, orgId, @@ -2202,9 +2233,42 @@ function normalizeRelayhistoryAssertionRequest( scopes: scopes.scopes, expiresIn: expiresIn.expiresIn, ...(historyOrigin.origin ? { historyOrigin: historyOrigin.origin } : {}), + ...(machineId.machineId ? { machineId: machineId.machineId } : {}), }; } +/** + * Canonical machine id: exactly the manifest key's `m=` component form. The + * value is taken byte for byte; anything that would need trimming, case + * folding or alias stripping is refused rather than rewritten, so one machine + * has exactly one signed spelling. Only an absent field counts as missing; + * `null`, `""` and non-strings are malformed. + */ +export function normalizeRelayhistoryMachineId( + value: unknown, +): + | { ok: true; machineId?: string } + | { ok: false; error: string; code: string; status: 400 } { + if (value === undefined) { + return { ok: true }; + } + if ( + typeof value !== "string" || + !RELAYHISTORY_MACHINE_ID_SHAPE.test(value) || + value.startsWith(".") || + value.startsWith(RELAYHISTORY_MACHINE_ID_ALIAS_PREFIX) + ) { + return { + ok: false, + error: + "machineId must be 1 to 128 characters of A-Za-z0-9._-, not starting with '.' or 'relay_'", + code: "invalid_machine_id", + status: 400, + }; + } + return { ok: true, machineId: value }; +} + /** * Raw shape of a bare origin: `scheme://authority` with at most one trailing * slash. The authority may not contain userinfo (`@`), percent escapes, a diff --git a/scripts/selfhost-smoke.sh b/scripts/selfhost-smoke.sh index 1df14cae..6206112e 100755 --- a/scripts/selfhost-smoke.sh +++ b/scripts/selfhost-smoke.sh @@ -118,7 +118,7 @@ post_assertion() { -X POST "$BASE_URL/v1/tokens/relayhistory-assertion" \ -H 'content-type: application/json' \ -H "x-api-key: $(tr -d '\n' < "$SECRETS/bootstrap-api-key")" \ - --data "{\"orgId\":\"org_smoke\",\"workspaceId\":\"ws_smoke\",\"sponsorId\":\"user_smoke\",\"scopes\":[\"rth:sync\",\"rth:read\"],\"expiresIn\":60,\"historyOrigin\":\"$1\"}" + --data "{\"orgId\":\"org_smoke\",\"workspaceId\":\"ws_smoke\",\"sponsorId\":\"user_smoke\",\"machineId\":\"machine-smoke\",\"scopes\":[\"rth:sync\",\"rth:read\"],\"expiresIn\":60,\"historyOrigin\":\"$1\"}" } # mint_assertion [origin] -> prints the access token