Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
# `b64` (RFC 7797) header
## Purpose
RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the signing input. When `b64: false`, the second compact-form segment is the raw payload bytes themselves (not base64url-encoded) and the signing input becomes `BASE64URL(protected) || "." || rawPayloadBytes`. The RFC's MUST clauses require that any `b64: false` header also list `"b64"` in `crit` so verifiers ignorant of RFC 7797 refuse the token rather than misinterpret it.
## Features
- `resolveB64(header)` — returns the effective boolean (`true` when absent).
- `b64: false` without `"b64"` in `crit` → `JWS.B64_INVALID`.
- Non-boolean `b64` value → `JWS.B64_INVALID`.
- Absent `b64` defaults to base64url encoding (RFC 7515 §3.1 default).
## Usage: Basic Example
```ts
import { resolveB64 } from "@suaveplan/jws";
resolveB64({ alg: "HS256" }); // true (default)
resolveB64({ alg: "HS256", b64: true }); // true
```
## Usage: Advanced Example
```ts
import { compactSign, compactVerify, resolveB64, JwsError } from "@suaveplan/jws";
// Produce a b64: false token (raw-payload signing input).
const token = await compactSign("hello", key, "HS256", {
protectedHeader: { b64: false, crit: ["b64"] },
b64: false,
});
const result = await compactVerify(token, key, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["b64"],
});
// result.payloadString === "hello" — raw, not base64url-decoded
// Missing crit listing — verifier rejects.
try {
resolveB64({ alg: "HS256", b64: false });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.B64_INVALID") {
// RFC 7797 §3 MUST violation.
}
}
```
## API Reference
- `resolveB64(header: JoseHeader): boolean`
- Returns `true` when `b64` is absent or `b64: true`.
- Returns `false` only when `b64: false` AND `crit` includes `"b64"`.
- Throws `JwsError(JWS.B64_INVALID)` for non-boolean values or `false` without `crit`.
## Implementation Notes
The RFC 7797 contract is asymmetric: producers may emit `b64: true` without `crit` (default behaviour), but `b64: false` MUST be marked critical. This module enforces only the verifier side; the sign pipeline emits the `crit: ["b64"]` companion automatically when callers pass `b64: false` so producers do not have to remember the dual-write. Raw-payload signing input requires special handling — the payload bytes cannot pass through `TextEncoder` when the producer's payload contains the `.` byte (0x2e), since that's also the segment separator; the compact-form parser handles this by treating the segment as bytes once `b64: false` is resolved, not as a UTF-8 string.

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { base64urlEncodeString } from "../base64url/base64url.js";
import { JWS_CODES, type JwsError } from "../errors/error-codes.js";
import {
compactSign,
compactVerify,
} from "../serialization/compact/compact.js";
import { resolveB64 } from "./b64.js";
const KEY = new Uint8Array(32).fill(0x12);
describe("header / b64 (RFC 7797)", () => {
it("defaults to true when absent", { timeout: 5000 }, () => {
expect(resolveB64({ alg: "HS256" })).toBe(true);
});
it("returns true for explicit b64: true", { timeout: 5000 }, () => {
expect(resolveB64({ alg: "HS256", b64: true, crit: ["b64"] })).toBe(
true
);
});
it("returns false for b64: false when in crit", { timeout: 5000 }, () => {
expect(resolveB64({ alg: "HS256", b64: false, crit: ["b64"] })).toBe(
false
);
});
it("rejects b64 false without crit listing", {
timeout: 5000,
}, async () => {
// Build a token manually to bypass sign-pipeline guard.
const headerObj = { alg: "HS256", b64: false };
const protectedB64 = base64urlEncodeString(JSON.stringify(headerObj));
const real = await compactSign("data", KEY, "HS256");
const sig = real.split(".")[2] as string;
const token = `${protectedB64}.data.${sig}`;
try {
await compactVerify(token, KEY, { algorithms: ["HS256"] });
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.B64_INVALID);
return;
}
throw new Error("did-not-throw");
});
it("round-trips a b64 false token with raw payload", {
timeout: 5000,
}, async () => {
const tok = await compactSign("data", KEY, "HS256", {
protectedHeader: { crit: ["b64"] },
b64: false,
});
// Middle segment should be the raw "data" string, not base64url.
expect(tok.split(".")[1]).toBe("data");
const result = await compactVerify(tok, KEY, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["b64"],
});
expect(result.verified).toBe(true);
expect(result.payloadString).toBe("data");
});
it("rejects non-boolean b64 with B64_INVALID", { timeout: 5000 }, () => {
try {
resolveB64({ alg: "HS256", b64: "false" as unknown as boolean });
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.B64_INVALID);
return;
}
throw new Error("did-not-throw");
});
it("compactSign with b64: false but missing crit ['b64'] throws", {
timeout: 5000,
}, async () => {
try {
await compactSign("data", KEY, "HS256", { b64: false });
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.B64_INVALID);
return;
}
throw new Error("did-not-throw");
});
it("compactSign with b64: false and Uint8Array payload", {
timeout: 5000,
}, async () => {
const tok = await compactSign(
new TextEncoder().encode("raw"),
KEY,
"HS256",
{ protectedHeader: { crit: ["b64"] }, b64: false }
);
// The middle segment is the decoded raw payload.
expect(tok.split(".")[1]).toBe("raw");
});
});

View File

@@ -0,0 +1,33 @@
import { JWS_CODES, JwsError } from "../errors/error-codes.js";
import { recordError } from "../observability/metrics.js";
import type { JoseHeader } from "./header.types.js";
function fail(reason: string): never {
const err = new JwsError(JWS_CODES.B64_INVALID, "error.jws.b64", {
context: { reason },
});
recordError(err.code);
throw err;
}
/**
* RFC 7797 §3 enforcement for the `b64` header parameter.
*
* - If absent, payloads are base64url-encoded (default behaviour).
* - If `b64: false`, `b64` MUST appear in `crit` per RFC 7797 §3.
* - Any non-boolean `b64` value is invalid.
*
* Returns the resolved `b64` boolean (`true` when absent).
*/
export function resolveB64(header: JoseHeader): boolean {
if (!("b64" in header) || header.b64 === undefined) return true;
const v = header.b64;
if (typeof v !== "boolean") fail("b64-not-boolean");
if (v === false) {
const crit = header.crit;
if (!Array.isArray(crit) || !crit.includes("b64")) {
fail("b64-false-not-in-crit");
}
}
return v;
}

View File

@@ -0,0 +1,57 @@
# `crit` enforcement
## Purpose
RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlist. The RFC's intent is that callers refuse to verify any token whose protected header carries critical parameters they do not understand — otherwise the producer could downgrade security guarantees (e.g. an `exp` claim becoming critical against a verifier that ignores it). This module owns that gate.
## Features
- Empty `crit` array rejected.
- Non-array `crit` rejected.
- Non-string entry rejected.
- `crit` referencing itself rejected (RFC 7515 §4.1.11 MUST).
- Entry not in caller's allowlist → `JWS.CRIT_UNRECOGNIZED`.
- Entry in allowlist accepted.
## Usage: Basic Example
```ts
import { enforceCrit, parseHeader } from "@suaveplan/jws";
const header = parseHeader("eyJhbGciOiJIUzI1NiIsImNyaXQiOlsiZXhwIl0sImV4cCI6MTYwfQ");
enforceCrit(header, ["exp"]); // does not throw
```
## Usage: Advanced Example
```ts
import { enforceCrit, JwsError } from "@suaveplan/jws";
try {
enforceCrit({ alg: "HS256", crit: ["exp", "tenant"] }, ["exp"]);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.CRIT_UNRECOGNIZED") {
// Reject the token: a critical parameter is outside the allowlist.
}
}
// Self-reference rejected:
try {
enforceCrit({ alg: "HS256", crit: ["crit"] }, ["crit"]);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.CRIT_MALFORMED") {
// RFC 7515 §4.1.11 MUST.
}
}
```
## API Reference
- `enforceCrit(header: JoseHeader, acceptableCriticalHeaders: readonly string[] | undefined): void`
- No-op when `header.crit` is absent.
- Throws `JwsError(JWS.CRIT_MALFORMED)` for shape failures (non-array, empty, non-string entry, self-reference).
- Throws `JwsError(JWS.CRIT_UNRECOGNIZED)` for entries not on the allowlist.
## Implementation Notes
The allowlist is supplied at every verify call rather than configured globally; this is deliberate because the allowlist is fundamentally a property of the consumer's threat model, not the JWS library. Callers integrating an RFC 7797 `b64: false` token MUST add `"b64"` to their `acceptableCriticalHeaders` even though this package implements the RFC 7797 spec internally — the gate is "do you know what to do with this parameter," not "does the library implement it." This makes it impossible to accidentally accept a `b64: false` token through a verifier that wasn't expecting one.

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { base64urlEncodeString } from "../base64url/base64url.js";
import { JWS_CODES, type JwsError } from "../errors/error-codes.js";
import {
compactSign,
compactVerify,
} from "../serialization/compact/compact.js";
import { enforceCrit } from "./crit.js";
import type { JoseHeader } from "./header.types.js";
const KEY = new Uint8Array(32).fill(0x11);
async function tokenWithCrit(
crit: unknown,
extra: Record<string, unknown> = {}
): Promise<string> {
// Build a custom header bypassing the sign-pipeline's b64-in-crit guard
// by writing the raw segments.
const headerObj = { alg: "HS256", crit, ...extra };
const protectedB64 = base64urlEncodeString(JSON.stringify(headerObj));
const payloadB64 = base64urlEncodeString("data");
const signed = await compactSign("data", KEY, "HS256");
// We only need a structurally-valid token; the signature will fail
// crit checks BEFORE crypto runs, so re-using the signature segment
// from a clean compactSign is fine for these scenarios.
const sigSeg = signed.split(".")[2] as string;
return `${protectedB64}.${payloadB64}.${sigSeg}`;
}
describe("header / crit", () => {
it("accepts a known critical header on the allowlist", {
timeout: 5000,
}, () => {
// enforceCrit returns void on success — the token verify path is
// covered separately; here we exercise the unit.
expect(() =>
enforceCrit({ alg: "HS256", crit: ["exp"] } as JoseHeader, ["exp"])
).not.toThrow();
});
it("rejects an unknown critical header with CRIT_UNRECOGNIZED", {
timeout: 5000,
}, async () => {
const tok = await tokenWithCrit(["exp", "tenant"]);
try {
await compactVerify(tok, KEY, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["exp"],
});
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.CRIT_UNRECOGNIZED);
return;
}
throw new Error("did-not-throw");
});
it("rejects an empty crit array with CRIT_MALFORMED", {
timeout: 5000,
}, async () => {
const tok = await tokenWithCrit([]);
try {
await compactVerify(tok, KEY, {
algorithms: ["HS256"],
});
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.CRIT_MALFORMED);
return;
}
throw new Error("did-not-throw");
});
it("rejects a non-array crit with CRIT_MALFORMED", {
timeout: 5000,
}, () => {
try {
enforceCrit({ alg: "HS256", crit: "exp" } as never, ["exp"]);
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.CRIT_MALFORMED);
return;
}
throw new Error("did-not-throw");
});
it("rejects a non-string crit entry with CRIT_MALFORMED", {
timeout: 5000,
}, () => {
try {
enforceCrit(
{ alg: "HS256", crit: [42 as unknown as string] } as JoseHeader,
["exp"]
);
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.CRIT_MALFORMED);
return;
}
throw new Error("did-not-throw");
});
it("rejects self-referencing crit (contains 'crit')", {
timeout: 5000,
}, () => {
try {
enforceCrit({ alg: "HS256", crit: ["crit"] } as JoseHeader, [
"crit",
]);
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.CRIT_MALFORMED);
return;
}
throw new Error("did-not-throw");
});
it("rejects all entries when acceptableCriticalHeaders is undefined", {
timeout: 5000,
}, () => {
try {
enforceCrit(
{ alg: "HS256", crit: ["exp"] } as JoseHeader,
undefined
);
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.CRIT_UNRECOGNIZED);
return;
}
throw new Error("did-not-throw");
});
it("is a no-op when header has no crit", { timeout: 5000 }, () => {
expect(() =>
enforceCrit({ alg: "HS256" } as JoseHeader, undefined)
).not.toThrow();
});
it("accepts allowlist=undefined when crit is undefined", {
timeout: 5000,
}, () => {
expect(() =>
enforceCrit({ alg: "HS256" } as JoseHeader, undefined)
).not.toThrow();
});
});

View File

@@ -0,0 +1,47 @@
import { JWS_CODES, JwsError } from "../errors/error-codes.js";
import { recordError } from "../observability/metrics.js";
import type { JoseHeader } from "./header.types.js";
function fail(
code: typeof JWS_CODES.CRIT_MALFORMED | typeof JWS_CODES.CRIT_UNRECOGNIZED,
reason: string,
context?: Record<string, unknown>
): never {
const err = new JwsError(code, "error.jws.crit", {
context: { reason, ...context },
});
recordError(err.code);
throw err;
}
/**
* RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST appear
* in the caller-supplied `acceptableCriticalHeaders` allowlist. The
* `crit` parameter itself MUST NOT name `crit`. Empty arrays and
* non-array `crit` values are rejected per the RFC's MUST clauses.
*/
export function enforceCrit(
header: JoseHeader,
acceptableCriticalHeaders: readonly string[] | undefined
): void {
const crit = header.crit;
if (crit === undefined) return;
if (!Array.isArray(crit)) {
fail(JWS_CODES.CRIT_MALFORMED, "crit-not-array");
}
if (crit.length === 0) {
fail(JWS_CODES.CRIT_MALFORMED, "crit-empty");
}
const allow = new Set(acceptableCriticalHeaders ?? []);
for (const entry of crit) {
if (typeof entry !== "string") {
fail(JWS_CODES.CRIT_MALFORMED, "crit-entry-not-string");
}
if (entry === "crit") {
fail(JWS_CODES.CRIT_MALFORMED, "crit-self-reference");
}
if (!allow.has(entry)) {
fail(JWS_CODES.CRIT_UNRECOGNIZED, "crit-unknown", { entry });
}
}
}

View File

@@ -0,0 +1,57 @@
# `header`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parse and serialise RFC 7515 §4.1 JOSE protected headers. Every registered parameter (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`, plus RFC 7797 `b64`) is shape-validated through the Zod schema; non-registered parameters survive round-trip via the schema's catch-all. The `alg` field is the only mandatory parameter; everything else is optional and the decoder preserves bytes verbatim so callers can re-emit the header without canonicalisation surprises.
## Features
- `parseHeader(base64urlString | object)` — base64url-decode + JSON-parse + Zod-shape-check; returns a typed `JoseHeader`.
- `serializeHeader(header)` — `JSON.stringify` + base64url-encode in one call.
- `JoseHeader` — strongly-typed shape (every registered parameter).
- Unknown parameters survive round-trip.
## Usage: Basic Example
```ts
import { parseHeader, serializeHeader } from "@suaveplan/jws";
const decoded = parseHeader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
// { alg: "HS256", typ: "JWT" }
const reencoded = serializeHeader(decoded);
```
## Usage: Advanced Example
```ts
import { JwsError, parseHeader } from "@suaveplan/jws";
try {
parseHeader("eyJhbGciOjQyfQ"); // alg as number
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.HEADER_MALFORMED") {
// The Zod boundary refused a non-string alg.
}
}
// Custom parameter survives round-trip:
const h = parseHeader({ alg: "ES256", custom: { tenant: "acme" } });
console.log(h["custom"]); // { tenant: "acme" }
```
## API Reference
- `parseHeader(input: string | Record<string, unknown>): JoseHeader`
- Decodes a base64url-encoded JSON object header, OR validates an already-parsed object.
- Throws `JwsError(JWS.HEADER_MALFORMED)` on shape failure.
- Throws `JwsError(JWS.BASE64URL_INVALID)` on base64url failure.
- `serializeHeader(header: JoseHeader | Record<string, unknown>): string`
- `interface JoseHeader` — every RFC 7515 §4.1 registered parameter, all readonly.
## Implementation Notes
The schema is `.passthrough()` so consumers retain non-registered parameters — required for downstream RFCs (RFC 7519 JWT, RFC 9202 EAT-JWT, custom tenant claims) that layer additional parameters into the same header structure. Encoding always uses `JSON.stringify` with no replacer, which means insertion order is preserved; callers needing canonical-order headers (e.g. for byte-identical reproduction of an upstream signer's output) should sort their object keys before passing in. The decoder uses a strict UTF-8 `TextDecoder` so malformed UTF-8 sequences fail rather than silently inserting replacement characters into the parsed JSON — a deliberate strictness that catches producers that emit windows-1252 or latin-1 bytes through a misconfigured pipeline.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { base64urlEncodeString } from "../base64url/base64url.js";
import { JWS_CODES, type JwsError } from "../errors/error-codes.js";
import { parseHeader, serializeHeader } from "./header.js";
describe("header / parse + serialize", () => {
it("parses a base64url-encoded JSON header", { timeout: 5000 }, () => {
const enc = base64urlEncodeString(
JSON.stringify({ alg: "HS256", kid: "k1" })
);
const header = parseHeader(enc);
expect(header.alg).toBe("HS256");
expect(header.kid).toBe("k1");
});
it("parses a header object directly", { timeout: 5000 }, () => {
const header = parseHeader({ alg: "ES256" });
expect(header.alg).toBe("ES256");
});
it("preserves unknown parameters via passthrough", {
timeout: 5000,
}, () => {
const header = parseHeader({ alg: "HS256", custom: "x" });
expect(header["custom"]).toBe("x");
});
it("rejects header with non-string alg", { timeout: 5000 }, () => {
try {
parseHeader({ alg: 42 });
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.HEADER_MALFORMED);
return;
}
throw new Error("did-not-throw");
});
it("rejects header missing alg", { timeout: 5000 }, () => {
try {
parseHeader({});
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.HEADER_MALFORMED);
return;
}
throw new Error("did-not-throw");
});
it("serializeHeader round-trips through parseHeader", {
timeout: 5000,
}, () => {
const enc = serializeHeader({ alg: "HS256", typ: "JWT" });
const dec = parseHeader(enc);
expect(dec.alg).toBe("HS256");
expect(dec.typ).toBe("JWT");
});
});

View File

@@ -0,0 +1,44 @@
import {
base64urlDecodeJson,
base64urlEncodeString,
} from "../base64url/base64url.js";
import { JWS_CODES, JwsError } from "../errors/error-codes.js";
import { recordError } from "../observability/metrics.js";
import { JoseHeaderSchema } from "../schemas/schemas.js";
import type { JoseHeader } from "./header.types.js";
/**
* Parse a base64url-encoded JOSE protected header (or a raw object) and
* validate the registered-parameter shape per RFC 7515 §4.1. Unknown
* parameters survive round-trip.
*
* @throws JwsError(JWS.HEADER_MALFORMED) when the input does not decode
* to a JSON object whose `alg` is a string.
*/
export function parseHeader(
input: string | Record<string, unknown>
): JoseHeader {
const raw = typeof input === "string" ? base64urlDecodeJson(input) : input;
const parsed = JoseHeaderSchema.safeParse(raw);
if (!parsed.success) {
const err = new JwsError(
JWS_CODES.HEADER_MALFORMED,
"error.jws.header-shape-invalid",
{ context: { issues: parsed.error.issues } }
);
recordError(err.code);
throw err;
}
return parsed.data as JoseHeader;
}
/**
* Serialize a JOSE header to a base64url-encoded UTF-8 JSON string.
* Keys preserve insertion order; callers that need a canonical order
* should sort before passing in.
*/
export function serializeHeader(
header: JoseHeader | Record<string, unknown>
): string {
return base64urlEncodeString(JSON.stringify(header));
}

View File

@@ -0,0 +1,32 @@
/**
* RFC 7515 §4.1 JOSE header — typed shape exposed to consumers. All
* registered parameters are optional except `alg`. Unknown parameters
* survive round-trip via the catch-all index signature.
*/
export interface JoseHeader {
/** RFC 7515 §4.1.1 — algorithm identifier. */
readonly alg: string;
/** RFC 7515 §4.1.2 — JWK Set URL. */
readonly jku?: string;
/** RFC 7515 §4.1.3 — embedded JSON Web Key. */
readonly jwk?: Record<string, unknown>;
/** RFC 7515 §4.1.4 — key id. */
readonly kid?: string;
/** RFC 7515 §4.1.5 — X.509 URL. */
readonly x5u?: string;
/** RFC 7515 §4.1.6 — base64-encoded X.509 cert chain. */
readonly x5c?: readonly string[];
/** RFC 7515 §4.1.7 — X.509 cert SHA-1 thumbprint. */
readonly x5t?: string;
/** RFC 7515 §4.1.8 — X.509 cert SHA-256 thumbprint. */
readonly "x5t#S256"?: string;
/** RFC 7515 §4.1.9 — type. */
readonly typ?: string;
/** RFC 7515 §4.1.10 — content type. */
readonly cty?: string;
/** RFC 7515 §4.1.11 — critical header parameters. */
readonly crit?: readonly string[];
/** RFC 7797 §3 — base64url encoding of payload. */
readonly b64?: boolean;
readonly [key: string]: unknown;
}

View File

@@ -0,0 +1,4 @@
export { resolveB64 } from "./b64.js";
export { enforceCrit } from "./crit.js";
export { parseHeader, serializeHeader } from "./header.js";
export type { JoseHeader } from "./header.types.js";