@dwk/http-signatures 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +141 -0
  3. package/dist/algorithms.d.ts +31 -0
  4. package/dist/algorithms.d.ts.map +1 -0
  5. package/dist/algorithms.js +106 -0
  6. package/dist/algorithms.js.map +1 -0
  7. package/dist/base64.d.ts +8 -0
  8. package/dist/base64.d.ts.map +1 -0
  9. package/dist/base64.js +23 -0
  10. package/dist/base64.js.map +1 -0
  11. package/dist/cavage.d.ts +38 -0
  12. package/dist/cavage.d.ts.map +1 -0
  13. package/dist/cavage.js +249 -0
  14. package/dist/cavage.js.map +1 -0
  15. package/dist/components.d.ts +26 -0
  16. package/dist/components.d.ts.map +1 -0
  17. package/dist/components.js +65 -0
  18. package/dist/components.js.map +1 -0
  19. package/dist/digest.d.ts +45 -0
  20. package/dist/digest.d.ts.map +1 -0
  21. package/dist/digest.js +128 -0
  22. package/dist/digest.js.map +1 -0
  23. package/dist/index.d.ts +28 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +26 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/rfc9421.d.ts +49 -0
  28. package/dist/rfc9421.d.ts.map +1 -0
  29. package/dist/rfc9421.js +216 -0
  30. package/dist/rfc9421.js.map +1 -0
  31. package/dist/sf.d.ts +49 -0
  32. package/dist/sf.d.ts.map +1 -0
  33. package/dist/sf.js +234 -0
  34. package/dist/sf.js.map +1 -0
  35. package/dist/sign.d.ts +46 -0
  36. package/dist/sign.d.ts.map +1 -0
  37. package/dist/sign.js +39 -0
  38. package/dist/sign.js.map +1 -0
  39. package/dist/types.d.ts +62 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +9 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/verify.d.ts +37 -0
  44. package/dist/verify.d.ts.map +1 -0
  45. package/dist/verify.js +60 -0
  46. package/dist/verify.js.map +1 -0
  47. package/package.json +44 -0
  48. package/src/algorithms.ts +168 -0
  49. package/src/base64.ts +25 -0
  50. package/src/cavage.ts +292 -0
  51. package/src/components.ts +79 -0
  52. package/src/digest.ts +156 -0
  53. package/src/index.ts +45 -0
  54. package/src/rfc9421.ts +280 -0
  55. package/src/sf.ts +246 -0
  56. package/src/sign.ts +79 -0
  57. package/src/types.ts +94 -0
  58. package/src/verify.ts +102 -0
package/src/digest.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Body integrity headers.
3
+ *
4
+ * - `Content-Digest` (RFC 9530) — a structured-field dictionary whose values
5
+ * are byte sequences, e.g. `sha-256=:<base64>:`.
6
+ * - `Digest` (the legacy RFC 3230 field much of the fediverse still sends),
7
+ * e.g. `SHA-256=<base64>`.
8
+ *
9
+ * Including a digest in the covered component set is how body tampering is
10
+ * caught: the signature covers the digest header value, and recomputing the
11
+ * digest over the received body proves that value still describes the body.
12
+ */
13
+
14
+ import { base64ToBytes, bytesToBase64 } from "./base64";
15
+ import { parseByteSequenceDictionary, SfParseError } from "./sf";
16
+
17
+ /** Hash algorithms supported for digests. */
18
+ export type DigestAlgorithm = "sha-256" | "sha-512";
19
+
20
+ const SUBTLE_HASH: Record<DigestAlgorithm, string> = {
21
+ "sha-256": "SHA-256",
22
+ "sha-512": "SHA-512",
23
+ };
24
+
25
+ function toBytes(body: Uint8Array | string): Uint8Array {
26
+ return typeof body === "string" ? new TextEncoder().encode(body) : body;
27
+ }
28
+
29
+ async function hashBytes(
30
+ body: Uint8Array | string,
31
+ alg: DigestAlgorithm,
32
+ ): Promise<Uint8Array> {
33
+ const digest = await crypto.subtle.digest(SUBTLE_HASH[alg], toBytes(body));
34
+ return new Uint8Array(digest);
35
+ }
36
+
37
+ async function hash(
38
+ body: Uint8Array | string,
39
+ alg: DigestAlgorithm,
40
+ ): Promise<string> {
41
+ return bytesToBase64(await hashBytes(body, alg));
42
+ }
43
+
44
+ /**
45
+ * Compute the RFC 9530 `Content-Digest` field value for `body`, e.g.
46
+ * `sha-256=:<base64>:`. The result is meant to be set as the `content-digest`
47
+ * header and listed among the covered components before signing.
48
+ */
49
+ export async function createContentDigest(
50
+ body: Uint8Array | string,
51
+ alg: DigestAlgorithm = "sha-256",
52
+ ): Promise<string> {
53
+ return `${alg}=:${await hash(body, alg)}:`;
54
+ }
55
+
56
+ /**
57
+ * Compute the legacy RFC 3230 `Digest` field value for `body`, e.g.
58
+ * `SHA-256=<base64>` — the spelling fediverse peers expect on the `Digest`
59
+ * header alongside a `draft-cavage` signature.
60
+ */
61
+ export async function createDigest(
62
+ body: Uint8Array | string,
63
+ alg: DigestAlgorithm = "sha-256",
64
+ ): Promise<string> {
65
+ return `${alg.toUpperCase()}=${await hash(body, alg)}`;
66
+ }
67
+
68
+ /** A reason a digest header failed to verify, or `null` when it matched. */
69
+ export type DigestRejection =
70
+ | "digest_missing"
71
+ | "digest_unsupported"
72
+ | "digest_mismatch";
73
+
74
+ /**
75
+ * Verify a received `Content-Digest` field value against `body`. The field is
76
+ * an RFC 8941 Dictionary of Byte Sequences (RFC 9530 §2), so it is parsed with
77
+ * the strict structured-fields parser rather than split by hand: a malformed
78
+ * value — including a lone uppercase algorithm key, which is not a valid sf-key
79
+ * — fails closed as `digest_mismatch`. Per RFC 9530 §3, entries whose algorithm
80
+ * the verifier does not support are ignored; every supported entry must match,
81
+ * and at least one supported entry must be present (otherwise the value is
82
+ * rejected as `digest_unsupported`).
83
+ */
84
+ export async function verifyContentDigest(
85
+ headerValue: string | null | undefined,
86
+ body: Uint8Array | string,
87
+ ): Promise<DigestRejection | null> {
88
+ if (!headerValue) return "digest_missing";
89
+ let dict: Map<string, Uint8Array>;
90
+ try {
91
+ dict = parseByteSequenceDictionary(headerValue);
92
+ } catch (err) {
93
+ if (err instanceof SfParseError) return "digest_mismatch";
94
+ throw err;
95
+ }
96
+ let sawSupported = false;
97
+ for (const [key, received] of dict) {
98
+ if (!(key in SUBTLE_HASH)) continue;
99
+ const alg = key as DigestAlgorithm;
100
+ if (!constantTimeEqualBytes(received, await hashBytes(body, alg))) {
101
+ return "digest_mismatch";
102
+ }
103
+ sawSupported = true;
104
+ }
105
+ return sawSupported ? null : "digest_unsupported";
106
+ }
107
+
108
+ /**
109
+ * Verify a received legacy `Digest` field value (`SHA-256=<base64>`, no colon
110
+ * wrapping) against `body`.
111
+ */
112
+ export async function verifyDigest(
113
+ headerValue: string | null | undefined,
114
+ body: Uint8Array | string,
115
+ ): Promise<DigestRejection | null> {
116
+ if (!headerValue) return "digest_missing";
117
+ let sawSupported = false;
118
+ for (const entry of headerValue.split(",")) {
119
+ const eq = entry.indexOf("=");
120
+ if (eq < 0) continue;
121
+ const alg = entry.slice(0, eq).trim().toLowerCase() as DigestAlgorithm;
122
+ if (!(alg in SUBTLE_HASH)) continue;
123
+ const value = entry.slice(eq + 1).trim();
124
+ if (!constantTimeEqualBase64(value, await hash(body, alg))) {
125
+ return "digest_mismatch";
126
+ }
127
+ sawSupported = true;
128
+ }
129
+ return sawSupported ? null : "digest_unsupported";
130
+ }
131
+
132
+ /** Compare two byte arrays in length-constant time. */
133
+ function constantTimeEqualBytes(x: Uint8Array, y: Uint8Array): boolean {
134
+ if (x.length !== y.length) return false;
135
+ let diff = 0;
136
+ for (let i = 0; i < x.length; i++) {
137
+ diff |= x[i]! ^ y[i]!;
138
+ }
139
+ return diff === 0;
140
+ }
141
+
142
+ /**
143
+ * Compare two base64 strings by their decoded bytes in length-constant time.
144
+ * A malformed encoding compares unequal rather than throwing.
145
+ */
146
+ function constantTimeEqualBase64(a: string, b: string): boolean {
147
+ let x: Uint8Array;
148
+ let y: Uint8Array;
149
+ try {
150
+ x = base64ToBytes(a);
151
+ y = base64ToBytes(b);
152
+ } catch {
153
+ return false;
154
+ }
155
+ return constantTimeEqualBytes(x, y);
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `@dwk/http-signatures` — HTTP Message Signatures (RFC 9421), with the legacy
3
+ * `draft-cavage-http-signatures` profile for fediverse interop.
4
+ *
5
+ * A pure, runtime-agnostic library: a request reduced to plain data (method,
6
+ * URL, headers) plus a resolved {@link CryptoKey} goes in, a signing result or a
7
+ * {@link VerifyResult} comes out. It performs no I/O beyond Web Crypto, holds no
8
+ * state, and needs no Cloudflare bindings, so it unit-tests without a Workers
9
+ * runtime.
10
+ *
11
+ * It is **protocol-agnostic** — it knows nothing about ActivityPub, IndieWeb, or
12
+ * Solid. Key resolution (fetching an actor's public key, caching it) is the
13
+ * caller's responsibility, supplied as a {@link KeyResolver}. Mirroring the
14
+ * `@dwk/dpop` hardening posture, only asymmetric algorithms from an explicit
15
+ * allow-list are accepted — never `none` or HMAC — and a resolved key is
16
+ * validated against the claimed algorithm (RSA modulus floor, EC curve) before
17
+ * any signature is checked.
18
+ *
19
+ * @see spec/packages/http-signatures.md
20
+ * @packageDocumentation
21
+ */
22
+
23
+ export { signMessage, type SignParams } from "./sign";
24
+ export { verifyMessage, type VerifyParams } from "./verify";
25
+
26
+ export type { KeyResolver } from "./rfc9421";
27
+
28
+ export {
29
+ createContentDigest,
30
+ createDigest,
31
+ verifyContentDigest,
32
+ verifyDigest,
33
+ type DigestAlgorithm,
34
+ type DigestRejection,
35
+ } from "./digest";
36
+
37
+ export { isSupportedAlgorithm } from "./algorithms";
38
+
39
+ export type {
40
+ HttpMessage,
41
+ SignatureAlgorithm,
42
+ SignatureProfile,
43
+ SignatureFailureReason,
44
+ VerifyResult,
45
+ } from "./types";
package/src/rfc9421.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The RFC 9421 wire profile: the `Signature-Input` / `Signature`
3
+ * structured-field pair, the signature base of §2.5, and the `@signature-params`
4
+ * line.
5
+ */
6
+
7
+ import {
8
+ deriveAlgFromKey,
9
+ isSupportedAlgorithm,
10
+ signBytes,
11
+ validateKey,
12
+ verifyBytes,
13
+ } from "./algorithms";
14
+ import { bytesToBase64, utf8 } from "./base64";
15
+ import { deriveComponentValue, derivationContext } from "./components";
16
+ import {
17
+ parseSignatureHeader,
18
+ parseSignatureInput,
19
+ serializeString,
20
+ type SfBareItem,
21
+ } from "./sf";
22
+ import type {
23
+ HttpMessage,
24
+ SignatureAlgorithm,
25
+ SignatureFailureReason,
26
+ VerifyResult,
27
+ } from "./types";
28
+
29
+ /** Resolved signing inputs for the RFC 9421 profile. */
30
+ export interface Rfc9421SignParams {
31
+ key: CryptoKey;
32
+ keyId: string;
33
+ alg: SignatureAlgorithm;
34
+ components: string[];
35
+ created: number;
36
+ expires?: number;
37
+ nonce?: string;
38
+ tag?: string;
39
+ label: string;
40
+ }
41
+
42
+ /** Build the `@signature-params` value (inner list plus its parameters). */
43
+ function signatureParamsValue(params: Rfc9421SignParams): string {
44
+ const items = params.components.map(serializeString).join(" ");
45
+ let out = `(${items});created=${params.created}`;
46
+ if (params.expires !== undefined) out += `;expires=${params.expires}`;
47
+ out += `;keyid=${serializeString(params.keyId)}`;
48
+ out += `;alg=${serializeString(params.alg)}`;
49
+ if (params.nonce !== undefined)
50
+ out += `;nonce=${serializeString(params.nonce)}`;
51
+ if (params.tag !== undefined) out += `;tag=${serializeString(params.tag)}`;
52
+ return out;
53
+ }
54
+
55
+ /** Build the signature base over `lines` and the `@signature-params` value. */
56
+ function signatureBase(lines: string[], sigParams: string): string {
57
+ return [...lines, `"@signature-params": ${sigParams}`].join("\n");
58
+ }
59
+
60
+ /**
61
+ * Sign `message` under the RFC 9421 profile. Returns the `Signature-Input` and
62
+ * `Signature` headers to merge into the request. Throws if a covered component
63
+ * is absent from the message or the URL cannot be parsed — both signer bugs.
64
+ */
65
+ export async function signRfc9421(
66
+ message: HttpMessage,
67
+ params: Rfc9421SignParams,
68
+ ): Promise<Record<string, string>> {
69
+ const ctx = derivationContext(message);
70
+ if (ctx === null)
71
+ throw new Error("http-signatures: message.url is not a valid URL");
72
+ const sigParams = signatureParamsValue(params);
73
+ const lines: string[] = [];
74
+ // RFC 9421 §2: each component identifier MUST occur only once in the covered
75
+ // component list. A repeated identifier is a signer bug — reject it loudly.
76
+ const seen = new Set<string>();
77
+ for (const name of params.components) {
78
+ const id = name.toLowerCase();
79
+ if (seen.has(id)) {
80
+ throw new Error(
81
+ `http-signatures: duplicate covered component "${name}" (RFC 9421 §2)`,
82
+ );
83
+ }
84
+ seen.add(id);
85
+ const value = deriveComponentValue(ctx, name);
86
+ if (value === null) {
87
+ throw new Error(
88
+ `http-signatures: covered component "${name}" is missing from the message`,
89
+ );
90
+ }
91
+ lines.push(`${serializeString(name)}: ${value}`);
92
+ }
93
+ const signature = await signBytes(
94
+ params.key,
95
+ params.alg,
96
+ utf8(signatureBase(lines, sigParams)),
97
+ );
98
+ return {
99
+ "Signature-Input": `${params.label}=${sigParams}`,
100
+ Signature: `${params.label}=:${bytesToBase64(new Uint8Array(signature))}:`,
101
+ };
102
+ }
103
+
104
+ /** Resolves a public key (and optionally the algorithm it dictates) for verification. */
105
+ export type KeyResolver = (params: {
106
+ keyId: string | null;
107
+ alg: SignatureAlgorithm | null;
108
+ }) => Promise<CryptoKey | null> | (CryptoKey | null);
109
+
110
+ /** Parameters for verifying an RFC 9421 signature. */
111
+ export interface Rfc9421VerifyParams {
112
+ resolveKey: KeyResolver;
113
+ /** Which labelled signature to verify; required when the message carries more than one. */
114
+ label?: string;
115
+ /** Component identifiers that MUST be covered (e.g. `@method`, `date`). */
116
+ requiredComponents?: string[];
117
+ /** Require a `created` parameter to be present. */
118
+ requireCreated?: boolean;
119
+ /** Current time, epoch seconds. Defaults to `Date.now()`. */
120
+ now?: number;
121
+ /** Allowed clock skew, in seconds, for `created`/`expires`. Defaults to 300. */
122
+ toleranceSeconds?: number;
123
+ }
124
+
125
+ function paramMap(
126
+ params: Array<[string, SfBareItem]>,
127
+ ): Map<string, SfBareItem> {
128
+ const map = new Map<string, SfBareItem>();
129
+ for (const [k, v] of params) map.set(k, v);
130
+ return map;
131
+ }
132
+
133
+ function getHeader(message: HttpMessage, name: string): string | null {
134
+ const lower = name.toLowerCase();
135
+ for (const [k, v] of Object.entries(message.headers)) {
136
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v.join(", ") : v;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function fail(reason: SignatureFailureReason): VerifyResult {
142
+ return { valid: false, profile: "rfc9421", reason };
143
+ }
144
+
145
+ const DEFAULT_TOLERANCE_SECONDS = 300;
146
+
147
+ /**
148
+ * Verify an RFC 9421 signature on `message`. Never throws — every failure is a
149
+ * `{ valid: false, reason }` with a stable {@link SignatureFailureReason}.
150
+ */
151
+ export async function verifyRfc9421(
152
+ message: HttpMessage,
153
+ params: Rfc9421VerifyParams,
154
+ ): Promise<VerifyResult> {
155
+ const inputHeader = getHeader(message, "signature-input");
156
+ const sigHeader = getHeader(message, "signature");
157
+ if (inputHeader === null || sigHeader === null)
158
+ return fail("signature_missing");
159
+
160
+ let inputs: ReturnType<typeof parseSignatureInput>;
161
+ let signatures: ReturnType<typeof parseSignatureHeader>;
162
+ try {
163
+ inputs = parseSignatureInput(inputHeader);
164
+ } catch {
165
+ return fail("signature_input_malformed");
166
+ }
167
+ try {
168
+ signatures = parseSignatureHeader(sigHeader);
169
+ } catch {
170
+ return fail("signature_malformed");
171
+ }
172
+
173
+ // Choose the label to verify.
174
+ let label = params.label;
175
+ if (label === undefined) {
176
+ if (inputs.size === 0) return fail("label_missing");
177
+ if (inputs.size > 1) return fail("label_ambiguous");
178
+ label = [...inputs.keys()][0]!;
179
+ }
180
+ const input = inputs.get(label);
181
+ if (input === undefined) return fail("label_missing");
182
+ const signature = signatures.get(label);
183
+ if (signature === undefined) return fail("signature_missing");
184
+
185
+ const sigParams = paramMap(input.params);
186
+ // RFC 9421 §2.3: `alg` is OPTIONAL. When present it must be allow-listed;
187
+ // when absent the algorithm is derived from the resolved key below.
188
+ const algRaw = sigParams.get("alg");
189
+ let alg: SignatureAlgorithm | null = null;
190
+ if (algRaw !== undefined) {
191
+ if (typeof algRaw !== "string" || !isSupportedAlgorithm(algRaw)) {
192
+ return fail("alg_unsupported");
193
+ }
194
+ alg = algRaw;
195
+ }
196
+ const keyIdRaw = sigParams.get("keyid");
197
+ const keyId = typeof keyIdRaw === "string" ? keyIdRaw : null;
198
+ if (keyId === null) return fail("keyid_missing");
199
+
200
+ // created / expires window.
201
+ const now = params.now ?? Math.floor(Date.now() / 1000);
202
+ const tolerance = params.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
203
+ let created: number | undefined;
204
+ if (sigParams.has("created")) {
205
+ const c = sigParams.get("created");
206
+ if (typeof c !== "number" || !Number.isInteger(c))
207
+ return fail("created_invalid");
208
+ created = c;
209
+ if (c > now + tolerance) return fail("signature_future");
210
+ } else if (params.requireCreated) {
211
+ return fail("created_required");
212
+ }
213
+ let expires: number | undefined;
214
+ if (sigParams.has("expires")) {
215
+ const e = sigParams.get("expires");
216
+ if (typeof e !== "number" || !Number.isInteger(e))
217
+ return fail("expires_invalid");
218
+ expires = e;
219
+ if (now > e + tolerance) return fail("signature_expired");
220
+ }
221
+ // RFC 9421 §2.3: `expires` MUST NOT precede `created`.
222
+ if (created !== undefined && expires !== undefined && expires < created) {
223
+ return fail("expires_invalid");
224
+ }
225
+
226
+ const coveredComponents = input.items.map((item) => String(item.value));
227
+
228
+ // Required-component policy.
229
+ if (params.requiredComponents) {
230
+ const covered = new Set(coveredComponents.map((c) => c.toLowerCase()));
231
+ for (const required of params.requiredComponents) {
232
+ if (!covered.has(required.toLowerCase()))
233
+ return fail("required_component_missing");
234
+ }
235
+ }
236
+
237
+ // Rebuild the signature base from the named components.
238
+ const ctx = derivationContext(message);
239
+ if (ctx === null) return fail("covered_component_missing");
240
+ const lines: string[] = [];
241
+ // RFC 9421 §2 / §2.5: each component identifier (name plus its serialized
242
+ // parameters) MUST occur only once; a repeated identifier is malformed.
243
+ const seen = new Set<string>();
244
+ for (const item of input.items) {
245
+ if (item.params.length > 0 || typeof item.value !== "string") {
246
+ return fail("components_malformed");
247
+ }
248
+ const id = item.value.toLowerCase();
249
+ if (seen.has(id)) return fail("components_malformed");
250
+ seen.add(id);
251
+ const value = deriveComponentValue(ctx, item.value);
252
+ if (value === null) return fail("covered_component_missing");
253
+ lines.push(`${item.raw}: ${value}`);
254
+ }
255
+ const base = [...lines, `"@signature-params": ${input.raw}`].join("\n");
256
+
257
+ // Resolve and validate the key, then verify. When `alg` was omitted, derive
258
+ // it from the resolved key's type (RFC 9421 §2.3).
259
+ const key = await params.resolveKey({ keyId, alg });
260
+ if (key === null) return fail("key_unresolved");
261
+ if (alg === null) {
262
+ alg = deriveAlgFromKey(key);
263
+ if (alg === null) return fail("alg_unsupported");
264
+ }
265
+ const rejection = validateKey(key, alg);
266
+ if (rejection !== null) return fail(rejection);
267
+
268
+ const ok = await verifyBytes(key, alg, signature, utf8(base));
269
+ if (!ok) return fail("signature_invalid");
270
+
271
+ return {
272
+ valid: true,
273
+ profile: "rfc9421",
274
+ label,
275
+ keyId,
276
+ coveredComponents,
277
+ created,
278
+ expires,
279
+ };
280
+ }