Generate + mount the JWT signing JWKS (todo §3); ES256 gen-jwks tool, committed dev key, key-rotation docs

This commit is contained in:
2026-06-17 13:24:31 +02:00
parent 95c759d773
commit 6640dfc84e
6 changed files with 112 additions and 2 deletions

44
src/gen-jwks.test.ts Normal file
View File

@@ -0,0 +1,44 @@
// 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 } 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("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);
});

32
src/gen-jwks.ts Normal file
View File

@@ -0,0 +1,32 @@
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
// ES256 signing JWKS for the Kratos session tokenizer (§3). Ory recommends ES* over the
// symmetric HS family; ES256 is also our verifier's preferred alg (src/jwt.ts). Kratos
// signs with the FIRST key in the set and the app verifies by `kid` (§4) — so rotation is
// prepend a fresh key, keep the old one ~one TTL (10m) for in-flight tokens, then drop it.
// (Re)generate the committed dev key (prod supplies its own — see README):
// docker compose run --rm -T web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
export interface SigningJwk {
kid: string;
alg: "ES256";
crv: string;
d: string; // private scalar — this is a signing key, keep it secret
kty: string;
use: "sig";
x: string;
y: string;
}
export interface JwkSet {
keys: SigningJwk[];
}
export function generateJwks(): JwkSet {
const { crv, d, kty, x, y } = generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" });
if (!crv || !d || !kty || !x || !y) throw new Error("unexpected JWK shape from EC key");
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`);