wayleave 0.1.3 → 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 (3) hide show
  1. package/README.md +4 -2
  2. package/index.js +88 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,14 +44,16 @@ app.use(gate.express());
44
44
 
45
45
  ## Guarantees, honestly stated
46
46
 
47
- - 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.
48
49
  - Sub-millisecond per request. Your latency budget won't notice.
49
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.
50
52
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
51
53
 
52
54
  ## Status
53
55
 
54
- 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.
55
57
 
56
58
  ## License
57
59
 
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,6 +1,6 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.1.3",
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",