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.
- package/README.md +4 -2
- package/index.js +88 -20
- 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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
99
|
+
// Structured-field string: the value is sent quoted.
|
|
100
|
+
headers['signature-agent'] = `"${directoryUrl}"`;
|
|
51
101
|
const params = buildParams(keyid, created, expires);
|
|
52
|
-
const
|
|
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
|
|
65
|
-
const sigHeader = h
|
|
66
|
-
const
|
|
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 || !
|
|
120
|
+
if (!sigInput || !sigHeader || !rawAgent)
|
|
69
121
|
return { ok: false, agentId: null, reason: 'no signature presented' };
|
|
70
122
|
|
|
71
|
-
const
|
|
72
|
-
if (!
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"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",
|