From 3c633e5ebd94e2dcad20bbfc2bd99de2d19fdc86 Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 20 Jun 2026 15:54:17 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A79=20JWT=20signing-key=20rotation=20runboo?= =?UTF-8?q?k=20(todo=20=C2=A79);=20turned=20the=20README's=203-line=20rota?= =?UTF-8?q?tion=20note=20into=20an=20operational=20runbook=20and=20closed?= =?UTF-8?q?=20the=20tooling=20gap=20that=20made=20its=20documented=20steps?= =?UTF-8?q?=20unrunnable=20=E2=80=94=20the=20old=20"prepend=20a=20key=20/?= =?UTF-8?q?=20drop=20it=20later"=20meant=20hand-editing=20a=20JSON=20file?= =?UTF-8?q?=20holding=20a=20private=20signing=20key.=20Tests-first:=20new?= =?UTF-8?q?=20pure=20rotateJwks(current,{prune})=20in=20gen-jwks.ts=20?= =?UTF-8?q?=E2=80=94=20--prepend=20puts=20a=20fresh=20ES256=20key=20first?= =?UTF-8?q?=20(Kratos=20signs=20with=20keys[0],=20the=20old=20keys=20still?= =?UTF-8?q?=20verify=20in-flight=20JWTs)=20and=20keeps=20the=20rest=20in?= =?UTF-8?q?=20order;=20--prune=20keeps=20only=20the=20newest=20(drop=20sup?= =?UTF-8?q?erseded=20post-TTL).=20CLI=20reads=20the=20existing=20set=20fro?= =?UTF-8?q?m=20a=20path=20arg=20and=20writes=20the=20new=20set=20to=20stdo?= =?UTF-8?q?ut=20(header=20documents=20the=20temp-file=20redirect=20so=20th?= =?UTF-8?q?e=20shell's=20>=20can't=20truncate=20the=20input).=20gen-jwks.t?= =?UTF-8?q?est.ts=20covers=20prepend=20(length+1,=20fresh=20kid=20first,?= =?UTF-8?q?=20old=20set=20preserved)=20+=20prune=20(=E2=86=92=201=20key).?= =?UTF-8?q?=20Runbook=20documents=20the=20two-sided=20install=20(Kratos=20?= =?UTF-8?q?signer=20env/mount=20+=20web=20JWKS=5FURL;=20file://=20hot-relo?= =?UTF-8?q?ads,=20base64://=20immutable),=20why=20it's=20zero-downtime=20(?= =?UTF-8?q?sign-with-first=20+=20verify-by-kid),=20the=20scheduled=20path?= =?UTF-8?q?=20(prepend=20=E2=86=92=20restart=20kratos=20=E2=86=92=20verify?= =?UTF-8?q?=20new=20kid=20=E2=86=92=20wait=20~12min=20=3D=2010m=20TTL=20+?= =?UTF-8?q?=20skew=20=E2=86=92=20prune;=20rollback=20before=20prune)=20and?= =?UTF-8?q?=20the=20emergency=20path=20(replace=20with=20a=20single=20key?= =?UTF-8?q?=20=E2=86=92=20every=20leaked-key=20token=20fails=20signature?= =?UTF-8?q?=20=E2=86=92=20forced=20re-login;=20the=20=C2=A79=20denylist=20?= =?UTF-8?q?is=20moot=20since=20the=20signature=20is=20already=20invalid).?= =?UTF-8?q?=20Verified=20the=20CLI=20live=20against=20the=20committed=20de?= =?UTF-8?q?v=20JWKS=20(bare=E2=86=921,=20--prepend=E2=86=922=20with=20old?= =?UTF-8?q?=20kid=20second,=20--prune=E2=86=921);=20jwks.json=20untouched.?= =?UTF-8?q?=20Docs/CLI-only,=20covered=20by=20units=20(per=20the=20=C2=A79?= =?UTF-8?q?=20precedent,=20no=20new=20browser=20E2E).=20README=20Status=20?= =?UTF-8?q?+=20Layout=20updated.=20typecheck=20+=20335=20units=20green=20(?= =?UTF-8?q?333=20=E2=86=92=20335).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 70 +++++++++++++++++++++++++++++++++++++++----- src/gen-jwks.test.ts | 17 ++++++++++- src/gen-jwks.ts | 30 ++++++++++++++++--- todo.md | 2 +- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8926668..4f17362 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). > SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent > / logout handlers** — all driven end-to-end by the Playwright suites, plus **production & ops > hardening** (the prod compose profile, response security headers, **structured logging + OTLP -> observability**). What's left is mainly a **JWT key-rotation runbook** — tracked in `todo.md` (§9). +> observability**, the **[JWT key-rotation runbook](#jwt-signing-key--rotation)**). Remaining +> polish is tracked in `todo.md` (§9–§10). ## The MVP — "clone, one command, hack on a plugin" @@ -204,18 +205,71 @@ same way. The session tokenizer (§3) signs each session→JWT with an **ES256** key at `ory/kratos/tokenizer/jwks.json`. The committed one is a **dev throwaway** (like the cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in -production**. (Re)generate with the bundled generator: +production**. Mint a fresh key with the bundled generator: ```bash docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json ``` -**Production:** mount a real key over that path, or set -`SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://`. +**Install in production.** Two endpoints must read the *same* key material: -**Rotation (zero downtime):** Kratos signs with the **first** key in the set; the app -selects the verify key by `kid` (§4). So prepend a freshly generated key, keep the old -one for ~one token TTL (10m) so in-flight JWTs still verify, then drop it. +- **Kratos (signer)** — mount the file over `…/tokenizer/jwks.json`, or set + `SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://`. +- **web (verifier)** — `JWKS_URL` (default `file://…/tokenizer/jwks.json`). A `file://` + set is re-read live (5-min TTL, plus an immediate reload on an unknown `kid`); a + `base64://` set is immutable and rotates only on a web redeploy. **For rotation, use + `file://` on the web side** so it picks up new keys without a restart. + +**Why rotation is zero-downtime.** Kratos signs with the **first** key in the set and +stamps its `kid` in each JWT header; web selects the verify key by that `kid` (§4). So a +set can hold the new key *and* the old one at once — tokens minted before and after the +swap both verify. + +#### Scheduled rotation + +The token TTL is **10 min** (`kratos.yml` → `whoami.tokenizer.…ttl`); the wait window +below is one TTL + clock skew, round up to **~12 min**. Run from the repo root (paths are +container-relative; with the dev bind-mount they edit the real file). + +1. **Prepend a fresh key** (new key first, old key kept) — write via a temp file so the + shell's `>` can't truncate the input before it's read: + ```bash + docker compose run --rm -T --no-deps web sh -c \ + 'node src/gen-jwks.ts --prepend ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \ + && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json + ``` +2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`. + (web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so + a brief Kratos blip only touches login/re-mint.) +3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT + header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients + present the new key. +4. **Wait ~12 min**, then **prune** the superseded key: + ```bash + docker compose run --rm -T --no-deps web sh -c \ + 'node src/gen-jwks.ts --prune ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \ + && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json + ``` + No Kratos restart needed — it already signs with that key; this only drops a now-unused + verify key. + +**Rollback** (before the prune): the old key is still in the set, so revert step 1's file +and `restart kratos` — in-flight tokens never broke. + +#### Emergency rotation (key compromise) + +Skip the overlap — you want every token signed with the leaked key to die now. **Replace** +the set with a single fresh key (no `--prepend`): + +```bash +docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json +docker compose restart kratos +``` + +Every existing JWT now fails signature verification → its bearer falls back to anonymous +and must re-authenticate (the §4 re-mint only covers *expired* tokens, not bad signatures, +so a forged/leaked-key token can't be silently refreshed). The instant-revoke denylist +(§9) is unnecessary here — the signature itself is already invalid. ## Type check & tests @@ -669,7 +723,7 @@ src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challen src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4) -src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation +src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate diff --git a/src/gen-jwks.test.ts b/src/gen-jwks.test.ts index a1677cc..7d2db94 100644 --- a/src/gen-jwks.test.ts +++ b/src/gen-jwks.test.ts @@ -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 })); diff --git a/src/gen-jwks.ts b/src/gen-jwks.ts index 259368b..a967f4d 100644 --- a/src/gen-jwks.ts +++ b/src/gen-jwks.ts @@ -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 → new key first + the old ones (zero-downtime rotation) +// gen-jwks.ts --prune → 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] "); + 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`); +} diff --git a/todo.md b/todo.md index d383b54..f5022b6 100644 --- a/todo.md +++ b/todo.md @@ -129,7 +129,7 @@ everything via Docker. - [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — the open gap was **response security headers**, now closed. New pure `src/security-headers.ts` (`securityHeaders({secure})`): a strict CSP for the zero-JS core — `default-src 'self'`, `script-src 'self'` with **no** `'unsafe-inline'` (an injected `