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).
209 lines
8.7 KiB
TypeScript
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;
|
|
}
|
|
}
|