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,65 @@
# Detached payload (RFC 7515 §A.5)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §A.5 / Appendix F detached form. The payload travels separately from the JWS — the compact-form token has an empty middle segment (`<header>..<sig>`) and the verifier supplies the payload bytes out-of-band via `opts.detachedPayload`. This is the canonical pattern when the payload is large (avoid double-encoding) or already lives somewhere addressable (file body, HTTP body, blob storage).
## Features
- `detachedSign(payload, key, alg, opts?)` — emits a compact token with empty middle segment.
- `detachedVerify(token, key, opts)` — sugar over `compactVerify` with `opts.detachedPayload`.
- Missing `detachedPayload` at verify time → `JWS.INVALID_OPTIONS`.
- Compatible with `b64: false` — raw-byte payloads also work in detached form.
## Usage: Basic Example
```ts
import { detachedSign, detachedVerify } from "@suaveplan/jws";
const token = await detachedSign("hello", secret, "HS256");
// token === "<header>..<sig>"
const result = await detachedVerify(token, secret, {
algorithms: ["HS256"],
detachedPayload: "hello",
});
```
## Usage: Advanced Example
```ts
import { compactVerify, detachedSign, JwsError } from "@suaveplan/jws";
// Sign a large blob without embedding it in the token:
const blob = await fetch("/big.bin").then((r) => r.bytes());
const token = await detachedSign(blob, key, "ES256");
// Later, given the token + blob (e.g. as separate Content-* headers):
const verified = await compactVerify(token, pub, {
algorithms: ["ES256"],
detachedPayload: blob, // Uint8Array also accepted
});
// Forgot to pass detachedPayload?
try {
await compactVerify(token, pub, { algorithms: ["ES256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.INVALID_OPTIONS") {
// Empty middle segment without out-of-band payload supplied.
}
}
```
## API Reference
- `detachedSign(payload, key, alg, opts?: Omit<SignOptions, "detached">): Promise<string>`
- `detachedVerify(token, key, opts: VerifyOptions): Promise<VerifyResult>`
`detachedSign` is sugar for `compactSign(..., { ...opts, detached: true })`. `detachedVerify` is identity-forward to `compactVerify` — the actual logic lives in the verify pipeline.
## Implementation Notes
The verify pipeline detects detached tokens by an empty middle segment + `opts.detachedPayload !== undefined`. When detected, the payload is re-base64url-encoded (or stringified for `b64: false`) before computing the signing input, so the on-wire signing input bytes match what the signer would have produced. This means the same `compactVerify` entry point handles attached AND detached tokens — `detachedVerify` is purely an ergonomic alias.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
A1_ENCODED_HEADER,
A1_ENCODED_PAYLOAD,
A1_SIGNATURE_B64URL,
a1HmacKey,
} from "../__fixtures__/rfc7515-vectors.js";
import { JWS_CODES, type JwsError } from "../errors/error-codes.js";
import {
compactSign,
compactVerify,
} from "../serialization/compact/compact.js";
import { detachedSign, detachedVerify } from "./detached.js";
const KEY = new Uint8Array(32).fill(0x66);
describe("detached / sign + verify", () => {
it("compactSign with detached: true emits empty middle segment", {
timeout: 5000,
}, async () => {
const tok = await compactSign("data", KEY, "HS256", { detached: true });
const parts = tok.split(".");
expect(parts.length).toBe(3);
expect(parts[1]).toBe("");
});
it("compactVerify accepts detachedPayload out-of-band", {
timeout: 5000,
}, async () => {
const tok = await compactSign("data", KEY, "HS256", { detached: true });
const r = await compactVerify(tok, KEY, {
algorithms: ["HS256"],
detachedPayload: "data",
});
expect(r.verified).toBe(true);
expect(r.payloadString).toBe("data");
});
it("compactVerify on a detached token without detachedPayload throws INVALID_OPTIONS", {
timeout: 5000,
}, async () => {
const tok = await compactSign("data", KEY, "HS256", { detached: true });
try {
await compactVerify(tok, KEY, { algorithms: ["HS256"] });
} catch (e) {
expect((e as JwsError).code).toBe(JWS_CODES.INVALID_OPTIONS);
return;
}
throw new Error("did-not-throw");
});
it("detachedSign + detachedVerify round-trip", {
timeout: 5000,
}, async () => {
const tok = await detachedSign("data", KEY, "HS256");
const r = await detachedVerify(tok, KEY, {
algorithms: ["HS256"],
detachedPayload: "data",
});
expect(r.verified).toBe(true);
});
it("verifies the RFC 7515 Appendix A.5 detached vector", {
timeout: 5000,
}, async () => {
// Build A.5 detached form from A.1 components — same header,
// empty middle, same signature.
const detachedTok = `${A1_ENCODED_HEADER}..${A1_SIGNATURE_B64URL}`;
const r = await compactVerify(detachedTok, a1HmacKey(), {
algorithms: ["HS256"],
detachedPayload: new TextEncoder().encode(
new TextDecoder().decode(
(await import("../base64url/base64url.js")).base64urlDecode(
A1_ENCODED_PAYLOAD
)
)
),
});
expect(r.verified).toBe(true);
});
it("compactVerify accepts Uint8Array detachedPayload", {
timeout: 5000,
}, async () => {
const tok = await compactSign("data", KEY, "HS256", { detached: true });
const r = await compactVerify(tok, KEY, {
algorithms: ["HS256"],
detachedPayload: new TextEncoder().encode("data"),
});
expect(r.verified).toBe(true);
});
it("detached + b64:false round-trip with Uint8Array payload", {
timeout: 5000,
}, async () => {
const tok = await compactSign("raw-data", KEY, "HS256", {
protectedHeader: { crit: ["b64"] },
b64: false,
detached: true,
});
// Middle segment should be empty.
expect(tok.split(".")[1]).toBe("");
const r = await compactVerify(tok, KEY, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["b64"],
detachedPayload: new TextEncoder().encode("raw-data"),
});
expect(r.verified).toBe(true);
});
it("detached + b64:false round-trip with string payload", {
timeout: 5000,
}, async () => {
const tok = await compactSign("raw-data", KEY, "HS256", {
protectedHeader: { crit: ["b64"] },
b64: false,
detached: true,
});
const r = await compactVerify(tok, KEY, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["b64"],
detachedPayload: "raw-data",
});
expect(r.verified).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
import {
compactSign,
compactVerify,
} from "../serialization/compact/compact.js";
import type { SignOptions } from "../sign/sign.types.js";
import type { VerifyOptions, VerifyResult } from "../verify/verify.types.js";
/**
* Compact-detached sign per RFC 7515 §A.5. Emits a token of the form
* `<header>..<sig>` — the middle (payload) segment is empty; the
* payload travels out-of-band.
*/
export function detachedSign(
payload: string | Uint8Array,
key: unknown,
alg: string,
opts: Omit<SignOptions, "detached"> = {}
): Promise<string> {
return compactSign(payload, key, alg, { ...opts, detached: true });
}
/**
* Compact-detached verify per RFC 7515 §A.5. The caller supplies the
* payload bytes out-of-band via `opts.detachedPayload`; the signing
* input is reconstituted from `BASE64URL(protected) || "." ||
* BASE64URL(payload)` (or the raw form when `b64: false`).
*/
export function detachedVerify(
token: string,
key: unknown,
opts: VerifyOptions
): Promise<VerifyResult> {
return compactVerify(token, key, opts);
}

View File

@@ -0,0 +1,4 @@
export {
detachedSign,
detachedVerify,
} from "./detached.js";