§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).
This commit is contained in:
@@ -5,7 +5,7 @@ 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 } from "./gen-jwks.ts";
|
||||
import { generateJwks, rotateJwks } from "./gen-jwks.ts";
|
||||
import { verifyJws } from "./jwt.ts";
|
||||
|
||||
const b64url = (s: string) => Buffer.from(s).toString("base64url");
|
||||
@@ -30,6 +30,21 @@ test("the committed dev JWKS is a valid ES256 signing key importable by node:cry
|
||||
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 }));
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// ES256 signing JWKS for the Kratos session tokenizer (§3) — Ory-recommended and the
|
||||
// verifier's preferred alg (src/jwt.ts). Rotation runbook: README, JWT signing key.
|
||||
// (Re)generate the committed dev key (prod supplies its own):
|
||||
// docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
|
||||
// CLI (prod supplies its own key; the committed one is a dev throwaway):
|
||||
// gen-jwks.ts → a fresh one-key set (mint/replace; emergency rotation)
|
||||
// gen-jwks.ts --prepend <jwks.json> → new key first + the old ones (zero-downtime rotation)
|
||||
// gen-jwks.ts --prune <jwks.json> → keep only the newest key (drop superseded, post-TTL)
|
||||
// All write to stdout; redirect into the JWKS file (use a temp file for --prepend/--prune so
|
||||
// the shell's `>` can't truncate the input before it's read).
|
||||
|
||||
export interface SigningJwk {
|
||||
kid: string;
|
||||
@@ -26,5 +31,22 @@ export function generateJwks(): JwkSet {
|
||||
return { keys: [{ kid: randomUUID(), alg: "ES256", crv, d, kty, use: "sig", x, y }] };
|
||||
}
|
||||
|
||||
// CLI: print a fresh set to stdout (redirect into the jwks.json above).
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) process.stdout.write(`${JSON.stringify(generateJwks(), null, 2)}\n`);
|
||||
// Rotate a JWKS: prepend a fresh key (Kratos signs with the first; the old keys still verify
|
||||
// in-flight JWTs) — or, with `prune`, keep only the newest key (drop superseded ones once the
|
||||
// old token TTL has elapsed). Pure list math; the active signing key is always keys[0].
|
||||
export function rotateJwks(current: JwkSet, opts: { prune?: boolean } = {}): JwkSet {
|
||||
return opts.prune ? { keys: current.keys.slice(0, 1) } : { keys: [generateJwks().keys[0]!, ...current.keys] };
|
||||
}
|
||||
|
||||
// CLI: print the resulting set to stdout (see the header for the redirect caveat).
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const rotate = args.includes("--prepend") || args.includes("--prune");
|
||||
let set: JwkSet;
|
||||
if (rotate) {
|
||||
const path = args.find((a) => !a.startsWith("--"));
|
||||
if (!path) throw new Error("usage: gen-jwks.ts [--prepend|--prune] <existing-jwks.json>");
|
||||
set = rotateJwks(JSON.parse(readFileSync(path, "utf8")) as JwkSet, { prune: args.includes("--prune") });
|
||||
} else set = generateJwks();
|
||||
process.stdout.write(`${JSON.stringify(set, null, 2)}\n`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user