@muretai/agent-entry 1.2.1 → 1.4.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 +51 -1
- package/muretai-agent-entry.mjs +991 -22
- package/package.json +1 -1
package/muretai-agent-entry.mjs
CHANGED
|
@@ -65,6 +65,274 @@ 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 name of the ONE way in this door accepts today — the card's `securitySchemes` key,
|
|
69
|
+
* its `type`, and the `scheme` of the refusal's `accepts[]` entry are all this string. The
|
|
70
|
+
* card and the refusal MUST name the same scheme or a visitor learns one thing from the
|
|
71
|
+
* menu and another from the door. Must match `SIGNED_ENVELOPE_SCHEME` in
|
|
72
|
+
* examples/agent_entry_reference.py. */
|
|
73
|
+
export const SIGNED_ENVELOPE_SCHEME = 'did-key-ed25519';
|
|
74
|
+
|
|
75
|
+
/** The stable link relation that names an agent door, emitted on the notice route (and the
|
|
76
|
+
* one line a SITE adds to its own front page to coexist with an entry — see
|
|
77
|
+
* docs/AGENT_ENTRY.md). An ABSOLUTE URI on purpose: RFC 8288 §2.1.2 allows a bare token
|
|
78
|
+
* only for an IANA-registered relation, so `rel="agent-entry"` would be non-conformant and
|
|
79
|
+
* a strict parser is entitled to drop it. Must match `AGENT_ENTRY_REL` in
|
|
80
|
+
* examples/agent_entry_reference.py. */
|
|
81
|
+
export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
|
|
82
|
+
|
|
83
|
+
/** Where a keyless visitor is sent to learn how to mint an identity and sign. It rides in
|
|
84
|
+
* the card AND in the refusal, so an agent that has only one of the two still has the URL.
|
|
85
|
+
*
|
|
86
|
+
* EMPTY MEANS OMITTED, and that is the safe default. NEVER EMIT A URL THAT DOES NOT
|
|
87
|
+
* RESOLVE: a real third-party agent (2026-08-18 proof run) received the complete
|
|
88
|
+
* requirement object, printed every field of it, went straight to `howTo`, hit a 404 and
|
|
89
|
+
* stopped — "since the provided 'howTo' link is broken, I have no way to get this
|
|
90
|
+
* information" — while holding `identity`, `signedFields`, `canonicalization`,
|
|
91
|
+
* `signature`, `timestamp` and `recipient` in the object it had just printed. A dangling
|
|
92
|
+
* pointer OUT-COMPETES the data beside it and reads as terminal. So the field is emitted
|
|
93
|
+
* only when this constant is set, and it is set only AFTER the page is live: ship the page
|
|
94
|
+
* first, or ship no pointer. Verified live before this value was set (200 at the URL
|
|
95
|
+
* below, 404 at a control path under the same prefix); `entry-howto-resolves` in
|
|
96
|
+
* .claude/skills/ship-check/checks.py re-checks it on every ship report. Must match
|
|
97
|
+
* `FIRST_KNOCK_URL` in examples/agent_entry_reference.py. */
|
|
98
|
+
export const FIRST_KNOCK_URL = 'https://docs.muretai.com/guides/first-knock/';
|
|
99
|
+
|
|
100
|
+
/** `Allow:` per RESOURCE, not per server. RFC 9110 §10.2.1 makes `Allow` a statement about
|
|
101
|
+
* the target resource, and §15.5.6 REQUIRES it on a 405 — a generic list is a wrong answer
|
|
102
|
+
* to a right question, and on a guest mount it would also claim verbs on addresses the SITE
|
|
103
|
+
* owns. HEAD is listed wherever GET is (RFC 9110 §9.3.2 makes it mandatory alongside GET,
|
|
104
|
+
* and both twins have always answered it). Wherever `Allow` is emitted,
|
|
105
|
+
* `Access-Control-Allow-Methods` is set to the SAME value (`allowHeaders`) — the two are
|
|
106
|
+
* one fact for two readers, and a response carrying `Allow: POST, OPTIONS` beside the
|
|
107
|
+
* origin-wide `Access-Control-Allow-Methods: GET, POST, OPTIONS` contradicts itself in one
|
|
108
|
+
* message. Everywhere else (a card GET, a signed reply, a 404) the CORS default stands.
|
|
109
|
+
* Must match the `ALLOW_*` constants in examples/agent_entry_reference.py. */
|
|
110
|
+
export const ALLOW_CARD = 'GET, HEAD, OPTIONS';
|
|
111
|
+
/** A guest mount's door: the entry owns the POST and nothing else there. */
|
|
112
|
+
export const ALLOW_DOOR = 'POST, OPTIONS';
|
|
113
|
+
/** A site-owning mount: ONE address that is both the human notice (GET) and the door (POST),
|
|
114
|
+
* so the truthful Allow is the union — listing only the GET half would hide the very door
|
|
115
|
+
* the card names. */
|
|
116
|
+
export const ALLOW_MOUNT = 'GET, HEAD, POST, OPTIONS';
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The machine-readable terms of THIS door, in one object used on BOTH surfaces: the card's
|
|
120
|
+
* `securitySchemes` entry (before the knock) and the `accepts[]` array of the no-envelope
|
|
121
|
+
* refusal (after it). One object, two surfaces, so the two can never drift apart — the
|
|
122
|
+
* property a visitor's test can assert without knowing what the scheme string is.
|
|
123
|
+
*
|
|
124
|
+
* The shape is x402's lesson turned into an identity vocabulary: a refusal that is the
|
|
125
|
+
* SPECIFICATION of what to do next, as an ARRAY so "sign", "arrive with an introduction"
|
|
126
|
+
* and later "pay" are siblings in one frame rather than three bespoke refusals.
|
|
127
|
+
*
|
|
128
|
+
* Everything a keyless visitor needs is here on purpose — the acceptance bar is that an
|
|
129
|
+
* agent holding ONLY this object, plus the crypto tooling on any machine, can mint a
|
|
130
|
+
* did:key, sign correctly and be answered on its NEXT post. That is why `recipient` is
|
|
131
|
+
* present (nobody can address a message without it) and why the canonicalization rule is
|
|
132
|
+
* spelled out rather than merely named.
|
|
133
|
+
*
|
|
134
|
+
* IT IS WRITTEN IN THE IMPERATIVE, and that is the fix a proof run bought (2026-08-18).
|
|
135
|
+
* Every field above was already correct and complete, and a real third-party agent read
|
|
136
|
+
* them, restated them accurately, and STOPPED: "since I don't have a way to generate
|
|
137
|
+
* cryptographic keys within this environment, I am stuck" — with `node` and `python3`
|
|
138
|
+
* both installed. A requirement object that describes a FORMAT tells a keyless visitor
|
|
139
|
+
* what it lacks; it does not tell it to ACT. So `instruction` leads with the sentence
|
|
140
|
+
* that unblocks a general-purpose agent — MAKE A KEY NOW, nobody's permission is needed,
|
|
141
|
+
* any crypto library will do — and every descriptive string below gained an imperative
|
|
142
|
+
* PREFIX. The old value of each is still a SUBSTRING of the new one, so a client that
|
|
143
|
+
* substring-matched on the format text keeps matching; no field was removed or renamed,
|
|
144
|
+
* and `howTo` is the only one whose presence is conditional (see FIRST_KNOCK_URL).
|
|
145
|
+
*
|
|
146
|
+
* IT TEACHES THE CARRIER AS WELL AS THE SIGNATURE, and that is the fix proof run 3 bought
|
|
147
|
+
* (2026-08-18, `exampleRequest`). The recipe was walked end to end — a third-party agent ran
|
|
148
|
+
* `identityCode`, minted a computed DID, signed the six canonical fields — and TWO runs were
|
|
149
|
+
* still refused, holding a correct key, a correct DID and correct signed bytes, on the A2A
|
|
150
|
+
* ENVELOPE: "not an A2A message object", "messageId must be a non-empty string". `in`
|
|
151
|
+
* ("params.message.metadata") plus six `signedFields` reads as "put these six in metadata",
|
|
152
|
+
* which is exactly what they built. One of them named the gap precisely: "the agent card is
|
|
153
|
+
* ambiguous on the precise nesting of messageId… This is a guess at a format, which goes
|
|
154
|
+
* against the rules." So the complete body a door accepts is emitted BESIDE `in` — the
|
|
155
|
+
* counter-example on the very next line of the object whose reading caused the mistake.
|
|
156
|
+
*
|
|
157
|
+
* `exampleRequest` IS A NESTED OBJECT, NOT A JSON STRING, and the choice is the same law
|
|
158
|
+
* again. A string of JSON arrives on the wire as `{\"jsonrpc\":\"2.0\",…}` — every quote
|
|
159
|
+
* escaped — and every agent in every proof run met this door through `curl`, i.e. reading
|
|
160
|
+
* the RAW response. An escaped document has to be UNESCAPED before it can be copied, which
|
|
161
|
+
* is one more transformation performed in the model's head, which is the precise failure
|
|
162
|
+
* mode base58-as-a-formula already cost two runs. A nested object IS the artifact: it
|
|
163
|
+
* renders as the thing to send, and `JSON.stringify(way.exampleRequest)` is a body. Nesting
|
|
164
|
+
* costs nothing here — the block already sits two levels deep under `securitySchemes`, and
|
|
165
|
+
* `signedFields` is already a non-string value, so no consumer needs a new capability.
|
|
166
|
+
*
|
|
167
|
+
* A copier fills FIVE `<…>` blanks (messageId, text, from, timestamp, sig); `to` is already
|
|
168
|
+
* this door's DID, `contextId` is already `null`, and `id`/`kind`/`role`/`parts` are already
|
|
169
|
+
* correct. `contextId: null` is deliberate and doubles as the answer to "what do I send when
|
|
170
|
+
* I have no conversation yet" — the same clause `canonicalization` now states in words.
|
|
171
|
+
*
|
|
172
|
+
* Must match `signed_envelope_requirement` in examples/agent_entry_reference.py field for
|
|
173
|
+
* field, string for string: the card and the refusal are one object, and two twins that
|
|
174
|
+
* teach a keyless visitor differently are two doors.
|
|
175
|
+
*/
|
|
176
|
+
export function signedEnvelopeRequirement(did) {
|
|
177
|
+
const requirement = {
|
|
178
|
+
scheme: SIGNED_ENVELOPE_SCHEME,
|
|
179
|
+
instruction: 'Generate an Ed25519 keypair NOW, locally, and knock again with it. You '
|
|
180
|
+
+ 'need no account, no registration and no permission from this site: any crypto '
|
|
181
|
+
+ 'library on the machine you are already running on will do (node:crypto '
|
|
182
|
+
+ 'generateKeyPairSync("ed25519"), Python cryptography or PyNaCl, Go crypto/ed25519, '
|
|
183
|
+
+ 'libsodium). Then work down the fields below in order - derive your did:key '
|
|
184
|
+
+ '(identity), build the canonical bytes over signedFields (canonicalization), sign '
|
|
185
|
+
+ 'them (signature), address the message to recipient with a fresh timestamp - and '
|
|
186
|
+
+ 'POST the same request again. Having no key yet is the normal starting state, not '
|
|
187
|
+
+ 'a refusal: minting one is step 1, and it is a few lines of code. The COMPLETE '
|
|
188
|
+
+ 'request body to POST is exampleRequest below: copy it and fill in its five <...> '
|
|
189
|
+
+ 'blanks. The A2A envelope AROUND your signature is checked too, so send that shape '
|
|
190
|
+
+ 'and do not improvise one.',
|
|
191
|
+
in: 'params.message.metadata',
|
|
192
|
+
// THE CARRIER, not just the signature. `in` tells you where the six envelope fields go
|
|
193
|
+
// and says nothing about the message that carries them, and two proof-run agents read it
|
|
194
|
+
// as the whole answer: they put the six in `metadata` and were refused on the A2A object
|
|
195
|
+
// itself ("not an A2A message object", "messageId must be a non-empty string") while
|
|
196
|
+
// holding a correct key and correct signed bytes. This is the body, whole, next to the
|
|
197
|
+
// line that misled them. A NESTED OBJECT on purpose (see the note above): a JSON string
|
|
198
|
+
// of JSON arrives escaped, and an agent reading a raw curl response would have to
|
|
199
|
+
// unescape it in its head before it could copy it.
|
|
200
|
+
exampleRequest: {
|
|
201
|
+
jsonrpc: '2.0',
|
|
202
|
+
id: 1,
|
|
203
|
+
method: 'message/send',
|
|
204
|
+
params: {
|
|
205
|
+
message: {
|
|
206
|
+
kind: 'message',
|
|
207
|
+
role: 'user',
|
|
208
|
+
messageId: '<a fresh unique string, e.g. a UUID>',
|
|
209
|
+
// Already filled in, and it is the answer to "what if I have no conversation
|
|
210
|
+
// yet": null. It is still one of the six signed fields; never omit it.
|
|
211
|
+
contextId: null,
|
|
212
|
+
parts: [{ kind: 'text', text: '<your message>' }],
|
|
213
|
+
metadata: {
|
|
214
|
+
from: '<your did:key>',
|
|
215
|
+
to: did,
|
|
216
|
+
timestamp: '<integer epoch seconds - a JSON number, not this string>',
|
|
217
|
+
sig: '<base64 signature over the canonical six fields>',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
recipient: did,
|
|
223
|
+
identity: 'Derive your DID from the public key you just generated and send it as '
|
|
224
|
+
+ 'metadata.from: did:key:z + base58btc(0xed01 || <32-byte Ed25519 public key>)',
|
|
225
|
+
// RUN this, do not write the address by hand. Measured 2026-08-18: a real agent minted a
|
|
226
|
+
// correct keypair, then "converted" the bytes in its head and produced the DOOR'S OWN DID
|
|
227
|
+
// with two characters changed — three times — and signed correct bytes under an address
|
|
228
|
+
// that was never its own. base58btc was the one step in this recipe stated as a formula
|
|
229
|
+
// instead of made runnable, and it is exactly the step a language model fabricates.
|
|
230
|
+
identityCode: 'Run this; do not encode base58btc by hand. node, no packages, `pub` = the '
|
|
231
|
+
+ '32 raw public-key bytes: '
|
|
232
|
+
+ "const A='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; "
|
|
233
|
+
+ 'let n=0n; for (const b of Buffer.concat([Buffer.from([0xed,0x01]),pub])) '
|
|
234
|
+
+ 'n=n*256n+BigInt(b); '
|
|
235
|
+
+ "let s=''; while (n>0n) { s=A[Number(n%58n)]+s; n/=58n; } "
|
|
236
|
+
+ "const did='did:key:z'+s; // 0xed leads, so no leading-zero '1' case can arise",
|
|
237
|
+
signedFields: ['contextId', 'from', 'messageId', 'text', 'timestamp', 'to'],
|
|
238
|
+
// The trailing clause is proof run 3's third fix: the verifier has always signed
|
|
239
|
+
// `contextId ?? null`, and "exactly those six fields" never said what a visitor with no
|
|
240
|
+
// conversation yet is supposed to put there. One run spent an entire refusal deducing
|
|
241
|
+
// it. The previous value is still a PREFIX of this one, so a substring matcher on the
|
|
242
|
+
// format text keeps matching.
|
|
243
|
+
canonicalization: 'Build the bytes to sign as a JSON object of exactly those six '
|
|
244
|
+
+ 'fields, keys sorted by Unicode code point, separators "," and ":", no whitespace, '
|
|
245
|
+
+ 'non-ASCII literal, UTF-8. When you have no conversation yet, contextId is JSON '
|
|
246
|
+
+ 'null - it is still one of the six and is still signed, so never omit it (see '
|
|
247
|
+
+ 'exampleRequest, which already has it right)',
|
|
248
|
+
signature: 'Sign those bytes with your private key and set metadata.sig = base64 '
|
|
249
|
+
+ '(standard alphabet, padded) of the 64-byte Ed25519 signature over those bytes',
|
|
250
|
+
timestamp: 'Set metadata.timestamp = integer epoch seconds, within 300 s of this '
|
|
251
|
+
+ 'entry\'s clock',
|
|
252
|
+
};
|
|
253
|
+
// Emitted ONLY when it is known to resolve — an unresolvable pointer out-competes every
|
|
254
|
+
// field beside it (see FIRST_KNOCK_URL). Appended last so the object's other bytes and
|
|
255
|
+
// their positions do not move when a site turns the pointer off.
|
|
256
|
+
if (FIRST_KNOCK_URL) requirement.howTo = FIRST_KNOCK_URL;
|
|
257
|
+
return requirement;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The User-Agent FAMILY table — OBSERVATION AND SIGNPOSTING, NEVER IDENTITY. A UA string
|
|
261
|
+
* is written by the client, so nothing here may ever affect `verified`, a ledger row, a
|
|
262
|
+
* rate lane or any refusal verdict (that is the Web Bot Auth / signed-envelope layer's
|
|
263
|
+
* job). What it buys: an owner-facing count of who is knocking (`stats()`), and a `Link`
|
|
264
|
+
* signpost on the notice route for the families that are AI agents.
|
|
265
|
+
*
|
|
266
|
+
* Ordered, FIRST MATCH WINS, and the order is load-bearing twice: real crawler UAs start
|
|
267
|
+
* with "Mozilla/5.0 …" so every bot needle must come before `mozilla`, and GPTBot's UA
|
|
268
|
+
* contains "openai.com/gptbot" so `gptbot` must come before `openai`. Needles are matched
|
|
269
|
+
* as substrings after an ASCII-ONLY lowercase fold (`asciiLower`, not `toLowerCase()` —
|
|
270
|
+
* Unicode case folding differs between runtimes and none of these needles needs it).
|
|
271
|
+
* FIXED table, deliberately not an option: an option would invite making UA matter, and
|
|
272
|
+
* the fixed table is what bounds the stats keyspace — an attacker-chosen UA string must
|
|
273
|
+
* never become a key. Must match `UA_FAMILIES` in examples/agent_entry_reference.py:
|
|
274
|
+
* one contract, two implementations, one verdict per string. */
|
|
275
|
+
export const UA_FAMILIES = [
|
|
276
|
+
['claude-user', 'claude-user'],
|
|
277
|
+
['claudebot', 'claudebot'],
|
|
278
|
+
['gptbot', 'gptbot'],
|
|
279
|
+
['chatgpt-user', 'openai'],
|
|
280
|
+
['openai', 'openai'],
|
|
281
|
+
['perplexity', 'perplexity'],
|
|
282
|
+
['google-extended', 'google-extended'],
|
|
283
|
+
['muretai-node', 'muretai-node'],
|
|
284
|
+
['curl', 'curl'],
|
|
285
|
+
['mozilla', 'browser'],
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
/** The families that read as an AI agent — the ones the notice route signposts with a
|
|
289
|
+
* `Link` header. `muretai-node` is deliberately absent: its Outbox already walks the
|
|
290
|
+
* well-known card paths, so a signpost buys it nothing. Must match the same set in
|
|
291
|
+
* examples/agent_entry_reference.py. */
|
|
292
|
+
export const AI_AGENT_FAMILIES = new Set([
|
|
293
|
+
'claude-user', 'claudebot', 'gptbot', 'openai', 'perplexity', 'google-extended',
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
/** ASCII-only lowercase fold. NOT `toLowerCase()`: Unicode casing is runtime- and
|
|
297
|
+
* locale-shaped (the Turkish-I class of surprise), and no needle in the table needs it —
|
|
298
|
+
* folding only A-Z is what makes the same UA string classify identically in both twins. */
|
|
299
|
+
function asciiLower(s) {
|
|
300
|
+
let out = '';
|
|
301
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
302
|
+
const c = s.charCodeAt(i);
|
|
303
|
+
out += (c >= 65 && c <= 90) ? String.fromCharCode(c + 32) : s[i];
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** UA string -> family. Absent/empty/non-string -> 'none'; no needle matched -> 'other'.
|
|
309
|
+
* Total on untrusted input, and the RETURN VALUE is always one of the twelve fixed
|
|
310
|
+
* family names — never a substring of the input (bounded stats keyspace). */
|
|
311
|
+
export function uaFamily(ua) {
|
|
312
|
+
if (typeof ua !== 'string' || !ua) return 'none';
|
|
313
|
+
const folded = asciiLower(ua);
|
|
314
|
+
for (const [needle, family] of UA_FAMILIES) {
|
|
315
|
+
if (folded.includes(needle)) return family;
|
|
316
|
+
}
|
|
317
|
+
return 'other';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The FIRST User-Agent value out of a headers mapping, or null. Case-insensitive key
|
|
321
|
+
* scan so an in-process host can pass any casing; Node's own `req.headers` already
|
|
322
|
+
* lowercases keys and keeps only the FIRST user-agent of a duplicated pair — the Python
|
|
323
|
+
* twin's `email.Message.get` does the same, which is the parity this relies on. A
|
|
324
|
+
* non-string value (an array, a number) reads as absent, never coerced. */
|
|
325
|
+
function uaOf(headers) {
|
|
326
|
+
if (!headers || typeof headers !== 'object') return null;
|
|
327
|
+
for (const key of Object.keys(headers)) {
|
|
328
|
+
if (asciiLower(key) === 'user-agent') {
|
|
329
|
+
const v = headers[key];
|
|
330
|
+
return typeof v === 'string' ? v : null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
68
336
|
const CARD_ENVELOPE_VERSION = 1;
|
|
69
337
|
const CARD_ENVELOPE_TYPE = 'agentcard';
|
|
70
338
|
|
|
@@ -438,6 +706,382 @@ export function verifyEnvelope(fields, opts = {}) {
|
|
|
438
706
|
}
|
|
439
707
|
}
|
|
440
708
|
|
|
709
|
+
// ================================================================ Web Bot Auth (RFC 9421 subset, verify-only) — T107
|
|
710
|
+
//
|
|
711
|
+
// The INBOUND half only: did the holder of one of the keys this entry was GIVEN sign
|
|
712
|
+
// THIS request, for THIS authority, as a `web-bot-auth` request? It mirrors EXACTLY the
|
|
713
|
+
// subset shared/webbotauth.py::verify_request implements — no more (content digests,
|
|
714
|
+
// @query-param, per-item parameters and every other RFC 9421 feature are refused, not
|
|
715
|
+
// ignored) and no less. The two are pinned to one fixture, testdata/wba_vectors.json:
|
|
716
|
+
// a vector one twin accepts and the other refuses is a red suite. Verification is
|
|
717
|
+
// BYTE-FAITHFUL, not canonical: the signature base is rebuilt from the RECEIVED
|
|
718
|
+
// `@signature-params` text, so a peer who orders or spaces parameters differently still
|
|
719
|
+
// verifies (signing is canonical, verifying is byte-faithful — the shared/jws.py split).
|
|
720
|
+
//
|
|
721
|
+
// One rule governs every caller in this file: WBA never changes a verdict — it only
|
|
722
|
+
// ever ADDS identity (`wba_did` on the backend envelope, a `wbaVisits` count). Absent,
|
|
723
|
+
// invalid, expired, unknown-key and tampered all behave exactly like "no WBA".
|
|
724
|
+
|
|
725
|
+
const WBA_TAG_REQUEST = 'web-bot-auth';
|
|
726
|
+
/** RFC 9421's HTTP-signature-registry name — NOT JOSE's "EdDSA". Same curve, two
|
|
727
|
+
* registries; mixing the spellings is a silent interop failure. */
|
|
728
|
+
const WBA_ALG = 'ed25519';
|
|
729
|
+
/** Tolerance for the peer's clock being ahead, applied to `created` only. */
|
|
730
|
+
const WBA_CLOCK_SKEW = 300;
|
|
731
|
+
/** The loosest accepted `expires - created`: these headers are a bearer credential
|
|
732
|
+
* while they live (webbotauth.REQUEST_SIG_WINDOW + CLOCK_SKEW). */
|
|
733
|
+
const WBA_MAX_REQUEST_LIFETIME = 600;
|
|
734
|
+
/** Refuse to even tokenize an absurd header — bounds parser work on hostile input. */
|
|
735
|
+
const WBA_MAX_HEADER_CHARS = 8192;
|
|
736
|
+
/** Standard base64, padded — what Python's base64.b64decode(validate=True) accepts.
|
|
737
|
+
* Node's Buffer.from(s, 'base64') silently IGNORES invalid characters and tolerates
|
|
738
|
+
* any padding, which is the classic twin-divergence; pre-validating is what keeps one
|
|
739
|
+
* Signature value from being two different byte strings. */
|
|
740
|
+
const WBA_B64_STANDARD = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
741
|
+
/** Unpadded base64url — what shared/jws.unb64url accepts for a JWK `x` ("+", "/" and
|
|
742
|
+
* "=" refused; a length ≡ 1 (mod 4) has no byte decoding). */
|
|
743
|
+
const WBA_B64URL = /^[A-Za-z0-9_-]*$/;
|
|
744
|
+
|
|
745
|
+
/** Serialize an RFC 8941 sf-string: quoted, `\` and `"` escaped — the only two escapes
|
|
746
|
+
* the RFC defines. */
|
|
747
|
+
function wbaSfString(s) {
|
|
748
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Python str.strip()'s default whitespace set, exactly — NOT String.prototype.trim().
|
|
752
|
+
* The two runtimes disagree at the edges (Python also strips \x1c-\x1f and \x85; JS
|
|
753
|
+
* also strips U+FEFF), and a covered header value the twins trim differently is a
|
|
754
|
+
* signature base only one of them can rebuild. */
|
|
755
|
+
const WBA_PY_WS_CLASS = '[\\t\\n\\v\\f\\r \\x1c-\\x1f\\x85\\xa0\\u1680'
|
|
756
|
+
+ '\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]+';
|
|
757
|
+
const WBA_PY_WS = new RegExp(`^${WBA_PY_WS_CLASS}|${WBA_PY_WS_CLASS}$`, 'g');
|
|
758
|
+
function wbaPyStrip(s) {
|
|
759
|
+
return s.replace(WBA_PY_WS, '');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function wbaIsKeyFirst(ch) { return (ch >= 'a' && ch <= 'z') || ch === '*'; }
|
|
763
|
+
function wbaIsKeyRest(ch) {
|
|
764
|
+
return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
|
|
765
|
+
|| ch === '_' || ch === '-' || ch === '.' || ch === '*';
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** An RFC 8941 key (a dictionary label or a parameter name) -> [key, next] or null. */
|
|
769
|
+
function wbaParseKey(s, i) {
|
|
770
|
+
if (i >= s.length || !wbaIsKeyFirst(s[i])) return null;
|
|
771
|
+
let j = i + 1;
|
|
772
|
+
while (j < s.length && wbaIsKeyRest(s[j])) j += 1;
|
|
773
|
+
return [s.slice(i, j), j];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** A quoted sf-string. Only `\"` and `\\` are escapes; every other character must be
|
|
777
|
+
* printable ASCII — rejecting the rest is what keeps one byte string from having two
|
|
778
|
+
* spellings. */
|
|
779
|
+
function wbaParseSfString(s, i) {
|
|
780
|
+
if (i >= s.length || s[i] !== '"') return null;
|
|
781
|
+
i += 1;
|
|
782
|
+
let out = '';
|
|
783
|
+
while (i < s.length) {
|
|
784
|
+
const ch = s[i];
|
|
785
|
+
if (ch === '\\') {
|
|
786
|
+
i += 1;
|
|
787
|
+
if (i >= s.length || (s[i] !== '"' && s[i] !== '\\')) return null;
|
|
788
|
+
out += s[i];
|
|
789
|
+
i += 1;
|
|
790
|
+
} else if (ch === '"') {
|
|
791
|
+
return [out, i + 1];
|
|
792
|
+
} else if (ch >= ' ' && ch <= '~') {
|
|
793
|
+
out += ch;
|
|
794
|
+
i += 1;
|
|
795
|
+
} else {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** An sf-integer: optional `-`, at most 15 ASCII digits (safely inside 2^53). */
|
|
803
|
+
function wbaParseInteger(s, i) {
|
|
804
|
+
let j = i;
|
|
805
|
+
if (j < s.length && s[j] === '-') j += 1;
|
|
806
|
+
let k = j;
|
|
807
|
+
while (k < s.length && s[k] >= '0' && s[k] <= '9') k += 1;
|
|
808
|
+
if (k === j || (k - j) > 15) return null;
|
|
809
|
+
return [parseInt(s.slice(i, k), 10), k];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The only parameter value types in this profile: sf-string and sf-integer. */
|
|
813
|
+
function wbaParseBareItem(s, i) {
|
|
814
|
+
if (i < s.length && s[i] === '"') return wbaParseSfString(s, i);
|
|
815
|
+
return wbaParseInteger(s, i);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** `*( ";" *SP key [ "=" bare-item ] )`. A repeated name is REFUSED rather than
|
|
819
|
+
* last-wins; a valueless parameter is boolean true. */
|
|
820
|
+
function wbaParseParams(s, i) {
|
|
821
|
+
const params = new Map();
|
|
822
|
+
while (i < s.length && s[i] === ';') {
|
|
823
|
+
i += 1;
|
|
824
|
+
while (i < s.length && s[i] === ' ') i += 1;
|
|
825
|
+
const gotKey = wbaParseKey(s, i);
|
|
826
|
+
if (gotKey === null) return null;
|
|
827
|
+
const name = gotKey[0];
|
|
828
|
+
i = gotKey[1];
|
|
829
|
+
if (params.has(name)) return null;
|
|
830
|
+
if (i < s.length && s[i] === '=') {
|
|
831
|
+
const val = wbaParseBareItem(s, i + 1);
|
|
832
|
+
if (val === null) return null;
|
|
833
|
+
params.set(name, val[0]);
|
|
834
|
+
i = val[1];
|
|
835
|
+
} else {
|
|
836
|
+
params.set(name, true);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return [params, i];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** The covered components: `"(" *SP [ sf-string *( 1*SP sf-string ) *SP ] ")"`.
|
|
843
|
+
* Per-item parameters are refused — they change what a component MEANS, and a profile
|
|
844
|
+
* that does not implement them must not silently ignore them. */
|
|
845
|
+
function wbaParseInnerList(s, i) {
|
|
846
|
+
if (i >= s.length || s[i] !== '(') return null;
|
|
847
|
+
i += 1;
|
|
848
|
+
const items = [];
|
|
849
|
+
for (;;) {
|
|
850
|
+
while (i < s.length && s[i] === ' ') i += 1;
|
|
851
|
+
if (i >= s.length) return null;
|
|
852
|
+
if (s[i] === ')') return [items, i + 1];
|
|
853
|
+
const got = wbaParseSfString(s, i);
|
|
854
|
+
if (got === null) return null;
|
|
855
|
+
i = got[1];
|
|
856
|
+
if (i < s.length && s[i] !== ' ' && s[i] !== ')') return null; // incl. ';' per-item
|
|
857
|
+
items.push(got[0]);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function wbaSkipOws(s, i) {
|
|
862
|
+
while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i += 1;
|
|
863
|
+
return i;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Parse a `Signature-Input` value into entries, PRESERVING the raw text of each
|
|
867
|
+
* entry's value — RFC 9421 signs that text, so rebuilding it from the parsed
|
|
868
|
+
* structure would only work for peers who serialize exactly as we do. */
|
|
869
|
+
function wbaParseSignatureInput(value) {
|
|
870
|
+
const s = value;
|
|
871
|
+
const n = s.length;
|
|
872
|
+
let i = wbaSkipOws(s, 0);
|
|
873
|
+
if (i >= n) return null;
|
|
874
|
+
const entries = [];
|
|
875
|
+
for (;;) {
|
|
876
|
+
const gotKey = wbaParseKey(s, i);
|
|
877
|
+
if (gotKey === null) return null;
|
|
878
|
+
const label = gotKey[0];
|
|
879
|
+
i = gotKey[1];
|
|
880
|
+
if (i >= n || s[i] !== '=') return null;
|
|
881
|
+
i += 1;
|
|
882
|
+
const start = i;
|
|
883
|
+
const gotList = wbaParseInnerList(s, i);
|
|
884
|
+
if (gotList === null) return null;
|
|
885
|
+
const components = gotList[0];
|
|
886
|
+
i = gotList[1];
|
|
887
|
+
const gotParams = wbaParseParams(s, i);
|
|
888
|
+
if (gotParams === null) return null;
|
|
889
|
+
const params = gotParams[0];
|
|
890
|
+
i = gotParams[1];
|
|
891
|
+
entries.push({ label, components, params, signatureParams: s.slice(start, i) });
|
|
892
|
+
i = wbaSkipOws(s, i);
|
|
893
|
+
if (i >= n) return entries;
|
|
894
|
+
if (s[i] !== ',') return null;
|
|
895
|
+
i = wbaSkipOws(s, i + 1);
|
|
896
|
+
if (i >= n) return null; // trailing comma
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Parse a `Signature` value: `label=:<standard base64>:` entries. */
|
|
901
|
+
function wbaParseSignature(value) {
|
|
902
|
+
const s = value;
|
|
903
|
+
const n = s.length;
|
|
904
|
+
let i = wbaSkipOws(s, 0);
|
|
905
|
+
if (i >= n) return null;
|
|
906
|
+
const out = [];
|
|
907
|
+
for (;;) {
|
|
908
|
+
const got = wbaParseKey(s, i);
|
|
909
|
+
if (got === null) return null;
|
|
910
|
+
const label = got[0];
|
|
911
|
+
i = got[1];
|
|
912
|
+
if (i + 1 >= n || s[i] !== '=' || s[i + 1] !== ':') return null;
|
|
913
|
+
i += 2;
|
|
914
|
+
const end = s.indexOf(':', i);
|
|
915
|
+
if (end < 0) return null;
|
|
916
|
+
const b64 = s.slice(i, end);
|
|
917
|
+
if (!WBA_B64_STANDARD.test(b64) || b64.length % 4 !== 0) return null;
|
|
918
|
+
const raw = Buffer.from(b64, 'base64');
|
|
919
|
+
i = end + 1;
|
|
920
|
+
if (i < n && s[i] === ';') return null; // parameters on a signature member
|
|
921
|
+
out.push([label, raw]);
|
|
922
|
+
i = wbaSkipOws(s, i);
|
|
923
|
+
if (i >= n) return out;
|
|
924
|
+
if (s[i] !== ',') return null;
|
|
925
|
+
i = wbaSkipOws(s, i + 1);
|
|
926
|
+
if (i >= n) return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The `(Signature-Input, Signature)` pair as verifiable entries, or null. Duplicate
|
|
931
|
+
* labels, a label present in one header but not the other, and every unexpected byte
|
|
932
|
+
* return null — each is a case where two implementations could disagree about what
|
|
933
|
+
* was signed. Never throws. */
|
|
934
|
+
function wbaParseSignatureHeaders(sigInput, sig) {
|
|
935
|
+
try {
|
|
936
|
+
if (typeof sigInput !== 'string' || typeof sig !== 'string') return null;
|
|
937
|
+
if (sigInput.length > WBA_MAX_HEADER_CHARS || sig.length > WBA_MAX_HEADER_CHARS) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const entries = wbaParseSignatureInput(sigInput);
|
|
941
|
+
const sigs = wbaParseSignature(sig);
|
|
942
|
+
if (entries === null || sigs === null) return null;
|
|
943
|
+
const labels = entries.map((e) => e.label);
|
|
944
|
+
if (new Set(labels).size !== labels.length) return null;
|
|
945
|
+
const byLabel = new Map();
|
|
946
|
+
for (const [label, raw] of sigs) {
|
|
947
|
+
if (byLabel.has(label)) return null;
|
|
948
|
+
byLabel.set(label, raw);
|
|
949
|
+
}
|
|
950
|
+
if (byLabel.size !== labels.length) return null;
|
|
951
|
+
for (const label of labels) { if (!byLabel.has(label)) return null; }
|
|
952
|
+
for (const e of entries) e.sig = byLabel.get(e.label);
|
|
953
|
+
return entries;
|
|
954
|
+
} catch {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Case-insensitive header lookup over a plain mapping. A non-string value (an array,
|
|
960
|
+
* a number) reads as absent, never coerced. */
|
|
961
|
+
function wbaHeaderGet(headers, name) {
|
|
962
|
+
if (!headers || typeof headers !== 'object') return null;
|
|
963
|
+
for (const key of Object.keys(headers)) {
|
|
964
|
+
if (asciiLower(key) === name) {
|
|
965
|
+
const v = headers[key];
|
|
966
|
+
return typeof v === 'string' ? v : null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** The 32 raw key bytes of an Ed25519 OKP JWK, or null — the strict gate every
|
|
973
|
+
* untrusted key passes through (mirrors shared/webbotauth.public_from_jwk). */
|
|
974
|
+
function wbaPublicFromJwk(jwk) {
|
|
975
|
+
try {
|
|
976
|
+
if (!jwk || typeof jwk !== 'object' || Array.isArray(jwk)) return null;
|
|
977
|
+
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') return null;
|
|
978
|
+
const x = jwk.x;
|
|
979
|
+
if (typeof x !== 'string') return null;
|
|
980
|
+
if (!WBA_B64URL.test(x) || x.length % 4 === 1) return null;
|
|
981
|
+
const raw = Buffer.from(x, 'base64url');
|
|
982
|
+
return raw.length === 32 ? raw : null;
|
|
983
|
+
} catch {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** RFC 7638 thumbprint of an Ed25519 public key — the `keyid` on the wire. Built from
|
|
989
|
+
* the CANONICAL re-encoding of the key bytes, so a differently-spelled (but valid) `x`
|
|
990
|
+
* still names the same key. The literal member order crv,kty,x IS Python's
|
|
991
|
+
* sort_keys+compact form (x is base64url, so no JSON escaping can differ). */
|
|
992
|
+
function wbaThumbprint(publicRaw) {
|
|
993
|
+
const payload = `{"crv":"Ed25519","kty":"OKP","x":"${publicRaw.toString('base64url')}"}`;
|
|
994
|
+
return createHash('sha256').update(payload, 'utf8').digest('base64url');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Resolve each covered component to the value to re-sign over, or null. `@authority`
|
|
998
|
+
* comes from the VERIFIER (our canonical baseUrl) — never from the message; every
|
|
999
|
+
* other derived component is refused; header values are stripped with Python's set. */
|
|
1000
|
+
function wbaComponentValues(components, authority, headers) {
|
|
1001
|
+
const out = [];
|
|
1002
|
+
const seen = new Set();
|
|
1003
|
+
for (const name of components) {
|
|
1004
|
+
if (typeof name !== 'string' || name !== asciiLower(name) || seen.has(name)) {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
seen.add(name);
|
|
1008
|
+
if (name === '@authority') {
|
|
1009
|
+
out.push([name, authority]);
|
|
1010
|
+
} else if (name.startsWith('@')) {
|
|
1011
|
+
return null;
|
|
1012
|
+
} else {
|
|
1013
|
+
const value = wbaHeaderGet(headers, name);
|
|
1014
|
+
if (value === null) return null;
|
|
1015
|
+
out.push([name, wbaPyStrip(value)]);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return out;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** The exact bytes covered by the signature (RFC 9421 §2.5): one `"name": value` line
|
|
1022
|
+
* per component, then `"@signature-params": <received text>`, LF-joined, no trailing
|
|
1023
|
+
* newline. */
|
|
1024
|
+
function wbaSignatureBase(pairs, paramsText) {
|
|
1025
|
+
const lines = pairs.map(([name, value]) => `${wbaSfString(asciiLower(name))}: ${value}`);
|
|
1026
|
+
lines.push(`${wbaSfString('@signature-params')}: ${paramsText}`);
|
|
1027
|
+
return Buffer.from(lines.join('\n'), 'utf8');
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** One parsed entry, checked end to end against one key. Order matters only for cost:
|
|
1031
|
+
* the cheap policy checks run before the Ed25519 verification. */
|
|
1032
|
+
function wbaEntryVerifies(entry, { keyid, publicRaw, authority, headers, now }) {
|
|
1033
|
+
const params = entry.params;
|
|
1034
|
+
if (params.get('keyid') !== keyid || params.get('tag') !== WBA_TAG_REQUEST) return false;
|
|
1035
|
+
const alg = params.get('alg');
|
|
1036
|
+
if (alg !== undefined && alg !== WBA_ALG) return false;
|
|
1037
|
+
const created = params.get('created');
|
|
1038
|
+
const expires = params.get('expires');
|
|
1039
|
+
if (!Number.isInteger(created) || !Number.isInteger(expires)) return false;
|
|
1040
|
+
if (created > now + WBA_CLOCK_SKEW || now >= expires) return false;
|
|
1041
|
+
if (expires <= created || (expires - created) > WBA_MAX_REQUEST_LIFETIME) return false;
|
|
1042
|
+
const components = entry.components || [];
|
|
1043
|
+
// Without @authority the signature says nothing about WHERE it was served.
|
|
1044
|
+
if (!components.includes('@authority')) return false;
|
|
1045
|
+
const pairs = wbaComponentValues(components, authority, headers);
|
|
1046
|
+
if (pairs === null) return false;
|
|
1047
|
+
return verifyBytes(publicRaw, entry.sig, wbaSignatureBase(pairs, entry.signatureParams));
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** The DID that signed this inbound request, or null. Never throws. `jwks` is a
|
|
1051
|
+
* directory document ({keys:[…]}) already established as trustworthy — who the keys
|
|
1052
|
+
* belong to was decided before this was called (DECISION 2: keys are GIVEN, never
|
|
1053
|
+
* fetched on the hot path). Mirrors shared/webbotauth.verify_request exactly;
|
|
1054
|
+
* testdata/wba_vectors.json holds the two to one verdict per input. */
|
|
1055
|
+
export function wbaVerifyRequest(headers, { authority, jwks, now } = {}) {
|
|
1056
|
+
try {
|
|
1057
|
+
const entries = wbaParseSignatureHeaders(
|
|
1058
|
+
wbaHeaderGet(headers, 'signature-input') || '',
|
|
1059
|
+
wbaHeaderGet(headers, 'signature') || '');
|
|
1060
|
+
if (!entries || !entries.length) return null;
|
|
1061
|
+
const keys = (jwks && typeof jwks === 'object' && !Array.isArray(jwks))
|
|
1062
|
+
? jwks.keys : null;
|
|
1063
|
+
if (!Array.isArray(keys)) return null;
|
|
1064
|
+
const auth = wbaPyStrip(String(authority || '')).toLowerCase();
|
|
1065
|
+
if (!auth) return null;
|
|
1066
|
+
const moment = Math.floor(
|
|
1067
|
+
(now === undefined || now === null) ? Date.now() / 1000 : now);
|
|
1068
|
+
for (const jwk of keys) {
|
|
1069
|
+
const publicRaw = wbaPublicFromJwk(jwk);
|
|
1070
|
+
if (publicRaw === null) continue;
|
|
1071
|
+
const keyid = wbaThumbprint(publicRaw);
|
|
1072
|
+
for (const entry of entries) {
|
|
1073
|
+
if (wbaEntryVerifies(entry, { keyid, publicRaw, authority: auth,
|
|
1074
|
+
headers, now: moment })) {
|
|
1075
|
+
return didFromPublicKeyHex(publicRaw);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return null;
|
|
1080
|
+
} catch {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
441
1085
|
// ================================================================ device-key binding v2 (T102)
|
|
442
1086
|
//
|
|
443
1087
|
// The ACCOUNT layer: a message may carry a countersigned DeviceKeyBinding v2 in
|
|
@@ -903,7 +1547,9 @@ function wireShapeError(msg) {
|
|
|
903
1547
|
|
|
904
1548
|
/** Every response carries these. An agent entry reads NO cookie, header credential or session —
|
|
905
1549
|
* authority comes only from an Ed25519 signature inside the body — so `*` grants a browser
|
|
906
|
-
* agent exactly what curl already had, and nothing more. Never add Allow-Credentials.
|
|
1550
|
+
* agent exactly what curl already had, and nothing more. Never add Allow-Credentials.
|
|
1551
|
+
* `Access-Control-Allow-Methods` is the ORIGIN-WIDE default and is overridden per resource
|
|
1552
|
+
* on any response that also carries `Allow` (see `allowHeaders`). */
|
|
907
1553
|
const CORS_HEADERS = {
|
|
908
1554
|
'Access-Control-Allow-Origin': '*',
|
|
909
1555
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -1477,6 +2123,15 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
1477
2123
|
* /.well-known/did-configuration.json, and a verifier requires both halves.
|
|
1478
2124
|
* basePath ONLY for a proxy that strips the prefix: '' or exactly baseUrl's path.
|
|
1479
2125
|
* See canonicalMount for why this is not a general knob.
|
|
2126
|
+
* guest GUEST MOUNT: the SITE keeps its own front page and this entry claims
|
|
2127
|
+
* only its card paths and the POST door (default false). It requires a
|
|
2128
|
+
* baseUrl WITH a path — the door — because a guest entry at a bare origin
|
|
2129
|
+
* would claim `/`, which is the one thing this mode exists to give back.
|
|
2130
|
+
* Three differences from the ordinary (site-owning) mount, and no others:
|
|
2131
|
+
* the GET notice is not served (the entry never shadows a page the site
|
|
2132
|
+
* owns), the card is ALSO served at the ORIGIN's well-known paths (where a
|
|
2133
|
+
* stranger's agent looks — RFC 8615), and `Allow` on the door names POST
|
|
2134
|
+
* only. `GET /` and `POST /` are not this entry's contract in this mode.
|
|
1480
2135
|
* responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
|
|
1481
2136
|
* openDoor advertise `muretai.open_door` (default true) — the flag that tells a
|
|
1482
2137
|
* visiting agent it may contact you without an introduction.
|
|
@@ -1486,6 +2141,13 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
1486
2141
|
* oracle. Signed senders are not rate-bound here: they are attributable,
|
|
1487
2142
|
* and every one of them is already in the ledger.
|
|
1488
2143
|
* anonRatePerMin anonymous replies per minute for the WHOLE agent entry (default 30).
|
|
2144
|
+
* wbaVerifiers OPTIONAL inbound Web Bot Auth (T107): a JWKS document {keys:[…]} of
|
|
2145
|
+
* Ed25519 keys whose holders this entry should RECOGNISE — the body of
|
|
2146
|
+
* a key directory you verified out of band. Absent (the default) the
|
|
2147
|
+
* feature is entirely off: no header is read, bytes are unchanged.
|
|
2148
|
+
* Recognition only ever ADDS identity (env.wba_did, the wbaVisits
|
|
2149
|
+
* count); it never changes verified, a ledger row, a rate lane or any
|
|
2150
|
+
* refusal verdict.
|
|
1489
2151
|
*/
|
|
1490
2152
|
export function createAgentEntry({
|
|
1491
2153
|
seedHex,
|
|
@@ -1500,7 +2162,9 @@ export function createAgentEntry({
|
|
|
1500
2162
|
skills = [],
|
|
1501
2163
|
domains = null,
|
|
1502
2164
|
basePath = null,
|
|
2165
|
+
guest = false,
|
|
1503
2166
|
maxAccounts = 50000,
|
|
2167
|
+
wbaVerifiers = null,
|
|
1504
2168
|
} = {}) {
|
|
1505
2169
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
1506
2170
|
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
@@ -1512,10 +2176,27 @@ export function createAgentEntry({
|
|
|
1512
2176
|
// the signed card cannot disagree. '' for a bare origin — every route below is then the
|
|
1513
2177
|
// byte-identical string this module always matched.
|
|
1514
2178
|
const mount = canonicalMount(canonUrl, basePath);
|
|
2179
|
+
const guestMount = Boolean(guest);
|
|
2180
|
+
// A guest entry with NO path would claim the origin — `isMountPath('/')` is the door and
|
|
2181
|
+
// the notice both — which is exactly the front page this mode exists to leave alone.
|
|
2182
|
+
// Refuse to start rather than take it: an operator who asked for coexistence and silently
|
|
2183
|
+
// got occupation finds out from their own home page.
|
|
2184
|
+
if (guestMount && !mount) {
|
|
2185
|
+
refuseBaseUrl(canonUrl, 'a guest mount needs a door path, and this url has none.',
|
|
2186
|
+
'A guest entry leaves GET / to the site and answers at a path beside it, so the '
|
|
2187
|
+
+ 'address it publishes must name that path — the mount, the card url and the POST '
|
|
2188
|
+
+ 'door are then one string by construction. Give the door in baseUrl (the card names '
|
|
2189
|
+
+ 'it, so a visitor that read the card posts to the right place with no other '
|
|
2190
|
+
+ 'knowledge), or drop `guest` and let this entry own its origin.',
|
|
2191
|
+
`${canonUrl}/agent`);
|
|
2192
|
+
}
|
|
1515
2193
|
// The agent half of a T88 domain binding. Refuses to start on anything that is not a
|
|
1516
2194
|
// bare domain: a name the domain's credential can never bind is not worth publishing.
|
|
1517
2195
|
const canonDomains = canonicalDomains(domains);
|
|
1518
2196
|
const did = didFromSeedHex(seedHex);
|
|
2197
|
+
// The terms of this door, built ONCE: the card publishes it (E1, before the knock) and
|
|
2198
|
+
// the no-envelope refusal returns the same object (E2, after it).
|
|
2199
|
+
const requirement = signedEnvelopeRequirement(did);
|
|
1519
2200
|
|
|
1520
2201
|
const card = {
|
|
1521
2202
|
protocolVersion: PROTOCOL_VERSION,
|
|
@@ -1538,22 +2219,184 @@ export function createAgentEntry({
|
|
|
1538
2219
|
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
1539
2220
|
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
1540
2221
|
// reads is worse than advertising none — mail would queue at the relay forever.
|
|
2222
|
+
//
|
|
2223
|
+
// THE DOOR'S TERMS, IN STANDARD A2A SHAPE (E1). `securitySchemes` + `security` are the
|
|
2224
|
+
// fields A2A has for exactly this, and until now both were absent — so a card that
|
|
2225
|
+
// advertised a skill said nothing at all about HOW to call it, and the only way to learn
|
|
2226
|
+
// the requirement was to knock and be refused. That is the discovery step an HTTP-402
|
|
2227
|
+
// style protocol structurally cannot have and a card CAN; publishing it is what turns the
|
|
2228
|
+
// refusal below into a fallback instead of the only teacher.
|
|
2229
|
+
//
|
|
2230
|
+
// APPENDED LAST so every field an already-deployed entry publishes keeps its bytes AND
|
|
2231
|
+
// its position. `type` is deliberately NOT one of OpenAPI's five: `http` would claim an
|
|
2232
|
+
// `Authorization` header this door never reads, and a standard-but-wrong type makes a
|
|
2233
|
+
// conformant client do the wrong thing, where an unknown one makes it read the block
|
|
2234
|
+
// beside it. The scheme is named three times (key, `type`, `muretai.scheme`) and every
|
|
2235
|
+
// spelling is the same string the refusal returns.
|
|
2236
|
+
//
|
|
2237
|
+
// The description is IMPERATIVE for the same reason the requirement object is: a real
|
|
2238
|
+
// agent that started here read the terms, concluded correctly that it could not sign,
|
|
2239
|
+
// and stopped — "the card does not offer an alternative communication method". A card
|
|
2240
|
+
// that states a requirement and nothing about how to come to meet it is a locked door
|
|
2241
|
+
// with the lock documented. The worked-example clause appears only when the page it
|
|
2242
|
+
// names is known to resolve (see FIRST_KNOCK_URL); a 404 here would out-compete the
|
|
2243
|
+
// block beside it.
|
|
2244
|
+
card.securitySchemes = {
|
|
2245
|
+
[SIGNED_ENVELOPE_SCHEME]: {
|
|
2246
|
+
type: SIGNED_ENVELOPE_SCHEME,
|
|
2247
|
+
description: 'Generate an Ed25519 keypair if you do not have one - no account, no '
|
|
2248
|
+
+ 'registration, any crypto library will do - then sign every message/send with '
|
|
2249
|
+
+ 'the did:key you present as metadata.from. The step-by-step instruction, the '
|
|
2250
|
+
+ 'signed fields, the canonical bytes and the address to send to are in `muretai` '
|
|
2251
|
+
+ 'beside this line'
|
|
2252
|
+
+ (FIRST_KNOCK_URL ? `; a worked example is at ${FIRST_KNOCK_URL}` : ''),
|
|
2253
|
+
muretai: requirement,
|
|
2254
|
+
},
|
|
2255
|
+
};
|
|
2256
|
+
card.security = [{ [SIGNED_ENVELOPE_SCHEME]: [] }];
|
|
1541
2257
|
|
|
1542
2258
|
const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
|
|
1543
2259
|
// ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
|
|
1544
2260
|
// 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.
|
|
2261
|
+
// sibling devices are ONE customer row. The entry never reads it back to gate, greet, or
|
|
2262
|
+
// rate-limit, so it runs fine unpersisted — but keeping it in the site's own store is
|
|
2263
|
+
// RECOMMENDED: it is the customer list (recognise a returning account, contact it again
|
|
2264
|
+
// later). An analytics sink records visits too, but can never be read back.
|
|
1546
2265
|
const ledger = new Map();
|
|
1547
2266
|
// device DID -> owner DID, the in-process TOFU pin (T102). The first VALID binding pins a
|
|
1548
2267
|
// 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.
|
|
2268
|
+
// refused. Per-process on purpose for v1.5 — persisting it (and the fold) is RECOMMENDED,
|
|
2269
|
+
// not required: without it the conflict rule resets to trust-on-first-use every restart.
|
|
2270
|
+
// Unlike the ledger it is READ on every message, so only a real store can carry it.
|
|
1551
2271
|
const deviceOwner = new Map();
|
|
1552
2272
|
const replay = new ReplayGuard();
|
|
1553
2273
|
const anonRate = new RateBound(anonRatePerMin);
|
|
1554
2274
|
let sigEnvelope = null;
|
|
1555
2275
|
let sigMintedAt = 0;
|
|
1556
2276
|
|
|
2277
|
+
// T107: inbound Web Bot Auth, verify-only, against keys GIVEN at construction — the
|
|
2278
|
+
// entry never fetches a directory on the hot path (network-free while answering).
|
|
2279
|
+
// Refuse-to-start posture, house style: a key the entry can never match is config the
|
|
2280
|
+
// operator believes protects them and does not.
|
|
2281
|
+
let wbaKeys = null;
|
|
2282
|
+
let wbaAuthority = null;
|
|
2283
|
+
// DID -> count of WBA-verified GET/HEAD fetches. DECISION 1: a signed GET IDENTIFIES
|
|
2284
|
+
// but never ENROLS — a crawler fetching 10,000 pages mints zero ledger rows; this
|
|
2285
|
+
// count is bounded by the configured key list, never by attacker choice. Exposed on
|
|
2286
|
+
// the returned object like `ledger` (in-process sample state, never on the wire).
|
|
2287
|
+
const wbaVisits = new Map();
|
|
2288
|
+
if (wbaVerifiers !== null && wbaVerifiers !== undefined) {
|
|
2289
|
+
const keys = (wbaVerifiers && typeof wbaVerifiers === 'object'
|
|
2290
|
+
&& !Array.isArray(wbaVerifiers)) ? wbaVerifiers.keys : null;
|
|
2291
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
2292
|
+
throw new TypeError('createAgentEntry: wbaVerifiers must be a JWKS document '
|
|
2293
|
+
+ '{keys:[…]} — the key-directory body you verified out of band');
|
|
2294
|
+
}
|
|
2295
|
+
if (keys.length > 64) {
|
|
2296
|
+
throw new TypeError(`createAgentEntry: wbaVerifiers holds ${keys.length} keys — `
|
|
2297
|
+
+ 'more than 64 is not a verifier list, it is a directory dump');
|
|
2298
|
+
}
|
|
2299
|
+
keys.forEach((jwk, i) => {
|
|
2300
|
+
if (wbaPublicFromJwk(jwk) === null) {
|
|
2301
|
+
throw new TypeError(`createAgentEntry: wbaVerifiers.keys[${i}] is not an `
|
|
2302
|
+
+ 'Ed25519 OKP JWK (kty "OKP", crv "Ed25519", x = unpadded base64url of '
|
|
2303
|
+
+ '32 bytes)');
|
|
2304
|
+
}
|
|
2305
|
+
});
|
|
2306
|
+
wbaKeys = { keys: keys.map((k) => ({ kty: k.kty, crv: k.crv, x: k.x })) };
|
|
2307
|
+
// @authority derives from the CANONICAL baseUrl, NEVER a Host header — a header a
|
|
2308
|
+
// client can set is not a fact about where we were reached. canonUrl already
|
|
2309
|
+
// lowercased the host and stripped the scheme's default port, so URL.host IS the
|
|
2310
|
+
// RFC 9421 authority (shared/webbotauth.authority_of computes the same string).
|
|
2311
|
+
wbaAuthority = new URL(canonUrl).host;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
/** The WBA-verified caller DID for this request's headers, or null. Total on hostile
|
|
2315
|
+
* input; costs one keyid comparison per configured key and an Ed25519 verify only on
|
|
2316
|
+
* a keyid match. */
|
|
2317
|
+
function wbaIdentify(headers) {
|
|
2318
|
+
if (!wbaKeys) return null;
|
|
2319
|
+
return wbaVerifyRequest(headers, { authority: wbaAuthority, jwks: wbaKeys });
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
function wbaObserve(headers) {
|
|
2323
|
+
const did = wbaIdentify(headers);
|
|
2324
|
+
if (did) wbaVisits.set(did, (wbaVisits.get(did) || 0) + 1);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// family -> stage -> count. OBSERVATION ONLY, and out-of-contract sample state like the
|
|
2328
|
+
// ledger's row shape: `stats()` is how a site owner sees who is knocking, it is never
|
|
2329
|
+
// served on the wire (a stats route would be new unauthenticated surface leaking traffic
|
|
2330
|
+
// composition to any stranger). Keyspace bounded by the fixed UA_FAMILIES table times
|
|
2331
|
+
// five stage names — an attacker choosing UA strings cannot grow it.
|
|
2332
|
+
const uaStats = new Map();
|
|
2333
|
+
|
|
2334
|
+
function tally(family, stage) {
|
|
2335
|
+
let row = uaStats.get(family);
|
|
2336
|
+
if (!row) { row = new Map(); uaStats.set(family, row); }
|
|
2337
|
+
row.set(stage, (row.get(stage) || 0) + 1);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/** A plain JSON-able copy of the counters: { family: { stage: n } }. */
|
|
2341
|
+
function stats() {
|
|
2342
|
+
const out = {};
|
|
2343
|
+
for (const [family, row] of uaStats) {
|
|
2344
|
+
out[family] = {};
|
|
2345
|
+
for (const [stage, n] of row) out[family][stage] = n;
|
|
2346
|
+
}
|
|
2347
|
+
return out;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** The `Link` header the notice route carries. TWO relations with different audiences,
|
|
2351
|
+
* in ONE header field (RFC 8288 allows several link-values in one field, and one field
|
|
2352
|
+
* is what keeps the two twins' bytes identical through their single-header plumbing):
|
|
2353
|
+
*
|
|
2354
|
+
* - the DOOR pointer, `rel="https://muretai.net/rel/agent-entry"`, for EVERY caller.
|
|
2355
|
+
* This is the coexistence primitive (E4): an agent that fetched a page finds the
|
|
2356
|
+
* machine-readable door in the RESPONSE, with no HTML to parse and no prose to
|
|
2357
|
+
* read, and a browser ignores it — which is what lets a site keep its own front
|
|
2358
|
+
* page and add ONE header instead of migrating. An absolute URI because RFC 8288
|
|
2359
|
+
* §2.1.2 permits a bare token only for an IANA-registered relation.
|
|
2360
|
+
* - `rel="service-desc"` (RFC 8631's registered relation for "service description …
|
|
2361
|
+
* primarily intended for consumption by machines"), FIRST and only for the UA
|
|
2362
|
+
* families that read as an AI agent — the T119 signpost, unchanged in meaning.
|
|
2363
|
+
*
|
|
2364
|
+
* HEADER-ONLY on purpose: the notice BODY is byte-identical for every caller, so what
|
|
2365
|
+
* the UA changes is still only this one additive relation and never a verdict. */
|
|
2366
|
+
function steerHeaders(family) {
|
|
2367
|
+
const door = `<${mount}${AGENT_CARD_PATH}>; rel="${AGENT_ENTRY_REL}"`;
|
|
2368
|
+
if (!AI_AGENT_FAMILIES.has(family)) return { Link: door };
|
|
2369
|
+
return { Link: `<${mount}${AGENT_CARD_PATH}>; rel="service-desc", ${door}` };
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** Which stage a finished POST was, from OBSERVABLES only — the request bytes and the
|
|
2373
|
+
* response we are about to return — so the refusal ladder in `handlePost` stays
|
|
2374
|
+
* byte-untouched by observation. A signed reply whose REQUEST carried metadata.sig is
|
|
2375
|
+
* a signed_post; a signed reply for a request without one is the anonymous lane; every
|
|
2376
|
+
* other outcome (413/400/any JSON-RPC error) is refused_post. Must decide identically
|
|
2377
|
+
* to `_post_stage` in examples/agent_entry_reference.py. */
|
|
2378
|
+
function postStage(bodyBuffer, out) {
|
|
2379
|
+
let signedReplyOut = false;
|
|
2380
|
+
try {
|
|
2381
|
+
const body = JSON.parse(out.body.toString('utf8'));
|
|
2382
|
+
const result = (body && typeof body === 'object') ? body.result : null;
|
|
2383
|
+
const meta = (result && typeof result === 'object') ? result.metadata : null;
|
|
2384
|
+
signedReplyOut = Boolean(meta && typeof meta === 'object' && typeof meta.sig === 'string');
|
|
2385
|
+
} catch { signedReplyOut = false; }
|
|
2386
|
+
if (!signedReplyOut) return 'refused_post';
|
|
2387
|
+
let hadSig = false;
|
|
2388
|
+
try {
|
|
2389
|
+
const req = JSON.parse(bodyBuffer.toString('utf8'));
|
|
2390
|
+
const params = (req && typeof req === 'object' && !Array.isArray(req)) ? req.params : null;
|
|
2391
|
+
const msg = (params && typeof params === 'object' && !Array.isArray(params))
|
|
2392
|
+
? params.message : null;
|
|
2393
|
+
const meta = (msg && typeof msg === 'object' && !Array.isArray(msg)) ? msg.metadata : null;
|
|
2394
|
+
hadSig = Boolean(meta && typeof meta === 'object'
|
|
2395
|
+
&& typeof meta.sig === 'string' && meta.sig);
|
|
2396
|
+
} catch { hadSig = false; }
|
|
2397
|
+
return hadSig ? 'signed_post' : 'anon_post';
|
|
2398
|
+
}
|
|
2399
|
+
|
|
1557
2400
|
/** The signed card, re-minted at most hourly. A CONSUMER REJECTS AN ENVELOPE OLDER THAN
|
|
1558
2401
|
* 6h (and one dated in the FUTURE), so this is a freshness window, not a cache tweak:
|
|
1559
2402
|
* without it a saved copy would still "prove" ownership to whoever holds the origin next. */
|
|
@@ -1662,7 +2505,7 @@ export function createAgentEntry({
|
|
|
1662
2505
|
/** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
|
|
1663
2506
|
* code consumes this, so the key set must not drift: a webhook push, a drive-API read
|
|
1664
2507
|
* and an agent entry callback all parse with ONE schema. */
|
|
1665
|
-
function backendEnvelope(msg, { verified, peerDid, ownerDid = null }) {
|
|
2508
|
+
function backendEnvelope(msg, { verified, peerDid, ownerDid = null, wbaDid = null }) {
|
|
1666
2509
|
const meta = msg.metadata || {};
|
|
1667
2510
|
return {
|
|
1668
2511
|
to_agent: name,
|
|
@@ -1674,6 +2517,12 @@ export function createAgentEntry({
|
|
|
1674
2517
|
// belongs to an owner, else null. `peer_did` STAYS the device that signed; sibling
|
|
1675
2518
|
// devices share one owner_did, which is how a merchant reads them as one account.
|
|
1676
2519
|
owner_did: ownerDid,
|
|
2520
|
+
// T107: the DID whose Web Bot Auth signature covered this REQUEST's transport
|
|
2521
|
+
// (@authority + signature-agent), or null. TRANSPORT-LEVEL identification only: it
|
|
2522
|
+
// does not prove the DID wrote `text` — `verified`/`peer_did` do that — and a WBA
|
|
2523
|
+
// header set is replayable until it expires, so it must never be read as
|
|
2524
|
+
// authorship. Additive; null whenever no verifier is configured.
|
|
2525
|
+
wba_did: wbaDid,
|
|
1677
2526
|
peer_name: null,
|
|
1678
2527
|
context_id: msg.contextId ?? null,
|
|
1679
2528
|
text: messageText(msg),
|
|
@@ -1721,7 +2570,7 @@ export function createAgentEntry({
|
|
|
1721
2570
|
* size checks come BEFORE any parsing or crypto — a check placed after the signature is
|
|
1722
2571
|
* a check the attacker simply skips.
|
|
1723
2572
|
*/
|
|
1724
|
-
function handlePost(rawBody) {
|
|
2573
|
+
function handlePost(rawBody, reqHeaders) {
|
|
1725
2574
|
// RAW BYTES, always. A host app that hands us a decoded string has already destroyed
|
|
1726
2575
|
// the evidence the strict decode below exists to find, so normalise once and measure
|
|
1727
2576
|
// the SIZE in bytes rather than in UTF-16 code units.
|
|
@@ -1806,6 +2655,23 @@ export function createAgentEntry({
|
|
|
1806
2655
|
if (!from || !to || !sig) {
|
|
1807
2656
|
const bare = !from && !to && !sig;
|
|
1808
2657
|
if (!(anonymousLane && bare)) {
|
|
2658
|
+
// THE REFUSAL TEACHES (E2) — but only the keyless walk-in. `data` keeps the human
|
|
2659
|
+
// string it always carried, under `detail`, and gains `accepts[]`: the ways in,
|
|
2660
|
+
// as an array, each naming its scheme. A visitor that has only this can mint a
|
|
2661
|
+
// did:key, sign the six fields and be answered on its next POST — which is the
|
|
2662
|
+
// whole point, because refusing an agent for not having a key it was never told
|
|
2663
|
+
// how to make is the error, not the key.
|
|
2664
|
+
//
|
|
2665
|
+
// A PARTIAL envelope (from/to present, sig stripped) gets the old refusal
|
|
2666
|
+
// unchanged. It is a DOWNGRADE ATTEMPT, not a walk-in: whoever sent it already
|
|
2667
|
+
// holds a key and already knows the shape, so there is nothing to teach and no
|
|
2668
|
+
// reason to hand a prober a machine-readable map of what to try next.
|
|
2669
|
+
if (bare) {
|
|
2670
|
+
return rpcError(reqId, ERRORS.UNAUTHENTICATED, {
|
|
2671
|
+
detail: 'missing signing envelope (from/to/sig)',
|
|
2672
|
+
accepts: [requirement],
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
1809
2675
|
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'missing signing envelope (from/to/sig)');
|
|
1810
2676
|
}
|
|
1811
2677
|
// Anonymous: answer, signed by us, addressed to nobody. NO ledger row — an
|
|
@@ -1826,8 +2692,13 @@ export function createAgentEntry({
|
|
|
1826
2692
|
if (!replay.checkAndRemember(msg.messageId)) {
|
|
1827
2693
|
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
1828
2694
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
2695
|
+
// T107: the interesting case — an anonymous inquiry whose TRANSPORT a known key
|
|
2696
|
+
// signed. `verified` STAYS false (the WBA signature covers @authority +
|
|
2697
|
+
// signature-agent, not the text), no ledger row is minted (the header set is a
|
|
2698
|
+
// bearer credential and replayable while it lives), and the anon rate bound
|
|
2699
|
+
// above already applied. Identify, don't enrol.
|
|
2700
|
+
return respond(backendEnvelope(msg, { verified: false, peerDid: null,
|
|
2701
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, '');
|
|
1831
2702
|
}
|
|
1832
2703
|
// 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
|
|
1833
2704
|
// misaddressed message never reaches the base58 decoder.
|
|
@@ -1874,8 +2745,10 @@ export function createAgentEntry({
|
|
|
1874
2745
|
const ownerDid = account !== from ? account : null;
|
|
1875
2746
|
|
|
1876
2747
|
noteContact(account);
|
|
1877
|
-
|
|
1878
|
-
|
|
2748
|
+
// T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
|
|
2749
|
+
// the message signer) — both facts are honest, and the schema says which is which.
|
|
2750
|
+
return respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
|
|
2751
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from);
|
|
1879
2752
|
}
|
|
1880
2753
|
|
|
1881
2754
|
function respond(env, reqId, msg, toDid) {
|
|
@@ -1904,7 +2777,48 @@ export function createAgentEntry({
|
|
|
1904
2777
|
return pathname === mount || pathname === `${mount}/`;
|
|
1905
2778
|
}
|
|
1906
2779
|
|
|
1907
|
-
|
|
2780
|
+
/** The GET routes this entry owns, built ONCE from the mount so a request path is a
|
|
2781
|
+
* lookup and never string arithmetic. On a GUEST mount the card is served at the
|
|
2782
|
+
* ORIGIN's well-known paths TOO — that is the only address a stranger's agent knows to
|
|
2783
|
+
* try (RFC 8615), and a door nobody can find is not a door. The card's `url` still names
|
|
2784
|
+
* the DOOR, which is ordinary A2A: the well-known location is where a card is
|
|
2785
|
+
* DISCOVERED, not the endpoint it describes. */
|
|
2786
|
+
const CARD_ROUTES = new Set([mount + AGENT_CARD_PATH, mount + AGENT_CARD_PATH_LEGACY]);
|
|
2787
|
+
const SIG_ROUTES = new Set([mount + AGENT_CARD_SIG_PATH]);
|
|
2788
|
+
if (guestMount) {
|
|
2789
|
+
CARD_ROUTES.add(AGENT_CARD_PATH).add(AGENT_CARD_PATH_LEGACY);
|
|
2790
|
+
SIG_ROUTES.add(AGENT_CARD_SIG_PATH);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/** What `Allow:` may truthfully say about THIS path, or null when the entry does not own
|
|
2794
|
+
* it at all. RFC 9110 §10.2.1 makes `Allow` a statement about the target RESOURCE, so a
|
|
2795
|
+
* single server-wide list is a wrong answer to a right question — and on a guest mount
|
|
2796
|
+
* it would be worse than wrong: answering for `/` at all, even with a 204 and a header,
|
|
2797
|
+
* is speaking for the site's own front page, which is the one thing a guest mount must
|
|
2798
|
+
* never do. Whatever the GET route does for an address we do not own (404), OPTIONS and
|
|
2799
|
+
* the method table do the same. */
|
|
2800
|
+
function allowFor(pathname) {
|
|
2801
|
+
if (CARD_ROUTES.has(pathname) || SIG_ROUTES.has(pathname)) return ALLOW_CARD;
|
|
2802
|
+
if (isMountPath(pathname)) return guestMount ? ALLOW_DOOR : ALLOW_MOUNT;
|
|
2803
|
+
return null;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/** The headers that state a resource's methods — BOTH spellings, always the same value.
|
|
2807
|
+
* `Allow` and `Access-Control-Allow-Methods` answer the same question for two different
|
|
2808
|
+
* readers, and a response that says `Allow: POST, OPTIONS` beside
|
|
2809
|
+
* `Access-Control-Allow-Methods: GET, POST, OPTIONS` contradicts itself in one message:
|
|
2810
|
+
* a browser-resident agent preflighting the guest door was told GET was on the menu at
|
|
2811
|
+
* the exact address whose GET is 405. `CORS_HEADERS` stays the origin-wide default
|
|
2812
|
+
* everywhere else (a card GET, a signed reply, a 404); it is narrowed only where the
|
|
2813
|
+
* resource's real method list is known, which is exactly where `Allow` is emitted. */
|
|
2814
|
+
function allowHeaders(allow) {
|
|
2815
|
+
return { Allow: allow, 'Access-Control-Allow-Methods': allow };
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
function route(method, path, bodyBuffer, headers) {
|
|
2819
|
+
// Classified ONCE per request, used only to count and to signpost. Everything the
|
|
2820
|
+
// ladder decides is decided exactly as if this line did not exist.
|
|
2821
|
+
const family = uaFamily(uaOf(headers));
|
|
1908
2822
|
const target = String(path || '/');
|
|
1909
2823
|
// ORIGIN FORM ONLY, and SAY SO. HTTP/1.1 lets a client write the request-target in
|
|
1910
2824
|
// absolute form (`POST http://elsewhere.example/support HTTP/1.1`) and RFC 9112 §3.2.2
|
|
@@ -1922,15 +2836,29 @@ export function createAgentEntry({
|
|
|
1922
2836
|
// appends a well-known path), so this is not a new convention; it is the one the
|
|
1923
2837
|
// fetcher already follows. With mount === '' these are the original constants.
|
|
1924
2838
|
if (method === 'GET' || method === 'HEAD') {
|
|
1925
|
-
if (pathname
|
|
1926
|
-
// Byte-identical on
|
|
2839
|
+
if (CARD_ROUTES.has(pathname)) {
|
|
2840
|
+
// Byte-identical on every path: the current A2A path, the legacy alias, and (on a
|
|
2841
|
+
// guest mount) the origin's well-known copy of both. Which address a client
|
|
2842
|
+
// happened to fetch must never change what it believes about this DID.
|
|
2843
|
+
tally(family, 'card_get');
|
|
2844
|
+
// T107: identify (count), never enrol, never change a byte. Runs only after a
|
|
2845
|
+
// route MATCHED, so refused/404 paths never pay for crypto.
|
|
2846
|
+
wbaObserve(headers);
|
|
1927
2847
|
return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
|
|
1928
2848
|
}
|
|
1929
|
-
if (pathname
|
|
2849
|
+
if (SIG_ROUTES.has(pathname)) {
|
|
1930
2850
|
const env = cardEnvelopeBytes();
|
|
2851
|
+
tally(family, 'card_get');
|
|
2852
|
+
wbaObserve(headers);
|
|
1931
2853
|
return { status: 200, headers: cardHeaders(env.length), body: env };
|
|
1932
2854
|
}
|
|
1933
|
-
|
|
2855
|
+
// The human notice — NOT served on a guest mount, where GET belongs to the site (E3).
|
|
2856
|
+
// Falling through to 404 is deliberate and is the same non-disclosure the POST route
|
|
2857
|
+
// already makes: an entry beside other agents does not confirm what lives at an
|
|
2858
|
+
// address it was not given.
|
|
2859
|
+
if (!guestMount && isMountPath(pathname)) {
|
|
2860
|
+
tally(family, 'notice_get');
|
|
2861
|
+
wbaObserve(headers);
|
|
1934
2862
|
const body = Buffer.from(
|
|
1935
2863
|
// "This ADDRESS", not "this origin": once an entry can be mounted under a
|
|
1936
2864
|
// path, the origin may hold several agents and this notice speaks for exactly
|
|
@@ -1941,9 +2869,28 @@ export function createAgentEntry({
|
|
|
1941
2869
|
'utf8');
|
|
1942
2870
|
return { status: 200,
|
|
1943
2871
|
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
|
1944
|
-
'Content-Length': String(body.length)
|
|
2872
|
+
'Content-Length': String(body.length),
|
|
2873
|
+
// The ONE wire-visible thing observation adds: an AI-agent UA is pointed at
|
|
2874
|
+
// the machine-readable door. The body above is byte-identical either way.
|
|
2875
|
+
...steerHeaders(family) },
|
|
1945
2876
|
body };
|
|
1946
2877
|
}
|
|
2878
|
+
// A GUEST MOUNT'S DOOR ANSWERS GET WITH 405, NOT 404 (proof run 3, 2026-08-18).
|
|
2879
|
+
// Run A guessed the door path CORRECTLY, GET it, and was told nothing was there —
|
|
2880
|
+
// its one correct guess, refuted. This is NOT the
|
|
2881
|
+
// DECISION(non-door-post-answers-404-not-405) non-disclosure case, and the
|
|
2882
|
+
// difference is where the address came from: that decision protects a path a caller
|
|
2883
|
+
// GUESSED AT RANDOM, where a 405 would confirm a door it has no right to know about.
|
|
2884
|
+
// The door's address is PUBLISHED, in the card, signed, at a well-known path — so
|
|
2885
|
+
// hiding it from a GET conceals nothing from anyone and costs a visitor the one
|
|
2886
|
+
// thing it got right. `allowFor` is non-null here only for the guest door: card and
|
|
2887
|
+
// signature routes matched above, and a site-owning mount already returned its
|
|
2888
|
+
// notice. A path this entry does not own still falls through to 404, and a guest
|
|
2889
|
+
// mount still answers nothing it does not own.
|
|
2890
|
+
const getAllow = allowFor(pathname);
|
|
2891
|
+
if (getAllow !== null) {
|
|
2892
|
+
return jsonResponse(405, { error: 'method not allowed' }, allowHeaders(getAllow));
|
|
2893
|
+
}
|
|
1947
2894
|
return jsonResponse(404, { error: 'not found' });
|
|
1948
2895
|
}
|
|
1949
2896
|
if (method === 'POST') {
|
|
@@ -1951,14 +2898,33 @@ export function createAgentEntry({
|
|
|
1951
2898
|
// and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
|
|
1952
2899
|
// host, which belongs to the site (or to the neighbour agent) and not to us.
|
|
1953
2900
|
if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
|
|
1954
|
-
|
|
2901
|
+
const buf = bodyBuffer || Buffer.alloc(0);
|
|
2902
|
+
const out = handlePost(buf, headers);
|
|
2903
|
+
// The stage is read off the finished answer, so an async responder tallies when it
|
|
2904
|
+
// resolves. Known micro-skew, accepted: in the misconfigured sync-caller-with-async-
|
|
2905
|
+
// responder case `handleRequest` replaces the thenable with -32603 AFTER this wrap,
|
|
2906
|
+
// so a stage is tallied for a reply that was then replaced. Sample state only.
|
|
2907
|
+
if (isThenable(out)) return out.then((o) => { tally(family, postStage(buf, o)); return o; });
|
|
2908
|
+
tally(family, postStage(buf, out));
|
|
2909
|
+
return out;
|
|
1955
2910
|
}
|
|
2911
|
+
// Everything below answers for a RESOURCE, so an address this entry does not own is a
|
|
2912
|
+
// 404 first — the same answer GET and POST give it. Before this, OPTIONS 204'd for
|
|
2913
|
+
// EVERY path (a guest mount would have spoken for the site's front page one verb over)
|
|
2914
|
+
// and the 405 carried no `Allow` at all, which RFC 9110 §15.5.6 REQUIRES.
|
|
2915
|
+
const allow = allowFor(pathname);
|
|
2916
|
+
if (allow === null) return jsonResponse(404, { error: 'not found' });
|
|
1956
2917
|
if (method === 'OPTIONS') {
|
|
2918
|
+
// The CORS PREFLIGHT lands here: a browser-resident agent POSTing application/json
|
|
2919
|
+
// is not a simple request, so this answer is what decides whether the POST is ever
|
|
2920
|
+
// sent. BOTH method statements narrow to the resource: `Allow` and
|
|
2921
|
+
// `Access-Control-Allow-Methods` are the same fact for two readers, and this is the
|
|
2922
|
+
// one response where the browser reader acts on it.
|
|
1957
2923
|
return { status: 204,
|
|
1958
|
-
headers: {
|
|
2924
|
+
headers: { 'Content-Length': '0', ...CORS_HEADERS, ...allowHeaders(allow) },
|
|
1959
2925
|
body: Buffer.alloc(0) };
|
|
1960
2926
|
}
|
|
1961
|
-
return jsonResponse(405, { error: 'method not allowed' });
|
|
2927
|
+
return jsonResponse(405, { error: 'method not allowed' }, allowHeaders(allow));
|
|
1962
2928
|
}
|
|
1963
2929
|
|
|
1964
2930
|
function cardHeaders(length) {
|
|
@@ -1975,7 +2941,7 @@ export function createAgentEntry({
|
|
|
1975
2941
|
* If `responder` returned a Promise, this answers -32603 rather than serializing
|
|
1976
2942
|
* "[object Promise]" into a signed reply — use `handleRequestAsync` for an async responder. */
|
|
1977
2943
|
function handleRequest(method, path, headers, bodyBuffer) {
|
|
1978
|
-
const out = route(method, path, bodyBuffer);
|
|
2944
|
+
const out = route(method, path, bodyBuffer, headers);
|
|
1979
2945
|
if (isThenable(out)) {
|
|
1980
2946
|
return rpcError(null, ERRORS.INTERNAL_ERROR,
|
|
1981
2947
|
'responder is async — serve this agent entry through listen()/handleRequestAsync()');
|
|
@@ -1985,7 +2951,7 @@ export function createAgentEntry({
|
|
|
1985
2951
|
|
|
1986
2952
|
/** Same contract, awaiting an async responder. This is what `listen()` uses. */
|
|
1987
2953
|
async function handleRequestAsync(method, path, headers, bodyBuffer) {
|
|
1988
|
-
return route(method, path, bodyBuffer);
|
|
2954
|
+
return route(method, path, bodyBuffer, headers);
|
|
1989
2955
|
}
|
|
1990
2956
|
|
|
1991
2957
|
/**
|
|
@@ -2063,7 +3029,10 @@ export function createAgentEntry({
|
|
|
2063
3029
|
|
|
2064
3030
|
// `mount` is exported so a host app can route exactly what this entry answers (and log
|
|
2065
3031
|
// it): it is derived, so reading it here can never disagree with the signed card.
|
|
2066
|
-
|
|
3032
|
+
// `stats` is the owner-facing UA-family counters and `wbaVisits` the DID->count of
|
|
3033
|
+
// WBA-verified fetches — both in-process only, like `ledger`.
|
|
3034
|
+
return { did, card, ledger, mount, stats, wbaVisits,
|
|
3035
|
+
handleRequest, handleRequestAsync, listen,
|
|
2067
3036
|
cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
|
|
2068
3037
|
}
|
|
2069
3038
|
|