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.
- package/README.md +9 -3
- package/index.d.ts +179 -0
- package/index.js +88 -20
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# wayleave
|
|
2
2
|
|
|
3
|
+
[](https://github.com/gibrancorbin11-hub/wayleave/actions/workflows/test.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/wayleave)
|
|
5
|
+
[](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
|
|
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 (
|
|
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
|
-
|
|
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,14 +1,19 @@
|
|
|
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",
|
|
7
|
+
"types": "./index.d.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
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
|
+
}
|