Files
plainpages/src/gen-jwks.test.ts
lilleman 3c633e5ebd §9 JWT signing-key rotation runbook (todo §9); turned the README's 3-line rotation note into an operational runbook and closed the tooling gap that made its documented steps unrunnable — the old "prepend a key / drop it later" meant hand-editing a JSON file holding a private signing key. Tests-first: new pure rotateJwks(current,{prune}) in gen-jwks.ts — --prepend puts a fresh ES256 key first (Kratos signs with keys[0], the old keys still verify in-flight JWTs) and keeps the rest in order; --prune keeps only the newest (drop superseded post-TTL). CLI reads the existing set from a path arg and writes the new set to stdout (header documents the temp-file redirect so the shell's > can't truncate the input). gen-jwks.test.ts covers prepend (length+1, fresh kid first, old set preserved) + prune (→ 1 key). Runbook documents the two-sided install (Kratos signer env/mount + web JWKS_URL; file:// hot-reloads, base64:// immutable), why it's zero-downtime (sign-with-first + verify-by-kid), the scheduled path (prepend → restart kratos → verify new kid → wait ~12min = 10m TTL + skew → prune; rollback before prune) and the emergency path (replace with a single key → every leaked-key token fails signature → forced re-login; the §9 denylist is moot since the signature is already invalid). Verified the CLI live against the committed dev JWKS (bare→1, --prepend→2 with old kid second, --prune→1); jwks.json untouched. Docs/CLI-only, covered by units (per the §9 precedent, no new browser E2E). README Status + Layout updated. typecheck + 335 units green (333 → 335).
2026-06-20 15:54:17 +02:00

60 lines
3.3 KiB
TypeScript

// Guards the session-tokenizer signing key (§3): generateJwks() emits a fresh ES256
// EC private signing key, the committed dev JWKS is a valid such key, and a token signed
// with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, §4 reads.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createPrivateKey, sign, type JsonWebKey } from "node:crypto";
import { generateJwks, rotateJwks } from "./gen-jwks.ts";
import { verifyJws } from "./jwt.ts";
const b64url = (s: string) => Buffer.from(s).toString("base64url");
const committed = JSON.parse(readFileSync(new URL("../ory/kratos/tokenizer/jwks.json", import.meta.url), "utf8"));
test("generateJwks emits one ES256 EC private signing key with a fresh kid", () => {
const a = generateJwks();
const b = generateJwks();
assert.equal(a.keys.length, 1);
const k = a.keys[0]!;
assert.deepEqual({ alg: k.alg, crv: k.crv, kty: k.kty, use: k.use }, { alg: "ES256", crv: "P-256", kty: "EC", use: "sig" });
assert.ok(k.d && k.x && k.y, "carries the private scalar d (a signing key) + public point");
assert.match(k.kid, /^[0-9a-f-]{36}$/, "kid is a uuid");
assert.notEqual(k.kid, b.keys[0]!.kid, "each call mints a unique kid (so rotation differs)");
});
test("the committed dev JWKS is a valid ES256 signing key importable by node:crypto", () => {
const k = committed.keys[0];
assert.equal(committed.keys.length, 1);
assert.deepEqual({ alg: k.alg, kty: k.kty, use: k.use }, { alg: "ES256", kty: "EC", use: "sig" });
assert.ok(k.kid && k.d, "has a kid and the private signing scalar");
assert.doesNotThrow(() => createPrivateKey({ key: k, format: "jwk" }), "Kratos can load it to sign");
});
test("rotateJwks prepends a fresh signing key, keeping the old ones for in-flight verification", () => {
const old = generateJwks(); // a one-key set, as Kratos signs with the first
const rotated = rotateJwks(old);
assert.equal(rotated.keys.length, old.keys.length + 1);
assert.notEqual(rotated.keys[0]!.kid, old.keys[0]!.kid, "the new key is first (Kratos signs with it) with a fresh kid");
assert.deepEqual(rotated.keys.slice(1), old.keys, "old keys are preserved in order so unexpired JWTs still verify");
assert.equal(rotated.keys[0]!.alg, "ES256");
});
test("rotateJwks --prune keeps only the newest (first) key, dropping superseded ones", () => {
const twoKeys = rotateJwks(generateJwks()); // prepend → 2 keys
const pruned = rotateJwks(twoKeys, { prune: true });
assert.deepEqual(pruned.keys, [twoKeys.keys[0]], "only the active signing key remains");
});
test("a JWS signed with a generated key verifies via our own verifier (§4 reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
const token = `${head}.${body}.${sig.toString("base64url")}`;
const { d: _d, ...pub } = key; // verify against the public half only
const decoded = verifyJws(token, pub);
assert.equal(decoded.payload.email, "a@b.c");
assert.equal(decoded.header.kid, key.kid);
});