@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/sf.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * A focused RFC 8941 structured-fields parser, scoped to the two fields HTTP
3
+ * Message Signatures use: `Signature-Input` (a Dictionary of Inner Lists with
4
+ * parameters) and `Signature` (a Dictionary of Byte Sequences).
5
+ *
6
+ * It is intentionally not a general structured-fields implementation. Parsing
7
+ * is strict — anything malformed throws {@link SfParseError}, which the callers
8
+ * turn into a verification failure rather than guessing at the sender's intent.
9
+ */
10
+
11
+ /** A structured-fields bare item value, as the native JS shape we use it as. */
12
+ export type SfBareItem = string | number | boolean | Uint8Array;
13
+
14
+ /** One member of an `Signature-Input` inner list (a covered-component id). */
15
+ export interface SfInnerItem {
16
+ /** Exact source text of the item (bare item plus its parameters). */
17
+ raw: string;
18
+ /** The bare item, a string for every component identifier we accept. */
19
+ value: SfBareItem;
20
+ /** Item parameters, in source order. */
21
+ params: Array<[string, SfBareItem]>;
22
+ }
23
+
24
+ /** A parsed `Signature-Input` dictionary member. */
25
+ export interface SfSignatureInput {
26
+ /** Exact source text of the member value (inner list plus its parameters). */
27
+ raw: string;
28
+ items: SfInnerItem[];
29
+ params: Array<[string, SfBareItem]>;
30
+ }
31
+
32
+ /** Thrown on any malformed structured-field input. */
33
+ export class SfParseError extends Error {}
34
+
35
+ const KEY_START = /[a-z*]/;
36
+ const KEY_CHAR = /[a-z0-9_*.-]/;
37
+ const TOKEN_START = /[a-zA-Z*]/;
38
+ const TOKEN_CHAR = /[a-zA-Z0-9:/!#$%&'*+\-.^_`|~]/;
39
+
40
+ class Reader {
41
+ pos = 0;
42
+ constructor(readonly input: string) {}
43
+
44
+ get done(): boolean {
45
+ return this.pos >= this.input.length;
46
+ }
47
+ peek(): string {
48
+ return this.input[this.pos] ?? "";
49
+ }
50
+ next(): string {
51
+ const c = this.input[this.pos] ?? "";
52
+ this.pos++;
53
+ return c;
54
+ }
55
+ expect(c: string): void {
56
+ if (this.next() !== c) throw new SfParseError(`expected ${c}`);
57
+ }
58
+ skipSP(): void {
59
+ while (this.peek() === " ") this.pos++;
60
+ }
61
+ skipOWS(): void {
62
+ while (this.peek() === " " || this.peek() === "\t") this.pos++;
63
+ }
64
+ }
65
+
66
+ function parseKey(r: Reader): string {
67
+ if (!KEY_START.test(r.peek())) throw new SfParseError("invalid key");
68
+ let key = r.next();
69
+ while (!r.done && KEY_CHAR.test(r.peek())) key += r.next();
70
+ return key;
71
+ }
72
+
73
+ function parseString(r: Reader): string {
74
+ r.expect('"');
75
+ let out = "";
76
+ for (;;) {
77
+ if (r.done) throw new SfParseError("unterminated string");
78
+ const c = r.next();
79
+ if (c === "\\") {
80
+ const esc = r.next();
81
+ if (esc !== '"' && esc !== "\\") throw new SfParseError("bad escape");
82
+ out += esc;
83
+ } else if (c === '"') {
84
+ return out;
85
+ } else {
86
+ const code = c.charCodeAt(0);
87
+ if (code < 0x20 || code >= 0x7f)
88
+ throw new SfParseError("bad string char");
89
+ out += c;
90
+ }
91
+ }
92
+ }
93
+
94
+ function parseToken(r: Reader): string {
95
+ let tok = r.next();
96
+ while (!r.done && TOKEN_CHAR.test(r.peek())) tok += r.next();
97
+ return tok;
98
+ }
99
+
100
+ function parseNumber(r: Reader): number {
101
+ let s = "";
102
+ if (r.peek() === "-") s += r.next();
103
+ while (!r.done && /[0-9.]/.test(r.peek())) s += r.next();
104
+ const n = Number(s);
105
+ if (!Number.isFinite(n)) throw new SfParseError("invalid number");
106
+ return n;
107
+ }
108
+
109
+ function parseByteSequence(r: Reader): Uint8Array {
110
+ r.expect(":");
111
+ let b64 = "";
112
+ while (!r.done && r.peek() !== ":") b64 += r.next();
113
+ r.expect(":");
114
+ try {
115
+ const binary = atob(b64);
116
+ const bytes = new Uint8Array(binary.length);
117
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
118
+ return bytes;
119
+ } catch {
120
+ throw new SfParseError("invalid byte sequence");
121
+ }
122
+ }
123
+
124
+ function parseBareItem(r: Reader): SfBareItem {
125
+ const c = r.peek();
126
+ if (c === '"') return parseString(r);
127
+ if (c === ":") return parseByteSequence(r);
128
+ if (c === "?") {
129
+ r.next();
130
+ const b = r.next();
131
+ if (b === "0") return false;
132
+ if (b === "1") return true;
133
+ throw new SfParseError("invalid boolean");
134
+ }
135
+ if (c === "-" || /[0-9]/.test(c)) return parseNumber(r);
136
+ if (TOKEN_START.test(c)) return parseToken(r);
137
+ throw new SfParseError("invalid bare item");
138
+ }
139
+
140
+ function parseParameters(r: Reader): Array<[string, SfBareItem]> {
141
+ const params: Array<[string, SfBareItem]> = [];
142
+ while (r.peek() === ";") {
143
+ r.next();
144
+ r.skipSP();
145
+ const key = parseKey(r);
146
+ let value: SfBareItem = true;
147
+ if (r.peek() === "=") {
148
+ r.next();
149
+ value = parseBareItem(r);
150
+ }
151
+ params.push([key, value]);
152
+ }
153
+ return params;
154
+ }
155
+
156
+ function parseInnerList(r: Reader): {
157
+ items: SfInnerItem[];
158
+ params: Array<[string, SfBareItem]>;
159
+ } {
160
+ r.expect("(");
161
+ const items: SfInnerItem[] = [];
162
+ for (;;) {
163
+ r.skipSP();
164
+ if (r.peek() === ")") {
165
+ r.next();
166
+ break;
167
+ }
168
+ if (r.done) throw new SfParseError("unterminated inner list");
169
+ const start = r.pos;
170
+ const value = parseBareItem(r);
171
+ const params = parseParameters(r);
172
+ items.push({ raw: r.input.slice(start, r.pos), value, params });
173
+ }
174
+ const params = parseParameters(r);
175
+ return { items, params };
176
+ }
177
+
178
+ /**
179
+ * Parse a `Signature-Input` header into its labelled inner lists, preserving
180
+ * for each member the exact source text of its value (the `@signature-params`
181
+ * value the base must reproduce byte-for-byte).
182
+ */
183
+ export function parseSignatureInput(
184
+ input: string,
185
+ ): Map<string, SfSignatureInput> {
186
+ const r = new Reader(input);
187
+ const out = new Map<string, SfSignatureInput>();
188
+ r.skipOWS();
189
+ if (r.done) throw new SfParseError("empty dictionary");
190
+ for (;;) {
191
+ const key = parseKey(r);
192
+ if (r.peek() !== "=")
193
+ throw new SfParseError("dictionary member needs a value");
194
+ r.next();
195
+ if (r.peek() !== "(")
196
+ throw new SfParseError("signature-input value must be an inner list");
197
+ const start = r.pos;
198
+ const { items, params } = parseInnerList(r);
199
+ out.set(key, { raw: r.input.slice(start, r.pos), items, params });
200
+ r.skipOWS();
201
+ if (r.done) break;
202
+ r.expect(",");
203
+ r.skipOWS();
204
+ }
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * Parse an RFC 8941 Dictionary whose member values are Byte Sequences — the
210
+ * shape shared by the `Signature` header and the RFC 9530 `Content-Digest` /
211
+ * `Want-Content-Digest` fields. Member parameters are accepted and discarded.
212
+ * Keys follow sf-key rules (lowercase only), so an uppercase key is a parse
213
+ * error rather than something silently lowercased.
214
+ */
215
+ export function parseByteSequenceDictionary(
216
+ input: string,
217
+ ): Map<string, Uint8Array> {
218
+ const r = new Reader(input);
219
+ const out = new Map<string, Uint8Array>();
220
+ r.skipOWS();
221
+ if (r.done) throw new SfParseError("empty dictionary");
222
+ for (;;) {
223
+ const key = parseKey(r);
224
+ if (r.peek() !== "=")
225
+ throw new SfParseError("dictionary member needs a value");
226
+ r.next();
227
+ const bytes = parseByteSequence(r);
228
+ parseParameters(r);
229
+ out.set(key, bytes);
230
+ r.skipOWS();
231
+ if (r.done) break;
232
+ r.expect(",");
233
+ r.skipOWS();
234
+ }
235
+ return out;
236
+ }
237
+
238
+ /** Parse a `Signature` header into its labelled byte sequences. */
239
+ export function parseSignatureHeader(input: string): Map<string, Uint8Array> {
240
+ return parseByteSequenceDictionary(input);
241
+ }
242
+
243
+ /** Serialize an sf-string: double-quoted with `"` and `\` escaped. */
244
+ export function serializeString(value: string): string {
245
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
246
+ }
package/src/sign.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The unified signing entry point. Dispatches on the wire profile and returns
3
+ * the header(s) to merge into the outbound request.
4
+ */
5
+
6
+ import { signCavage } from "./cavage";
7
+ import { signRfc9421 } from "./rfc9421";
8
+ import type {
9
+ HttpMessage,
10
+ SignatureAlgorithm,
11
+ SignatureProfile,
12
+ } from "./types";
13
+
14
+ /** Inputs for {@link signMessage}. */
15
+ export interface SignParams {
16
+ /** Wire profile. Defaults to `"rfc9421"`. */
17
+ profile?: SignatureProfile;
18
+ /** The private {@link CryptoKey}, imported by the caller for `alg`. */
19
+ key: CryptoKey;
20
+ /** The `keyid` advertised so the verifier can resolve the public half. */
21
+ keyId: string;
22
+ /** Signature algorithm; must be in the allow-list. */
23
+ alg: SignatureAlgorithm;
24
+ /**
25
+ * Covered components, in signing order. For `"rfc9421"`: derived names like
26
+ * `@method`, `@target-uri`, `@authority`, plus lowercase header names. For
27
+ * `"cavage"`: `(request-target)`, `(created)`, `(expires)`, and lowercase
28
+ * header names.
29
+ */
30
+ components: string[];
31
+ /** Signature creation time, epoch seconds. Defaults to now. */
32
+ created?: number;
33
+ /** Signature expiry, epoch seconds. Omitted when absent. */
34
+ expires?: number;
35
+ /** Anti-replay nonce (RFC 9421 only). */
36
+ nonce?: string;
37
+ /** Application tag (RFC 9421 only). */
38
+ tag?: string;
39
+ /** Signature label (RFC 9421 only). Defaults to `"sig1"`. */
40
+ label?: string;
41
+ /** Override "now" for the `created` default, epoch seconds (deterministic tests). */
42
+ now?: number;
43
+ }
44
+
45
+ /**
46
+ * Sign `message`, returning the headers to add to the request. The caller is
47
+ * responsible for having already set (and listed among `components`) any
48
+ * `content-digest` / `digest` header it wants covered — see
49
+ * {@link createContentDigest} / {@link createDigest}.
50
+ *
51
+ * @returns A map of header name → value (RFC 9421: `Signature-Input` and
52
+ * `Signature`; cavage: a single `Signature`).
53
+ */
54
+ export function signMessage(
55
+ message: HttpMessage,
56
+ params: SignParams,
57
+ ): Promise<Record<string, string>> {
58
+ if (params.profile === "cavage") {
59
+ return signCavage(message, {
60
+ key: params.key,
61
+ keyId: params.keyId,
62
+ alg: params.alg,
63
+ components: params.components,
64
+ created: params.created,
65
+ expires: params.expires,
66
+ });
67
+ }
68
+ return signRfc9421(message, {
69
+ key: params.key,
70
+ keyId: params.keyId,
71
+ alg: params.alg,
72
+ components: params.components,
73
+ created: params.created ?? params.now ?? Math.floor(Date.now() / 1000),
74
+ expires: params.expires,
75
+ nonce: params.nonce,
76
+ tag: params.tag,
77
+ label: params.label ?? "sig1",
78
+ });
79
+ }
package/src/types.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared plain-data types for {@link signMessage} and {@link verifyMessage}.
3
+ *
4
+ * The library never touches a live HTTP object: a request (or response) is
5
+ * described by its method, URL, and header values, so it unit-tests under Node
6
+ * with no Workers runtime.
7
+ */
8
+
9
+ /**
10
+ * Asymmetric signature algorithms this library accepts, named by their RFC 9421
11
+ * §3.3 registry identifiers.
12
+ *
13
+ * Symmetric (`hmac-sha256`) and unsigned (`none`) algorithms are deliberately
14
+ * excluded: an HTTP message signature must be made by the sender's private key
15
+ * whose public half the verifier resolves out of band.
16
+ */
17
+ export type SignatureAlgorithm =
18
+ | "rsa-pss-sha512"
19
+ | "rsa-v1_5-sha256"
20
+ | "ecdsa-p256-sha256"
21
+ | "ecdsa-p384-sha384"
22
+ | "ed25519";
23
+
24
+ /**
25
+ * Wire profile.
26
+ *
27
+ * - `"rfc9421"` — the modern `Signature-Input` / `Signature` structured-field
28
+ * pair (RFC 9421).
29
+ * - `"cavage"` — the legacy single `Signature` header from
30
+ * `draft-cavage-http-signatures`, still emitted across the fediverse.
31
+ */
32
+ export type SignatureProfile = "rfc9421" | "cavage";
33
+
34
+ /**
35
+ * A request (or response) reduced to the facts a signature is computed over.
36
+ *
37
+ * `headers` keys are matched case-insensitively; a value array models a field
38
+ * that appears multiple times (its values are joined with `", "` per RFC 9421
39
+ * §2.1).
40
+ */
41
+ export interface HttpMessage {
42
+ /** HTTP method, e.g. `"POST"`. */
43
+ method: string;
44
+ /** Absolute request URI, e.g. `"https://example.com/inbox"`. */
45
+ url: string;
46
+ /** Header field values, keyed by (case-insensitive) field name. */
47
+ headers: Record<string, string | string[]>;
48
+ }
49
+
50
+ /** Stable, locale-independent failure codes returned in {@link VerifyResult.reason}. */
51
+ export type SignatureFailureReason =
52
+ | "signature_missing"
53
+ | "signature_input_malformed"
54
+ | "signature_malformed"
55
+ | "label_ambiguous"
56
+ | "label_missing"
57
+ | "components_malformed"
58
+ | "alg_unsupported"
59
+ | "keyid_missing"
60
+ | "key_unresolved"
61
+ | "key_alg_mismatch"
62
+ | "key_too_small"
63
+ | "key_invalid"
64
+ | "covered_component_missing"
65
+ | "required_component_missing"
66
+ | "created_invalid"
67
+ | "created_required"
68
+ | "signature_expired"
69
+ | "signature_future"
70
+ | "expires_invalid"
71
+ | "signature_invalid"
72
+ | "digest_missing"
73
+ | "digest_unsupported"
74
+ | "digest_mismatch";
75
+
76
+ /** Result of verifying an HTTP message signature. */
77
+ export interface VerifyResult {
78
+ /** Whether the signature is valid. */
79
+ valid: boolean;
80
+ /** The profile the signature was read under, when one could be identified. */
81
+ profile?: SignatureProfile;
82
+ /** The signature label that was verified (RFC 9421 only). */
83
+ label?: string;
84
+ /** The `keyid` the signature claimed (so the caller can audit the principal). */
85
+ keyId?: string;
86
+ /** The covered-component identifiers, in signing order. */
87
+ coveredComponents?: string[];
88
+ /** The signature's `created` time (epoch seconds), if present. */
89
+ created?: number;
90
+ /** The signature's `expires` time (epoch seconds), if present. */
91
+ expires?: number;
92
+ /** Stable failure code (see {@link SignatureFailureReason}) when `valid` is false. */
93
+ reason?: SignatureFailureReason;
94
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The unified verification entry point. Auto-detects the wire profile (unless
3
+ * one is forced), verifies the signature, and — when a body is supplied —
4
+ * checks any covered `content-digest` / `digest` against it so body tampering
5
+ * is caught alongside header tampering.
6
+ */
7
+
8
+ import { verifyCavage } from "./cavage";
9
+ import { verifyContentDigest, verifyDigest } from "./digest";
10
+ import { verifyRfc9421, type KeyResolver } from "./rfc9421";
11
+ import type { HttpMessage, SignatureProfile, VerifyResult } from "./types";
12
+
13
+ /** Inputs for {@link verifyMessage}. */
14
+ export interface VerifyParams {
15
+ /** Resolves the public {@link CryptoKey} for a `keyid` (and, when known, `alg`). */
16
+ resolveKey: KeyResolver;
17
+ /** Force a profile. When omitted, it is detected from the present headers. */
18
+ profile?: SignatureProfile;
19
+ /** Which labelled signature to verify (RFC 9421); required if more than one. */
20
+ label?: string;
21
+ /** Component identifiers that MUST be covered (e.g. `@method`, `date`, `content-digest`). */
22
+ requiredComponents?: string[];
23
+ /** Require a `created` parameter (RFC 9421). */
24
+ requireCreated?: boolean;
25
+ /** Current time, epoch seconds. Defaults to `Date.now()`. */
26
+ now?: number;
27
+ /** Allowed clock skew, in seconds, for `created`/`expires`. Defaults to 300. */
28
+ toleranceSeconds?: number;
29
+ /**
30
+ * The received body. When provided and a digest component is covered, the
31
+ * corresponding `content-digest` / `digest` header is recomputed over it; a
32
+ * mismatch fails verification with a `digest_*` reason.
33
+ */
34
+ body?: Uint8Array | string;
35
+ }
36
+
37
+ function hasHeader(message: HttpMessage, name: string): boolean {
38
+ const lower = name.toLowerCase();
39
+ return Object.keys(message.headers).some((k) => k.toLowerCase() === lower);
40
+ }
41
+
42
+ function getHeader(message: HttpMessage, name: string): string | null {
43
+ const lower = name.toLowerCase();
44
+ for (const [k, v] of Object.entries(message.headers)) {
45
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v.join(", ") : v;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Verify the HTTP message signature on `message`. Never throws — every failure
52
+ * is `{ valid: false, reason }` with a stable {@link SignatureFailureReason}.
53
+ */
54
+ export async function verifyMessage(
55
+ message: HttpMessage,
56
+ params: VerifyParams,
57
+ ): Promise<VerifyResult> {
58
+ const profile: SignatureProfile =
59
+ params.profile ??
60
+ (hasHeader(message, "signature-input") ? "rfc9421" : "cavage");
61
+
62
+ const result =
63
+ profile === "rfc9421"
64
+ ? await verifyRfc9421(message, {
65
+ resolveKey: params.resolveKey,
66
+ label: params.label,
67
+ requiredComponents: params.requiredComponents,
68
+ requireCreated: params.requireCreated,
69
+ now: params.now,
70
+ toleranceSeconds: params.toleranceSeconds,
71
+ })
72
+ : await verifyCavage(message, {
73
+ resolveKey: params.resolveKey,
74
+ requiredComponents: params.requiredComponents,
75
+ now: params.now,
76
+ toleranceSeconds: params.toleranceSeconds,
77
+ });
78
+
79
+ if (!result.valid || params.body === undefined) return result;
80
+
81
+ // Body integrity: only when a digest component was actually covered.
82
+ const covered = new Set(
83
+ (result.coveredComponents ?? []).map((c) => c.toLowerCase()),
84
+ );
85
+ if (covered.has("content-digest")) {
86
+ const rejection = await verifyContentDigest(
87
+ getHeader(message, "content-digest"),
88
+ params.body,
89
+ );
90
+ if (rejection !== null)
91
+ return { ...result, valid: false, reason: rejection };
92
+ } else if (covered.has("digest")) {
93
+ const rejection = await verifyDigest(
94
+ getHeader(message, "digest"),
95
+ params.body,
96
+ );
97
+ if (rejection !== null)
98
+ return { ...result, valid: false, reason: rejection };
99
+ }
100
+
101
+ return result;
102
+ }