Add node:crypto JWS signature verification primitive (todo §0)

This commit is contained in:
2026-06-14 18:27:34 +02:00
parent d021fd701e
commit 5020be592a
4 changed files with 201 additions and 1 deletions

View File

@@ -337,6 +337,7 @@ _(Production compose grows to include the Ory services and Postgres — planned.
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering
src/static.ts Static file serving with path-traversal protection
src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
views/ Core EJS templates (index, 404, partials/)
public/ Static assets under /public/ (css/, favicon, robots.txt)

97
src/jwt.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
import { test } from "node:test";
import { decodeJws, verifyJws } from "./jwt.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
// Sign a compact JWS the way a JOSE signer (Kratos tokenizer) would, via node:crypto.
function makeJws(alg: "ES256" | "RS256", privateKey: KeyObject, payload: unknown): string {
const signingInput = `${b64url(JSON.stringify({ alg, typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
const signature =
alg === "ES256"
? sign("SHA256", Buffer.from(signingInput), { key: privateKey, dsaEncoding: "ieee-p1363" })
: sign("RSA-SHA256", Buffer.from(signingInput), privateKey);
return `${signingInput}.${b64url(signature)}`;
}
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
test("verifies an RS256 token, returning the decoded header + payload", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
const verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256");
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
});
test("verifies an ES256 token (raw r‖s signature)", () => {
const token = makeJws("ES256", ec.privateKey, { sub: "u" });
assert.deepEqual(verifyJws(token, ecJwk).payload, { sub: "u" });
});
test("rejects a tampered payload", () => {
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
const [header, , signature] = token.split(".");
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
});
test("rejects a signature from a different key", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
const other = generateKeyPairSync("rsa", { modulusLength: 2048 });
assert.throws(() => verifyJws(token, other.publicKey.export({ format: "jwk" }) as JsonWebKey), /invalid signature/);
});
test("rejects an empty signature segment", () => {
const [header, payload] = makeJws("RS256", rsa.privateKey, { sub: "u" }).split(".");
assert.throws(() => verifyJws(`${header}.${payload}.`, rsaJwk), /invalid signature/);
});
test("rejects alg:none", () => {
const token = `${b64url(JSON.stringify({ alg: "none", typ: "JWT" }))}.${b64url(JSON.stringify({ sub: "u" }))}.`;
assert.throws(() => verifyJws(token, rsaJwk), /unsupported alg/);
});
test("rejects symmetric alg HS256", () => {
const token = `${b64url(JSON.stringify({ alg: "HS256" }))}.${b64url(JSON.stringify({ sub: "u" }))}.${b64url("x")}`;
assert.throws(() => verifyJws(token, rsaJwk), /unsupported alg/);
});
test("rejects when key type does not match the alg family", () => {
const token = makeJws("ES256", ec.privateKey, { sub: "u" });
assert.throws(() => verifyJws(token, rsaJwk), /does not match alg/);
});
test("rejects when the JWK pins a different alg", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
assert.throws(() => verifyJws(token, { ...rsaJwk, alg: "RS512" }), /alg mismatch/);
});
test("rejects a token without three segments", () => {
assert.throws(() => verifyJws("only.two", rsaJwk), /expected 3 segments/);
});
test("rejects a non-object (array) payload", () => {
const token = `${b64url(JSON.stringify({ alg: "RS256" }))}.${b64url(JSON.stringify([1, 2, 3]))}.${b64url("x")}`;
assert.throws(() => verifyJws(token, rsaJwk), /payload not an object/);
});
test("rejects a non-canonical base64url segment before any crypto", () => {
assert.throws(() => verifyJws(`ab*c.${b64url(JSON.stringify({ sub: "u" }))}.${b64url("x")}`, rsaJwk), /base64url/);
});
test("rejects a non-string kid in the header", () => {
const token = `${b64url(JSON.stringify({ alg: "RS256", kid: 123 }))}.${b64url(JSON.stringify({ sub: "u" }))}.${b64url("x")}`;
assert.throws(() => verifyJws(token, rsaJwk), /kid/);
});
test("decodeJws exposes header and payload without verifying", () => {
const token = makeJws("RS256", rsa.privateKey, { sub: "u" });
const decoded = decodeJws(token);
assert.equal(decoded.header.alg, "RS256");
assert.deepEqual(decoded.payload, { sub: "u" });
});

102
src/jwt.ts Normal file
View File

@@ -0,0 +1,102 @@
import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
// JWT signature verification with the Node standard library — no `jose`/JWT package.
// Decision (todo §0): `node:crypto` imports a JWK directly (`createPublicKey({format:"jwk"})`)
// and verifies the RS*/ES* signatures the Kratos session tokenizer produces — everything
// we need. A dependency would add supply-chain surface for capability we already have; see
// AGENTS.md (few dependencies, prefer stdlib).
//
// Scope is signature verification only. The §4 auth layer builds the rest on top of this:
// claim checks (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and — at its
// network boundary — guarding `token` is a string and bounding its length before calling in.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
// Widen support by extending this map. Security invariant: never add an `HS*` (symmetric)
// entry — this map is the allowlist, and one would let an attacker-supplied HMAC key verify.
// `none` is absent for the same reason.
const algParams: Record<string, { hash: string; keyType: "ec" | "rsa"; dsaEncoding?: "ieee-p1363" }> = {
ES256: { dsaEncoding: "ieee-p1363", hash: "SHA256", keyType: "ec" },
RS256: { hash: "RSA-SHA256", keyType: "rsa" },
};
export interface JwsHeader {
alg: string;
kid?: string;
}
export interface DecodedJws {
header: JwsHeader;
payload: Record<string, unknown>;
signingInput: string;
signature: Buffer;
}
// Unpadded base64url alphabet — `Buffer.from(_, "base64url")` is lax (drops junk, tolerates
// non-canonical padding), so reject non-canonical segments up front. §4 reads `kid` from the
// still-unverified header, so this stops laundered bytes reaching key selection.
const base64url = /^[A-Za-z0-9_-]+$/;
function decodeSegment(segment: string): unknown {
if (!base64url.test(segment)) throw new Error("malformed JWS: invalid base64url segment");
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Split a compact JWS and base64url-decode its header/payload. No signature check.
export function decodeJws(token: string): DecodedJws {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("malformed JWS: expected 3 segments");
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
const rawHeader = decodeSegment(headerB64);
const payload = decodeSegment(payloadB64);
if (!isPlainObject(rawHeader)) throw new Error("malformed JWS: header not an object");
if (!isPlainObject(payload)) throw new Error("malformed JWS: payload not an object");
const { alg, kid } = rawHeader;
if (typeof alg !== "string") throw new Error("malformed JWS: header missing `alg`");
if (kid !== undefined && typeof kid !== "string") throw new Error("malformed JWS: `kid` must be a string");
return {
header: kid === undefined ? { alg } : { alg, kid },
payload,
// Verify over the original encoded strings — never re-encode the decoded JSON.
signingInput: `${headerB64}.${payloadB64}`,
signature: Buffer.from(signatureB64, "base64url"),
};
}
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
// Signature only — the caller validates claims. The returned header is post-verification,
// so §4 can trust its `alg`/`kid` when logging.
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
const decoded = decodeJws(token);
const { header, signingInput, signature } = decoded;
const params = algParams[header.alg];
if (!params) throw new Error(`unsupported alg: ${header.alg}`);
// Block alg confusion: a key may pin its own `alg`, and its type must match the family.
if (typeof jwk.alg === "string" && jwk.alg !== header.alg) throw new Error("alg mismatch between JWS header and JWK");
let key: KeyObject;
try {
key = createPublicKey({ format: "jwk", key: jwk });
} catch {
throw new Error("invalid JWK");
}
if (key.asymmetricKeyType !== params.keyType) {
throw new Error(`JWK type ${key.asymmetricKeyType} does not match alg ${header.alg}`);
}
const data = Buffer.from(signingInput);
const ok = params.dsaEncoding
? verify(params.hash, data, { dsaEncoding: params.dsaEncoding, key }, signature)
: verify(params.hash, data, key, signature);
if (!ok) throw new Error("invalid signature");
return decoded;
}

View File

@@ -12,7 +12,7 @@ everything via Docker.
> real. Hydra/SSO are explicitly *post-MVP*.
## 0. Housekeeping / primitives
- [ ] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding.
- [x] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding.`node:crypto` (no new dep); `src/jwt.ts` verifies JWS signatures.
- [ ] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite).
- [ ] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`.
- [ ] Error templates: add 403 + 500 (404 exists).