import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, verify, type JsonWebKey, type KeyObject } from "node:crypto"; import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { SealedCredential } from "../vault/vault.js"; /** * ES256 signing keys for the Hub's OIDC issuer. * * KEY MANAGEMENT CHOICE. The Hub's existing pattern for secret material is the * vault envelope (SUPERPOSTER_KEK wraps a fresh DEK, AES-256-GCM, AAD-bound). * The keyset is a single small blob, so it is sealed with that same envelope * and written to OIDC_KEY_DIR (a volume next to the API's data) instead of a * new Postgres table: no migration, and the file alone is useless without the * KEK. The private key is generated on first boot, never leaves this process * unsealed, and is never logged. * * First boot is race-safe: the sealed blob is written to a temp file and * hard-linked into place, which fails with EEXIST if another process won โ€” * the loser re-reads the winner's keyset, so every replica signs with one key. * * The keyset holds a LIST of keys (newest signs, all are published in JWKS) so * rotation can be added without a format change; phase 1 never rotates. */ /** The vault surface the keyring needs; `Vault` satisfies it, tests pass a fake. */ export interface KeySealer { seal(workspaceId: string, credentialId: string, plaintext: Buffer): Promise; open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise; } /** Where the sealed keyset lives. `createExclusive` returns false if it already exists. */ export interface OidcKeyStore { read(): Promise; createExclusive(blob: Buffer): Promise; } /** AAD for the vault envelope โ€” binds the blob to its purpose. */ const SEAL_SCOPE = "hub-oidc"; const SEAL_ID = "signing-keyset"; const FILE_FORMAT = "tmbh-oidc-keyset/v1"; const KEYSET_FILE = "keyset.sealed.json"; export class FileKeyStore implements OidcKeyStore { constructor(private readonly dir: string) {} async read(): Promise { try { return await readFile(join(this.dir, KEYSET_FILE)); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } } async createExclusive(blob: Buffer): Promise { await mkdir(this.dir, { recursive: true, mode: 0o700 }); const temp = join(this.dir, `.${KEYSET_FILE}.${randomBytes(6).toString("hex")}.tmp`); await writeFile(temp, blob, { mode: 0o600, flag: "wx" }); try { await link(temp, join(this.dir, KEYSET_FILE)); return true; } catch (err) { if ((err as NodeJS.ErrnoException).code === "EEXIST") return false; throw err; } finally { await unlink(temp).catch(() => undefined); } } } interface StoredKey { kid: string; createdAt: number; privateJwk: JsonWebKey; } interface Keyset { keys: StoredKey[]; } interface LoadedKey { kid: string; privateKey: KeyObject; publicKey: KeyObject; publicJwk: PublicJwk; } export interface PublicJwk { kty: "EC"; crv: "P-256"; x: string; y: string; kid: string; use: "sig"; alg: "ES256"; } export class OidcKeyError extends Error {} function b64json(value: unknown): string { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } /** RFC 7638 thumbprint โ€” a stable kid derived from the public key alone. */ function thumbprint(jwk: JsonWebKey): string { const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); return createHash("sha256").update(canonical).digest("base64url"); } function loadKey(stored: StoredKey): LoadedKey { const privateKey = createPrivateKey({ key: stored.privateJwk, format: "jwk" }); const publicKey = createPublicKey(privateKey); const jwk = publicKey.export({ format: "jwk" }); if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") { throw new OidcKeyError("keyset holds a key that is not P-256"); } if (thumbprint(jwk) !== stored.kid) throw new OidcKeyError("keyset kid does not match its key"); return { kid: stored.kid, privateKey, publicKey, publicJwk: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, kid: stored.kid, use: "sig", alg: "ES256" } }; } function newKey(now: number): StoredKey { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); const privateJwk = privateKey.export({ format: "jwk" }); return { kid: thumbprint(privateJwk), createdAt: now, privateJwk }; } async function sealKeyset(sealer: KeySealer, keyset: Keyset): Promise { const sealed = await sealer.seal(SEAL_SCOPE, SEAL_ID, Buffer.from(JSON.stringify(keyset), "utf8")); return Buffer.from(JSON.stringify({ format: FILE_FORMAT, keyVersion: sealed.keyVersion, ciphertext: sealed.ciphertext.toString("base64") }), "utf8"); } async function openKeyset(sealer: KeySealer, blob: Buffer): Promise { let envelope: { format?: unknown; keyVersion?: unknown; ciphertext?: unknown }; try { envelope = JSON.parse(blob.toString("utf8")) as typeof envelope; } catch { throw new OidcKeyError("keyset file is not valid JSON"); } if (envelope.format !== FILE_FORMAT || typeof envelope.keyVersion !== "number" || typeof envelope.ciphertext !== "string") { throw new OidcKeyError("keyset file has an unknown format"); } let plaintext: Buffer; try { plaintext = await sealer.open(SEAL_SCOPE, SEAL_ID, { keyVersion: envelope.keyVersion, ciphertext: Buffer.from(envelope.ciphertext, "base64") }); } catch { // Wrong SUPERPOSTER_KEK or a tampered file. Never generate a replacement // silently: that would invalidate every token oCIS holds. throw new OidcKeyError("keyset could not be unsealed (SUPERPOSTER_KEK changed or file corrupted)"); } const keyset = JSON.parse(plaintext.toString("utf8")) as Keyset; if (!Array.isArray(keyset.keys) || keyset.keys.length === 0) throw new OidcKeyError("keyset is empty"); return keyset; } /** Loaded signing keys. The newest key signs; JWKS publishes all of them. */ export class OidcKeyring { private constructor(private readonly keys: readonly LoadedKey[]) {} /** Load the persisted keyset, generating and persisting one on first boot. */ static async open(store: OidcKeyStore, sealer: KeySealer, nowSeconds = Math.floor(Date.now() / 1000)): Promise { let blob = await store.read(); if (blob === null) { const fresh = await sealKeyset(sealer, { keys: [newKey(nowSeconds)] }); // Lost the first-boot race: fall through and read the winner's keyset. blob = (await store.createExclusive(fresh)) ? fresh : await store.read(); if (blob === null) throw new OidcKeyError("keyset vanished after creation"); } const keyset = await openKeyset(sealer, blob); const keys = [...keyset.keys].sort((a, b) => b.createdAt - a.createdAt).map(loadKey); return new OidcKeyring(keys); } get signingKid(): string { return this.keys[0]!.kid; } jwks(): { keys: PublicJwk[] } { return { keys: this.keys.map((key) => ({ ...key.publicJwk })) }; } /** Compact JWS, ES256 (raw r||s signature per RFC 7518 ยง3.4). */ sign(payload: Record, typ = "JWT"): string { const key = this.keys[0]!; const signingInput = `${b64json({ alg: "ES256", typ, kid: key.kid })}.${b64json(payload)}`; const signature = sign("sha256", Buffer.from(signingInput), { key: key.privateKey, dsaEncoding: "ieee-p1363" }); return `${signingInput}.${signature.toString("base64url")}`; } /** Signature-checked header + payload, or null. Claim checks are the caller's job. */ verify(token: string): { header: Record; payload: Record } | null { const parts = token.split("."); if (parts.length !== 3) return null; const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string]; let header: Record; let payload: Record; try { header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as Record; payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as Record; } catch { return null; } if (typeof header !== "object" || header === null || typeof payload !== "object" || payload === null) return null; if (header.alg !== "ES256") return null; const key = this.keys.find((k) => k.kid === header.kid); if (key === undefined) return null; const ok = verify("sha256", Buffer.from(`${rawHeader}.${rawPayload}`), { key: key.publicKey, dsaEncoding: "ieee-p1363" }, Buffer.from(rawSignature, "base64url")); return ok ? { header, payload } : null; } }