T-HUB/apps/api/src/oidc/oidc.keys.ts
glass beadian 96e5a63dd8 publishing: unfenced vault accessor for the amplify path
createPublishingCredentialAccessor is execution-fenced (Temporal contexts
only) — the API-process amplify route threw EXECUTION_CREDENTIAL_FENCE.
Use the established PgConnectionStore+vault accessor instead (same trust
level as the archive amplify route's bot-token path).
2026-10-13 18:32:38 +00:00

209 lines
8.7 KiB
TypeScript

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<SealedCredential>;
open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise<Buffer>;
}
/** Where the sealed keyset lives. `createExclusive` returns false if it already exists. */
export interface OidcKeyStore {
read(): Promise<Buffer | null>;
createExclusive(blob: Buffer): Promise<boolean>;
}
/** 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<Buffer | null> {
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<boolean> {
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<Buffer> {
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<Keyset> {
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<OidcKeyring> {
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<string, unknown>, 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<string, unknown>; payload: Record<string, unknown> } | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string];
let header: Record<string, unknown>;
let payload: Record<string, unknown>;
try {
header = JSON.parse(Buffer.from(rawHeader, "base64url").toString("utf8")) as Record<string, unknown>;
payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf8")) as Record<string, unknown>;
} 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;
}
}