wb-auth 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,8 @@
1
- # Temporary Holding Version
1
+ # wbauth (TypeScript)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```bash
4
+ npm install wbauth # Phase 5 — not yet published
5
+ ```
6
+
7
+ Phase 1: stub package — only used as the cross-language vector check oracle.
8
+ Phase 4: full SDK with createSignedFetch + Playwright applyTo.
@@ -0,0 +1,189 @@
1
+ import { Signer } from 'http-message-sig';
2
+ import { Page } from 'playwright';
3
+
4
+ /**
5
+ * Identity, KeyPair, and key-file helpers for the wbauth TypeScript SDK.
6
+ *
7
+ * Mirrors python/src/wbauth/identity.py — same on-disk format (PKCS8
8
+ * NoEncryption PEM at 0o600), same kid (RFC 7638 JWK thumbprint), same
9
+ * multi-key rotation semantics (active + retiring), same REDACTED repr
10
+ * (IDENT-08).
11
+ *
12
+ * Cross-language guarantee (D-60): the PEM file Python writes via
13
+ * `Ed25519PrivateKey.private_bytes(PEM, PKCS8, NoEncryption)` is byte-equal
14
+ * to what Node's `crypto.generateKeyPairSync('ed25519').privateKey.export({
15
+ * format: 'pem', type: 'pkcs8' })` writes. Both runtimes can load each
16
+ * other's keyfile and produce the same kid + signatures.
17
+ *
18
+ * Key implementation choice (Pitfall 1): PEM→JWK is done via Node 20+
19
+ * stdlib `node:crypto.createPrivateKey(pem).export({format:'jwk'})`. We do
20
+ * NOT use `jose` or any third-party JOSE library — the stdlib output is
21
+ * verified byte-identical to what Python's cryptography library produces
22
+ * for the same PKCS8 PEM (RESEARCH §2.2 live verification 2026-05-10).
23
+ */
24
+
25
+ /** Default path for the active signing key. Matches Python DEFAULT_KEY_PATH. */
26
+ declare const DEFAULT_KEY_PATH: string;
27
+ interface IdentityOptions {
28
+ /** Stable HTTPS URL where this agent's JWKS is published. Must start with `https://`. */
29
+ signatureAgentUrl: string;
30
+ /** Optional User-Agent string adapters inject when caller did not supply one. */
31
+ userAgent?: string;
32
+ }
33
+ interface KeyPair {
34
+ /** Private JWK (includes "d") — used to mint the signer. Never logged. */
35
+ privateJwk: JsonWebKey;
36
+ /** Public JWK ({kty, crv, x, kid}) — what exportJwks returns per key. */
37
+ publicJwk: JsonWebKey & {
38
+ kid: string;
39
+ };
40
+ /** RFC 7638 base64url-no-pad thumbprint of the public key. */
41
+ kid: string;
42
+ /** Pre-resolved web-bot-auth Signer (caches the imported CryptoKey). */
43
+ signer: Signer;
44
+ }
45
+ declare class Identity {
46
+ private _active;
47
+ private _retiring;
48
+ readonly signatureAgentUrl: string;
49
+ readonly userAgent: string | undefined;
50
+ private constructor();
51
+ /** The active key's kid (used as `keyid=` param in Signature-Input). */
52
+ get kid(): string;
53
+ /** Module-internal: returns the resolved Signer for sign() to use. */
54
+ _signer(): Signer;
55
+ /**
56
+ * Export the public JWKS document.
57
+ *
58
+ * Returns `{keys: [active_jwk]}` if no retiring key is held, or
59
+ * `{keys: [active_jwk, retiring_jwk]}` after a rotation. Active is always
60
+ * first. Verifiers select by `kid`. Mirror of Python `export_jwks`.
61
+ */
62
+ exportJwks(): {
63
+ keys: JsonWebKey[];
64
+ };
65
+ /**
66
+ * IDENT-07: generate a new active key, demote current to retiring.
67
+ *
68
+ * Returns a NEW Identity (immutable update — the old Identity is unchanged).
69
+ * Any previously-retiring key is dropped (only one overlap window).
70
+ */
71
+ rotate(newPath?: string): Promise<Identity>;
72
+ /**
73
+ * Primary entry point. Loads `path` if it exists, generates a fresh key
74
+ * there otherwise. The on-disk file is PKCS8 NoEncryption PEM at 0o600,
75
+ * byte-equal to what Python's `Identity.load_or_generate` writes (D-60).
76
+ */
77
+ static loadOrGenerate(path: string | undefined, opts: IdentityOptions): Promise<Identity>;
78
+ /**
79
+ * Construct from the RFC 9421 Appendix B.1.4 publicly-known test key.
80
+ * For tests + Cloudflare debug smoke checks. NEVER use in production.
81
+ * Does NOT write any file to disk.
82
+ */
83
+ static fromTestKey(signatureAgentUrl: string): Promise<Identity>;
84
+ toString(): string;
85
+ }
86
+
87
+ /**
88
+ * createSignedFetch — drop-in `fetch` wrapper that signs every outgoing request.
89
+ *
90
+ * Mirrors python/src/wbauth/adapters/httpx_auth.py. Stateless: closure over
91
+ * `identity` only. Returns a function whose signature matches `typeof fetch`,
92
+ * so user code can swap `fetch` for `signedFetch` without other changes.
93
+ *
94
+ * v1 limitation (Pitfall 4): streaming bodies are NOT supported. The adapter
95
+ * reads the full body via `req.clone().arrayBuffer()` to compute Content-Digest.
96
+ */
97
+
98
+ declare function createSignedFetch(identity: Identity): typeof fetch;
99
+
100
+ /**
101
+ * applyTo — Playwright async helper for Web Bot Auth (ADAPT-05).
102
+ *
103
+ * Mirrors python/src/wbauth/adapters/playwright.py. Registers a route
104
+ * handler that intercepts every outgoing request, signs it via wbauth.sign(),
105
+ * and continues with the signed headers.
106
+ *
107
+ * Pitfall 3: Call applyTo(page, identity) BEFORE the first page.goto()/click
108
+ * that should produce signed requests. page.route must be registered before
109
+ * navigation begins, or the navigation request leaves the browser unsigned.
110
+ *
111
+ * Pitfall (iframe coverage): page.route('**\/*', handler) covers all
112
+ * sub-frame requests by default. If the target site uses Service Workers,
113
+ * set serviceWorkers="block" on the BrowserContext (Playwright option).
114
+ *
115
+ * Note: `playwright` is a peerDependency (optional). End users only install
116
+ * it if they actually use this adapter — fetch-only consumers pay zero cost.
117
+ */
118
+
119
+ declare function applyTo(page: Page, identity: Identity): Promise<void>;
120
+
121
+ /**
122
+ * NormalizedRequest — the structural input to wbauth.sign().
123
+ *
124
+ * Mirrors python/src/wbauth/normalized_request.py. The Web Bot Auth signer
125
+ * does not care which HTTP client produced the request; it only needs the
126
+ * five fields below. Adapter glue (createSignedFetch, applyTo) constructs
127
+ * a NormalizedRequest from its native request type and hands it to sign().
128
+ *
129
+ * The `headers` dict is mutated in place by sign() — Signature, Signature-Input,
130
+ * and Signature-Agent are written back so the caller can read them out.
131
+ */
132
+ interface NormalizedRequest {
133
+ /** HTTP method ("GET" | "POST" | "PUT" | "PATCH" | "DELETE" | ...). */
134
+ method: string;
135
+ /** Absolute URL — must be https:// for production targets. */
136
+ url: string;
137
+ /** Mutable header dict. sign() writes Signature, Signature-Input, Signature-Agent. */
138
+ headers: Record<string, string>;
139
+ /** Body bytes for content-digest computation; null/undefined for bodiless methods. */
140
+ body: Uint8Array | null;
141
+ }
142
+
143
+ interface SignatureHeaders {
144
+ signature: string;
145
+ signatureInput: string;
146
+ signatureAgent: string;
147
+ }
148
+ interface SignOptions {
149
+ /** Signing timestamp. Defaults to `new Date()`. Override for vector reproducibility. */
150
+ created?: Date;
151
+ /** Validity window in seconds. Defaults to 60. */
152
+ expiresAfterSeconds?: number;
153
+ /** Anti-replay nonce. Defaults to web-bot-auth's `generateNonce()` (64-byte b64). */
154
+ nonce?: string;
155
+ /** Signature label (the `sig1=` prefix). Defaults to "sig1". */
156
+ label?: string;
157
+ }
158
+ /**
159
+ * Sign a NormalizedRequest with the Web Bot Auth profile.
160
+ *
161
+ * Mutates `request.headers` in place: writes Signature-Agent BEFORE building
162
+ * the signing base (Pitfall 6 — without pre-set, web-bot-auth defaults to
163
+ * `["@authority"]` only and silently breaks byte-equality with the Python
164
+ * signer). After signatureHeaders returns, also writes Signature and
165
+ * Signature-Input back into `request.headers`.
166
+ *
167
+ * @returns the same three values as a typed dataclass for callers that prefer
168
+ * a return value over header inspection.
169
+ */
170
+ declare function sign(request: NormalizedRequest, identity: Identity, opts?: SignOptions): Promise<SignatureHeaders>;
171
+
172
+ /**
173
+ * wbauth: Web Bot Auth (RFC 9421) TypeScript SDK — Phase 4 public surface.
174
+ *
175
+ * Per CONTEXT D-74: flat root exports. Subpath exports (`wbauth/adapters`)
176
+ * are a v1.x optimization, not required for Phase 4.
177
+ *
178
+ * Quickstart:
179
+ * import { Identity, createSignedFetch } from "wbauth";
180
+ * const id = await Identity.loadOrGenerate("./key.pem", {
181
+ * signatureAgentUrl: "https://example.com/.well-known/.../<kid>",
182
+ * });
183
+ * const sf = createSignedFetch(id);
184
+ * const res = await sf("https://api.example.com/data");
185
+ */
186
+
187
+ declare const VERSION = "0.1.0";
188
+
189
+ export { DEFAULT_KEY_PATH, Identity, type IdentityOptions, type KeyPair, type NormalizedRequest, type SignOptions, type SignatureHeaders, VERSION, applyTo, createSignedFetch, sign };
@@ -0,0 +1,189 @@
1
+ import { Signer } from 'http-message-sig';
2
+ import { Page } from 'playwright';
3
+
4
+ /**
5
+ * Identity, KeyPair, and key-file helpers for the wbauth TypeScript SDK.
6
+ *
7
+ * Mirrors python/src/wbauth/identity.py — same on-disk format (PKCS8
8
+ * NoEncryption PEM at 0o600), same kid (RFC 7638 JWK thumbprint), same
9
+ * multi-key rotation semantics (active + retiring), same REDACTED repr
10
+ * (IDENT-08).
11
+ *
12
+ * Cross-language guarantee (D-60): the PEM file Python writes via
13
+ * `Ed25519PrivateKey.private_bytes(PEM, PKCS8, NoEncryption)` is byte-equal
14
+ * to what Node's `crypto.generateKeyPairSync('ed25519').privateKey.export({
15
+ * format: 'pem', type: 'pkcs8' })` writes. Both runtimes can load each
16
+ * other's keyfile and produce the same kid + signatures.
17
+ *
18
+ * Key implementation choice (Pitfall 1): PEM→JWK is done via Node 20+
19
+ * stdlib `node:crypto.createPrivateKey(pem).export({format:'jwk'})`. We do
20
+ * NOT use `jose` or any third-party JOSE library — the stdlib output is
21
+ * verified byte-identical to what Python's cryptography library produces
22
+ * for the same PKCS8 PEM (RESEARCH §2.2 live verification 2026-05-10).
23
+ */
24
+
25
+ /** Default path for the active signing key. Matches Python DEFAULT_KEY_PATH. */
26
+ declare const DEFAULT_KEY_PATH: string;
27
+ interface IdentityOptions {
28
+ /** Stable HTTPS URL where this agent's JWKS is published. Must start with `https://`. */
29
+ signatureAgentUrl: string;
30
+ /** Optional User-Agent string adapters inject when caller did not supply one. */
31
+ userAgent?: string;
32
+ }
33
+ interface KeyPair {
34
+ /** Private JWK (includes "d") — used to mint the signer. Never logged. */
35
+ privateJwk: JsonWebKey;
36
+ /** Public JWK ({kty, crv, x, kid}) — what exportJwks returns per key. */
37
+ publicJwk: JsonWebKey & {
38
+ kid: string;
39
+ };
40
+ /** RFC 7638 base64url-no-pad thumbprint of the public key. */
41
+ kid: string;
42
+ /** Pre-resolved web-bot-auth Signer (caches the imported CryptoKey). */
43
+ signer: Signer;
44
+ }
45
+ declare class Identity {
46
+ private _active;
47
+ private _retiring;
48
+ readonly signatureAgentUrl: string;
49
+ readonly userAgent: string | undefined;
50
+ private constructor();
51
+ /** The active key's kid (used as `keyid=` param in Signature-Input). */
52
+ get kid(): string;
53
+ /** Module-internal: returns the resolved Signer for sign() to use. */
54
+ _signer(): Signer;
55
+ /**
56
+ * Export the public JWKS document.
57
+ *
58
+ * Returns `{keys: [active_jwk]}` if no retiring key is held, or
59
+ * `{keys: [active_jwk, retiring_jwk]}` after a rotation. Active is always
60
+ * first. Verifiers select by `kid`. Mirror of Python `export_jwks`.
61
+ */
62
+ exportJwks(): {
63
+ keys: JsonWebKey[];
64
+ };
65
+ /**
66
+ * IDENT-07: generate a new active key, demote current to retiring.
67
+ *
68
+ * Returns a NEW Identity (immutable update — the old Identity is unchanged).
69
+ * Any previously-retiring key is dropped (only one overlap window).
70
+ */
71
+ rotate(newPath?: string): Promise<Identity>;
72
+ /**
73
+ * Primary entry point. Loads `path` if it exists, generates a fresh key
74
+ * there otherwise. The on-disk file is PKCS8 NoEncryption PEM at 0o600,
75
+ * byte-equal to what Python's `Identity.load_or_generate` writes (D-60).
76
+ */
77
+ static loadOrGenerate(path: string | undefined, opts: IdentityOptions): Promise<Identity>;
78
+ /**
79
+ * Construct from the RFC 9421 Appendix B.1.4 publicly-known test key.
80
+ * For tests + Cloudflare debug smoke checks. NEVER use in production.
81
+ * Does NOT write any file to disk.
82
+ */
83
+ static fromTestKey(signatureAgentUrl: string): Promise<Identity>;
84
+ toString(): string;
85
+ }
86
+
87
+ /**
88
+ * createSignedFetch — drop-in `fetch` wrapper that signs every outgoing request.
89
+ *
90
+ * Mirrors python/src/wbauth/adapters/httpx_auth.py. Stateless: closure over
91
+ * `identity` only. Returns a function whose signature matches `typeof fetch`,
92
+ * so user code can swap `fetch` for `signedFetch` without other changes.
93
+ *
94
+ * v1 limitation (Pitfall 4): streaming bodies are NOT supported. The adapter
95
+ * reads the full body via `req.clone().arrayBuffer()` to compute Content-Digest.
96
+ */
97
+
98
+ declare function createSignedFetch(identity: Identity): typeof fetch;
99
+
100
+ /**
101
+ * applyTo — Playwright async helper for Web Bot Auth (ADAPT-05).
102
+ *
103
+ * Mirrors python/src/wbauth/adapters/playwright.py. Registers a route
104
+ * handler that intercepts every outgoing request, signs it via wbauth.sign(),
105
+ * and continues with the signed headers.
106
+ *
107
+ * Pitfall 3: Call applyTo(page, identity) BEFORE the first page.goto()/click
108
+ * that should produce signed requests. page.route must be registered before
109
+ * navigation begins, or the navigation request leaves the browser unsigned.
110
+ *
111
+ * Pitfall (iframe coverage): page.route('**\/*', handler) covers all
112
+ * sub-frame requests by default. If the target site uses Service Workers,
113
+ * set serviceWorkers="block" on the BrowserContext (Playwright option).
114
+ *
115
+ * Note: `playwright` is a peerDependency (optional). End users only install
116
+ * it if they actually use this adapter — fetch-only consumers pay zero cost.
117
+ */
118
+
119
+ declare function applyTo(page: Page, identity: Identity): Promise<void>;
120
+
121
+ /**
122
+ * NormalizedRequest — the structural input to wbauth.sign().
123
+ *
124
+ * Mirrors python/src/wbauth/normalized_request.py. The Web Bot Auth signer
125
+ * does not care which HTTP client produced the request; it only needs the
126
+ * five fields below. Adapter glue (createSignedFetch, applyTo) constructs
127
+ * a NormalizedRequest from its native request type and hands it to sign().
128
+ *
129
+ * The `headers` dict is mutated in place by sign() — Signature, Signature-Input,
130
+ * and Signature-Agent are written back so the caller can read them out.
131
+ */
132
+ interface NormalizedRequest {
133
+ /** HTTP method ("GET" | "POST" | "PUT" | "PATCH" | "DELETE" | ...). */
134
+ method: string;
135
+ /** Absolute URL — must be https:// for production targets. */
136
+ url: string;
137
+ /** Mutable header dict. sign() writes Signature, Signature-Input, Signature-Agent. */
138
+ headers: Record<string, string>;
139
+ /** Body bytes for content-digest computation; null/undefined for bodiless methods. */
140
+ body: Uint8Array | null;
141
+ }
142
+
143
+ interface SignatureHeaders {
144
+ signature: string;
145
+ signatureInput: string;
146
+ signatureAgent: string;
147
+ }
148
+ interface SignOptions {
149
+ /** Signing timestamp. Defaults to `new Date()`. Override for vector reproducibility. */
150
+ created?: Date;
151
+ /** Validity window in seconds. Defaults to 60. */
152
+ expiresAfterSeconds?: number;
153
+ /** Anti-replay nonce. Defaults to web-bot-auth's `generateNonce()` (64-byte b64). */
154
+ nonce?: string;
155
+ /** Signature label (the `sig1=` prefix). Defaults to "sig1". */
156
+ label?: string;
157
+ }
158
+ /**
159
+ * Sign a NormalizedRequest with the Web Bot Auth profile.
160
+ *
161
+ * Mutates `request.headers` in place: writes Signature-Agent BEFORE building
162
+ * the signing base (Pitfall 6 — without pre-set, web-bot-auth defaults to
163
+ * `["@authority"]` only and silently breaks byte-equality with the Python
164
+ * signer). After signatureHeaders returns, also writes Signature and
165
+ * Signature-Input back into `request.headers`.
166
+ *
167
+ * @returns the same three values as a typed dataclass for callers that prefer
168
+ * a return value over header inspection.
169
+ */
170
+ declare function sign(request: NormalizedRequest, identity: Identity, opts?: SignOptions): Promise<SignatureHeaders>;
171
+
172
+ /**
173
+ * wbauth: Web Bot Auth (RFC 9421) TypeScript SDK — Phase 4 public surface.
174
+ *
175
+ * Per CONTEXT D-74: flat root exports. Subpath exports (`wbauth/adapters`)
176
+ * are a v1.x optimization, not required for Phase 4.
177
+ *
178
+ * Quickstart:
179
+ * import { Identity, createSignedFetch } from "wbauth";
180
+ * const id = await Identity.loadOrGenerate("./key.pem", {
181
+ * signatureAgentUrl: "https://example.com/.well-known/.../<kid>",
182
+ * });
183
+ * const sf = createSignedFetch(id);
184
+ * const res = await sf("https://api.example.com/data");
185
+ */
186
+
187
+ declare const VERSION = "0.1.0";
188
+
189
+ export { DEFAULT_KEY_PATH, Identity, type IdentityOptions, type KeyPair, type NormalizedRequest, type SignOptions, type SignatureHeaders, VERSION, applyTo, createSignedFetch, sign };
package/dist/index.js ADDED
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_KEY_PATH: () => DEFAULT_KEY_PATH,
24
+ Identity: () => Identity,
25
+ VERSION: () => VERSION,
26
+ applyTo: () => applyTo,
27
+ createSignedFetch: () => createSignedFetch,
28
+ sign: () => sign
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/signer.ts
33
+ var import_web_bot_auth = require("web-bot-auth");
34
+ var DEFAULT_LABEL = "sig1";
35
+ var DEFAULT_EXPIRES_SECONDS = 60;
36
+ var DIGEST_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
37
+ function componentsFor(method, hasBody) {
38
+ const base = ["@authority", "signature-agent"];
39
+ if (hasBody && DIGEST_METHODS.has(method.toUpperCase())) {
40
+ base.push("content-digest");
41
+ }
42
+ return base;
43
+ }
44
+ async function sign(request, identity, opts = {}) {
45
+ if (!identity.signatureAgentUrl.startsWith("https://")) {
46
+ throw new Error(
47
+ `signatureAgentUrl must be https://, got: ${identity.signatureAgentUrl}`
48
+ );
49
+ }
50
+ const signatureAgentHeader = `"${identity.signatureAgentUrl}"`;
51
+ request.headers["Signature-Agent"] = signatureAgentHeader;
52
+ const created = opts.created ?? /* @__PURE__ */ new Date();
53
+ const expiresAfterSeconds = opts.expiresAfterSeconds ?? DEFAULT_EXPIRES_SECONDS;
54
+ const expires = new Date(created.getTime() + expiresAfterSeconds * 1e3);
55
+ const label = opts.label ?? DEFAULT_LABEL;
56
+ const hasBody = request.body !== null && request.body !== void 0;
57
+ const components = componentsFor(request.method, hasBody);
58
+ const headers = new Headers();
59
+ for (const [k, v] of Object.entries(request.headers)) headers.set(k, v);
60
+ const init = { method: request.method, headers };
61
+ if (hasBody) init.body = request.body;
62
+ const req = new Request(request.url, init);
63
+ const params = {
64
+ created,
65
+ expires,
66
+ key: label,
67
+ components,
68
+ ...opts.nonce !== void 0 ? { nonce: opts.nonce } : {}
69
+ };
70
+ const result = await (0, import_web_bot_auth.signatureHeaders)(req, identity._signer(), params);
71
+ request.headers.Signature = result.Signature;
72
+ request.headers["Signature-Input"] = result["Signature-Input"];
73
+ return {
74
+ signature: result.Signature,
75
+ signatureInput: result["Signature-Input"],
76
+ signatureAgent: signatureAgentHeader
77
+ };
78
+ }
79
+
80
+ // src/adapters/_utils.ts
81
+ var import_node_crypto = require("crypto");
82
+ var DIGEST_METHODS2 = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
83
+ function ensureContentDigest(method, headers, body) {
84
+ if (!body || body.byteLength === 0) return;
85
+ if (!DIGEST_METHODS2.has(method.toUpperCase())) return;
86
+ if (Object.keys(headers).some((k) => k.toLowerCase() === "content-digest")) {
87
+ return;
88
+ }
89
+ const b64 = (0, import_node_crypto.createHash)("sha256").update(body).digest("base64");
90
+ headers["Content-Digest"] = `sha-256=:${b64}:`;
91
+ }
92
+
93
+ // src/adapters/fetch.ts
94
+ function createSignedFetch(identity) {
95
+ return async function signedFetch(input, init = {}) {
96
+ const req = new Request(input, init);
97
+ const method = req.method;
98
+ const url = req.url;
99
+ let body = null;
100
+ if (req.body) {
101
+ const buf = await req.clone().arrayBuffer();
102
+ body = buf.byteLength > 0 ? new Uint8Array(buf) : null;
103
+ }
104
+ const headers = {};
105
+ req.headers.forEach((value, key) => {
106
+ headers[key] = value;
107
+ });
108
+ ensureContentDigest(method, headers, body);
109
+ await sign({ method, url, headers, body }, identity);
110
+ if (identity.userAgent && !Object.keys(headers).some((k) => k.toLowerCase() === "user-agent")) {
111
+ headers["User-Agent"] = identity.userAgent;
112
+ }
113
+ return fetch(url, {
114
+ ...init,
115
+ method,
116
+ headers,
117
+ body: body ?? void 0
118
+ });
119
+ };
120
+ }
121
+
122
+ // src/adapters/playwright.ts
123
+ async function applyTo(page, identity) {
124
+ await page.route("**/*", async (route, request) => {
125
+ const headers = await request.allHeaders();
126
+ const postData = request.postDataBuffer();
127
+ const body = postData ? new Uint8Array(postData) : null;
128
+ ensureContentDigest(request.method(), headers, body);
129
+ await sign(
130
+ { method: request.method(), url: request.url(), headers, body },
131
+ identity
132
+ );
133
+ if (identity.userAgent && !Object.keys(headers).some((k) => k.toLowerCase() === "user-agent")) {
134
+ headers["User-Agent"] = identity.userAgent;
135
+ }
136
+ await route.continue({ headers });
137
+ });
138
+ }
139
+
140
+ // src/identity.ts
141
+ var import_node_crypto2 = require("crypto");
142
+ var import_node_fs = require("fs");
143
+ var import_node_os = require("os");
144
+ var import_node_path = require("path");
145
+ var import_crypto = require("web-bot-auth/crypto");
146
+ var DEFAULT_KEY_PATH = (0, import_node_path.resolve)(
147
+ (0, import_node_os.homedir)(),
148
+ ".config",
149
+ "wbauth",
150
+ "key.pem"
151
+ );
152
+ var TEST_KEY_JWK = {
153
+ kty: "OKP",
154
+ crv: "Ed25519",
155
+ d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
156
+ x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
157
+ };
158
+ var Identity = class _Identity {
159
+ _active;
160
+ _retiring;
161
+ signatureAgentUrl;
162
+ userAgent;
163
+ constructor(active, opts, retiring = null) {
164
+ if (!opts.signatureAgentUrl.startsWith("https://")) {
165
+ throw new Error(
166
+ `signatureAgentUrl must be https://, got: ${opts.signatureAgentUrl}`
167
+ );
168
+ }
169
+ this._active = active;
170
+ this._retiring = retiring;
171
+ this.signatureAgentUrl = opts.signatureAgentUrl;
172
+ this.userAgent = opts.userAgent;
173
+ }
174
+ // ---------- Public read-only API ----------
175
+ /** The active key's kid (used as `keyid=` param in Signature-Input). */
176
+ get kid() {
177
+ return this._active.kid;
178
+ }
179
+ /** Module-internal: returns the resolved Signer for sign() to use. */
180
+ _signer() {
181
+ return this._active.signer;
182
+ }
183
+ /**
184
+ * Export the public JWKS document.
185
+ *
186
+ * Returns `{keys: [active_jwk]}` if no retiring key is held, or
187
+ * `{keys: [active_jwk, retiring_jwk]}` after a rotation. Active is always
188
+ * first. Verifiers select by `kid`. Mirror of Python `export_jwks`.
189
+ */
190
+ exportJwks() {
191
+ const keys = [this._active.publicJwk];
192
+ if (this._retiring) keys.push(this._retiring.publicJwk);
193
+ return { keys };
194
+ }
195
+ /**
196
+ * IDENT-07: generate a new active key, demote current to retiring.
197
+ *
198
+ * Returns a NEW Identity (immutable update — the old Identity is unchanged).
199
+ * Any previously-retiring key is dropped (only one overlap window).
200
+ */
201
+ async rotate(newPath = DEFAULT_KEY_PATH) {
202
+ const newPair = await generateKeypairTo(expandUser(newPath));
203
+ return new _Identity(
204
+ newPair,
205
+ { signatureAgentUrl: this.signatureAgentUrl, userAgent: this.userAgent },
206
+ this._active
207
+ );
208
+ }
209
+ // ---------- Constructors ----------
210
+ /**
211
+ * Primary entry point. Loads `path` if it exists, generates a fresh key
212
+ * there otherwise. The on-disk file is PKCS8 NoEncryption PEM at 0o600,
213
+ * byte-equal to what Python's `Identity.load_or_generate` writes (D-60).
214
+ */
215
+ static async loadOrGenerate(path = DEFAULT_KEY_PATH, opts) {
216
+ const resolved = expandUser(path);
217
+ const pair = (0, import_node_fs.existsSync)(resolved) ? await loadKeypair(resolved) : await generateKeypairTo(resolved);
218
+ return new _Identity(pair, opts);
219
+ }
220
+ /**
221
+ * Construct from the RFC 9421 Appendix B.1.4 publicly-known test key.
222
+ * For tests + Cloudflare debug smoke checks. NEVER use in production.
223
+ * Does NOT write any file to disk.
224
+ */
225
+ static async fromTestKey(signatureAgentUrl) {
226
+ const pair = await keyPairFromJwk(TEST_KEY_JWK);
227
+ return new _Identity(pair, { signatureAgentUrl });
228
+ }
229
+ // ---------- Redaction guarantees (IDENT-08) ----------
230
+ toString() {
231
+ return `<Identity REDACTED kid='${this.kid}' sig_agent='${this.signatureAgentUrl}'>`;
232
+ }
233
+ /** util.inspect / console.log render the same REDACTED form (IDENT-08). */
234
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
235
+ return this.toString();
236
+ }
237
+ };
238
+ function expandUser(path) {
239
+ return (0, import_node_path.resolve)(path.startsWith("~") ? path.replace(/^~/, (0, import_node_os.homedir)()) : path);
240
+ }
241
+ async function keyPairFromJwk(privateJwk) {
242
+ const signer = await (0, import_crypto.signerFromJWK)(privateJwk);
243
+ const kid = signer.keyid;
244
+ const publicJwk = {
245
+ kty: privateJwk.kty,
246
+ crv: privateJwk.crv,
247
+ x: privateJwk.x,
248
+ kid
249
+ };
250
+ return { privateJwk, publicJwk, kid, signer };
251
+ }
252
+ async function loadKeypair(path) {
253
+ if (process.platform !== "win32") {
254
+ const mode = (0, import_node_fs.statSync)(path).mode & 511;
255
+ if ((mode & 63) !== 0) {
256
+ throw new Error(
257
+ `Key file ${path} has mode 0o${mode.toString(8)}; expected 0o600. Fix: chmod 600 ${path}`
258
+ );
259
+ }
260
+ } else {
261
+ process.emitWarning(
262
+ `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.`
263
+ );
264
+ }
265
+ const pem = (0, import_node_fs.readFileSync)(path, "utf8");
266
+ const jwk = (0, import_node_crypto2.createPrivateKey)(pem).export({ format: "jwk" });
267
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
268
+ throw new TypeError(`Expected Ed25519, got kty=${jwk.kty}, crv=${jwk.crv}`);
269
+ }
270
+ return keyPairFromJwk(jwk);
271
+ }
272
+ async function generateKeypairTo(path) {
273
+ const dir = (0, import_node_path.dirname)(path);
274
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
275
+ if ((0, import_node_fs.existsSync)(path)) {
276
+ throw new Error(`Key already exists at ${path}; refuse overwrite`);
277
+ }
278
+ const { privateKey } = (0, import_node_crypto2.generateKeyPairSync)("ed25519");
279
+ const pem = privateKey.export({ format: "pem", type: "pkcs8" });
280
+ const fd = (0, import_node_fs.openSync)(path, "wx", 384);
281
+ try {
282
+ (0, import_node_fs.writeFileSync)(fd, pem);
283
+ } finally {
284
+ (0, import_node_fs.closeSync)(fd);
285
+ }
286
+ const jwk = privateKey.export({ format: "jwk" });
287
+ return keyPairFromJwk(jwk);
288
+ }
289
+
290
+ // src/index.ts
291
+ var VERSION = "0.1.0";
292
+ // Annotate the CommonJS export names for ESM import in node:
293
+ 0 && (module.exports = {
294
+ DEFAULT_KEY_PATH,
295
+ Identity,
296
+ VERSION,
297
+ applyTo,
298
+ createSignedFetch,
299
+ sign
300
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,276 @@
1
+ // src/signer.ts
2
+ import { signatureHeaders } from "web-bot-auth";
3
+ var DEFAULT_LABEL = "sig1";
4
+ var DEFAULT_EXPIRES_SECONDS = 60;
5
+ var DIGEST_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
6
+ function componentsFor(method, hasBody) {
7
+ const base = ["@authority", "signature-agent"];
8
+ if (hasBody && DIGEST_METHODS.has(method.toUpperCase())) {
9
+ base.push("content-digest");
10
+ }
11
+ return base;
12
+ }
13
+ async function sign(request, identity, opts = {}) {
14
+ if (!identity.signatureAgentUrl.startsWith("https://")) {
15
+ throw new Error(
16
+ `signatureAgentUrl must be https://, got: ${identity.signatureAgentUrl}`
17
+ );
18
+ }
19
+ const signatureAgentHeader = `"${identity.signatureAgentUrl}"`;
20
+ request.headers["Signature-Agent"] = signatureAgentHeader;
21
+ const created = opts.created ?? /* @__PURE__ */ new Date();
22
+ const expiresAfterSeconds = opts.expiresAfterSeconds ?? DEFAULT_EXPIRES_SECONDS;
23
+ const expires = new Date(created.getTime() + expiresAfterSeconds * 1e3);
24
+ const label = opts.label ?? DEFAULT_LABEL;
25
+ const hasBody = request.body !== null && request.body !== void 0;
26
+ const components = componentsFor(request.method, hasBody);
27
+ const headers = new Headers();
28
+ for (const [k, v] of Object.entries(request.headers)) headers.set(k, v);
29
+ const init = { method: request.method, headers };
30
+ if (hasBody) init.body = request.body;
31
+ const req = new Request(request.url, init);
32
+ const params = {
33
+ created,
34
+ expires,
35
+ key: label,
36
+ components,
37
+ ...opts.nonce !== void 0 ? { nonce: opts.nonce } : {}
38
+ };
39
+ const result = await signatureHeaders(req, identity._signer(), params);
40
+ request.headers.Signature = result.Signature;
41
+ request.headers["Signature-Input"] = result["Signature-Input"];
42
+ return {
43
+ signature: result.Signature,
44
+ signatureInput: result["Signature-Input"],
45
+ signatureAgent: signatureAgentHeader
46
+ };
47
+ }
48
+
49
+ // src/adapters/_utils.ts
50
+ import { createHash } from "crypto";
51
+ var DIGEST_METHODS2 = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
52
+ function ensureContentDigest(method, headers, body) {
53
+ if (!body || body.byteLength === 0) return;
54
+ if (!DIGEST_METHODS2.has(method.toUpperCase())) return;
55
+ if (Object.keys(headers).some((k) => k.toLowerCase() === "content-digest")) {
56
+ return;
57
+ }
58
+ const b64 = createHash("sha256").update(body).digest("base64");
59
+ headers["Content-Digest"] = `sha-256=:${b64}:`;
60
+ }
61
+
62
+ // src/adapters/fetch.ts
63
+ function createSignedFetch(identity) {
64
+ return async function signedFetch(input, init = {}) {
65
+ const req = new Request(input, init);
66
+ const method = req.method;
67
+ const url = req.url;
68
+ let body = null;
69
+ if (req.body) {
70
+ const buf = await req.clone().arrayBuffer();
71
+ body = buf.byteLength > 0 ? new Uint8Array(buf) : null;
72
+ }
73
+ const headers = {};
74
+ req.headers.forEach((value, key) => {
75
+ headers[key] = value;
76
+ });
77
+ ensureContentDigest(method, headers, body);
78
+ await sign({ method, url, headers, body }, identity);
79
+ if (identity.userAgent && !Object.keys(headers).some((k) => k.toLowerCase() === "user-agent")) {
80
+ headers["User-Agent"] = identity.userAgent;
81
+ }
82
+ return fetch(url, {
83
+ ...init,
84
+ method,
85
+ headers,
86
+ body: body ?? void 0
87
+ });
88
+ };
89
+ }
90
+
91
+ // src/adapters/playwright.ts
92
+ async function applyTo(page, identity) {
93
+ await page.route("**/*", async (route, request) => {
94
+ const headers = await request.allHeaders();
95
+ const postData = request.postDataBuffer();
96
+ const body = postData ? new Uint8Array(postData) : null;
97
+ ensureContentDigest(request.method(), headers, body);
98
+ await sign(
99
+ { method: request.method(), url: request.url(), headers, body },
100
+ identity
101
+ );
102
+ if (identity.userAgent && !Object.keys(headers).some((k) => k.toLowerCase() === "user-agent")) {
103
+ headers["User-Agent"] = identity.userAgent;
104
+ }
105
+ await route.continue({ headers });
106
+ });
107
+ }
108
+
109
+ // src/identity.ts
110
+ import { createPrivateKey, generateKeyPairSync } from "crypto";
111
+ import {
112
+ closeSync,
113
+ existsSync,
114
+ mkdirSync,
115
+ openSync,
116
+ readFileSync,
117
+ statSync,
118
+ writeFileSync
119
+ } from "fs";
120
+ import { homedir } from "os";
121
+ import { dirname, resolve } from "path";
122
+ import { signerFromJWK } from "web-bot-auth/crypto";
123
+ var DEFAULT_KEY_PATH = resolve(
124
+ homedir(),
125
+ ".config",
126
+ "wbauth",
127
+ "key.pem"
128
+ );
129
+ var TEST_KEY_JWK = {
130
+ kty: "OKP",
131
+ crv: "Ed25519",
132
+ d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
133
+ x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
134
+ };
135
+ var Identity = class _Identity {
136
+ _active;
137
+ _retiring;
138
+ signatureAgentUrl;
139
+ userAgent;
140
+ constructor(active, opts, retiring = null) {
141
+ if (!opts.signatureAgentUrl.startsWith("https://")) {
142
+ throw new Error(
143
+ `signatureAgentUrl must be https://, got: ${opts.signatureAgentUrl}`
144
+ );
145
+ }
146
+ this._active = active;
147
+ this._retiring = retiring;
148
+ this.signatureAgentUrl = opts.signatureAgentUrl;
149
+ this.userAgent = opts.userAgent;
150
+ }
151
+ // ---------- Public read-only API ----------
152
+ /** The active key's kid (used as `keyid=` param in Signature-Input). */
153
+ get kid() {
154
+ return this._active.kid;
155
+ }
156
+ /** Module-internal: returns the resolved Signer for sign() to use. */
157
+ _signer() {
158
+ return this._active.signer;
159
+ }
160
+ /**
161
+ * Export the public JWKS document.
162
+ *
163
+ * Returns `{keys: [active_jwk]}` if no retiring key is held, or
164
+ * `{keys: [active_jwk, retiring_jwk]}` after a rotation. Active is always
165
+ * first. Verifiers select by `kid`. Mirror of Python `export_jwks`.
166
+ */
167
+ exportJwks() {
168
+ const keys = [this._active.publicJwk];
169
+ if (this._retiring) keys.push(this._retiring.publicJwk);
170
+ return { keys };
171
+ }
172
+ /**
173
+ * IDENT-07: generate a new active key, demote current to retiring.
174
+ *
175
+ * Returns a NEW Identity (immutable update — the old Identity is unchanged).
176
+ * Any previously-retiring key is dropped (only one overlap window).
177
+ */
178
+ async rotate(newPath = DEFAULT_KEY_PATH) {
179
+ const newPair = await generateKeypairTo(expandUser(newPath));
180
+ return new _Identity(
181
+ newPair,
182
+ { signatureAgentUrl: this.signatureAgentUrl, userAgent: this.userAgent },
183
+ this._active
184
+ );
185
+ }
186
+ // ---------- Constructors ----------
187
+ /**
188
+ * Primary entry point. Loads `path` if it exists, generates a fresh key
189
+ * there otherwise. The on-disk file is PKCS8 NoEncryption PEM at 0o600,
190
+ * byte-equal to what Python's `Identity.load_or_generate` writes (D-60).
191
+ */
192
+ static async loadOrGenerate(path = DEFAULT_KEY_PATH, opts) {
193
+ const resolved = expandUser(path);
194
+ const pair = existsSync(resolved) ? await loadKeypair(resolved) : await generateKeypairTo(resolved);
195
+ return new _Identity(pair, opts);
196
+ }
197
+ /**
198
+ * Construct from the RFC 9421 Appendix B.1.4 publicly-known test key.
199
+ * For tests + Cloudflare debug smoke checks. NEVER use in production.
200
+ * Does NOT write any file to disk.
201
+ */
202
+ static async fromTestKey(signatureAgentUrl) {
203
+ const pair = await keyPairFromJwk(TEST_KEY_JWK);
204
+ return new _Identity(pair, { signatureAgentUrl });
205
+ }
206
+ // ---------- Redaction guarantees (IDENT-08) ----------
207
+ toString() {
208
+ return `<Identity REDACTED kid='${this.kid}' sig_agent='${this.signatureAgentUrl}'>`;
209
+ }
210
+ /** util.inspect / console.log render the same REDACTED form (IDENT-08). */
211
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
212
+ return this.toString();
213
+ }
214
+ };
215
+ function expandUser(path) {
216
+ return resolve(path.startsWith("~") ? path.replace(/^~/, homedir()) : path);
217
+ }
218
+ async function keyPairFromJwk(privateJwk) {
219
+ const signer = await signerFromJWK(privateJwk);
220
+ const kid = signer.keyid;
221
+ const publicJwk = {
222
+ kty: privateJwk.kty,
223
+ crv: privateJwk.crv,
224
+ x: privateJwk.x,
225
+ kid
226
+ };
227
+ return { privateJwk, publicJwk, kid, signer };
228
+ }
229
+ async function loadKeypair(path) {
230
+ if (process.platform !== "win32") {
231
+ const mode = statSync(path).mode & 511;
232
+ if ((mode & 63) !== 0) {
233
+ throw new Error(
234
+ `Key file ${path} has mode 0o${mode.toString(8)}; expected 0o600. Fix: chmod 600 ${path}`
235
+ );
236
+ }
237
+ } else {
238
+ process.emitWarning(
239
+ `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.`
240
+ );
241
+ }
242
+ const pem = readFileSync(path, "utf8");
243
+ const jwk = createPrivateKey(pem).export({ format: "jwk" });
244
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
245
+ throw new TypeError(`Expected Ed25519, got kty=${jwk.kty}, crv=${jwk.crv}`);
246
+ }
247
+ return keyPairFromJwk(jwk);
248
+ }
249
+ async function generateKeypairTo(path) {
250
+ const dir = dirname(path);
251
+ mkdirSync(dir, { recursive: true });
252
+ if (existsSync(path)) {
253
+ throw new Error(`Key already exists at ${path}; refuse overwrite`);
254
+ }
255
+ const { privateKey } = generateKeyPairSync("ed25519");
256
+ const pem = privateKey.export({ format: "pem", type: "pkcs8" });
257
+ const fd = openSync(path, "wx", 384);
258
+ try {
259
+ writeFileSync(fd, pem);
260
+ } finally {
261
+ closeSync(fd);
262
+ }
263
+ const jwk = privateKey.export({ format: "jwk" });
264
+ return keyPairFromJwk(jwk);
265
+ }
266
+
267
+ // src/index.ts
268
+ var VERSION = "0.1.0";
269
+ export {
270
+ DEFAULT_KEY_PATH,
271
+ Identity,
272
+ VERSION,
273
+ applyTo,
274
+ createSignedFetch,
275
+ sign
276
+ };
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "wb-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Web Bot Auth (RFC 9421) TypeScript SDK",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "license": "Apache-2.0",
20
+ "dependencies": {
21
+ "http-message-sig": "^0.2.0",
22
+ "web-bot-auth": "^0.1.3"
23
+ },
24
+ "devDependencies": {
25
+ "@biomejs/biome": "^2",
26
+ "@types/node": "^20",
27
+ "playwright": "^1.59",
28
+ "tsup": "^8",
29
+ "typescript": "^6",
30
+ "vitest": "^4"
31
+ },
32
+ "peerDependencies": {
33
+ "playwright": "^1"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "playwright": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "test": "vitest run",
43
+ "lint": "biome check ."
44
+ }
6
45
  }