wb-auth 0.3.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -1,19 +1,360 @@
1
1
  // src/signer.ts
2
- import { signatureHeaders } from "web-bot-auth";
2
+ import { randomBytes } from "crypto";
3
+
4
+ // src/identity.ts
5
+ import {
6
+ createHash,
7
+ createPrivateKey,
8
+ createPublicKey,
9
+ sign as cryptoSign,
10
+ generateKeyPairSync
11
+ } from "crypto";
12
+ import { mkdir, open, readFile, rename, stat } from "fs/promises";
13
+ import { homedir } from "os";
14
+ import { dirname, join, resolve } from "path";
15
+ var DEFAULT_KEY_PATH = resolve(
16
+ homedir(),
17
+ ".config",
18
+ "wbauth",
19
+ "key.pem"
20
+ );
21
+ var KEY_PASSWORD_ENV = "WBAUTH_KEY_PASSWORD";
22
+ var KEYRING_ACTIVE = "active.pem";
23
+ var KEYRING_RETIRING = "retiring.pem";
24
+ var TEST_KEY_JWK = {
25
+ kty: "OKP",
26
+ crv: "Ed25519",
27
+ d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
28
+ x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
29
+ };
30
+ var warnedWindows = false;
31
+ function validateSignatureAgentUrl(url) {
32
+ if (typeof url !== "string" || !url.startsWith("https://")) {
33
+ throw new Error(`signatureAgentUrl must be https://, got: ${String(url)}`);
34
+ }
35
+ for (const ch of url) {
36
+ const c = ch.charCodeAt(0);
37
+ if (c < 33 || c > 126 || ch === '"' || ch === "\\") {
38
+ throw new Error(
39
+ `signatureAgentUrl must be printable ASCII without spaces, quotes or backslashes, got: ${JSON.stringify(url)}`
40
+ );
41
+ }
42
+ }
43
+ let host = "";
44
+ try {
45
+ host = new URL(url).hostname;
46
+ } catch {
47
+ }
48
+ if (!host) throw new Error(`signatureAgentUrl has no host: ${url}`);
49
+ return url;
50
+ }
51
+ function validateUserAgent(ua) {
52
+ if (ua !== void 0 && [...ua].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) {
53
+ throw new Error(
54
+ `userAgent must not contain control characters: ${JSON.stringify(ua)}`
55
+ );
56
+ }
57
+ return ua;
58
+ }
59
+ var Identity = class _Identity {
60
+ #active;
61
+ #retiring;
62
+ #signatureAgentUrl;
63
+ #userAgent;
64
+ constructor(active, opts, retiring = null) {
65
+ this.#signatureAgentUrl = validateSignatureAgentUrl(opts.signatureAgentUrl);
66
+ this.#userAgent = validateUserAgent(opts.userAgent);
67
+ this.#active = active;
68
+ this.#retiring = retiring;
69
+ }
70
+ // ---------- Public read-only API ----------
71
+ get signatureAgentUrl() {
72
+ return this.#signatureAgentUrl;
73
+ }
74
+ get userAgent() {
75
+ return this.#userAgent;
76
+ }
77
+ /** The active key's kid (used as `keyid=` param in Signature-Input). */
78
+ get kid() {
79
+ return this.#active.kid;
80
+ }
81
+ /** The active key's public JWK (copy; never contains private material). */
82
+ get publicJwk() {
83
+ return { ...this.#active.publicJwk };
84
+ }
85
+ /** Module-internal: returns the active Signer for sign() to use. */
86
+ _signer() {
87
+ return this.#active.signer;
88
+ }
89
+ /** Public JWKS: active first, then the retiring key (if any). */
90
+ exportJwks() {
91
+ const keys = [{ ...this.#active.publicJwk }];
92
+ if (this.#retiring) keys.push({ ...this.#retiring.publicJwk });
93
+ return { keys };
94
+ }
95
+ /**
96
+ * IDENT-07: generate a new active key, demote current to retiring.
97
+ * In-memory only — use `rotateKeyring()` to persist. The default path is
98
+ * a fresh timestamped file next to DEFAULT_KEY_PATH (never collides).
99
+ */
100
+ async rotate(newPath, keyOpts = {}) {
101
+ const path = newPath ? expandUser(newPath) : join(
102
+ dirname(DEFAULT_KEY_PATH),
103
+ `key-${Math.floor(Date.now() / 1e3)}.pem`
104
+ );
105
+ const newPair = await generateKeypairTo(path, keyOpts);
106
+ return new _Identity(newPair, this.#opts(), this.#active);
107
+ }
108
+ /**
109
+ * Persisted rotation: `<dir>/active.pem` → `retiring.pem` (replacing the
110
+ * previous retiring key), then a fresh `active.pem` is generated.
111
+ */
112
+ async rotateKeyring(dir, keyOpts = {}) {
113
+ const d = expandUser(dir);
114
+ const active = join(d, KEYRING_ACTIVE);
115
+ const retiring = join(d, KEYRING_RETIRING);
116
+ await stat(active);
117
+ await rename(active, retiring);
118
+ let pair;
119
+ try {
120
+ pair = await generateKeypairTo(active, keyOpts);
121
+ } catch (e) {
122
+ await rename(retiring, active);
123
+ throw e;
124
+ }
125
+ return new _Identity(
126
+ pair,
127
+ this.#opts(),
128
+ await loadKeypair(retiring, keyOpts)
129
+ );
130
+ }
131
+ #opts() {
132
+ return {
133
+ signatureAgentUrl: this.#signatureAgentUrl,
134
+ userAgent: this.#userAgent
135
+ };
136
+ }
137
+ // ---------- Constructors ----------
138
+ /**
139
+ * Loads `path` if it exists, generates a fresh key there otherwise.
140
+ * Concurrent first starts are safe: the loser of the O_EXCL race loads the
141
+ * winner's key. `retiringPath` optionally loads the previous key.
142
+ */
143
+ static async loadOrGenerate(path = DEFAULT_KEY_PATH, opts) {
144
+ validateSignatureAgentUrl(opts.signatureAgentUrl);
145
+ const resolved = expandUser(path);
146
+ let pair = null;
147
+ if (!await exists(resolved)) {
148
+ try {
149
+ pair = await generateKeypairTo(resolved, opts);
150
+ } catch (e) {
151
+ if (e.code !== "EEXIST" && !await exists(resolved))
152
+ throw e;
153
+ }
154
+ }
155
+ pair ??= await loadKeypair(resolved, opts);
156
+ let retiring = null;
157
+ if (opts.retiringPath && await exists(expandUser(opts.retiringPath))) {
158
+ retiring = await loadKeypair(expandUser(opts.retiringPath), opts);
159
+ }
160
+ return new _Identity(pair, opts, retiring);
161
+ }
162
+ /** Load an existing key; never creates one. */
163
+ static async load(path, opts) {
164
+ return new _Identity(await loadKeypair(expandUser(path), opts), opts);
165
+ }
166
+ /** Generate a new key at `path`; never overwrites. */
167
+ static async generate(path, opts) {
168
+ validateSignatureAgentUrl(opts.signatureAgentUrl);
169
+ return new _Identity(await generateKeypairTo(expandUser(path), opts), opts);
170
+ }
171
+ /** `<dir>/active.pem` (+ `retiring.pem`), generating active on first use. */
172
+ static async loadKeyring(dir, opts) {
173
+ const d = expandUser(dir);
174
+ return _Identity.loadOrGenerate(join(d, KEYRING_ACTIVE), {
175
+ ...opts,
176
+ retiringPath: join(d, KEYRING_RETIRING)
177
+ });
178
+ }
179
+ /** Wrap an external signer (KMS, HSM, keychain). */
180
+ static async fromSigner(backend, opts) {
181
+ const jwk = backend.publicJwk;
182
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
183
+ throw new TypeError("fromSigner: publicJwk must be an Ed25519 OKP JWK");
184
+ }
185
+ const kid = thumbprint(jwk);
186
+ const enc = new TextEncoder();
187
+ return new _Identity(
188
+ {
189
+ kid,
190
+ publicJwk: { kty: "OKP", crv: "Ed25519", x: jwk.x, kid },
191
+ signer: {
192
+ alg: "ed25519",
193
+ keyid: kid,
194
+ sign: async (data) => backend.sign(enc.encode(data))
195
+ }
196
+ },
197
+ opts
198
+ );
199
+ }
200
+ /** RFC 9421 Appendix B.1.4 test key. NEVER use in production. */
201
+ static async fromTestKey(signatureAgentUrl, opts = {}) {
202
+ const key = createPrivateKey({ key: TEST_KEY_JWK, format: "jwk" });
203
+ return new _Identity(keyPairFromKeyObject(key), {
204
+ ...opts,
205
+ signatureAgentUrl
206
+ });
207
+ }
208
+ // ---------- Redaction guarantees (IDENT-08) ----------
209
+ toString() {
210
+ return `<Identity REDACTED kid='${this.kid}' sig_agent='${this.#signatureAgentUrl}'>`;
211
+ }
212
+ /** JSON.stringify(identity) → public data only. */
213
+ toJSON() {
214
+ return {
215
+ kid: this.kid,
216
+ signatureAgentUrl: this.#signatureAgentUrl,
217
+ ...this.#userAgent !== void 0 ? { userAgent: this.#userAgent } : {},
218
+ jwks: this.exportJwks()
219
+ };
220
+ }
221
+ /** util.inspect / console.log render the same REDACTED form (IDENT-08). */
222
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
223
+ return this.toString();
224
+ }
225
+ };
226
+ function expandUser(path) {
227
+ if (path === "~") return homedir();
228
+ if (path.startsWith("~/") || path.startsWith("~\\")) {
229
+ return resolve(homedir(), path.slice(2));
230
+ }
231
+ return resolve(path);
232
+ }
233
+ async function exists(path) {
234
+ try {
235
+ await stat(path);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+ function b64url(buf) {
242
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
243
+ }
244
+ function thumbprint(jwk) {
245
+ const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x });
246
+ return b64url(createHash("sha256").update(canonical).digest());
247
+ }
248
+ function keyPairFromKeyObject(privateKey) {
249
+ const pub = createPublicKey(privateKey).export({
250
+ format: "jwk"
251
+ });
252
+ if (pub.kty !== "OKP" || pub.crv !== "Ed25519") {
253
+ throw new TypeError(`Expected Ed25519, got kty=${pub.kty}, crv=${pub.crv}`);
254
+ }
255
+ const kid = thumbprint(pub);
256
+ return {
257
+ kid,
258
+ publicJwk: { kty: "OKP", crv: "Ed25519", x: pub.x, kid },
259
+ signer: {
260
+ alg: "ed25519",
261
+ keyid: kid,
262
+ // Sync node:crypto Ed25519 — no WebCrypto import / promise hop.
263
+ sign: async (data) => new Uint8Array(cryptoSign(null, Buffer.from(data, "utf8"), privateKey))
264
+ }
265
+ };
266
+ }
267
+ function passwordFor(opts) {
268
+ return opts.password ?? (process.env[KEY_PASSWORD_ENV] || void 0);
269
+ }
270
+ async function loadKeypair(path, opts = {}) {
271
+ if (process.platform !== "win32") {
272
+ const mode = (await stat(path)).mode & 511;
273
+ if ((mode & 63) !== 0) {
274
+ throw new Error(
275
+ `Key file ${path} has mode 0o${mode.toString(8)}; expected 0o600. Fix: chmod 600 ${path}`
276
+ );
277
+ }
278
+ } else if (!warnedWindows) {
279
+ warnedWindows = true;
280
+ process.emitWarning(
281
+ `Windows detected \u2014 file permissions on ${path} cannot be enforced via POSIX mode. Use NTFS ACLs or store the key in a per-user secrets vault.`
282
+ );
283
+ }
284
+ const pem = await readFile(path, "utf8");
285
+ const encrypted = pem.includes("ENCRYPTED");
286
+ const passphrase = passwordFor(opts);
287
+ if (encrypted && !passphrase) {
288
+ throw new TypeError(
289
+ `Key file ${path} is encrypted; pass { password } or set $${KEY_PASSWORD_ENV}`
290
+ );
291
+ }
292
+ const key = createPrivateKey(
293
+ encrypted ? { key: pem, format: "pem", passphrase } : pem
294
+ );
295
+ return keyPairFromKeyObject(key);
296
+ }
297
+ async function generateKeypairTo(path, opts = {}) {
298
+ await mkdir(dirname(path), { recursive: true });
299
+ if (await exists(path)) {
300
+ const err = new Error(
301
+ `Key already exists at ${path}; refuse overwrite`
302
+ );
303
+ err.code = "EEXIST";
304
+ throw err;
305
+ }
306
+ const { privateKey } = generateKeyPairSync("ed25519");
307
+ const passphrase = passwordFor(opts);
308
+ const pem = privateKey.export(
309
+ passphrase ? { format: "pem", type: "pkcs8", cipher: "aes-256-cbc", passphrase } : { format: "pem", type: "pkcs8" }
310
+ );
311
+ const fh = await open(path, "wx", 384);
312
+ try {
313
+ await fh.writeFile(pem);
314
+ await fh.sync();
315
+ } finally {
316
+ await fh.close();
317
+ }
318
+ return keyPairFromKeyObject(privateKey);
319
+ }
320
+
321
+ // src/signer.ts
322
+ var WEB_BOT_AUTH_TAG = "web-bot-auth";
3
323
  var DEFAULT_LABEL = "sig1";
4
324
  var DEFAULT_EXPIRES_SECONDS = 60;
5
325
  var MAX_EXPIRES_SECONDS = 3600;
6
- function componentsFor(_method, hasBody) {
7
- const base = ["@authority", "signature-agent"];
8
- if (hasBody) {
9
- base.push("content-digest");
326
+ var NONCE_BYTES = 64;
327
+ var SF_KEY_RE = /^[a-z*][a-z0-9_\-.*]*$/;
328
+ var DEFAULT_PORTS = {
329
+ "https:": "443",
330
+ "http:": "80"
331
+ };
332
+ function sfString(value) {
333
+ for (const ch of value) {
334
+ const c = ch.charCodeAt(0);
335
+ if (c < 32 || c > 126) {
336
+ throw new Error(
337
+ `value is not a valid structured-field string: ${JSON.stringify(value)}`
338
+ );
339
+ }
10
340
  }
11
- return base;
341
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
342
+ }
343
+ function normalizeAuthority(url) {
344
+ const u = new URL(url);
345
+ if (!u.hostname) throw new Error(`URL has no host: ${url}`);
346
+ const host = u.hostname.toLowerCase();
347
+ const port = u.port && u.port !== DEFAULT_PORTS[u.protocol] ? `:${u.port}` : "";
348
+ return `${host}${port}`;
349
+ }
350
+ function componentsFor(hasBody) {
351
+ return hasBody ? ["@authority", "signature-agent", "content-digest"] : ["@authority", "signature-agent"];
12
352
  }
13
353
  var RESIGN_HEADERS = ["signature", "signature-input", "signature-agent"];
14
- function hasHeader(headers, name) {
354
+ function getHeader(headers, name) {
15
355
  const n = name.toLowerCase();
16
- return Object.keys(headers).some((k) => k.toLowerCase() === n);
356
+ const k = Object.keys(headers).find((key) => key.toLowerCase() === n);
357
+ return k === void 0 ? void 0 : headers[k];
17
358
  }
18
359
  function removeHeaders(headers, names) {
19
360
  for (const k of Object.keys(headers)) {
@@ -21,57 +362,54 @@ function removeHeaders(headers, names) {
21
362
  }
22
363
  }
23
364
  async function sign(request, identity, opts = {}) {
24
- if (!identity.signatureAgentUrl.startsWith("https://")) {
25
- throw new Error(
26
- `signatureAgentUrl must be https://, got: ${identity.signatureAgentUrl}`
27
- );
28
- }
365
+ validateSignatureAgentUrl(identity.signatureAgentUrl);
29
366
  const expiresAfterSeconds = opts.expiresAfterSeconds ?? DEFAULT_EXPIRES_SECONDS;
30
367
  if (typeof expiresAfterSeconds !== "number" || !Number.isFinite(expiresAfterSeconds) || expiresAfterSeconds <= 0 || expiresAfterSeconds > MAX_EXPIRES_SECONDS) {
31
368
  throw new RangeError(
32
369
  `expiresAfterSeconds must be a number in (0, ${MAX_EXPIRES_SECONDS}], got: ${expiresAfterSeconds}`
33
370
  );
34
371
  }
35
- removeHeaders(request.headers, RESIGN_HEADERS);
36
- const signatureAgentHeader = `"${identity.signatureAgentUrl}"`;
37
- request.headers["Signature-Agent"] = signatureAgentHeader;
38
- const created = opts.created ?? /* @__PURE__ */ new Date();
39
- const expires = new Date(created.getTime() + expiresAfterSeconds * 1e3);
40
372
  const label = opts.label ?? DEFAULT_LABEL;
41
- const hasBody = request.body !== null && request.body !== void 0 && request.body.byteLength > 0;
42
- const components = componentsFor(request.method, hasBody);
43
- if (components.includes("content-digest") && !hasHeader(request.headers, "content-digest")) {
373
+ if (!SF_KEY_RE.test(label)) {
44
374
  throw new Error(
45
- 'Covered header field "content-digest" not found in the message. Call ensureContentDigest() or use createSignedFetch()/applyTo(), which add it automatically.'
375
+ `label must be an RFC 8941 key, got: ${JSON.stringify(label)}`
46
376
  );
47
377
  }
48
- const headers = new Headers();
49
- for (const [k, v] of Object.entries(request.headers)) headers.set(k, v);
50
- const init = { method: request.method, headers };
51
- const m = request.method.toUpperCase();
52
- if (hasBody && m !== "GET" && m !== "HEAD") {
53
- init.body = request.body;
54
- }
55
- const req = new Request(request.url, init);
56
- const params = {
57
- created,
58
- expires,
59
- key: label,
60
- components,
61
- ...opts.nonce !== void 0 ? { nonce: opts.nonce } : {}
62
- };
63
- const result = await signatureHeaders(req, identity._signer(), params);
64
- request.headers.Signature = result.Signature;
65
- request.headers["Signature-Input"] = result["Signature-Input"];
66
- return {
67
- signature: result.Signature,
68
- signatureInput: result["Signature-Input"],
69
- signatureAgent: signatureAgentHeader
378
+ removeHeaders(request.headers, RESIGN_HEADERS);
379
+ const signatureAgentHeader = sfString(identity.signatureAgentUrl);
380
+ request.headers["Signature-Agent"] = signatureAgentHeader;
381
+ const created = Math.floor((opts.created ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
382
+ const expires = created + Math.floor(expiresAfterSeconds);
383
+ const nonce = opts.nonce ?? randomBytes(NONCE_BYTES).toString("base64");
384
+ const hasBody = request.body != null && request.body.byteLength > 0;
385
+ const components = componentsFor(hasBody);
386
+ const values = {
387
+ "@authority": normalizeAuthority(request.url),
388
+ "signature-agent": signatureAgentHeader
70
389
  };
390
+ if (hasBody) {
391
+ const cd = getHeader(request.headers, "content-digest");
392
+ if (cd === void 0) {
393
+ throw new Error(
394
+ 'Covered header field "content-digest" not found in the message. Call ensureContentDigest() or use createSignedFetch()/applyTo(), which add it automatically.'
395
+ );
396
+ }
397
+ values["content-digest"] = cd.trim();
398
+ }
399
+ const signer = identity._signer();
400
+ const params = `(${components.map((c) => `"${c}"`).join(" ")});created=${created};keyid=${sfString(signer.keyid)};alg="ed25519";expires=${expires};nonce=${sfString(nonce)};tag=${sfString(WEB_BOT_AUTH_TAG)}`;
401
+ const base = `${components.map((c) => `"${c}": ${values[c]}`).join("\n")}
402
+ "@signature-params": ${params}`;
403
+ const raw = await signer.sign(base);
404
+ const signatureInput = `${label}=${params}`;
405
+ const signature = `${label}=:${Buffer.from(raw).toString("base64")}:`;
406
+ request.headers["Signature-Input"] = signatureInput;
407
+ request.headers.Signature = signature;
408
+ return { signature, signatureInput, signatureAgent: signatureAgentHeader };
71
409
  }
72
410
 
73
411
  // src/adapters/_utils.ts
74
- import { createHash } from "crypto";
412
+ import { createHash as createHash2 } from "crypto";
75
413
  var LIBRARY_DEFAULT_UA = /^(node|undici|node-fetch)(\/|$)/i;
76
414
  function findHeader(headers, name) {
77
415
  const n = name.toLowerCase();
@@ -80,10 +418,41 @@ function findHeader(headers, name) {
80
418
  function ensureContentDigest(method, headers, body) {
81
419
  if (!body || body.byteLength === 0) return;
82
420
  void method;
83
- if (findHeader(headers, "content-digest")) return;
84
- const b64 = createHash("sha256").update(body).digest("base64");
421
+ const b64 = createHash2("sha256").update(body).digest("base64");
422
+ const key = findHeader(headers, "content-digest");
423
+ if (key !== void 0) {
424
+ const m = /(?:^|,)\s*sha-256=:([A-Za-z0-9+/=]+):/.exec(headers[key] ?? "");
425
+ if (m === null || m[1] === b64) return;
426
+ delete headers[key];
427
+ }
85
428
  headers["Content-Digest"] = `sha-256=:${b64}:`;
86
429
  }
430
+ var SIGNATURE_HEADERS = /* @__PURE__ */ new Set([
431
+ "signature",
432
+ "signature-input",
433
+ "signature-agent"
434
+ ]);
435
+ function stripSignatureHeaders(headers) {
436
+ for (const k of Object.keys(headers)) {
437
+ if (SIGNATURE_HEADERS.has(k.toLowerCase())) delete headers[k];
438
+ }
439
+ }
440
+ function makeSignDecider(opts = {}) {
441
+ return (raw) => {
442
+ let u;
443
+ try {
444
+ u = new URL(raw);
445
+ } catch {
446
+ return false;
447
+ }
448
+ if (u.protocol !== "https:" && u.protocol !== "http:") return false;
449
+ if (!u.hostname) return false;
450
+ if (u.protocol === "http:" && !opts.allowInsecure) return false;
451
+ if (opts.shouldSign) return opts.shouldSign(u);
452
+ if (opts.hosts) return hostMatches(u.hostname, opts.hosts);
453
+ return true;
454
+ };
455
+ }
87
456
  function applyUserAgent(headers, userAgent) {
88
457
  if (!userAgent) return;
89
458
  const key = findHeader(headers, "user-agent");
@@ -129,6 +498,7 @@ function dropHeaders(headers, names) {
129
498
  }
130
499
  }
131
500
  function createSignedFetch(identity, options = {}) {
501
+ const decide = makeSignDecider(options);
132
502
  return async function signedFetch(input, init = {}) {
133
503
  const req = new Request(input, init);
134
504
  let method = req.method;
@@ -148,8 +518,12 @@ function createSignedFetch(identity, options = {}) {
148
518
  const doFetch = options.fetch ?? globalThis.fetch;
149
519
  for (let hop = 0; ; hop++) {
150
520
  const headers = { ...baseHeaders };
151
- ensureContentDigest(method, headers, body);
152
- await sign({ method, url, headers, body }, identity);
521
+ if (decide(url)) {
522
+ ensureContentDigest(method, headers, body);
523
+ await sign({ method, url, headers, body }, identity);
524
+ } else {
525
+ stripSignatureHeaders(headers);
526
+ }
153
527
  applyUserAgent(headers, identity.userAgent);
154
528
  const outInit = {
155
529
  ...init,
@@ -196,19 +570,8 @@ function createSignedFetch(identity, options = {}) {
196
570
  // src/adapters/playwright.ts
197
571
  async function applyTo(page, identity, options = {}) {
198
572
  const pattern = options.pattern ?? "**/*";
199
- const decide = (raw) => {
200
- let u;
201
- try {
202
- u = new URL(raw);
203
- } catch {
204
- return false;
205
- }
206
- if (u.protocol !== "https:" && u.protocol !== "http:") return false;
207
- if (options.shouldSign) return options.shouldSign(u);
208
- if (options.hosts) return hostMatches(u.hostname, options.hosts);
209
- return true;
210
- };
211
- await page.route(pattern, async (route, request) => {
573
+ const decide = makeSignDecider(options);
574
+ const handler = async (route, request) => {
212
575
  const url = request.url();
213
576
  if (!decide(url)) {
214
577
  const r = route;
@@ -222,175 +585,320 @@ async function applyTo(page, identity, options = {}) {
222
585
  await sign({ method: request.method(), url, headers, body }, identity);
223
586
  applyUserAgent(headers, identity.userAgent);
224
587
  await route.continue({ headers });
225
- });
588
+ };
589
+ await page.route(pattern, handler);
590
+ return async () => {
591
+ await page.unroute(pattern, handler);
592
+ };
226
593
  }
227
594
 
228
- // src/identity.ts
229
- import { createPrivateKey, generateKeyPairSync } from "crypto";
595
+ // src/verifier.ts
230
596
  import {
231
- closeSync,
232
- existsSync,
233
- mkdirSync,
234
- openSync,
235
- readFileSync,
236
- statSync,
237
- writeFileSync
238
- } from "fs";
239
- import { homedir } from "os";
240
- import { dirname, resolve } from "path";
241
- import { signerFromJWK } from "web-bot-auth/crypto";
242
- var DEFAULT_KEY_PATH = resolve(
243
- homedir(),
244
- ".config",
245
- "wbauth",
246
- "key.pem"
247
- );
248
- var TEST_KEY_JWK = {
249
- kty: "OKP",
250
- crv: "Ed25519",
251
- d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
252
- x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
597
+ createHash as createHash3,
598
+ createPublicKey as createPublicKey2,
599
+ verify as cryptoVerify
600
+ } from "crypto";
601
+ var DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
602
+ var DIRECTORY_MAX_BYTES = 64 * 1024;
603
+ var VerificationError = class extends Error {
604
+ name = "VerificationError";
253
605
  };
254
- var Identity = class _Identity {
255
- _active;
256
- _retiring;
257
- signatureAgentUrl;
258
- userAgent;
259
- constructor(active, opts, retiring = null) {
260
- if (!opts.signatureAgentUrl.startsWith("https://")) {
261
- throw new Error(
262
- `signatureAgentUrl must be https://, got: ${opts.signatureAgentUrl}`
263
- );
264
- }
265
- this._active = active;
266
- this._retiring = retiring;
267
- this.signatureAgentUrl = opts.signatureAgentUrl;
268
- this.userAgent = opts.userAgent;
606
+ var NonceStore = class {
607
+ constructor(maxSize = 1e5) {
608
+ this.maxSize = maxSize;
269
609
  }
270
- // ---------- Public read-only API ----------
271
- /** The active key's kid (used as `keyid=` param in Signature-Input). */
272
- get kid() {
273
- return this._active.kid;
274
- }
275
- /** Module-internal: returns the resolved Signer for sign() to use. */
276
- _signer() {
277
- return this._active.signer;
610
+ maxSize;
611
+ #seen = /* @__PURE__ */ new Map();
612
+ seen(nonce, expiresAt) {
613
+ const now = Date.now() / 1e3;
614
+ if (this.#seen.size >= this.maxSize) {
615
+ for (const [n, e] of this.#seen) if (e <= now) this.#seen.delete(n);
616
+ for (const n of [...this.#seen.keys()].slice(
617
+ 0,
618
+ Math.ceil(this.maxSize / 10)
619
+ )) {
620
+ if (this.#seen.size < this.maxSize) break;
621
+ this.#seen.delete(n);
622
+ }
623
+ }
624
+ const prev = this.#seen.get(nonce);
625
+ if (prev !== void 0 && prev > now) return true;
626
+ this.#seen.set(nonce, expiresAt);
627
+ return false;
278
628
  }
279
- /**
280
- * Export the public JWKS document.
281
- *
282
- * Returns `{keys: [active_jwk]}` if no retiring key is held, or
283
- * `{keys: [active_jwk, retiring_jwk]}` after a rotation. Active is always
284
- * first. Verifiers select by `kid`. Mirror of Python `export_jwks`.
285
- */
286
- exportJwks() {
287
- const keys = [this._active.publicJwk];
288
- if (this._retiring) keys.push(this._retiring.publicJwk);
289
- return { keys };
629
+ };
630
+ var SF_STRING = String.raw`"(?:[^"\\]|\\.)*"`;
631
+ var PARAM = String.raw`;\s*([a-z*][a-z0-9_.*-]*)(?:=(${SF_STRING}|-?\d+(?:\.\d+)?|\?[01]|[A-Za-z*][^;,\s]*))?`;
632
+ var SIG_INPUT_RE = new RegExp(
633
+ String.raw`\s*([a-z*][a-z0-9_.*-]*)=(\((?:\s*${SF_STRING}(?:;[^\s)"]+)*)*\s*\))((?:${PARAM})*)`,
634
+ "y"
635
+ );
636
+ var unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replace(/\\(.)/g, "$1") : v;
637
+ function parseSignatureInput(value) {
638
+ const out = [];
639
+ let pos = 0;
640
+ while (pos < value.length) {
641
+ SIG_INPUT_RE.lastIndex = pos;
642
+ const m = SIG_INPUT_RE.exec(value);
643
+ if (!m) throw new VerificationError("malformed Signature-Input");
644
+ const [, label = "", inner = "", rest = ""] = m;
645
+ if (/"\s*;/.test(inner))
646
+ throw new VerificationError("component parameters are not supported");
647
+ const params = {};
648
+ for (const p of rest.matchAll(new RegExp(PARAM, "g"))) {
649
+ const k = p[1];
650
+ const v = p[2];
651
+ params[k] = v === void 0 ? true : v.startsWith('"') ? unquote(v) : /^-?\d+$/.test(v) ? Number(v) : v;
652
+ }
653
+ out.push({
654
+ label,
655
+ rawParams: inner + rest,
656
+ components: [...inner.matchAll(new RegExp(SF_STRING, "g"))].map(
657
+ (x) => unquote(x[0])
658
+ ),
659
+ params
660
+ });
661
+ pos = SIG_INPUT_RE.lastIndex;
662
+ while (pos < value.length && (value[pos] === " " || value[pos] === ","))
663
+ pos++;
290
664
  }
291
- /**
292
- * IDENT-07: generate a new active key, demote current to retiring.
293
- *
294
- * Returns a NEW Identity (immutable update — the old Identity is unchanged).
295
- * Any previously-retiring key is dropped (only one overlap window).
296
- */
297
- async rotate(newPath = DEFAULT_KEY_PATH) {
298
- const newPair = await generateKeypairTo(expandUser(newPath));
299
- return new _Identity(
300
- newPair,
301
- { signatureAgentUrl: this.signatureAgentUrl, userAgent: this.userAgent },
302
- this._active
303
- );
665
+ return out;
666
+ }
667
+ function parseSignature(value) {
668
+ const out = /* @__PURE__ */ new Map();
669
+ for (const m of value.matchAll(
670
+ /\s*([a-z*][a-z0-9_.*-]*)=:([A-Za-z0-9+/=]*):/g
671
+ )) {
672
+ out.set(m[1], Buffer.from(m[2], "base64"));
304
673
  }
305
- // ---------- Constructors ----------
306
- /**
307
- * Primary entry point. Loads `path` if it exists, generates a fresh key
308
- * there otherwise. The on-disk file is PKCS8 NoEncryption PEM at 0o600,
309
- * byte-equal to what Python's `Identity.load_or_generate` writes (D-60).
310
- */
311
- static async loadOrGenerate(path = DEFAULT_KEY_PATH, opts) {
312
- const resolved = expandUser(path);
313
- const pair = existsSync(resolved) ? await loadKeypair(resolved) : await generateKeypairTo(resolved);
314
- return new _Identity(pair, opts);
674
+ return out;
675
+ }
676
+ function componentValue(name, req, headers, authority) {
677
+ const u = new URL(req.url);
678
+ switch (name) {
679
+ case "@authority":
680
+ return authority ?? normalizeAuthority(req.url);
681
+ case "@method":
682
+ return req.method.toUpperCase();
683
+ case "@scheme":
684
+ return u.protocol.slice(0, -1);
685
+ case "@target-uri":
686
+ return u.href;
687
+ case "@path":
688
+ return u.pathname || "/";
689
+ case "@query":
690
+ return u.search || "?";
315
691
  }
316
- /**
317
- * Construct from the RFC 9421 Appendix B.1.4 publicly-known test key.
318
- * For tests + Cloudflare debug smoke checks. NEVER use in production.
319
- * Does NOT write any file to disk.
320
- */
321
- static async fromTestKey(signatureAgentUrl, opts = {}) {
322
- const pair = await keyPairFromJwk(TEST_KEY_JWK);
323
- return new _Identity(pair, { ...opts, signatureAgentUrl });
692
+ if (name.startsWith("@"))
693
+ throw new VerificationError(`unsupported derived component ${name}`);
694
+ const v = headers.get(name);
695
+ if (v === null) throw new VerificationError(`covered header ${name} missing`);
696
+ return v.trim();
697
+ }
698
+ function directoryUrl(signatureAgent) {
699
+ const u = new URL(signatureAgent);
700
+ if (u.protocol !== "https:")
701
+ throw new VerificationError("Signature-Agent must be https://");
702
+ if (u.pathname === "/" || u.pathname === "") {
703
+ u.pathname = DIRECTORY_PATH;
704
+ u.search = "";
324
705
  }
325
- // ---------- Redaction guarantees (IDENT-08) ----------
326
- toString() {
327
- return `<Identity REDACTED kid='${this.kid}' sig_agent='${this.signatureAgentUrl}'>`;
706
+ u.hash = "";
707
+ return u.href;
708
+ }
709
+ var Verifier = class {
710
+ #opts;
711
+ #nonces;
712
+ #jwks = /* @__PURE__ */ new Map();
713
+ constructor(opts = {}) {
714
+ this.#opts = opts;
715
+ this.#nonces = opts.nonceStore ?? new NonceStore();
328
716
  }
329
- /** util.inspect / console.log render the same REDACTED form (IDENT-08). */
330
- [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
331
- return this.toString();
717
+ async #directoryKeys(url) {
718
+ const hit = this.#jwks.get(url);
719
+ if (hit && hit.until > Date.now()) return hit.keys;
720
+ const f = this.#opts.fetch ?? globalThis.fetch;
721
+ let res;
722
+ try {
723
+ res = await f(url, {
724
+ headers: {
725
+ accept: "application/http-message-signatures-directory+json, application/json"
726
+ },
727
+ redirect: "error",
728
+ signal: AbortSignal.timeout(3e3)
729
+ });
730
+ } catch (e) {
731
+ throw new VerificationError(`directory unreachable: ${String(e)}`);
732
+ }
733
+ if (res.status !== 200)
734
+ throw new VerificationError(`directory ${url} answered ${res.status}`);
735
+ const buf = await res.arrayBuffer();
736
+ if (buf.byteLength > DIRECTORY_MAX_BYTES)
737
+ throw new VerificationError("directory too large");
738
+ let keys;
739
+ try {
740
+ keys = JSON.parse(new TextDecoder().decode(buf)).keys;
741
+ } catch {
742
+ throw new VerificationError("directory is not a JWKS");
743
+ }
744
+ if (!Array.isArray(keys))
745
+ throw new VerificationError("directory is not a JWKS");
746
+ this.#jwks.set(url, {
747
+ until: Date.now() + (this.#opts.jwksTtlSeconds ?? 300) * 1e3,
748
+ keys
749
+ });
750
+ return keys;
332
751
  }
333
- };
334
- function expandUser(path) {
335
- return resolve(path.startsWith("~") ? path.replace(/^~/, homedir()) : path);
336
- }
337
- async function keyPairFromJwk(privateJwk) {
338
- const signer = await signerFromJWK(privateJwk);
339
- const kid = signer.keyid;
340
- const publicJwk = {
341
- kty: privateJwk.kty,
342
- crv: privateJwk.crv,
343
- x: privateJwk.x,
344
- kid
345
- };
346
- return { privateJwk, publicJwk, kid, signer };
347
- }
348
- async function loadKeypair(path) {
349
- if (process.platform !== "win32") {
350
- const mode = statSync(path).mode & 511;
351
- if ((mode & 63) !== 0) {
352
- throw new Error(
353
- `Key file ${path} has mode 0o${mode.toString(8)}; expected 0o600. Fix: chmod 600 ${path}`
752
+ async #resolve(kid, signatureAgent) {
753
+ if (this.#opts.keyResolver) {
754
+ const jwk = await this.#opts.keyResolver(kid, signatureAgent);
755
+ if (!jwk) throw new VerificationError(`unknown keyid ${kid}`);
756
+ return jwk;
757
+ }
758
+ if (!signatureAgent)
759
+ throw new VerificationError(
760
+ "no Signature-Agent and no keyResolver configured"
354
761
  );
762
+ for (const k of await this.#directoryKeys(directoryUrl(signatureAgent))) {
763
+ if (k && k.kty === "OKP" && k.crv === "Ed25519" && k.x && thumbprint(k) === kid)
764
+ return k;
355
765
  }
356
- } else {
357
- process.emitWarning(
358
- `Windows detected \u2014 file permissions on ${path} cannot be enforced via POSIX mode. Use NTFS ACLs or store the key in a per-user secrets vault.`
766
+ throw new VerificationError(
767
+ `keyid ${kid} not published by ${signatureAgent}`
359
768
  );
360
769
  }
361
- const pem = readFileSync(path, "utf8");
362
- const jwk = createPrivateKey(pem).export({ format: "jwk" });
363
- if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
364
- throw new TypeError(`Expected Ed25519, got kty=${jwk.kty}, crv=${jwk.crv}`);
365
- }
366
- return keyPairFromJwk(jwk);
367
- }
368
- async function generateKeypairTo(path) {
369
- const dir = dirname(path);
370
- mkdirSync(dir, { recursive: true });
371
- if (existsSync(path)) {
372
- throw new Error(`Key already exists at ${path}; refuse overwrite`);
373
- }
374
- const { privateKey } = generateKeyPairSync("ed25519");
375
- const pem = privateKey.export({ format: "pem", type: "pkcs8" });
376
- const fd = openSync(path, "wx", 384);
377
- try {
378
- writeFileSync(fd, pem);
379
- } finally {
380
- closeSync(fd);
770
+ /**
771
+ * Verify a WHATWG Request (body is read from a clone). `authority`
772
+ * overrides the value derived from the URL (e.g. behind a proxy).
773
+ */
774
+ async verify(request, opts = {}) {
775
+ const h = request.headers;
776
+ const sigInput = h.get("signature-input");
777
+ const sigHeader = h.get("signature");
778
+ if (!sigInput || !sigHeader)
779
+ throw new VerificationError("request is not signed");
780
+ const sigs = parseSignature(sigHeader);
781
+ const cand = parseSignatureInput(sigInput).find(
782
+ (c) => c.params.tag === WEB_BOT_AUTH_TAG
783
+ );
784
+ if (!cand)
785
+ throw new VerificationError('no signature with tag="web-bot-auth"');
786
+ const { label, rawParams, components, params } = cand;
787
+ const raw = sigs.get(label);
788
+ if (!raw) throw new VerificationError(`Signature has no ${label} member`);
789
+ if ((params.alg ?? "ed25519") !== "ed25519")
790
+ throw new VerificationError("only alg=ed25519 is supported");
791
+ const { created, expires, keyid } = params;
792
+ if (typeof created !== "number" || typeof expires !== "number" || typeof keyid !== "string") {
793
+ throw new VerificationError("created, expires and keyid are required");
794
+ }
795
+ const skew = this.#opts.maxSkew ?? 5;
796
+ const now = opts.now ?? Date.now() / 1e3;
797
+ if (created > now + skew)
798
+ throw new VerificationError("signature created in the future");
799
+ if (expires < now - skew) throw new VerificationError("signature expired");
800
+ if (expires <= created || expires - created > MAX_EXPIRES_SECONDS) {
801
+ throw new VerificationError("invalid validity window");
802
+ }
803
+ if (!components.includes("@authority"))
804
+ throw new VerificationError("@authority must be covered");
805
+ let signatureAgent = null;
806
+ const sa = h.get("signature-agent");
807
+ if (sa !== null) {
808
+ if (!components.includes("signature-agent")) {
809
+ throw new VerificationError("Signature-Agent sent but not covered");
810
+ }
811
+ signatureAgent = unquote(sa.trim());
812
+ }
813
+ const body = request.body ? new Uint8Array(await request.clone().arrayBuffer()) : new Uint8Array();
814
+ if (body.byteLength > 0) {
815
+ if (!components.includes("content-digest")) {
816
+ throw new VerificationError(
817
+ "request has a body but content-digest is not covered"
818
+ );
819
+ }
820
+ const m = /(?:^|,)\s*sha-256=:([A-Za-z0-9+/=]+):/.exec(
821
+ h.get("content-digest") ?? ""
822
+ );
823
+ const actual = createHash3("sha256").update(body).digest("base64");
824
+ if (!m || m[1] !== actual)
825
+ throw new VerificationError("Content-Digest does not match the body");
826
+ }
827
+ const nonce = typeof params.nonce === "string" ? params.nonce : null;
828
+ if (this.#opts.requireNonce && !nonce)
829
+ throw new VerificationError("nonce required");
830
+ const base = [
831
+ ...components.map(
832
+ (c) => `"${c}": ${componentValue(c, request, h, opts.authority)}`
833
+ ),
834
+ `"@signature-params": ${rawParams}`
835
+ ].join("\n");
836
+ const jwk = await this.#resolve(keyid, signatureAgent);
837
+ let ok = false;
838
+ try {
839
+ const key = createPublicKey2({
840
+ key: { kty: "OKP", crv: "Ed25519", x: jwk.x },
841
+ format: "jwk"
842
+ });
843
+ ok = cryptoVerify(null, Buffer.from(base, "utf8"), key, raw);
844
+ } catch {
845
+ ok = false;
846
+ }
847
+ if (!ok) throw new VerificationError("signature does not verify");
848
+ if (nonce && this.#nonces.seen(nonce, expires + skew))
849
+ throw new VerificationError("nonce replayed");
850
+ return {
851
+ kid: keyid,
852
+ signatureAgentUrl: signatureAgent,
853
+ label,
854
+ created,
855
+ expires,
856
+ nonce,
857
+ covered: components,
858
+ jwk
859
+ };
381
860
  }
382
- const jwk = privateKey.export({ format: "jwk" });
383
- return keyPairFromJwk(jwk);
861
+ };
862
+ function createVerifierMiddleware(opts = {}) {
863
+ const verifier = new Verifier(opts);
864
+ return async (request) => {
865
+ const signed = request.headers.has("signature-input");
866
+ if (!signed && !opts.required) return null;
867
+ try {
868
+ if (!signed) throw new VerificationError("request is not signed");
869
+ return await verifier.verify(request);
870
+ } catch (e) {
871
+ if (!opts.required) return null;
872
+ return new Response(
873
+ JSON.stringify({
874
+ error: "web_bot_auth",
875
+ reason: String(e.message)
876
+ }),
877
+ {
878
+ status: 401,
879
+ headers: { "content-type": "application/json" }
880
+ }
881
+ );
882
+ }
883
+ };
384
884
  }
385
885
 
386
886
  // src/index.ts
387
- var VERSION = "0.3.0";
887
+ var VERSION = "0.4.0";
388
888
  export {
389
889
  DEFAULT_KEY_PATH,
390
890
  Identity,
891
+ KEY_PASSWORD_ENV,
892
+ NonceStore,
391
893
  VERSION,
894
+ VerificationError,
895
+ Verifier,
392
896
  applyTo,
393
897
  createSignedFetch,
394
- sign
898
+ createVerifierMiddleware,
899
+ normalizeAuthority,
900
+ sign,
901
+ thumbprint,
902
+ validateSignatureAgentUrl
395
903
  };
396
904
  //# sourceMappingURL=index.js.map