diff --git a/README.md b/README.md index d20bb8d..fcf2cbb 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/jwt.test.ts b/src/jwt.test.ts new file mode 100644 index 0000000..85f2d9e --- /dev/null +++ b/src/jwt.test.ts @@ -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" }); +}); diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 0000000..73aac44 --- /dev/null +++ b/src/jwt.ts @@ -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 = { + 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; + 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 { + 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; +} diff --git a/todo.md b/todo.md index 0fead6f..517c0ad 100644 --- a/todo.md +++ b/todo.md @@ -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).