Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
detachedSign,
|
||||
detachedVerify,
|
||||
} from "./detached.js";
|
||||
Reference in New Issue
Block a user