wayleave 0.1.2 → 0.1.4

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 (4) hide show
  1. package/README.md +9 -3
  2. package/index.d.ts +179 -0
  3. package/index.js +88 -20
  4. package/package.json +8 -3
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # wayleave
2
2
 
3
+ [![Tests](https://github.com/gibrancorbin11-hub/wayleave/actions/workflows/test.yml/badge.svg)](https://github.com/gibrancorbin11-hub/wayleave/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/wayleave.svg)](https://www.npmjs.com/package/wayleave)
5
+ [![install size](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/wayleave)
6
+
3
7
  **Charge AI agents for passage across your app.**
4
8
 
5
9
  A *wayleave* is the fee paid for the right of passage across private land — utility companies have paid them for 150 years to run cables through property. AI agents are about to cross your app millions of times for free. Charge them a wayleave.
6
10
 
7
- Zero dependencies. Node's native crypto only.
11
+ Zero dependencies. Node's native crypto only. TypeScript declarations included.
8
12
 
9
13
  ## What it does
10
14
 
@@ -40,14 +44,16 @@ app.use(gate.express());
40
44
 
41
45
  ## Guarantees, honestly stated
42
46
 
43
- - Signature verification is real Ed25519 over an RFC 9421 signature base (`@authority`, `signature-agent`) — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially.
47
+ - Signature verification is real Ed25519 over an RFC 9421 signature base — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially, and against other implementations' wire formats rather than only its own.
48
+ - Signature parameters are parsed as an RFC 9421 dictionary: order-independent, `alg` enforced as Ed25519, any signature label, and the signature base is built from whatever components the signer declared. Requests signed in Cloudflare's documented format verify.
44
49
  - Sub-millisecond per request. Your latency budget won't notice.
45
50
  - The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
51
+ - Key directories still load from config rather than a live JWKS fetch, so key rotation is not handled. That is the next real gap.
46
52
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
47
53
 
48
54
  ## Status
49
55
 
50
- v0.1 — verification, lanes, policy, pricing, and the metering hook, all under test (22 scenarios, including forgery, tampering, and replay). Payment settlement network integration is in progress; today the 402 challenge and payment-proof check are pluggable.
56
+ v0.1 — verification, lanes, policy, pricing, and the metering hook, all under test (29 scenarios, including forgery, tampering, replay, and cross-implementation wire formats). Payment settlement network integration is in progress; today the 402 challenge and payment-proof check are pluggable.
51
57
 
52
58
  ## License
53
59
 
package/index.d.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * wayleave — charge AI agents for passage across your app.
3
+ *
4
+ * Verifies Web Bot Auth signatures (RFC 9421 profile, Ed25519), classifies
5
+ * every request into a lane, applies per-lane policy, and returns 402 with an
6
+ * x402-shaped challenge on routes you price. Humans browse free.
7
+ */
8
+
9
+ /**
10
+ * A Node `KeyObject` from `node:crypto`. Typed structurally rather than
11
+ * imported so this declaration file needs no @types/node — the package has
12
+ * zero dependencies and the types shouldn't reintroduce one. A real
13
+ * KeyObject satisfies this.
14
+ */
15
+ export interface KeyObjectLike {
16
+ readonly type: string;
17
+ readonly asymmetricKeyType?: string;
18
+ }
19
+
20
+ /** The four lanes every request is sorted into. */
21
+ export type Lane =
22
+ | 'verified_agent'
23
+ | 'declared_agent'
24
+ | 'suspected_bot'
25
+ | 'human';
26
+
27
+ export const LANES: Readonly<{
28
+ VERIFIED: 'verified_agent';
29
+ DECLARED: 'declared_agent';
30
+ SUSPECT: 'suspected_bot';
31
+ HUMAN: 'human';
32
+ }>;
33
+
34
+ /**
35
+ * A public key for a signature agent. Accepts a KeyObject, a PEM string, or a
36
+ * bare base64 raw Ed25519 public key (32 bytes, SPKI prefix added for you).
37
+ * Strings are converted to a KeyObject and cached on first use.
38
+ */
39
+ export type PublicKeyLike = KeyObjectLike | string;
40
+
41
+ /**
42
+ * Key directories you trust, keyed by the URL an agent presents in its
43
+ * `signature-agent` header, then by keyid.
44
+ *
45
+ * Note: these are read from config, not fetched. Rotation is not handled.
46
+ */
47
+ export type KeyDirectories = Record<string, Record<string, PublicKeyLike>>;
48
+
49
+ /** Incoming request, normalised. Framework-agnostic on purpose. */
50
+ export interface WayleaveRequest {
51
+ method: string;
52
+ path: string;
53
+ /** Host authority the signature was computed over, e.g. "example.com". */
54
+ authority: string;
55
+ headers: Record<string, string | string[] | undefined>;
56
+ }
57
+
58
+ export interface VerifyResult {
59
+ ok: boolean;
60
+ /** `${directoryUrl}#${keyid}` when ok, otherwise null. */
61
+ agentId: string | null;
62
+ /** Human-readable reason, safe to log. */
63
+ reason: string;
64
+ }
65
+
66
+ export interface Classification {
67
+ lane: Lane;
68
+ agentId: string | null;
69
+ /** Why this lane was chosen. Loggable. */
70
+ evidence: string[];
71
+ }
72
+
73
+ /** x402-shaped payment challenge returned with a 402. */
74
+ export interface PaymentChallenge {
75
+ scheme: 'x402';
76
+ price_usd: number;
77
+ /** The priced path prefix that matched. */
78
+ resource: string;
79
+ }
80
+
81
+ export interface Decision {
82
+ lane: Lane;
83
+ /** agentId when verified, else `${lane}:${xff ?? 'unknown'}`. */
84
+ identity: string;
85
+ status: 200 | 402 | 403 | 429;
86
+ /** Why this status was returned. */
87
+ why: string;
88
+ evidence: string[];
89
+ /** Present only on 402. */
90
+ challenge?: PaymentChallenge;
91
+ /** Present only when a priced request was paid. */
92
+ billed?: number;
93
+ }
94
+
95
+ /** What the metering hook receives. It may throw; serving continues. */
96
+ export interface MeterEvent {
97
+ /** Unix seconds. */
98
+ t: number;
99
+ path: string;
100
+ lane: Lane;
101
+ identity: string;
102
+ status: number;
103
+ why: string;
104
+ evidence: string[];
105
+ billedUsd: number;
106
+ }
107
+
108
+ /** `[pathPrefix, allow]` — first matching prefix wins. */
109
+ export type PolicyRule = [prefix: string, allow: boolean];
110
+
111
+ export interface WayleaveOptions {
112
+ directories?: KeyDirectories;
113
+ /** Per-lane allow/deny rules, evaluated in order. */
114
+ rules?: Partial<Record<Lane, PolicyRule[]>>;
115
+ /** Max requests per identity per window, per lane. */
116
+ rateLimits?: Partial<Record<Lane, number>>;
117
+ /** Window length in seconds. Default 60. */
118
+ rateWindow?: number;
119
+ /** Path prefix -> price in USD. Non-human lanes get 402 here. */
120
+ pricedPaths?: Record<string, number>;
121
+ /**
122
+ * Metering hook, called once per request. Exceptions are swallowed —
123
+ * billing must never break serving.
124
+ */
125
+ onEvent?: (event: MeterEvent) => void;
126
+ }
127
+
128
+ /** Build the `@signature-params` value for the Web Bot Auth profile. */
129
+ export function buildParams(
130
+ keyid: string,
131
+ created: number,
132
+ expires: number
133
+ ): string;
134
+
135
+ /**
136
+ * Sign a request the way a legitimate agent operator's SDK would.
137
+ * Mutates and returns `headers`.
138
+ */
139
+ export function signRequest(
140
+ headers: Record<string, string>,
141
+ authority: string,
142
+ privateKey: KeyObjectLike,
143
+ keyid: string,
144
+ directoryUrl: string,
145
+ created?: number,
146
+ expires?: number
147
+ ): Record<string, string>;
148
+
149
+ /** Verify a presented signature against your configured directories. */
150
+ export function verifySignature(
151
+ headers: Record<string, string | string[] | undefined>,
152
+ authority: string,
153
+ directories: KeyDirectories,
154
+ now?: number
155
+ ): VerifyResult;
156
+
157
+ /**
158
+ * Sort a request into a lane. A signature that FAILS verification lands in
159
+ * `suspected_bot`, not `human` — forging identity is the loudest signal there is.
160
+ */
161
+ export function classify(
162
+ req: WayleaveRequest,
163
+ directories: KeyDirectories,
164
+ now?: number
165
+ ): Classification;
166
+
167
+ export class Wayleave {
168
+ constructor(opts?: WayleaveOptions);
169
+ /** Classify, apply policy, price, and emit the metering event. */
170
+ handle(
171
+ req: WayleaveRequest,
172
+ now?: number,
173
+ paymentProof?: string
174
+ ): Decision;
175
+ /** Express/Connect adapter. Sets `req.wayleave` to the Decision. */
176
+ express(): (req: any, res: any, next: (err?: any) => void) => void;
177
+ }
178
+
179
+ export default Wayleave;
package/index.js CHANGED
@@ -28,28 +28,80 @@ export const LANES = Object.freeze({
28
28
 
29
29
  // ── RFC 9421 profile (Web Bot Auth usage) ───────────────────────────────
30
30
 
31
- const PARAMS_RE = /created=(\d+);expires=(\d+);keyid="([^"]+)";tag="([^"]+)"/;
31
+ /**
32
+ * Parse an RFC 9421 `signature-input` value: `label=("a" "b");k=v;k2="v2"`.
33
+ *
34
+ * Parameters form a dictionary — order is NOT significant, and no
35
+ * implementation is obliged to emit them in any particular sequence. The
36
+ * label is arbitrary too (Cloudflare uses `sig2`, we emit `sig1`). Anything
37
+ * that hardcodes either will reject legitimately signed traffic.
38
+ *
39
+ * Returns { label, components, params, paramsRaw } or null.
40
+ * `paramsRaw` is preserved verbatim — the signature base must reproduce it
41
+ * byte for byte or verification fails.
42
+ */
43
+ function parseSignatureInput(raw) {
44
+ const eq = raw.indexOf('=');
45
+ if (eq < 1) return null;
46
+ const label = raw.slice(0, eq).trim();
47
+ if (!/^[A-Za-z0-9_-]+$/.test(label)) return null;
48
+
49
+ const paramsRaw = raw.slice(eq + 1);
50
+ const close = paramsRaw.indexOf(')');
51
+ if (!paramsRaw.startsWith('(') || close < 0) return null;
52
+
53
+ const components = (paramsRaw.slice(1, close).match(/"[^"]*"/g) || [])
54
+ .map(s => s.slice(1, -1).toLowerCase());
55
+
56
+ const params = {};
57
+ const re = /;\s*([A-Za-z0-9_.-]+)=("[^"]*"|[^;]+)/g;
58
+ let m;
59
+ while ((m = re.exec(paramsRaw.slice(close + 1))) !== null) {
60
+ const v = m[2].trim();
61
+ params[m[1]] = v.startsWith('"') ? v.slice(1, -1) : v;
62
+ }
63
+ return { label, components, params, paramsRaw };
64
+ }
65
+
66
+ /** Structured-field strings arrive quoted; the directory URL itself is not. */
67
+ function unquote(s) {
68
+ return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
69
+ }
70
+
71
+ function headerValue(headers, name) {
72
+ const v = headers[name];
73
+ return Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v));
74
+ }
32
75
 
33
- function sigBase(authority, signatureAgent, params) {
34
- return Buffer.from(
35
- `"@authority": ${authority}\n` +
36
- `"signature-agent": ${signatureAgent}\n` +
37
- `"@signature-params": ${params}`
76
+ /**
77
+ * Build the signature base from the components the signer actually declared,
78
+ * not from a fixed list. An agent covering only ("@authority") is valid.
79
+ */
80
+ function sigBase(components, ctx, paramsRaw) {
81
+ const lines = components.map(c =>
82
+ c === '@authority'
83
+ ? `"@authority": ${ctx.authority}`
84
+ : `"${c}": ${headerValue(ctx.headers, c)}`
38
85
  );
86
+ lines.push(`"@signature-params": ${paramsRaw}`);
87
+ return Buffer.from(lines.join('\n'));
39
88
  }
40
89
 
41
90
  export function buildParams(keyid, created, expires) {
42
91
  return `("@authority" "signature-agent");created=${created};` +
43
- `expires=${expires};keyid="${keyid}";tag="web-bot-auth"`;
92
+ `keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"`;
44
93
  }
45
94
 
46
95
  /** What a legitimate agent operator's SDK does before sending. */
47
96
  export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
48
97
  created = Math.floor(Date.now() / 1000),
49
98
  expires = created + 300) {
50
- headers['signature-agent'] = directoryUrl;
99
+ // Structured-field string: the value is sent quoted.
100
+ headers['signature-agent'] = `"${directoryUrl}"`;
51
101
  const params = buildParams(keyid, created, expires);
52
- const sig = edSign(null, sigBase(authority, directoryUrl, params), privateKey);
102
+ const base = sigBase(['@authority', 'signature-agent'],
103
+ { authority, headers }, params);
104
+ const sig = edSign(null, base, privateKey);
53
105
  headers['signature-input'] = `sig1=${params}`;
54
106
  headers['signature'] = `sig1=:${sig.toString('base64')}:`;
55
107
  return headers;
@@ -61,18 +113,32 @@ export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
61
113
  */
62
114
  export function verifySignature(h, authority, directories,
63
115
  now = Math.floor(Date.now() / 1000)) {
64
- const sigInput = h['signature-input'] || '';
65
- const sigHeader = h['signature'] || '';
66
- const directory = h['signature-agent'] || '';
116
+ const sigInput = headerValue(h, 'signature-input');
117
+ const sigHeader = headerValue(h, 'signature');
118
+ const rawAgent = headerValue(h, 'signature-agent');
67
119
 
68
- if (!sigInput || !sigHeader || !directory)
120
+ if (!sigInput || !sigHeader || !rawAgent)
69
121
  return { ok: false, agentId: null, reason: 'no signature presented' };
70
122
 
71
- const m = PARAMS_RE.exec(sigInput);
72
- if (!m) return { ok: false, agentId: null, reason: 'malformed signature-input' };
73
- const [, createdS, expiresS, keyid, tag] = m;
74
- const created = +createdS, expires = +expiresS;
123
+ const parsed = parseSignatureInput(sigInput);
124
+ if (!parsed) return { ok: false, agentId: null, reason: 'malformed signature-input' };
125
+ const { label, components, params, paramsRaw } = parsed;
126
+
127
+ const directory = unquote(rawAgent);
128
+ const keyid = params.keyid;
129
+ const tag = params.tag;
130
+ const created = Number(params.created);
131
+ const expires = Number(params.expires);
75
132
 
133
+ if (!keyid) return { ok: false, agentId: null, reason: 'signature-input missing keyid' };
134
+ if (!Number.isFinite(created) || !Number.isFinite(expires))
135
+ return { ok: false, agentId: null, reason: 'signature-input missing created/expires' };
136
+ if (!components.includes('@authority'))
137
+ return { ok: false, agentId: null, reason: '@authority not covered by signature' };
138
+ // alg is mandatory in the Web Bot Auth profile; tolerate its absence for
139
+ // signers that omit it, but reject anything that is not Ed25519.
140
+ if (params.alg && params.alg.toLowerCase() !== 'ed25519')
141
+ return { ok: false, agentId: null, reason: `unsupported alg "${params.alg}"` };
76
142
  if (tag !== 'web-bot-auth')
77
143
  return { ok: false, agentId: null, reason: `unexpected tag "${tag}"` };
78
144
  if (now < created - 30)
@@ -96,11 +162,13 @@ export function verifySignature(h, authority, directories,
96
162
  dir[keyid] = key; // cache the KeyObject
97
163
  }
98
164
 
99
- const sm = /sig1=:([A-Za-z0-9+/=]+):/.exec(sigHeader);
165
+ // Match the signature by the label the signer used, not a hardcoded "sig1".
166
+ const sm = new RegExp(
167
+ `(?:^|,)\\s*${label.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}=:([A-Za-z0-9+/=]+):`
168
+ ).exec(sigHeader);
100
169
  if (!sm) return { ok: false, agentId: null, reason: 'malformed signature header' };
101
170
 
102
- const params = sigInput.slice(sigInput.indexOf('sig1=') + 5);
103
- const valid = edVerify(null, sigBase(authority, directory, params),
171
+ const valid = edVerify(null, sigBase(components, { authority, headers: h }, paramsRaw),
104
172
  key, Buffer.from(sm[1], 'base64'));
105
173
  if (!valid)
106
174
  return { ok: false, agentId: null,
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Charge AI agents for passage across your app. Verify agent signatures (Web Bot Auth / Ed25519), classify every request, and return 402 with a price on routes you choose. Humans browse free.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
+ "types": "./index.d.ts",
7
8
  "exports": {
8
- ".": "./index.js"
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
9
13
  },
10
14
  "files": [
11
15
  "index.js",
16
+ "index.d.ts",
12
17
  "README.md",
13
18
  "LICENSE"
14
19
  ],
@@ -40,4 +45,4 @@
40
45
  "url": "https://github.com/gibrancorbin11-hub/wayleave/issues"
41
46
  },
42
47
  "homepage": "https://github.com/gibrancorbin11-hub/wayleave#readme"
43
- }
48
+ }