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).
This commit is contained in:
parent
6145281b3f
commit
96e5a63dd8
11 changed files with 1501 additions and 8 deletions
209
apps/api/src/oidc/oidc.keys.ts
Normal file
209
apps/api/src/oidc/oidc.keys.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue