@muretai/agent-entry 1.2.1 → 1.3.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 +136 -7
- package/examples/server.mjs +39 -0
- package/muretai-agent-entry.mjs +629 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ It verifies who is knocking, opens an account for them, and answers — in the s
|
|
|
6
6
|
response. No signup form, because the visitor's key already is the account. When that
|
|
7
7
|
person replaces their phone, your site still knows it is them.
|
|
8
8
|
|
|
9
|
-
One file. Zero dependencies. Node 20+.
|
|
9
|
+
One file. Zero dependencies. No database. Node 20+.
|
|
10
10
|
|
|
11
11
|
**Running in production — check it yourself, right now:**
|
|
12
12
|
|
|
@@ -69,6 +69,101 @@ resolves it and files them under `owner_did`, so a replaced phone is not a new c
|
|
|
69
69
|
Your entry does not need to poll or be told: a node that carries the account learns it on
|
|
70
70
|
its own, and refuses that key.
|
|
71
71
|
|
|
72
|
+
## Say what your door answers
|
|
73
|
+
|
|
74
|
+
A visiting agent reads your card **before** it knocks. Left alone, that card says something
|
|
75
|
+
answers here and nothing about what it answers, so the visitor has to guess and learns your
|
|
76
|
+
menu only from whatever comes back when it guesses wrong.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
createAgentEntry({
|
|
80
|
+
seedHex, name: 'Example Studio', baseUrl: 'https://studio.example', responder,
|
|
81
|
+
skills: [{
|
|
82
|
+
id: 'ask',
|
|
83
|
+
name: 'signed-answers-about-the-studio',
|
|
84
|
+
description: 'Ask what a shoot costs, what the studio does, and how to book. '
|
|
85
|
+
+ 'The answer comes back in the same HTTP response, signed by this domain.',
|
|
86
|
+
tags: ['studio', 'booking', 'signed', 'inline-reply'],
|
|
87
|
+
examples: ['Do you shoot weddings?', 'What does a half-day cost?', 'How do I book?'],
|
|
88
|
+
}],
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It is an A2A `AgentSkill` list, so an agent that already speaks A2A reads it without being
|
|
93
|
+
taught anything new, and it goes into the plain card **and** the signed envelope — the menu
|
|
94
|
+
is signed too.
|
|
95
|
+
|
|
96
|
+
Two rules worth holding yourself to. **Every example must be answerable:** an example is a
|
|
97
|
+
promise printed on your card, and the visitor who copies one verbatim is the best-behaved
|
|
98
|
+
visitor you will get, so drive your examples through your own responder in your tests.
|
|
99
|
+
**Declare only what the responder does:** a skill that mentions booking, on an entry that
|
|
100
|
+
answers questions and hands off nothing, is a signed claim you cannot keep.
|
|
101
|
+
|
|
102
|
+
### The rest of the settings
|
|
103
|
+
|
|
104
|
+
| option | default | what it does |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `skills` | `[]` | the menu above — what a visitor learns before knocking |
|
|
107
|
+
| `openDoor` | `true` | publishes `muretai.open_door`: the field that tells a visiting agent it may message you with no introduction |
|
|
108
|
+
| `anonymousLane` | `false` | also answer **unsigned** inquiries. They create no account row, and the lane is capped entry-wide — an unauthenticated caller must never become an unmetered signing oracle |
|
|
109
|
+
| `anonRatePerMin` | `30` | anonymous replies per minute, entry-wide. Signed senders are not bound by it: they are attributable and already in your ledger |
|
|
110
|
+
| `maxAccounts` | `50000` | how many accounts the in-process ledger holds |
|
|
111
|
+
| `domains` | none | the domains this entry speaks for (see below) |
|
|
112
|
+
| `basePath` | from `baseUrl` | the path this entry answers at, derived rather than set beside it |
|
|
113
|
+
| `wbaVerifiers` | none | a JWKS document (`{"keys":[…]}`) of Ed25519 keys whose holders this entry should **recognise** on inbound signed requests (Web Bot Auth / RFC 9421 — see *Who is knocking*). Recognition only adds `env.wba_did` and a visit count; it never changes a verdict |
|
|
114
|
+
| `name`, `description`, `version` | — | the card's own words. `description` is the line a person reads in a directory listing |
|
|
115
|
+
|
|
116
|
+
`seedHex` and `baseUrl` are the two an entry refuses to start without: the seed **is** the
|
|
117
|
+
address, and the url it publishes must equal the origin the visitor dialled.
|
|
118
|
+
|
|
119
|
+
## Who is knocking — observation, never identity
|
|
120
|
+
|
|
121
|
+
The person who found you often never opens a browser: they hand your link to their
|
|
122
|
+
agent, and the agent fetches your card and knocks. That traffic is invisible to every
|
|
123
|
+
page-view metric you have — the only place it can be seen is the door itself. So the
|
|
124
|
+
door counts it:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
entry.stats()
|
|
128
|
+
// { gptbot: { card_get: 12, signed_post: 3 },
|
|
129
|
+
// browser: { notice_get: 5 } }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Each request's `User-Agent` is classified into a fixed family (`claude-user`,
|
|
133
|
+
`claudebot`, `gptbot`, `openai`, `perplexity`, `google-extended`, `muretai-node`,
|
|
134
|
+
`curl`, `browser`, `none`/`other`) and counted by stage. In-process state like the
|
|
135
|
+
ledger — read it, log it, ship it to your analytics; it is never served on the wire.
|
|
136
|
+
An AI-agent family also gets one nudge: `GET /` answers it with
|
|
137
|
+
`Link: </.well-known/agent-card.json>; rel="service-desc"` (RFC 8631), so a crawler
|
|
138
|
+
that landed on prose is handed the machine-readable door. The body stays
|
|
139
|
+
byte-identical for every caller.
|
|
140
|
+
|
|
141
|
+
One rule holds this together, enforced by the contract suite rather than promised:
|
|
142
|
+
**a User-Agent never affects `verified`, an account row, a rate limit, or any
|
|
143
|
+
refusal.** A UA string is written by the client; a door that trusted it would be a
|
|
144
|
+
door anyone could talk their way through.
|
|
145
|
+
|
|
146
|
+
### From hint to proof: recognising signed crawlers (Web Bot Auth)
|
|
147
|
+
|
|
148
|
+
Major AI crawlers now **sign** their requests (HTTP Message Signatures, RFC 9421).
|
|
149
|
+
Hand your entry the public keys you trust — the body of a key directory you fetched
|
|
150
|
+
and verified out of band — and it verifies them, with no network call at answer time:
|
|
151
|
+
|
|
152
|
+
```js
|
|
153
|
+
createAgentEntry({
|
|
154
|
+
seedHex, name, baseUrl, responder,
|
|
155
|
+
wbaVerifiers: { keys: [{ kty: 'OKP', crv: 'Ed25519', x: '…' }] },
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A verified fetch is counted (`entry.wbaVisits`); a verified message hands your
|
|
160
|
+
responder `env.wba_did` — the identity whose key signed the *request*, beside
|
|
161
|
+
`env.peer_did`, the identity that signed the *message*. The same rule holds:
|
|
162
|
+
recognition never changes a verdict, mints no account, and lifts no rate limit. A
|
|
163
|
+
signature over the transport proves who fetched — not who wrote the text, and a
|
|
164
|
+
captured header set is replayable until it expires (minutes), which is why `wba_did`
|
|
165
|
+
is identification, never authorship.
|
|
166
|
+
|
|
72
167
|
## Install
|
|
73
168
|
|
|
74
169
|
```bash
|
|
@@ -82,6 +177,13 @@ which is the point — you can read all of it before you trust it.
|
|
|
82
177
|
curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs
|
|
83
178
|
```
|
|
84
179
|
|
|
180
|
+
That is the whole footprint. **There is no database to install** and no schema to create —
|
|
181
|
+
an entry runs, in production, on its bounded in-process state, which is how muretai.com's
|
|
182
|
+
own door runs. Once your door is answering, a store of your own is the **recommended**
|
|
183
|
+
upgrade — the ledger is your customer list, and more features stand on keeping it — while
|
|
184
|
+
an analytics tool covers statistics without one. Both are described under
|
|
185
|
+
[Before you put it in production](#before-you-put-it-in-production).
|
|
186
|
+
|
|
85
187
|
## Put one on a site you already have
|
|
86
188
|
|
|
87
189
|
A visiting agent knows only your **domain**, so the three paths it walks are fixed — it
|
|
@@ -323,12 +425,39 @@ makes the visitor someone you can recognise the next time.
|
|
|
323
425
|
|
|
324
426
|
## Before you put it in production
|
|
325
427
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
428
|
+
**Nothing here is needed to start** — an entry runs, and every exchange stays correct,
|
|
429
|
+
on its in-process state alone; some installers have read this section as a prerequisite,
|
|
430
|
+
and it is not one. It is the upgrade path:
|
|
431
|
+
|
|
432
|
+
- **Recommended — persist the ledger in a store of your own: it is your customer list.**
|
|
433
|
+
Every row is keyed by a customer's DID, which is their address: what you need to
|
|
434
|
+
recognise a returning customer and to contact them again later. In memory that list
|
|
435
|
+
evaporates on restart. Kept in the database your site already has — keyed by exactly
|
|
436
|
+
the account DID you are handed — it is what the features beyond answering stand on:
|
|
437
|
+
greeting a returning account by its history, following up on yesterday's inquiry,
|
|
438
|
+
pricing by relationship. Keep the device→owner pins and the replay guard beside it and
|
|
439
|
+
the security rules — a device is never re-owned, a message is never accepted twice —
|
|
440
|
+
survive restarts as well; those two are read on every message, so only a real store
|
|
441
|
+
can carry them.
|
|
442
|
+
- **Statistics without a store: an analytics sink.** Nothing in the entry reads the
|
|
443
|
+
ledger back to gate, greet or rate-limit, so a fire-and-forget sink records visiting
|
|
444
|
+
agents with no database anywhere. Your `responder` is handed the account DID; Google
|
|
445
|
+
Analytics 4 over the Measurement Protocol is one `fetch` inside it:
|
|
446
|
+
|
|
447
|
+
```js
|
|
448
|
+
fetch('https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX'
|
|
449
|
+
+ '&api_secret=' + process.env.GA_API_SECRET, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
body: JSON.stringify({ client_id: env.owner_did || env.peer_did,
|
|
452
|
+
events: [{ name: 'agent_contact' }] }),
|
|
453
|
+
}).catch(() => {}); // analytics must never block a reply
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Keyed by `client_id`, GA tells new from returning visitors by itself — and a DID is a
|
|
457
|
+
public key, not personal data, though the record then lives with a third party, which
|
|
458
|
+
is your call. A sink cannot be read back during a request: it counts customers, it
|
|
459
|
+
cannot recognise one. It replaces a log line, not the store above — none of the
|
|
460
|
+
recommended features stand on it.
|
|
332
461
|
- **Revocation reaches you through your backend, not through this file.** An Agent Entry
|
|
333
462
|
is deliberately network-free on the hot path: it never dials out while answering a
|
|
334
463
|
visitor. Bindings carry an expiry, and a full node checks published revocations within
|
package/examples/server.mjs
CHANGED
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
* AGENT_ENTRY_NAME public display name on the card
|
|
34
34
|
* AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
|
|
35
35
|
* AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
|
|
36
|
+
* AGENT_ENTRY_WBA_JWKS OPTIONAL: a JWKS document {"keys":[…]} as one JSON string —
|
|
37
|
+
* the Web Bot Auth key directory (verified out of band) whose
|
|
38
|
+
* holders this entry should RECOGNISE on inbound requests. Off
|
|
39
|
+
* when absent. Recognition only ADDS identity (env.wba_did);
|
|
40
|
+
* it never changes a verdict.
|
|
36
41
|
*/
|
|
37
42
|
|
|
38
43
|
import { createAgentEntry, newSeedHex, didFromSeedHex, trimOuter, AGENT_CARD_PATH }
|
|
@@ -76,6 +81,11 @@ function responder(env) {
|
|
|
76
81
|
// the account", and that is invisible if the ledger only lives in memory: an operator
|
|
77
82
|
// watching this log is how you SEE a stranger's identity appear, and how you tell an
|
|
78
83
|
// anonymous walk-in (no account) from a verified first contact (an account) at a glance.
|
|
84
|
+
// `[WBA]` is the transport-level identity (T107): the request's HTTP signature named a
|
|
85
|
+
// key we were configured to recognise — identification, never authorship of the text.
|
|
86
|
+
if (env.wba_did) {
|
|
87
|
+
console.log(`[WBA] transport signed by ${env.wba_did}`);
|
|
88
|
+
}
|
|
79
89
|
// NOTE for anyone copying this file: `ledger` here is a **Map** (the Python reference in
|
|
80
90
|
// examples/agent_entry_reference.py uses a dict) — use .get()/.size, not obj[key]/Object.keys.
|
|
81
91
|
// The row is written BEFORE the backend is called, so `messages === 1` means "this very
|
|
@@ -107,6 +117,17 @@ function responder(env) {
|
|
|
107
117
|
// tells a site operator nothing.
|
|
108
118
|
let entry;
|
|
109
119
|
try {
|
|
120
|
+
// A malformed AGENT_ENTRY_WBA_JWKS refuses to start, same posture as a bad domain
|
|
121
|
+
// list: silently starting without the keys the operator named only looks protective.
|
|
122
|
+
let wbaVerifiers = null;
|
|
123
|
+
if (process.env.AGENT_ENTRY_WBA_JWKS) {
|
|
124
|
+
try {
|
|
125
|
+
wbaVerifiers = JSON.parse(process.env.AGENT_ENTRY_WBA_JWKS);
|
|
126
|
+
} catch {
|
|
127
|
+
throw new TypeError('AGENT_ENTRY_WBA_JWKS is not valid JSON — paste the key '
|
|
128
|
+
+ 'directory body ({"keys":[…]}) as one JSON string');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
110
131
|
entry = createAgentEntry({
|
|
111
132
|
seedHex,
|
|
112
133
|
name,
|
|
@@ -116,6 +137,7 @@ try {
|
|
|
116
137
|
responder,
|
|
117
138
|
openDoor: true, // "you may contact me, no introduction"
|
|
118
139
|
anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
|
|
140
|
+
wbaVerifiers,
|
|
119
141
|
});
|
|
120
142
|
} catch (err) {
|
|
121
143
|
console.error(err && err.message ? err.message : String(err));
|
|
@@ -134,6 +156,23 @@ const server = entry.listen(port, host, () => {
|
|
|
134
156
|
+ `${entry.mount || '/'}`);
|
|
135
157
|
});
|
|
136
158
|
|
|
159
|
+
// The observation counters, surfaced the way the ledger is: on this runner's stdout,
|
|
160
|
+
// printed only when they changed. `[ua]` is greppable; the shape is entry.stats()
|
|
161
|
+
// verbatim ({family: {stage: n}}). `unref()` so the timer never holds the process open.
|
|
162
|
+
let lastStats = '';
|
|
163
|
+
setInterval(() => {
|
|
164
|
+
// Sorted keys at every level so the line is stable run to run (and diffable against
|
|
165
|
+
// the Python runner's `json.dumps(..., sort_keys=True)` spelling of the same shape).
|
|
166
|
+
const line = JSON.stringify(entry.stats(), (k, v) =>
|
|
167
|
+
(v && typeof v === 'object' && !Array.isArray(v))
|
|
168
|
+
? Object.fromEntries(Object.keys(v).sort().map((key) => [key, v[key]]))
|
|
169
|
+
: v);
|
|
170
|
+
if (line !== '{}' && line !== lastStats) {
|
|
171
|
+
console.log(`[ua] ${line}`);
|
|
172
|
+
lastStats = line;
|
|
173
|
+
}
|
|
174
|
+
}, 60_000).unref();
|
|
175
|
+
|
|
137
176
|
// A port collision is the first thing anyone running this twice hits (a previous run that was
|
|
138
177
|
// backgrounded and orphaned, usually). An unhandled 'error' event prints a Node stack trace,
|
|
139
178
|
// which tells a site operator nothing — say what happened and what to do instead.
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -65,6 +65,82 @@ export const AGENT_CARD_PATH = '/.well-known/agent-card.json';
|
|
|
65
65
|
export const AGENT_CARD_PATH_LEGACY = '/.well-known/agent.json';
|
|
66
66
|
export const AGENT_CARD_SIG_PATH = '/.well-known/agent-card.sig.json';
|
|
67
67
|
|
|
68
|
+
/** The User-Agent FAMILY table — OBSERVATION AND SIGNPOSTING, NEVER IDENTITY. A UA string
|
|
69
|
+
* is written by the client, so nothing here may ever affect `verified`, a ledger row, a
|
|
70
|
+
* rate lane or any refusal verdict (that is the Web Bot Auth / signed-envelope layer's
|
|
71
|
+
* job). What it buys: an owner-facing count of who is knocking (`stats()`), and a `Link`
|
|
72
|
+
* signpost on the notice route for the families that are AI agents.
|
|
73
|
+
*
|
|
74
|
+
* Ordered, FIRST MATCH WINS, and the order is load-bearing twice: real crawler UAs start
|
|
75
|
+
* with "Mozilla/5.0 …" so every bot needle must come before `mozilla`, and GPTBot's UA
|
|
76
|
+
* contains "openai.com/gptbot" so `gptbot` must come before `openai`. Needles are matched
|
|
77
|
+
* as substrings after an ASCII-ONLY lowercase fold (`asciiLower`, not `toLowerCase()` —
|
|
78
|
+
* Unicode case folding differs between runtimes and none of these needles needs it).
|
|
79
|
+
* FIXED table, deliberately not an option: an option would invite making UA matter, and
|
|
80
|
+
* the fixed table is what bounds the stats keyspace — an attacker-chosen UA string must
|
|
81
|
+
* never become a key. Must match `UA_FAMILIES` in examples/agent_entry_reference.py:
|
|
82
|
+
* one contract, two implementations, one verdict per string. */
|
|
83
|
+
export const UA_FAMILIES = [
|
|
84
|
+
['claude-user', 'claude-user'],
|
|
85
|
+
['claudebot', 'claudebot'],
|
|
86
|
+
['gptbot', 'gptbot'],
|
|
87
|
+
['chatgpt-user', 'openai'],
|
|
88
|
+
['openai', 'openai'],
|
|
89
|
+
['perplexity', 'perplexity'],
|
|
90
|
+
['google-extended', 'google-extended'],
|
|
91
|
+
['muretai-node', 'muretai-node'],
|
|
92
|
+
['curl', 'curl'],
|
|
93
|
+
['mozilla', 'browser'],
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** The families that read as an AI agent — the ones the notice route signposts with a
|
|
97
|
+
* `Link` header. `muretai-node` is deliberately absent: its Outbox already walks the
|
|
98
|
+
* well-known card paths, so a signpost buys it nothing. Must match the same set in
|
|
99
|
+
* examples/agent_entry_reference.py. */
|
|
100
|
+
export const AI_AGENT_FAMILIES = new Set([
|
|
101
|
+
'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
/** ASCII-only lowercase fold. NOT `toLowerCase()`: Unicode casing is runtime- and
|
|
105
|
+
* locale-shaped (the Turkish-I class of surprise), and no needle in the table needs it —
|
|
106
|
+
* folding only A-Z is what makes the same UA string classify identically in both twins. */
|
|
107
|
+
function asciiLower(s) {
|
|
108
|
+
let out = '';
|
|
109
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
110
|
+
const c = s.charCodeAt(i);
|
|
111
|
+
out += (c >= 65 && c <= 90) ? String.fromCharCode(c + 32) : s[i];
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** UA string -> family. Absent/empty/non-string -> 'none'; no needle matched -> 'other'.
|
|
117
|
+
* Total on untrusted input, and the RETURN VALUE is always one of the twelve fixed
|
|
118
|
+
* family names — never a substring of the input (bounded stats keyspace). */
|
|
119
|
+
export function uaFamily(ua) {
|
|
120
|
+
if (typeof ua !== 'string' || !ua) return 'none';
|
|
121
|
+
const folded = asciiLower(ua);
|
|
122
|
+
for (const [needle, family] of UA_FAMILIES) {
|
|
123
|
+
if (folded.includes(needle)) return family;
|
|
124
|
+
}
|
|
125
|
+
return 'other';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The FIRST User-Agent value out of a headers mapping, or null. Case-insensitive key
|
|
129
|
+
* scan so an in-process host can pass any casing; Node's own `req.headers` already
|
|
130
|
+
* lowercases keys and keeps only the FIRST user-agent of a duplicated pair — the Python
|
|
131
|
+
* twin's `email.Message.get` does the same, which is the parity this relies on. A
|
|
132
|
+
* non-string value (an array, a number) reads as absent, never coerced. */
|
|
133
|
+
function uaOf(headers) {
|
|
134
|
+
if (!headers || typeof headers !== 'object') return null;
|
|
135
|
+
for (const key of Object.keys(headers)) {
|
|
136
|
+
if (asciiLower(key) === 'user-agent') {
|
|
137
|
+
const v = headers[key];
|
|
138
|
+
return typeof v === 'string' ? v : null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
68
144
|
const CARD_ENVELOPE_VERSION = 1;
|
|
69
145
|
const CARD_ENVELOPE_TYPE = 'agentcard';
|
|
70
146
|
|
|
@@ -438,6 +514,382 @@ export function verifyEnvelope(fields, opts = {}) {
|
|
|
438
514
|
}
|
|
439
515
|
}
|
|
440
516
|
|
|
517
|
+
// ================================================================ Web Bot Auth (RFC 9421 subset, verify-only) — T107
|
|
518
|
+
//
|
|
519
|
+
// The INBOUND half only: did the holder of one of the keys this entry was GIVEN sign
|
|
520
|
+
// THIS request, for THIS authority, as a `web-bot-auth` request? It mirrors EXACTLY the
|
|
521
|
+
// subset shared/webbotauth.py::verify_request implements — no more (content digests,
|
|
522
|
+
// @query-param, per-item parameters and every other RFC 9421 feature are refused, not
|
|
523
|
+
// ignored) and no less. The two are pinned to one fixture, testdata/wba_vectors.json:
|
|
524
|
+
// a vector one twin accepts and the other refuses is a red suite. Verification is
|
|
525
|
+
// BYTE-FAITHFUL, not canonical: the signature base is rebuilt from the RECEIVED
|
|
526
|
+
// `@signature-params` text, so a peer who orders or spaces parameters differently still
|
|
527
|
+
// verifies (signing is canonical, verifying is byte-faithful — the shared/jws.py split).
|
|
528
|
+
//
|
|
529
|
+
// One rule governs every caller in this file: WBA never changes a verdict — it only
|
|
530
|
+
// ever ADDS identity (`wba_did` on the backend envelope, a `wbaVisits` count). Absent,
|
|
531
|
+
// invalid, expired, unknown-key and tampered all behave exactly like "no WBA".
|
|
532
|
+
|
|
533
|
+
const WBA_TAG_REQUEST = 'web-bot-auth';
|
|
534
|
+
/** RFC 9421's HTTP-signature-registry name — NOT JOSE's "EdDSA". Same curve, two
|
|
535
|
+
* registries; mixing the spellings is a silent interop failure. */
|
|
536
|
+
const WBA_ALG = 'ed25519';
|
|
537
|
+
/** Tolerance for the peer's clock being ahead, applied to `created` only. */
|
|
538
|
+
const WBA_CLOCK_SKEW = 300;
|
|
539
|
+
/** The loosest accepted `expires - created`: these headers are a bearer credential
|
|
540
|
+
* while they live (webbotauth.REQUEST_SIG_WINDOW + CLOCK_SKEW). */
|
|
541
|
+
const WBA_MAX_REQUEST_LIFETIME = 600;
|
|
542
|
+
/** Refuse to even tokenize an absurd header — bounds parser work on hostile input. */
|
|
543
|
+
const WBA_MAX_HEADER_CHARS = 8192;
|
|
544
|
+
/** Standard base64, padded — what Python's base64.b64decode(validate=True) accepts.
|
|
545
|
+
* Node's Buffer.from(s, 'base64') silently IGNORES invalid characters and tolerates
|
|
546
|
+
* any padding, which is the classic twin-divergence; pre-validating is what keeps one
|
|
547
|
+
* Signature value from being two different byte strings. */
|
|
548
|
+
const WBA_B64_STANDARD = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
549
|
+
/** Unpadded base64url — what shared/jws.unb64url accepts for a JWK `x` ("+", "/" and
|
|
550
|
+
* "=" refused; a length ≡ 1 (mod 4) has no byte decoding). */
|
|
551
|
+
const WBA_B64URL = /^[A-Za-z0-9_-]*$/;
|
|
552
|
+
|
|
553
|
+
/** Serialize an RFC 8941 sf-string: quoted, `\` and `"` escaped — the only two escapes
|
|
554
|
+
* the RFC defines. */
|
|
555
|
+
function wbaSfString(s) {
|
|
556
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Python str.strip()'s default whitespace set, exactly — NOT String.prototype.trim().
|
|
560
|
+
* The two runtimes disagree at the edges (Python also strips \x1c-\x1f and \x85; JS
|
|
561
|
+
* also strips U+FEFF), and a covered header value the twins trim differently is a
|
|
562
|
+
* signature base only one of them can rebuild. */
|
|
563
|
+
const WBA_PY_WS_CLASS = '[\\t\\n\\v\\f\\r \\x1c-\\x1f\\x85\\xa0\\u1680'
|
|
564
|
+
+ '\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]+';
|
|
565
|
+
const WBA_PY_WS = new RegExp(`^${WBA_PY_WS_CLASS}|${WBA_PY_WS_CLASS}$`, 'g');
|
|
566
|
+
function wbaPyStrip(s) {
|
|
567
|
+
return s.replace(WBA_PY_WS, '');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function wbaIsKeyFirst(ch) { return (ch >= 'a' && ch <= 'z') || ch === '*'; }
|
|
571
|
+
function wbaIsKeyRest(ch) {
|
|
572
|
+
return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
|
573
|
+
|| ch === '_' || ch === '-' || ch === '.' || ch === '*';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** An RFC 8941 key (a dictionary label or a parameter name) -> [key, next] or null. */
|
|
577
|
+
function wbaParseKey(s, i) {
|
|
578
|
+
if (i >= s.length || !wbaIsKeyFirst(s[i])) return null;
|
|
579
|
+
let j = i + 1;
|
|
580
|
+
while (j < s.length && wbaIsKeyRest(s[j])) j += 1;
|
|
581
|
+
return [s.slice(i, j), j];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** A quoted sf-string. Only `\"` and `\\` are escapes; every other character must be
|
|
585
|
+
* printable ASCII — rejecting the rest is what keeps one byte string from having two
|
|
586
|
+
* spellings. */
|
|
587
|
+
function wbaParseSfString(s, i) {
|
|
588
|
+
if (i >= s.length || s[i] !== '"') return null;
|
|
589
|
+
i += 1;
|
|
590
|
+
let out = '';
|
|
591
|
+
while (i < s.length) {
|
|
592
|
+
const ch = s[i];
|
|
593
|
+
if (ch === '\\') {
|
|
594
|
+
i += 1;
|
|
595
|
+
if (i >= s.length || (s[i] !== '"' && s[i] !== '\\')) return null;
|
|
596
|
+
out += s[i];
|
|
597
|
+
i += 1;
|
|
598
|
+
} else if (ch === '"') {
|
|
599
|
+
return [out, i + 1];
|
|
600
|
+
} else if (ch >= ' ' && ch <= '~') {
|
|
601
|
+
out += ch;
|
|
602
|
+
i += 1;
|
|
603
|
+
} else {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** An sf-integer: optional `-`, at most 15 ASCII digits (safely inside 2^53). */
|
|
611
|
+
function wbaParseInteger(s, i) {
|
|
612
|
+
let j = i;
|
|
613
|
+
if (j < s.length && s[j] === '-') j += 1;
|
|
614
|
+
let k = j;
|
|
615
|
+
while (k < s.length && s[k] >= '0' && s[k] <= '9') k += 1;
|
|
616
|
+
if (k === j || (k - j) > 15) return null;
|
|
617
|
+
return [parseInt(s.slice(i, k), 10), k];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** The only parameter value types in this profile: sf-string and sf-integer. */
|
|
621
|
+
function wbaParseBareItem(s, i) {
|
|
622
|
+
if (i < s.length && s[i] === '"') return wbaParseSfString(s, i);
|
|
623
|
+
return wbaParseInteger(s, i);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** `*( ";" *SP key [ "=" bare-item ] )`. A repeated name is REFUSED rather than
|
|
627
|
+
* last-wins; a valueless parameter is boolean true. */
|
|
628
|
+
function wbaParseParams(s, i) {
|
|
629
|
+
const params = new Map();
|
|
630
|
+
while (i < s.length && s[i] === ';') {
|
|
631
|
+
i += 1;
|
|
632
|
+
while (i < s.length && s[i] === ' ') i += 1;
|
|
633
|
+
const gotKey = wbaParseKey(s, i);
|
|
634
|
+
if (gotKey === null) return null;
|
|
635
|
+
const name = gotKey[0];
|
|
636
|
+
i = gotKey[1];
|
|
637
|
+
if (params.has(name)) return null;
|
|
638
|
+
if (i < s.length && s[i] === '=') {
|
|
639
|
+
const val = wbaParseBareItem(s, i + 1);
|
|
640
|
+
if (val === null) return null;
|
|
641
|
+
params.set(name, val[0]);
|
|
642
|
+
i = val[1];
|
|
643
|
+
} else {
|
|
644
|
+
params.set(name, true);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return [params, i];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The covered components: `"(" *SP [ sf-string *( 1*SP sf-string ) *SP ] ")"`.
|
|
651
|
+
* Per-item parameters are refused — they change what a component MEANS, and a profile
|
|
652
|
+
* that does not implement them must not silently ignore them. */
|
|
653
|
+
function wbaParseInnerList(s, i) {
|
|
654
|
+
if (i >= s.length || s[i] !== '(') return null;
|
|
655
|
+
i += 1;
|
|
656
|
+
const items = [];
|
|
657
|
+
for (;;) {
|
|
658
|
+
while (i < s.length && s[i] === ' ') i += 1;
|
|
659
|
+
if (i >= s.length) return null;
|
|
660
|
+
if (s[i] === ')') return [items, i + 1];
|
|
661
|
+
const got = wbaParseSfString(s, i);
|
|
662
|
+
if (got === null) return null;
|
|
663
|
+
i = got[1];
|
|
664
|
+
if (i < s.length && s[i] !== ' ' && s[i] !== ')') return null; // incl. ';' per-item
|
|
665
|
+
items.push(got[0]);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function wbaSkipOws(s, i) {
|
|
670
|
+
while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i += 1;
|
|
671
|
+
return i;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Parse a `Signature-Input` value into entries, PRESERVING the raw text of each
|
|
675
|
+
* entry's value — RFC 9421 signs that text, so rebuilding it from the parsed
|
|
676
|
+
* structure would only work for peers who serialize exactly as we do. */
|
|
677
|
+
function wbaParseSignatureInput(value) {
|
|
678
|
+
const s = value;
|
|
679
|
+
const n = s.length;
|
|
680
|
+
let i = wbaSkipOws(s, 0);
|
|
681
|
+
if (i >= n) return null;
|
|
682
|
+
const entries = [];
|
|
683
|
+
for (;;) {
|
|
684
|
+
const gotKey = wbaParseKey(s, i);
|
|
685
|
+
if (gotKey === null) return null;
|
|
686
|
+
const label = gotKey[0];
|
|
687
|
+
i = gotKey[1];
|
|
688
|
+
if (i >= n || s[i] !== '=') return null;
|
|
689
|
+
i += 1;
|
|
690
|
+
const start = i;
|
|
691
|
+
const gotList = wbaParseInnerList(s, i);
|
|
692
|
+
if (gotList === null) return null;
|
|
693
|
+
const components = gotList[0];
|
|
694
|
+
i = gotList[1];
|
|
695
|
+
const gotParams = wbaParseParams(s, i);
|
|
696
|
+
if (gotParams === null) return null;
|
|
697
|
+
const params = gotParams[0];
|
|
698
|
+
i = gotParams[1];
|
|
699
|
+
entries.push({ label, components, params, signatureParams: s.slice(start, i) });
|
|
700
|
+
i = wbaSkipOws(s, i);
|
|
701
|
+
if (i >= n) return entries;
|
|
702
|
+
if (s[i] !== ',') return null;
|
|
703
|
+
i = wbaSkipOws(s, i + 1);
|
|
704
|
+
if (i >= n) return null; // trailing comma
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Parse a `Signature` value: `label=:<standard base64>:` entries. */
|
|
709
|
+
function wbaParseSignature(value) {
|
|
710
|
+
const s = value;
|
|
711
|
+
const n = s.length;
|
|
712
|
+
let i = wbaSkipOws(s, 0);
|
|
713
|
+
if (i >= n) return null;
|
|
714
|
+
const out = [];
|
|
715
|
+
for (;;) {
|
|
716
|
+
const got = wbaParseKey(s, i);
|
|
717
|
+
if (got === null) return null;
|
|
718
|
+
const label = got[0];
|
|
719
|
+
i = got[1];
|
|
720
|
+
if (i + 1 >= n || s[i] !== '=' || s[i + 1] !== ':') return null;
|
|
721
|
+
i += 2;
|
|
722
|
+
const end = s.indexOf(':', i);
|
|
723
|
+
if (end < 0) return null;
|
|
724
|
+
const b64 = s.slice(i, end);
|
|
725
|
+
if (!WBA_B64_STANDARD.test(b64) || b64.length % 4 !== 0) return null;
|
|
726
|
+
const raw = Buffer.from(b64, 'base64');
|
|
727
|
+
i = end + 1;
|
|
728
|
+
if (i < n && s[i] === ';') return null; // parameters on a signature member
|
|
729
|
+
out.push([label, raw]);
|
|
730
|
+
i = wbaSkipOws(s, i);
|
|
731
|
+
if (i >= n) return out;
|
|
732
|
+
if (s[i] !== ',') return null;
|
|
733
|
+
i = wbaSkipOws(s, i + 1);
|
|
734
|
+
if (i >= n) return null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** The `(Signature-Input, Signature)` pair as verifiable entries, or null. Duplicate
|
|
739
|
+
* labels, a label present in one header but not the other, and every unexpected byte
|
|
740
|
+
* return null — each is a case where two implementations could disagree about what
|
|
741
|
+
* was signed. Never throws. */
|
|
742
|
+
function wbaParseSignatureHeaders(sigInput, sig) {
|
|
743
|
+
try {
|
|
744
|
+
if (typeof sigInput !== 'string' || typeof sig !== 'string') return null;
|
|
745
|
+
if (sigInput.length > WBA_MAX_HEADER_CHARS || sig.length > WBA_MAX_HEADER_CHARS) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
const entries = wbaParseSignatureInput(sigInput);
|
|
749
|
+
const sigs = wbaParseSignature(sig);
|
|
750
|
+
if (entries === null || sigs === null) return null;
|
|
751
|
+
const labels = entries.map((e) => e.label);
|
|
752
|
+
if (new Set(labels).size !== labels.length) return null;
|
|
753
|
+
const byLabel = new Map();
|
|
754
|
+
for (const [label, raw] of sigs) {
|
|
755
|
+
if (byLabel.has(label)) return null;
|
|
756
|
+
byLabel.set(label, raw);
|
|
757
|
+
}
|
|
758
|
+
if (byLabel.size !== labels.length) return null;
|
|
759
|
+
for (const label of labels) { if (!byLabel.has(label)) return null; }
|
|
760
|
+
for (const e of entries) e.sig = byLabel.get(e.label);
|
|
761
|
+
return entries;
|
|
762
|
+
} catch {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Case-insensitive header lookup over a plain mapping. A non-string value (an array,
|
|
768
|
+
* a number) reads as absent, never coerced. */
|
|
769
|
+
function wbaHeaderGet(headers, name) {
|
|
770
|
+
if (!headers || typeof headers !== 'object') return null;
|
|
771
|
+
for (const key of Object.keys(headers)) {
|
|
772
|
+
if (asciiLower(key) === name) {
|
|
773
|
+
const v = headers[key];
|
|
774
|
+
return typeof v === 'string' ? v : null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The 32 raw key bytes of an Ed25519 OKP JWK, or null — the strict gate every
|
|
781
|
+
* untrusted key passes through (mirrors shared/webbotauth.public_from_jwk). */
|
|
782
|
+
function wbaPublicFromJwk(jwk) {
|
|
783
|
+
try {
|
|
784
|
+
if (!jwk || typeof jwk !== 'object' || Array.isArray(jwk)) return null;
|
|
785
|
+
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') return null;
|
|
786
|
+
const x = jwk.x;
|
|
787
|
+
if (typeof x !== 'string') return null;
|
|
788
|
+
if (!WBA_B64URL.test(x) || x.length % 4 === 1) return null;
|
|
789
|
+
const raw = Buffer.from(x, 'base64url');
|
|
790
|
+
return raw.length === 32 ? raw : null;
|
|
791
|
+
} catch {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** RFC 7638 thumbprint of an Ed25519 public key — the `keyid` on the wire. Built from
|
|
797
|
+
* the CANONICAL re-encoding of the key bytes, so a differently-spelled (but valid) `x`
|
|
798
|
+
* still names the same key. The literal member order crv,kty,x IS Python's
|
|
799
|
+
* sort_keys+compact form (x is base64url, so no JSON escaping can differ). */
|
|
800
|
+
function wbaThumbprint(publicRaw) {
|
|
801
|
+
const payload = `{"crv":"Ed25519","kty":"OKP","x":"${publicRaw.toString('base64url')}"}`;
|
|
802
|
+
return createHash('sha256').update(payload, 'utf8').digest('base64url');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Resolve each covered component to the value to re-sign over, or null. `@authority`
|
|
806
|
+
* comes from the VERIFIER (our canonical baseUrl) — never from the message; every
|
|
807
|
+
* other derived component is refused; header values are stripped with Python's set. */
|
|
808
|
+
function wbaComponentValues(components, authority, headers) {
|
|
809
|
+
const out = [];
|
|
810
|
+
const seen = new Set();
|
|
811
|
+
for (const name of components) {
|
|
812
|
+
if (typeof name !== 'string' || name !== asciiLower(name) || seen.has(name)) {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
seen.add(name);
|
|
816
|
+
if (name === '@authority') {
|
|
817
|
+
out.push([name, authority]);
|
|
818
|
+
} else if (name.startsWith('@')) {
|
|
819
|
+
return null;
|
|
820
|
+
} else {
|
|
821
|
+
const value = wbaHeaderGet(headers, name);
|
|
822
|
+
if (value === null) return null;
|
|
823
|
+
out.push([name, wbaPyStrip(value)]);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** The exact bytes covered by the signature (RFC 9421 §2.5): one `"name": value` line
|
|
830
|
+
* per component, then `"@signature-params": <received text>`, LF-joined, no trailing
|
|
831
|
+
* newline. */
|
|
832
|
+
function wbaSignatureBase(pairs, paramsText) {
|
|
833
|
+
const lines = pairs.map(([name, value]) => `${wbaSfString(asciiLower(name))}: ${value}`);
|
|
834
|
+
lines.push(`${wbaSfString('@signature-params')}: ${paramsText}`);
|
|
835
|
+
return Buffer.from(lines.join('\n'), 'utf8');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** One parsed entry, checked end to end against one key. Order matters only for cost:
|
|
839
|
+
* the cheap policy checks run before the Ed25519 verification. */
|
|
840
|
+
function wbaEntryVerifies(entry, { keyid, publicRaw, authority, headers, now }) {
|
|
841
|
+
const params = entry.params;
|
|
842
|
+
if (params.get('keyid') !== keyid || params.get('tag') !== WBA_TAG_REQUEST) return false;
|
|
843
|
+
const alg = params.get('alg');
|
|
844
|
+
if (alg !== undefined && alg !== WBA_ALG) return false;
|
|
845
|
+
const created = params.get('created');
|
|
846
|
+
const expires = params.get('expires');
|
|
847
|
+
if (!Number.isInteger(created) || !Number.isInteger(expires)) return false;
|
|
848
|
+
if (created > now + WBA_CLOCK_SKEW || now >= expires) return false;
|
|
849
|
+
if (expires <= created || (expires - created) > WBA_MAX_REQUEST_LIFETIME) return false;
|
|
850
|
+
const components = entry.components || [];
|
|
851
|
+
// Without @authority the signature says nothing about WHERE it was served.
|
|
852
|
+
if (!components.includes('@authority')) return false;
|
|
853
|
+
const pairs = wbaComponentValues(components, authority, headers);
|
|
854
|
+
if (pairs === null) return false;
|
|
855
|
+
return verifyBytes(publicRaw, entry.sig, wbaSignatureBase(pairs, entry.signatureParams));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** The DID that signed this inbound request, or null. Never throws. `jwks` is a
|
|
859
|
+
* directory document ({keys:[…]}) already established as trustworthy — who the keys
|
|
860
|
+
* belong to was decided before this was called (DECISION 2: keys are GIVEN, never
|
|
861
|
+
* fetched on the hot path). Mirrors shared/webbotauth.verify_request exactly;
|
|
862
|
+
* testdata/wba_vectors.json holds the two to one verdict per input. */
|
|
863
|
+
export function wbaVerifyRequest(headers, { authority, jwks, now } = {}) {
|
|
864
|
+
try {
|
|
865
|
+
const entries = wbaParseSignatureHeaders(
|
|
866
|
+
wbaHeaderGet(headers, 'signature-input') || '',
|
|
867
|
+
wbaHeaderGet(headers, 'signature') || '');
|
|
868
|
+
if (!entries || !entries.length) return null;
|
|
869
|
+
const keys = (jwks && typeof jwks === 'object' && !Array.isArray(jwks))
|
|
870
|
+
? jwks.keys : null;
|
|
871
|
+
if (!Array.isArray(keys)) return null;
|
|
872
|
+
const auth = wbaPyStrip(String(authority || '')).toLowerCase();
|
|
873
|
+
if (!auth) return null;
|
|
874
|
+
const moment = Math.floor(
|
|
875
|
+
(now === undefined || now === null) ? Date.now() / 1000 : now);
|
|
876
|
+
for (const jwk of keys) {
|
|
877
|
+
const publicRaw = wbaPublicFromJwk(jwk);
|
|
878
|
+
if (publicRaw === null) continue;
|
|
879
|
+
const keyid = wbaThumbprint(publicRaw);
|
|
880
|
+
for (const entry of entries) {
|
|
881
|
+
if (wbaEntryVerifies(entry, { keyid, publicRaw, authority: auth,
|
|
882
|
+
headers, now: moment })) {
|
|
883
|
+
return didFromPublicKeyHex(publicRaw);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
} catch {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
441
893
|
// ================================================================ device-key binding v2 (T102)
|
|
442
894
|
//
|
|
443
895
|
// The ACCOUNT layer: a message may carry a countersigned DeviceKeyBinding v2 in
|
|
@@ -1486,6 +1938,13 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
1486
1938
|
* oracle. Signed senders are not rate-bound here: they are attributable,
|
|
1487
1939
|
* and every one of them is already in the ledger.
|
|
1488
1940
|
* anonRatePerMin anonymous replies per minute for the WHOLE agent entry (default 30).
|
|
1941
|
+
* wbaVerifiers OPTIONAL inbound Web Bot Auth (T107): a JWKS document {keys:[…]} of
|
|
1942
|
+
* Ed25519 keys whose holders this entry should RECOGNISE — the body of
|
|
1943
|
+
* a key directory you verified out of band. Absent (the default) the
|
|
1944
|
+
* feature is entirely off: no header is read, bytes are unchanged.
|
|
1945
|
+
* Recognition only ever ADDS identity (env.wba_did, the wbaVisits
|
|
1946
|
+
* count); it never changes verified, a ledger row, a rate lane or any
|
|
1947
|
+
* refusal verdict.
|
|
1489
1948
|
*/
|
|
1490
1949
|
export function createAgentEntry({
|
|
1491
1950
|
seedHex,
|
|
@@ -1501,6 +1960,7 @@ export function createAgentEntry({
|
|
|
1501
1960
|
domains = null,
|
|
1502
1961
|
basePath = null,
|
|
1503
1962
|
maxAccounts = 50000,
|
|
1963
|
+
wbaVerifiers = null,
|
|
1504
1964
|
} = {}) {
|
|
1505
1965
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
1506
1966
|
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
@@ -1542,18 +2002,134 @@ export function createAgentEntry({
|
|
|
1542
2002
|
const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
|
|
1543
2003
|
// ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
|
|
1544
2004
|
// the OWNER DID when a valid v2 binding rides along, else the device DID — so an owner's
|
|
1545
|
-
// sibling devices are ONE customer row.
|
|
2005
|
+
// sibling devices are ONE customer row. The entry never reads it back to gate, greet, or
|
|
2006
|
+
// rate-limit, so it runs fine unpersisted — but keeping it in the site's own store is
|
|
2007
|
+
// RECOMMENDED: it is the customer list (recognise a returning account, contact it again
|
|
2008
|
+
// later). An analytics sink records visits too, but can never be read back.
|
|
1546
2009
|
const ledger = new Map();
|
|
1547
2010
|
// device DID -> owner DID, the in-process TOFU pin (T102). The first VALID binding pins a
|
|
1548
2011
|
// device to its owner; a later binding for the same device naming a DIFFERENT owner is
|
|
1549
|
-
// refused. Per-process on purpose for v1.5 —
|
|
1550
|
-
// the conflict rule resets to trust-on-first-use every restart.
|
|
2012
|
+
// refused. Per-process on purpose for v1.5 — persisting it (and the fold) is RECOMMENDED,
|
|
2013
|
+
// not required: without it the conflict rule resets to trust-on-first-use every restart.
|
|
2014
|
+
// Unlike the ledger it is READ on every message, so only a real store can carry it.
|
|
1551
2015
|
const deviceOwner = new Map();
|
|
1552
2016
|
const replay = new ReplayGuard();
|
|
1553
2017
|
const anonRate = new RateBound(anonRatePerMin);
|
|
1554
2018
|
let sigEnvelope = null;
|
|
1555
2019
|
let sigMintedAt = 0;
|
|
1556
2020
|
|
|
2021
|
+
// T107: inbound Web Bot Auth, verify-only, against keys GIVEN at construction — the
|
|
2022
|
+
// entry never fetches a directory on the hot path (network-free while answering).
|
|
2023
|
+
// Refuse-to-start posture, house style: a key the entry can never match is config the
|
|
2024
|
+
// operator believes protects them and does not.
|
|
2025
|
+
let wbaKeys = null;
|
|
2026
|
+
let wbaAuthority = null;
|
|
2027
|
+
// DID -> count of WBA-verified GET/HEAD fetches. DECISION 1: a signed GET IDENTIFIES
|
|
2028
|
+
// but never ENROLS — a crawler fetching 10,000 pages mints zero ledger rows; this
|
|
2029
|
+
// count is bounded by the configured key list, never by attacker choice. Exposed on
|
|
2030
|
+
// the returned object like `ledger` (in-process sample state, never on the wire).
|
|
2031
|
+
const wbaVisits = new Map();
|
|
2032
|
+
if (wbaVerifiers !== null && wbaVerifiers !== undefined) {
|
|
2033
|
+
const keys = (wbaVerifiers && typeof wbaVerifiers === 'object'
|
|
2034
|
+
&& !Array.isArray(wbaVerifiers)) ? wbaVerifiers.keys : null;
|
|
2035
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
2036
|
+
throw new TypeError('createAgentEntry: wbaVerifiers must be a JWKS document '
|
|
2037
|
+
+ '{keys:[…]} — the key-directory body you verified out of band');
|
|
2038
|
+
}
|
|
2039
|
+
if (keys.length > 64) {
|
|
2040
|
+
throw new TypeError(`createAgentEntry: wbaVerifiers holds ${keys.length} keys — `
|
|
2041
|
+
+ 'more than 64 is not a verifier list, it is a directory dump');
|
|
2042
|
+
}
|
|
2043
|
+
keys.forEach((jwk, i) => {
|
|
2044
|
+
if (wbaPublicFromJwk(jwk) === null) {
|
|
2045
|
+
throw new TypeError(`createAgentEntry: wbaVerifiers.keys[${i}] is not an `
|
|
2046
|
+
+ 'Ed25519 OKP JWK (kty "OKP", crv "Ed25519", x = unpadded base64url of '
|
|
2047
|
+
+ '32 bytes)');
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
wbaKeys = { keys: keys.map((k) => ({ kty: k.kty, crv: k.crv, x: k.x })) };
|
|
2051
|
+
// @authority derives from the CANONICAL baseUrl, NEVER a Host header — a header a
|
|
2052
|
+
// client can set is not a fact about where we were reached. canonUrl already
|
|
2053
|
+
// lowercased the host and stripped the scheme's default port, so URL.host IS the
|
|
2054
|
+
// RFC 9421 authority (shared/webbotauth.authority_of computes the same string).
|
|
2055
|
+
wbaAuthority = new URL(canonUrl).host;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/** The WBA-verified caller DID for this request's headers, or null. Total on hostile
|
|
2059
|
+
* input; costs one keyid comparison per configured key and an Ed25519 verify only on
|
|
2060
|
+
* a keyid match. */
|
|
2061
|
+
function wbaIdentify(headers) {
|
|
2062
|
+
if (!wbaKeys) return null;
|
|
2063
|
+
return wbaVerifyRequest(headers, { authority: wbaAuthority, jwks: wbaKeys });
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function wbaObserve(headers) {
|
|
2067
|
+
const did = wbaIdentify(headers);
|
|
2068
|
+
if (did) wbaVisits.set(did, (wbaVisits.get(did) || 0) + 1);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// family -> stage -> count. OBSERVATION ONLY, and out-of-contract sample state like the
|
|
2072
|
+
// ledger's row shape: `stats()` is how a site owner sees who is knocking, it is never
|
|
2073
|
+
// served on the wire (a stats route would be new unauthenticated surface leaking traffic
|
|
2074
|
+
// composition to any stranger). Keyspace bounded by the fixed UA_FAMILIES table times
|
|
2075
|
+
// five stage names — an attacker choosing UA strings cannot grow it.
|
|
2076
|
+
const uaStats = new Map();
|
|
2077
|
+
|
|
2078
|
+
function tally(family, stage) {
|
|
2079
|
+
let row = uaStats.get(family);
|
|
2080
|
+
if (!row) { row = new Map(); uaStats.set(family, row); }
|
|
2081
|
+
row.set(stage, (row.get(stage) || 0) + 1);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/** A plain JSON-able copy of the counters: { family: { stage: n } }. */
|
|
2085
|
+
function stats() {
|
|
2086
|
+
const out = {};
|
|
2087
|
+
for (const [family, row] of uaStats) {
|
|
2088
|
+
out[family] = {};
|
|
2089
|
+
for (const [stage, n] of row) out[family][stage] = n;
|
|
2090
|
+
}
|
|
2091
|
+
return out;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/** The steering header for the notice route, or nothing. AI-agent families get a
|
|
2095
|
+
* signpost to the machine-readable door; a browser or curl gets the same bytes it
|
|
2096
|
+
* always got. HEADER-ONLY on purpose: the notice BODY is byte-identical for every
|
|
2097
|
+
* caller, so observation stays invisible on the wire except for this one additive
|
|
2098
|
+
* header — and `rel="service-desc"` is RFC 8631's registered relation for exactly
|
|
2099
|
+
* this ("service description … primarily intended for consumption by machines"). */
|
|
2100
|
+
function steerHeaders(family) {
|
|
2101
|
+
if (!AI_AGENT_FAMILIES.has(family)) return {};
|
|
2102
|
+
return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc"` };
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/** Which stage a finished POST was, from OBSERVABLES only — the request bytes and the
|
|
2106
|
+
* response we are about to return — so the refusal ladder in `handlePost` stays
|
|
2107
|
+
* byte-untouched by observation. A signed reply whose REQUEST carried metadata.sig is
|
|
2108
|
+
* a signed_post; a signed reply for a request without one is the anonymous lane; every
|
|
2109
|
+
* other outcome (413/400/any JSON-RPC error) is refused_post. Must decide identically
|
|
2110
|
+
* to `_post_stage` in examples/agent_entry_reference.py. */
|
|
2111
|
+
function postStage(bodyBuffer, out) {
|
|
2112
|
+
let signedReplyOut = false;
|
|
2113
|
+
try {
|
|
2114
|
+
const body = JSON.parse(out.body.toString('utf8'));
|
|
2115
|
+
const result = (body && typeof body === 'object') ? body.result : null;
|
|
2116
|
+
const meta = (result && typeof result === 'object') ? result.metadata : null;
|
|
2117
|
+
signedReplyOut = Boolean(meta && typeof meta === 'object' && typeof meta.sig === 'string');
|
|
2118
|
+
} catch { signedReplyOut = false; }
|
|
2119
|
+
if (!signedReplyOut) return 'refused_post';
|
|
2120
|
+
let hadSig = false;
|
|
2121
|
+
try {
|
|
2122
|
+
const req = JSON.parse(bodyBuffer.toString('utf8'));
|
|
2123
|
+
const params = (req && typeof req === 'object' && !Array.isArray(req)) ? req.params : null;
|
|
2124
|
+
const msg = (params && typeof params === 'object' && !Array.isArray(params))
|
|
2125
|
+
? params.message : null;
|
|
2126
|
+
const meta = (msg && typeof msg === 'object' && !Array.isArray(msg)) ? msg.metadata : null;
|
|
2127
|
+
hadSig = Boolean(meta && typeof meta === 'object'
|
|
2128
|
+
&& typeof meta.sig === 'string' && meta.sig);
|
|
2129
|
+
} catch { hadSig = false; }
|
|
2130
|
+
return hadSig ? 'signed_post' : 'anon_post';
|
|
2131
|
+
}
|
|
2132
|
+
|
|
1557
2133
|
/** The signed card, re-minted at most hourly. A CONSUMER REJECTS AN ENVELOPE OLDER THAN
|
|
1558
2134
|
* 6h (and one dated in the FUTURE), so this is a freshness window, not a cache tweak:
|
|
1559
2135
|
* without it a saved copy would still "prove" ownership to whoever holds the origin next. */
|
|
@@ -1662,7 +2238,7 @@ export function createAgentEntry({
|
|
|
1662
2238
|
/** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
|
|
1663
2239
|
* code consumes this, so the key set must not drift: a webhook push, a drive-API read
|
|
1664
2240
|
* and an agent entry callback all parse with ONE schema. */
|
|
1665
|
-
function backendEnvelope(msg, { verified, peerDid, ownerDid = null }) {
|
|
2241
|
+
function backendEnvelope(msg, { verified, peerDid, ownerDid = null, wbaDid = null }) {
|
|
1666
2242
|
const meta = msg.metadata || {};
|
|
1667
2243
|
return {
|
|
1668
2244
|
to_agent: name,
|
|
@@ -1674,6 +2250,12 @@ export function createAgentEntry({
|
|
|
1674
2250
|
// belongs to an owner, else null. `peer_did` STAYS the device that signed; sibling
|
|
1675
2251
|
// devices share one owner_did, which is how a merchant reads them as one account.
|
|
1676
2252
|
owner_did: ownerDid,
|
|
2253
|
+
// T107: the DID whose Web Bot Auth signature covered this REQUEST's transport
|
|
2254
|
+
// (@authority + signature-agent), or null. TRANSPORT-LEVEL identification only: it
|
|
2255
|
+
// does not prove the DID wrote `text` — `verified`/`peer_did` do that — and a WBA
|
|
2256
|
+
// header set is replayable until it expires, so it must never be read as
|
|
2257
|
+
// authorship. Additive; null whenever no verifier is configured.
|
|
2258
|
+
wba_did: wbaDid,
|
|
1677
2259
|
peer_name: null,
|
|
1678
2260
|
context_id: msg.contextId ?? null,
|
|
1679
2261
|
text: messageText(msg),
|
|
@@ -1721,7 +2303,7 @@ export function createAgentEntry({
|
|
|
1721
2303
|
* size checks come BEFORE any parsing or crypto — a check placed after the signature is
|
|
1722
2304
|
* a check the attacker simply skips.
|
|
1723
2305
|
*/
|
|
1724
|
-
function handlePost(rawBody) {
|
|
2306
|
+
function handlePost(rawBody, reqHeaders) {
|
|
1725
2307
|
// RAW BYTES, always. A host app that hands us a decoded string has already destroyed
|
|
1726
2308
|
// the evidence the strict decode below exists to find, so normalise once and measure
|
|
1727
2309
|
// the SIZE in bytes rather than in UTF-16 code units.
|
|
@@ -1826,8 +2408,13 @@ export function createAgentEntry({
|
|
|
1826
2408
|
if (!replay.checkAndRemember(msg.messageId)) {
|
|
1827
2409
|
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
1828
2410
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
2411
|
+
// T107: the interesting case — an anonymous inquiry whose TRANSPORT a known key
|
|
2412
|
+
// signed. `verified` STAYS false (the WBA signature covers @authority +
|
|
2413
|
+
// signature-agent, not the text), no ledger row is minted (the header set is a
|
|
2414
|
+
// bearer credential and replayable while it lives), and the anon rate bound
|
|
2415
|
+
// above already applied. Identify, don't enrol.
|
|
2416
|
+
return respond(backendEnvelope(msg, { verified: false, peerDid: null,
|
|
2417
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, '');
|
|
1831
2418
|
}
|
|
1832
2419
|
// 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
|
|
1833
2420
|
// misaddressed message never reaches the base58 decoder.
|
|
@@ -1874,8 +2461,10 @@ export function createAgentEntry({
|
|
|
1874
2461
|
const ownerDid = account !== from ? account : null;
|
|
1875
2462
|
|
|
1876
2463
|
noteContact(account);
|
|
1877
|
-
|
|
1878
|
-
|
|
2464
|
+
// T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
|
|
2465
|
+
// the message signer) — both facts are honest, and the schema says which is which.
|
|
2466
|
+
return respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
|
|
2467
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from);
|
|
1879
2468
|
}
|
|
1880
2469
|
|
|
1881
2470
|
function respond(env, reqId, msg, toDid) {
|
|
@@ -1904,7 +2493,10 @@ export function createAgentEntry({
|
|
|
1904
2493
|
return pathname === mount || pathname === `${mount}/`;
|
|
1905
2494
|
}
|
|
1906
2495
|
|
|
1907
|
-
function route(method, path, bodyBuffer) {
|
|
2496
|
+
function route(method, path, bodyBuffer, headers) {
|
|
2497
|
+
// Classified ONCE per request, used only to count and to signpost. Everything the
|
|
2498
|
+
// ladder decides is decided exactly as if this line did not exist.
|
|
2499
|
+
const family = uaFamily(uaOf(headers));
|
|
1908
2500
|
const target = String(path || '/');
|
|
1909
2501
|
// ORIGIN FORM ONLY, and SAY SO. HTTP/1.1 lets a client write the request-target in
|
|
1910
2502
|
// absolute form (`POST http://elsewhere.example/support HTTP/1.1`) and RFC 9112 §3.2.2
|
|
@@ -1924,13 +2516,21 @@ export function createAgentEntry({
|
|
|
1924
2516
|
if (method === 'GET' || method === 'HEAD') {
|
|
1925
2517
|
if (pathname === mount + AGENT_CARD_PATH || pathname === mount + AGENT_CARD_PATH_LEGACY) {
|
|
1926
2518
|
// Byte-identical on both paths: the current A2A path and the legacy alias.
|
|
2519
|
+
tally(family, 'card_get');
|
|
2520
|
+
// T107: identify (count), never enrol, never change a byte. Runs only after a
|
|
2521
|
+
// route MATCHED, so refused/404 paths never pay for crypto.
|
|
2522
|
+
wbaObserve(headers);
|
|
1927
2523
|
return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
|
|
1928
2524
|
}
|
|
1929
2525
|
if (pathname === mount + AGENT_CARD_SIG_PATH) {
|
|
1930
2526
|
const env = cardEnvelopeBytes();
|
|
2527
|
+
tally(family, 'card_get');
|
|
2528
|
+
wbaObserve(headers);
|
|
1931
2529
|
return { status: 200, headers: cardHeaders(env.length), body: env };
|
|
1932
2530
|
}
|
|
1933
2531
|
if (isMountPath(pathname)) {
|
|
2532
|
+
tally(family, 'notice_get');
|
|
2533
|
+
wbaObserve(headers);
|
|
1934
2534
|
const body = Buffer.from(
|
|
1935
2535
|
// "This ADDRESS", not "this origin": once an entry can be mounted under a
|
|
1936
2536
|
// path, the origin may hold several agents and this notice speaks for exactly
|
|
@@ -1941,7 +2541,10 @@ export function createAgentEntry({
|
|
|
1941
2541
|
'utf8');
|
|
1942
2542
|
return { status: 200,
|
|
1943
2543
|
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
|
1944
|
-
'Content-Length': String(body.length)
|
|
2544
|
+
'Content-Length': String(body.length),
|
|
2545
|
+
// The ONE wire-visible thing observation adds: an AI-agent UA is pointed at
|
|
2546
|
+
// the machine-readable door. The body above is byte-identical either way.
|
|
2547
|
+
...steerHeaders(family) },
|
|
1945
2548
|
body };
|
|
1946
2549
|
}
|
|
1947
2550
|
return jsonResponse(404, { error: 'not found' });
|
|
@@ -1951,7 +2554,15 @@ export function createAgentEntry({
|
|
|
1951
2554
|
// and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
|
|
1952
2555
|
// host, which belongs to the site (or to the neighbour agent) and not to us.
|
|
1953
2556
|
if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
|
|
1954
|
-
|
|
2557
|
+
const buf = bodyBuffer || Buffer.alloc(0);
|
|
2558
|
+
const out = handlePost(buf, headers);
|
|
2559
|
+
// The stage is read off the finished answer, so an async responder tallies when it
|
|
2560
|
+
// resolves. Known micro-skew, accepted: in the misconfigured sync-caller-with-async-
|
|
2561
|
+
// responder case `handleRequest` replaces the thenable with -32603 AFTER this wrap,
|
|
2562
|
+
// so a stage is tallied for a reply that was then replaced. Sample state only.
|
|
2563
|
+
if (isThenable(out)) return out.then((o) => { tally(family, postStage(buf, o)); return o; });
|
|
2564
|
+
tally(family, postStage(buf, out));
|
|
2565
|
+
return out;
|
|
1955
2566
|
}
|
|
1956
2567
|
if (method === 'OPTIONS') {
|
|
1957
2568
|
return { status: 204,
|
|
@@ -1975,7 +2586,7 @@ export function createAgentEntry({
|
|
|
1975
2586
|
* If `responder` returned a Promise, this answers -32603 rather than serializing
|
|
1976
2587
|
* "[object Promise]" into a signed reply — use `handleRequestAsync` for an async responder. */
|
|
1977
2588
|
function handleRequest(method, path, headers, bodyBuffer) {
|
|
1978
|
-
const out = route(method, path, bodyBuffer);
|
|
2589
|
+
const out = route(method, path, bodyBuffer, headers);
|
|
1979
2590
|
if (isThenable(out)) {
|
|
1980
2591
|
return rpcError(null, ERRORS.INTERNAL_ERROR,
|
|
1981
2592
|
'responder is async — serve this agent entry through listen()/handleRequestAsync()');
|
|
@@ -1985,7 +2596,7 @@ export function createAgentEntry({
|
|
|
1985
2596
|
|
|
1986
2597
|
/** Same contract, awaiting an async responder. This is what `listen()` uses. */
|
|
1987
2598
|
async function handleRequestAsync(method, path, headers, bodyBuffer) {
|
|
1988
|
-
return route(method, path, bodyBuffer);
|
|
2599
|
+
return route(method, path, bodyBuffer, headers);
|
|
1989
2600
|
}
|
|
1990
2601
|
|
|
1991
2602
|
/**
|
|
@@ -2063,7 +2674,10 @@ export function createAgentEntry({
|
|
|
2063
2674
|
|
|
2064
2675
|
// `mount` is exported so a host app can route exactly what this entry answers (and log
|
|
2065
2676
|
// it): it is derived, so reading it here can never disagree with the signed card.
|
|
2066
|
-
|
|
2677
|
+
// `stats` is the owner-facing UA-family counters and `wbaVisits` the DID->count of
|
|
2678
|
+
// WBA-verified fetches — both in-process only, like `ledger`.
|
|
2679
|
+
return { did, card, ledger, mount, stats, wbaVisits,
|
|
2680
|
+
handleRequest, handleRequestAsync, listen,
|
|
2067
2681
|
cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
|
|
2068
2682
|
}
|
|
2069
2683
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Make your website answer AI agents: an A2A agent endpoint that verifies who is knocking, opens an account for them and replies signed, in one HTTP round trip. Zero dependencies. Pairs with llms.txt and WebMCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "muretai-agent-entry.mjs",
|