@muretai/agent-entry 1.4.0 → 1.6.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/conformance/run.mjs +119 -0
- package/conformance/vectors.json +637 -0
- package/examples/server.mjs +12 -0
- package/muretai-agent-entry.mjs +196 -11
- package/package.json +3 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* conformance/run.mjs — check an Agent Entry implementation against the golden vectors.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SHIPS IN THE PACKAGE. A conformance suite that lives on a website is a suite
|
|
6
|
+
* with an uptime requirement, and until now this one was worse than that: the vectors sat
|
|
7
|
+
* in a git checkout that `npm install` never delivers, so "run the suite" meant "send us
|
|
8
|
+
* your implementation and we will run it". Everything needed to hold this code to its own
|
|
9
|
+
* contract is now in the tarball: `npm test`, no network, no dependencies, no account.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT CHECKS, AND WHY BOTH HALVES ARE HERE. The positive half proves this build
|
|
12
|
+
* produces the same BYTES as every other implementation — canonical JSON, did:key, the six
|
|
13
|
+
* signed fields. The negative half proves it REFUSES what it must, and it is the half that
|
|
14
|
+
* catches the failure nobody notices: an implementation that verifies nothing passes every
|
|
15
|
+
* positive vector in the file. A drift in either direction is silent on the wire — nothing
|
|
16
|
+
* throws, signatures simply stop verifying for everyone else.
|
|
17
|
+
*
|
|
18
|
+
* Run: node conformance/run.mjs (from the package root)
|
|
19
|
+
* npm test
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
canonicalJSON, didFromPublicKeyHex, publicKeyFromSeedHex, signingPayload,
|
|
28
|
+
signEnvelope, verifyEnvelope,
|
|
29
|
+
} from '../muretai-agent-entry.mjs';
|
|
30
|
+
|
|
31
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const vectors = JSON.parse(readFileSync(join(HERE, 'vectors.json'), 'utf8'));
|
|
33
|
+
|
|
34
|
+
let pass = 0;
|
|
35
|
+
const failures = [];
|
|
36
|
+
|
|
37
|
+
function check(ok, label, detail) {
|
|
38
|
+
if (ok) { pass += 1; return true; }
|
|
39
|
+
failures.push(detail ? `${label}\n ${detail}` : label);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- canonical JSON
|
|
44
|
+
for (const v of vectors.canonical) {
|
|
45
|
+
let got;
|
|
46
|
+
try { got = canonicalJSON(v.payload); } catch (e) { got = `THREW: ${e.message}`; }
|
|
47
|
+
check(got === v.canonical, `canonical/${v.name}`,
|
|
48
|
+
got === v.canonical ? '' : `want ${JSON.stringify(v.canonical)}\n got ${JSON.stringify(got)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `numberHazards` is DELIBERATELY NOT EXECUTED, and reading it is the point. Every case
|
|
52
|
+
// there is a value whose canonical bytes differ between languages, so asserting either
|
|
53
|
+
// spelling would be asserting one runtime's float formatting — the opposite of the
|
|
54
|
+
// contract. The rule it carries is a SIGNER discipline (`signMustNotEmit`), not a
|
|
55
|
+
// canonicaliser output: never sign a payload containing one, because the bytes you produce
|
|
56
|
+
// will only verify where they were produced. An agent entry meets it for free — the only
|
|
57
|
+
// number among the six signed fields is `timestamp`, and integer epoch seconds is the
|
|
58
|
+
// contract.
|
|
59
|
+
const hazards = vectors.numberHazards?.length ?? 0;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- did:key
|
|
62
|
+
for (const v of vectors.did) {
|
|
63
|
+
if (v.curve !== 'ed25519') continue; // p256 did:key is not an envelope signer
|
|
64
|
+
let got;
|
|
65
|
+
try { got = didFromPublicKeyHex(v.publicHex); } catch (e) { got = `THREW: ${e.message}`; }
|
|
66
|
+
check(got === v.did, `did/${v.publicHex.slice(0, 12)}…`,
|
|
67
|
+
got === v.did ? '' : `want ${v.did}\n got ${got}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------- the six signed fields
|
|
71
|
+
for (const v of vectors.envelope) {
|
|
72
|
+
const fields = { from: v.from, to: v.to, messageId: v.messageId,
|
|
73
|
+
contextId: v.contextId ?? null, timestamp: v.timestamp, text: v.text };
|
|
74
|
+
let got;
|
|
75
|
+
try { got = signingPayload(fields); } catch (e) { got = `THREW: ${e.message}`; }
|
|
76
|
+
check(got === v.signingPayload, `envelope/${v.name}`,
|
|
77
|
+
got === v.signingPayload ? '' : `want ${JSON.stringify(v.signingPayload)}\n got ${JSON.stringify(got)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A signature this build makes must verify in this build. Round-tripping is the weakest
|
|
81
|
+
// possible claim on its own — it only says the code agrees with itself — which is exactly
|
|
82
|
+
// why the byte checks above and the refusals below are not optional.
|
|
83
|
+
{
|
|
84
|
+
const seed = '11'.repeat(32);
|
|
85
|
+
const from = didFromPublicKeyHex(publicKeyFromSeedHex(seed));
|
|
86
|
+
const fields = { from, to: from, messageId: 'm1', contextId: null,
|
|
87
|
+
timestamp: 1752451200, text: 'round trip' };
|
|
88
|
+
const sig = signEnvelope(seed, fields);
|
|
89
|
+
check(verifyEnvelope({ ...fields, sig }, { recipientDid: from }), 'envelope/round-trip');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------- the refusals
|
|
93
|
+
// The half that catches an implementation which verifies nothing.
|
|
94
|
+
for (const v of vectors.reject.message) {
|
|
95
|
+
const fields = { from: v.from, to: v.to, messageId: v.messageId,
|
|
96
|
+
contextId: v.contextId ?? null, timestamp: v.timestamp,
|
|
97
|
+
text: v.text, sig: v.sig };
|
|
98
|
+
let accepted;
|
|
99
|
+
try {
|
|
100
|
+
accepted = verifyEnvelope(fields, { recipientDid: v.recipientDid ?? v.to });
|
|
101
|
+
} catch {
|
|
102
|
+
accepted = false; // refusing by throwing is still refusing
|
|
103
|
+
}
|
|
104
|
+
check(accepted === false, `reject/${v.name}`,
|
|
105
|
+
accepted === false ? '' : `ACCEPTED a message it must refuse — ${v.why || ''}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------- verdict
|
|
109
|
+
console.log(`\n${vectors.note}\n`);
|
|
110
|
+
if (failures.length) {
|
|
111
|
+
console.log(`FAILED — ${failures.length} of ${pass + failures.length} checks:\n`);
|
|
112
|
+
for (const f of failures) console.log(` ✗ ${f}`);
|
|
113
|
+
console.log('\nA mismatch here is not cosmetic: these bytes are what every other');
|
|
114
|
+
console.log('implementation signs and verifies.\n');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
console.log(`OK — ${pass} checks: the bytes match, and every case that must be refused was.`);
|
|
118
|
+
console.log(` (${hazards} numberHazards read, not executed — see the comment in this file:`);
|
|
119
|
+
console.log(` they are a SIGNER rule, not bytes any single runtime can be held to.)\n`);
|
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Golden wire vectors for an Agent Entry implementation. Reproduce every `canonical`, `did`, `signingPayload` and `bindingPayload` field BYTE-FOR-BYTE, and REFUSE every case under `reject`. A drift does not throw: it silently makes your signatures unverifiable by everyone else, and an implementation that refuses nothing passes every positive vector in this file. Header and URL values inside a signed vector are OPAQUE TEST DATA covered by that vector's signature: reproduce them verbatim. Substituting your own host there does not make the case yours, it makes it fail.",
|
|
3
|
+
"protocolVersion": "0.2",
|
|
4
|
+
"canonicalSpec": "python json.dumps(sort_keys=True, separators=(',',':'), ensure_ascii=False).encode('utf-8')",
|
|
5
|
+
"timestampNote": "Timestamps in signed payloads are INTEGER epoch seconds — this is the CONTRACT, not a convenience of these vectors. It used to be the latter: core minted floats and the vectors side-stepped them, which meant a client could pass every vector here and still be unable to verify a real message from a Python node. `crypto.signing_payload` does not cast, so the type on the wire IS the type in the signed bytes, and a float there is bytes no other language can reproduce (Python's shortest-round-trip repr). Since 2026-07-17 core mints ints (shared/protocol.Message, cardpub publishers). Clients: send ints; keep VERIFYING whatever type arrives — an older node still sends floats and its signature is over those exact bytes. Never coerce inside a payload builder.",
|
|
6
|
+
"canonical": [
|
|
7
|
+
{
|
|
8
|
+
"name": "sorted-keys",
|
|
9
|
+
"payload": {
|
|
10
|
+
"b": 1,
|
|
11
|
+
"a": "x"
|
|
12
|
+
},
|
|
13
|
+
"why": "sort_keys=True: insertion order must not survive into the bytes",
|
|
14
|
+
"canonical": "{\"a\":\"x\",\"b\":1}"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "no-whitespace",
|
|
18
|
+
"payload": {
|
|
19
|
+
"a": 1,
|
|
20
|
+
"b": 2
|
|
21
|
+
},
|
|
22
|
+
"why": "separators=(',',':'): a default json.dumps would add spaces here",
|
|
23
|
+
"canonical": "{\"a\":1,\"b\":2}"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "non-ascii-literal",
|
|
27
|
+
"payload": {
|
|
28
|
+
"text": "群れたい"
|
|
29
|
+
},
|
|
30
|
+
"why": "ensure_ascii=False: non-ASCII stays literal UTF-8, NOT \\uXXXX. This is the one most clients get wrong, and message text is routinely non-ASCII",
|
|
31
|
+
"canonical": "{\"text\":\"群れたい\"}"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "escape-shorthands",
|
|
35
|
+
"payload": {
|
|
36
|
+
"k": "\"\\\b\f\n\r\t"
|
|
37
|
+
},
|
|
38
|
+
"why": "Python's ESCAPE_DCT: exactly these seven shorthands",
|
|
39
|
+
"canonical": "{\"k\":\"\\\"\\\\\\b\\f\\n\\r\\t\"}"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "control-chars",
|
|
43
|
+
"payload": {
|
|
44
|
+
"k": "\u0000\u0001\u001f"
|
|
45
|
+
},
|
|
46
|
+
"why": "other <0x20 become lowercase \\u00xx",
|
|
47
|
+
"canonical": "{\"k\":\"\\u0000\\u0001\\u001f\"}"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "slash-and-del-unescaped",
|
|
51
|
+
"payload": {
|
|
52
|
+
"k": "a/b"
|
|
53
|
+
},
|
|
54
|
+
"why": "Python escapes NEITHER '/' nor DEL — many JSON writers escape both",
|
|
55
|
+
"canonical": "{\"k\":\"a/b\"}"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "null-value",
|
|
59
|
+
"payload": {
|
|
60
|
+
"contextId": null
|
|
61
|
+
},
|
|
62
|
+
"why": "None -> null (contextId is genuinely nullable on the wire)",
|
|
63
|
+
"canonical": "{\"contextId\":null}"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "empty-string",
|
|
67
|
+
"payload": {
|
|
68
|
+
"text": ""
|
|
69
|
+
},
|
|
70
|
+
"why": "empty string is not null",
|
|
71
|
+
"canonical": "{\"text\":\"\"}"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "surrogate-pair",
|
|
75
|
+
"payload": {
|
|
76
|
+
"text": "🐦"
|
|
77
|
+
},
|
|
78
|
+
"why": "an astral char is one code point in Python but two UTF-16 units elsewhere",
|
|
79
|
+
"canonical": "{\"text\":\"🐦\"}"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "combining-marks",
|
|
83
|
+
"payload": {
|
|
84
|
+
"text": "が"
|
|
85
|
+
},
|
|
86
|
+
"why": "NOT normalized: canonical JSON must never NFC/NFD the input",
|
|
87
|
+
"canonical": "{\"text\":\"が\"}"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "key-ordering-unicode",
|
|
91
|
+
"payload": {
|
|
92
|
+
"z": 1,
|
|
93
|
+
"a": 2,
|
|
94
|
+
"群": 3,
|
|
95
|
+
"A": 4
|
|
96
|
+
},
|
|
97
|
+
"why": "keys sort by CODE POINT (Python str order), not by UTF-16 unit or locale",
|
|
98
|
+
"canonical": "{\"A\":4,\"a\":2,\"z\":1,\"群\":3}"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "negative-and-zero",
|
|
102
|
+
"payload": {
|
|
103
|
+
"a": 0,
|
|
104
|
+
"b": -1
|
|
105
|
+
},
|
|
106
|
+
"why": "integers render bare, no + or leading zeros",
|
|
107
|
+
"canonical": "{\"a\":0,\"b\":-1}"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "large-int-within-double",
|
|
111
|
+
"payload": {
|
|
112
|
+
"a": 9007199254740991
|
|
113
|
+
},
|
|
114
|
+
"why": "2**53-1, the largest integer a JavaScript Number holds exactly. Signed integers must stay inside +/-(2**53-1) — see numberHazards/int-beyond-2exp53",
|
|
115
|
+
"canonical": "{\"a\":9007199254740991}"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "fractional-float-agrees",
|
|
119
|
+
"payload": {
|
|
120
|
+
"a": 0.1,
|
|
121
|
+
"b": 1785937682.845164
|
|
122
|
+
},
|
|
123
|
+
"why": "the CONTRAST case for numberHazards: ordinary FRACTIONAL floats do agree byte-for-byte across languages. That is exactly why float timestamps looked fine for a year — they are reproducible until one lands on a whole second",
|
|
124
|
+
"canonical": "{\"a\":0.1,\"b\":1785937682.845164}"
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"numberHazards": [
|
|
128
|
+
{
|
|
129
|
+
"name": "integral-float",
|
|
130
|
+
"payload": {
|
|
131
|
+
"a": 1.0
|
|
132
|
+
},
|
|
133
|
+
"pythonCanonical": "{\"a\":1.0}",
|
|
134
|
+
"javascriptWouldWrite": "{\"a\":1}",
|
|
135
|
+
"signMustNotEmit": true,
|
|
136
|
+
"verifyNeedsPythonRepr": true,
|
|
137
|
+
"why": "Python writes 1.0, JavaScript writes 1 — and JSON.parse('1.0') is irrecoverably 1, so a client cannot even reconstruct what was signed. `credentialSubject.trustLevel` was this value on every introduction minted by `operator_cli.introduce` and a Room's `/introduce`; fixed 2026-08-07 by minting integer basis points (`trustLevelBp`)."
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "integral-float-zero",
|
|
141
|
+
"payload": {
|
|
142
|
+
"a": 0.0
|
|
143
|
+
},
|
|
144
|
+
"pythonCanonical": "{\"a\":0.0}",
|
|
145
|
+
"javascriptWouldWrite": "{\"a\":0}",
|
|
146
|
+
"signMustNotEmit": true,
|
|
147
|
+
"verifyNeedsPythonRepr": true,
|
|
148
|
+
"why": "`keystate.notBefore` defaulted to 0.0 and was therefore unverifiable outside Python 100% of the time; fixed 2026-08-07."
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "integral-float-negative",
|
|
152
|
+
"payload": {
|
|
153
|
+
"a": -1.0
|
|
154
|
+
},
|
|
155
|
+
"pythonCanonical": "{\"a\":-1.0}",
|
|
156
|
+
"javascriptWouldWrite": "{\"a\":-1}",
|
|
157
|
+
"signMustNotEmit": true,
|
|
158
|
+
"verifyNeedsPythonRepr": true,
|
|
159
|
+
"why": "the sign does not save it"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"name": "negative-zero",
|
|
163
|
+
"payload": {
|
|
164
|
+
"a": -0.0
|
|
165
|
+
},
|
|
166
|
+
"pythonCanonical": "{\"a\":-0.0}",
|
|
167
|
+
"javascriptWouldWrite": "{\"a\":0}",
|
|
168
|
+
"signMustNotEmit": true,
|
|
169
|
+
"verifyNeedsPythonRepr": true,
|
|
170
|
+
"why": "Python keeps the sign, JavaScript does not. Matrix bans -0 from its canonical JSON outright, for this reason."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"name": "exponent-small",
|
|
174
|
+
"payload": {
|
|
175
|
+
"a": 1e-07
|
|
176
|
+
},
|
|
177
|
+
"pythonCanonical": "{\"a\":1e-07}",
|
|
178
|
+
"javascriptWouldWrite": "{\"a\":1e-7}",
|
|
179
|
+
"signMustNotEmit": true,
|
|
180
|
+
"verifyNeedsPythonRepr": true,
|
|
181
|
+
"why": "Python zero-pads and always signs the exponent (1e-07); JavaScript does neither"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"name": "exponent-threshold",
|
|
185
|
+
"payload": {
|
|
186
|
+
"a": 1e+16
|
|
187
|
+
},
|
|
188
|
+
"pythonCanonical": "{\"a\":1e+16}",
|
|
189
|
+
"javascriptWouldWrite": "{\"a\":10000000000000000}",
|
|
190
|
+
"signMustNotEmit": true,
|
|
191
|
+
"verifyNeedsPythonRepr": true,
|
|
192
|
+
"why": "Python switches to exponent notation here (1e+16); JavaScript does not switch until 1e21. The thresholds differ, so agreement is a coin flip."
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"name": "int-beyond-2exp53",
|
|
196
|
+
"payload": {
|
|
197
|
+
"a": 9007199254740993
|
|
198
|
+
},
|
|
199
|
+
"pythonCanonical": "{\"a\":9007199254740993}",
|
|
200
|
+
"javascriptWouldWrite": "{\"a\":9007199254740992}",
|
|
201
|
+
"signMustNotEmit": true,
|
|
202
|
+
"verifyNeedsPythonRepr": true,
|
|
203
|
+
"why": "NOT a formatting mismatch — SILENT DATA CORRUPTION. Python has arbitrary-precision integers; a JavaScript Number rounds. Keep signed integers inside +/-(2**53-1), the bound Matrix and RFC 8785 both set."
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"name": "nested-float",
|
|
207
|
+
"payload": {
|
|
208
|
+
"credentialSubject": {
|
|
209
|
+
"trustLevel": 1.0
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
"pythonCanonical": "{\"credentialSubject\":{\"trustLevel\":1.0}}",
|
|
213
|
+
"javascriptWouldWrite": "{\"credentialSubject\":{\"trustLevel\":1}}",
|
|
214
|
+
"signMustNotEmit": true,
|
|
215
|
+
"verifyNeedsPythonRepr": true,
|
|
216
|
+
"why": "the real shape of the bug: two levels down inside an introduction credential, where nothing at the top level looked wrong"
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
"numberHazardNote": "These are NOT in `canonical`, and that distinction is the point. Every case here is a value whose canonical bytes DIFFER between Python and JavaScript, so requiring a client to reproduce them would be requiring it to re-implement CPython's float repr — the opposite of the contract. `signMustNotEmit` means exactly what it says: a signer that emits one of these produces bytes only Python can verify. `verifyNeedsPythonRepr` means a client CAN still meet a lesser duty — recognise the shape and report honestly that it cannot verify that artifact, rather than reporting a bad signature. This section exists because `trustLevel: 1.0` shipped for a year while all twelve original `canonical` cases stayed green: none of them contained a float, so nothing caught it, and the failure surfaced as a signature error pointing at the client's Ed25519 code. Same rule as timestampNote, stated for every number: signed payloads carry INTEGERS inside +/-(2**53-1). Never coerce inside a payload builder; never coerce on the verify path either.",
|
|
220
|
+
"did": [
|
|
221
|
+
{
|
|
222
|
+
"curve": "ed25519",
|
|
223
|
+
"multicodec": "ed01",
|
|
224
|
+
"publicHex": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
225
|
+
"did": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"curve": "ed25519",
|
|
229
|
+
"multicodec": "ed01",
|
|
230
|
+
"publicHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
231
|
+
"did": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"curve": "ed25519",
|
|
235
|
+
"multicodec": "ed01",
|
|
236
|
+
"publicHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
237
|
+
"did": "did:key:z6MkeTGwHmLmuCmgg4ABYhzWVh6ZX7hTwWt8gguAretUfc9c"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"curve": "ed25519",
|
|
241
|
+
"multicodec": "ed01",
|
|
242
|
+
"publicHex": "094119b513c9c595718eaba5a497a8e0ea1dd19bd5e043dad8b5f1ee9a893b64",
|
|
243
|
+
"did": "did:key:z6Mkf5PHmUf14MDpPoHgRVVNDEFSZ62ecAHhjSJGicWcjrno"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"curve": "p256",
|
|
247
|
+
"multicodec": "8024",
|
|
248
|
+
"publicHex": "020000000000000000000000000000000000000000000000000000000000000000",
|
|
249
|
+
"did": "did:key:zDnaeQRy3dcKsKa1zmKtVKsTy3m2HYoQnFnfKuxD6HfSTQgYf"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"curve": "p256",
|
|
253
|
+
"multicodec": "8024",
|
|
254
|
+
"publicHex": "02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
255
|
+
"did": "did:key:zDnaehfHR8Q5U7ckmLQfuZ3eGEypooJ46zzjRQ1AR9asDvdnv"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"curve": "p256",
|
|
259
|
+
"multicodec": "8024",
|
|
260
|
+
"publicHex": "02000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
261
|
+
"did": "did:key:zDnaeQRywL8RCtEJDKtCyC1VdhMZrFLqPnRJ6udCLK3MvA4ut"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"curve": "p256",
|
|
265
|
+
"multicodec": "8024",
|
|
266
|
+
"publicHex": "030000000000000000000000000000000000000000000000000000000000000000",
|
|
267
|
+
"did": "did:key:zDnaehfHR8Q5U7ckmLQfuZ3eGEypooJ46zzjRQ1AR9asDvdnw"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"curve": "p256",
|
|
271
|
+
"multicodec": "8024",
|
|
272
|
+
"publicHex": "03ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
273
|
+
"did": "did:key:zDnaeztbndBq4ufVXuVTKnDpZSCdL3nhRkCoWt47k1WHzSb3C"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"curve": "p256",
|
|
277
|
+
"multicodec": "8024",
|
|
278
|
+
"publicHex": "03000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
279
|
+
"did": "did:key:zDnaehfJJpvAogH2ytxzPRBfvtaNNVqUiXdNCPg9fAxngg2AA"
|
|
280
|
+
}
|
|
281
|
+
],
|
|
282
|
+
"envelope": [
|
|
283
|
+
{
|
|
284
|
+
"name": "plain",
|
|
285
|
+
"from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
|
|
286
|
+
"to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
|
|
287
|
+
"messageId": "m1",
|
|
288
|
+
"contextId": "c1",
|
|
289
|
+
"timestamp": 1752451200,
|
|
290
|
+
"text": "hi",
|
|
291
|
+
"signingPayload": "{\"contextId\":\"c1\",\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m1\",\"text\":\"hi\",\"timestamp\":1752451200,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
"name": "null-context",
|
|
295
|
+
"from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
|
|
296
|
+
"to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
|
|
297
|
+
"messageId": "m1",
|
|
298
|
+
"contextId": null,
|
|
299
|
+
"timestamp": 1752451200,
|
|
300
|
+
"text": "hi",
|
|
301
|
+
"signingPayload": "{\"contextId\":null,\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m1\",\"text\":\"hi\",\"timestamp\":1752451200,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"name": "non-ascii-text",
|
|
305
|
+
"from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
|
|
306
|
+
"to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
|
|
307
|
+
"messageId": "m-群",
|
|
308
|
+
"contextId": "c1",
|
|
309
|
+
"timestamp": 1752451200,
|
|
310
|
+
"text": "群れたい — hello",
|
|
311
|
+
"signingPayload": "{\"contextId\":\"c1\",\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m-群\",\"text\":\"群れたい — hello\",\"timestamp\":1752451200,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"name": "empty-text",
|
|
315
|
+
"from": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
|
|
316
|
+
"to": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
|
|
317
|
+
"messageId": "m1",
|
|
318
|
+
"contextId": "c1",
|
|
319
|
+
"timestamp": 0,
|
|
320
|
+
"text": "",
|
|
321
|
+
"signingPayload": "{\"contextId\":\"c1\",\"from\":\"did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP\",\"messageId\":\"m1\",\"text\":\"\",\"timestamp\":0,\"to\":\"did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e\"}"
|
|
322
|
+
}
|
|
323
|
+
],
|
|
324
|
+
"bindingV2": {
|
|
325
|
+
"checkNow": 1784273741,
|
|
326
|
+
"cases": [
|
|
327
|
+
{
|
|
328
|
+
"name": "no-expiry",
|
|
329
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
330
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
331
|
+
"ownerSeed": "1717171717171717171717171717171717171717171717171717171717171717",
|
|
332
|
+
"deviceSeed": "1818181818181818181818181818181818181818181818181818181818181818",
|
|
333
|
+
"ts": 1784273681,
|
|
334
|
+
"validUntil": 0,
|
|
335
|
+
"bindingPayload": "{\"deviceDid\":\"did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU\",\"rootDid\":\"did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q\",\"ts\":1784273681,\"typ\":\"muretai/devicebinding/2\",\"validUntil\":0}",
|
|
336
|
+
"binding": {
|
|
337
|
+
"typ": "muretai/devicebinding/2",
|
|
338
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
339
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
340
|
+
"ts": 1784273681,
|
|
341
|
+
"validUntil": 0,
|
|
342
|
+
"sig": "9uaChaM0gQoIgiZqV/k8FDBZXHiaJPvtZbijhoQWogbCVb0ylI+hMNESnQ4bSsreEbE+d50kaBv+12E8UxBCDw==",
|
|
343
|
+
"deviceSig": "OowWKRk3TNZr58K+/Yd+y/iA5siZPN4IF/0lQYFqMcWy8/EHaodbt8tWx+K883uTI55xh1rKpwd5pFuJ8CSvBQ=="
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
"name": "bounded",
|
|
348
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
349
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
350
|
+
"ownerSeed": "1717171717171717171717171717171717171717171717171717171717171717",
|
|
351
|
+
"deviceSeed": "1818181818181818181818181818181818181818181818181818181818181818",
|
|
352
|
+
"ts": 1784273681,
|
|
353
|
+
"validUntil": 1815809681,
|
|
354
|
+
"bindingPayload": "{\"deviceDid\":\"did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU\",\"rootDid\":\"did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q\",\"ts\":1784273681,\"typ\":\"muretai/devicebinding/2\",\"validUntil\":1815809681}",
|
|
355
|
+
"binding": {
|
|
356
|
+
"typ": "muretai/devicebinding/2",
|
|
357
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
358
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
359
|
+
"ts": 1784273681,
|
|
360
|
+
"validUntil": 1815809681,
|
|
361
|
+
"sig": "5alfC8XRi6brgqjfzzbq9Jam8aXyA08PgQ3vopL5r+N0BPr+AXl6xR+vGtzc5ZlszVVBle4P/rzplq+2e+SVAA==",
|
|
362
|
+
"deviceSig": "BZDaDqB0kQPNsry0Qfn9JSlLdX5Z+EkWAsPS9027/j/YHdpsKZeT4EDVffPfYwaCSP75NaIrxv5yiC1Nb1utCg=="
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
],
|
|
366
|
+
"reject": [
|
|
367
|
+
{
|
|
368
|
+
"name": "float-ts",
|
|
369
|
+
"category": "float-ts",
|
|
370
|
+
"mustReject": true,
|
|
371
|
+
"input": {
|
|
372
|
+
"typ": "muretai/devicebinding/2",
|
|
373
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
374
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
375
|
+
"ts": 1784273681.0,
|
|
376
|
+
"validUntil": 0,
|
|
377
|
+
"sig": "Z5kh7m9BZs3QGw7W51fx7S7/Nit0uCo6qsdKdjnt2agsl4PEl0lwfbmoCoO/+mMc+x0XDWSMu/16F9/ftdd9DA==",
|
|
378
|
+
"deviceSig": "LbqqmEBJzge+cJgXTdIWYmAE0PUGurrNeE/Hy6F7NkaqNiPDnbySWEl42eU+lrm6861t2COBUxhgrHErI39mBA=="
|
|
379
|
+
},
|
|
380
|
+
"note": "ts is 1784273681.0 and both signatures are GENUINE over those float bytes. Reject on the type, before any crypto: a float repr is bytes only Python reproduces, so accepting it forks the contract (numberHazards/integral-float is this same value)."
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
"name": "missing-deviceSig",
|
|
384
|
+
"category": "missing-deviceSig",
|
|
385
|
+
"mustReject": true,
|
|
386
|
+
"input": {
|
|
387
|
+
"typ": "muretai/devicebinding/2",
|
|
388
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
389
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
390
|
+
"ts": 1784273681,
|
|
391
|
+
"validUntil": 0,
|
|
392
|
+
"sig": "9uaChaM0gQoIgiZqV/k8FDBZXHiaJPvtZbijhoQWogbCVb0ylI+hMNESnQ4bSsreEbE+d50kaBv+12E8UxBCDw=="
|
|
393
|
+
},
|
|
394
|
+
"note": "the owner signature is valid and the device countersignature is absent — without it a foreign owner can claim someone else's device, which is the v1 gap v2 exists to close. Never fall back to owner-only."
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"name": "wrong-typ",
|
|
398
|
+
"category": "wrong-typ",
|
|
399
|
+
"mustReject": true,
|
|
400
|
+
"input": {
|
|
401
|
+
"typ": "muretai/devicebinding/1",
|
|
402
|
+
"rootDid": "did:key:z6Mkhow21JWUi49xXiMVnHL18JjZf5dYMSmSwPoHfWU4VN5q",
|
|
403
|
+
"deviceDid": "did:key:z6Mkk4Li7FrksouqV6ehQnbKh8R5kEkkqtmpQtohPvE3fnKU",
|
|
404
|
+
"ts": 1784273681,
|
|
405
|
+
"validUntil": 0,
|
|
406
|
+
"sig": "rQVkNoTQbOrxOCeAeIV2aQBT2EIZWvDB3rWeW7BbPVRCZjmiqYa77GlLAMcG+Od2BIwhT48xsiHbDXjHAWaEAQ==",
|
|
407
|
+
"deviceSig": "zJGq14Oifmq08Z6LcWVx4Re0H1v+FT6EuaE2vQ8Ntf4wC8mW9IEu+LLCnzcy8b1dbtw30r9MYYLxMAQOIQl4DA=="
|
|
408
|
+
},
|
|
409
|
+
"note": "typ says muretai/devicebinding/1 and both signatures are genuine over those bytes. `typ` is INSIDE the signed payload precisely so another artifact type can never be replayed as an account binding — match it exactly, before the signatures."
|
|
410
|
+
}
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
"domainLinkage": [
|
|
414
|
+
{
|
|
415
|
+
"name": "plain",
|
|
416
|
+
"did": "did:key:z6MktojHN9D8obak7C9wjpTzCRrdE5zC6cxt5ANUFnQskgbs",
|
|
417
|
+
"domain": "example.com",
|
|
418
|
+
"nbf": 1754870400,
|
|
419
|
+
"exp": 1762646400,
|
|
420
|
+
"signingInput": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2V4YW1wbGUuY29tIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ",
|
|
421
|
+
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2V4YW1wbGUuY29tIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ.PC86WXKk1O_0asYAWi81QrUDp2aRdUbqjAmINiSHCZmZIVNOffrkqSxPnYVFvVZf87_g_EKhC2b2F8dr5z0JDA"
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
"name": "subdomain",
|
|
425
|
+
"did": "did:key:z6MktojHN9D8obak7C9wjpTzCRrdE5zC6cxt5ANUFnQskgbs",
|
|
426
|
+
"domain": "agents.example.com",
|
|
427
|
+
"nbf": 1754870400,
|
|
428
|
+
"exp": 1762646400,
|
|
429
|
+
"signingInput": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2FnZW50cy5leGFtcGxlLmNvbSJ9LCJleHBpcmF0aW9uRGF0ZSI6IjIwMjUtMTEtMDlUMDA6MDA6MDBaIiwiaXNzdWFuY2VEYXRlIjoiMjAyNS0wOC0xMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJEb21haW5MaW5rYWdlQ3JlZGVudGlhbCJdfX0",
|
|
430
|
+
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovL2FnZW50cy5leGFtcGxlLmNvbSJ9LCJleHBpcmF0aW9uRGF0ZSI6IjIwMjUtMTEtMDlUMDA6MDA6MDBaIiwiaXNzdWFuY2VEYXRlIjoiMjAyNS0wOC0xMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJEb21haW5MaW5rYWdlQ3JlZGVudGlhbCJdfX0.USRlay6FLlmoBpEwywyZtocxsgwzr8PR5uGkJbpnSL4UkqqVVx66QZoSjn3vdf3aJiSQvBXdIPV-WT_uwFk3BQ"
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
"name": "loopback-port",
|
|
434
|
+
"did": "did:key:z6MktojHN9D8obak7C9wjpTzCRrdE5zC6cxt5ANUFnQskgbs",
|
|
435
|
+
"domain": "127.0.0.1:8443",
|
|
436
|
+
"nbf": 1754870400,
|
|
437
|
+
"exp": 1762646400,
|
|
438
|
+
"signingInput": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovLzEyNy4wLjAuMTo4NDQzIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ",
|
|
439
|
+
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzI3o2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI2NDY0MDAsImlzcyI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwibmJmIjoxNzU0ODcwNDAwLCJzdWIiOiJkaWQ6a2V5Ono2TWt0b2pITjlEOG9iYWs3Qzl3anBUekNScmRFNXpDNmN4dDVBTlVGblFza2dicyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9pZGVudGl0eS5mb3VuZGF0aW9uLy53ZWxsLWtub3duL2RpZC1jb25maWd1cmF0aW9uL3YxIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rdG9qSE45RDhvYmFrN0M5d2pwVHpDUnJkRTV6QzZjeHQ1QU5VRm5Rc2tnYnMiLCJvcmlnaW4iOiJodHRwczovLzEyNy4wLjAuMTo4NDQzIn0sImV4cGlyYXRpb25EYXRlIjoiMjAyNS0xMS0wOVQwMDowMDowMFoiLCJpc3N1YW5jZURhdGUiOiIyMDI1LTA4LTExVDAwOjAwOjAwWiIsImlzc3VlciI6ImRpZDprZXk6ejZNa3RvakhOOUQ4b2JhazdDOXdqcFR6Q1JyZEU1ekM2Y3h0NUFOVUZuUXNrZ2JzIiwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIkRvbWFpbkxpbmthZ2VDcmVkZW50aWFsIl19fQ.k_XOyNvWVUGtS88CBYl7IYUptxnM3A3hGlhRymmQxDSaItKxvhOnj2NT1F5dZauqoUQdVMhexVpzxTEIUVwXCQ"
|
|
440
|
+
}
|
|
441
|
+
],
|
|
442
|
+
"domainLinkageNote": "The DIF Domain Linkage Credential (compact JWS, shared/domainbind.py). The signature covers the TEXT of the first two segments — b64url(header).b64url(payload) — never the object they decode to, so `signingInput` is the byte string to reproduce and re-serializing a decoded payload is NOT how you verify. Header is {alg:\"EdDSA\",typ:\"JWT\",kid:\"<did>#<multibase>\"}; base64url is UNPADDED and a padded or standard-alphabet spelling is refused rather than repaired. `nbf`/`exp` are INTEGER epoch seconds and are the values a verifier compares; the ISO strings inside `vc` are their human-facing echo. `exp` is MANDATORY — a domain is leased, not owned, so a credential with no end date is indefinite authority over a name the issuer may no longer hold. iss == sub == vc.credentialSubject.id, all three the identical string, or refuse. Origins are compared canonicalized on BOTH sides (shared/neturl.origin), and a non-default port is part of the origin. DIRECTION: this proves only that the holder of `did` CLAIMS `domain`; it becomes a proof once the document is fetched from that domain's /.well-known/did-configuration.json and the DID's card names the domain back.",
|
|
443
|
+
"webBotAuth": {
|
|
444
|
+
"note": "The `keys` entries are the SAME public keys as the `did` section. `keyid` is the RFC 7638 thumbprint of the JWK, and `thumbprintInput` is the exact text hashed to produce it. `alg` is lowercase \"ed25519\" (RFC 9421), NOT JOSE's \"EdDSA\" — domainLinkage uses the other spelling for the same curve. A signature base is LF-joined with NO trailing newline. The request path covers @authority (so it cannot be replayed at another origin) and signature-agent (so the site can look us up); the covered signature-agent value and the Signature-Agent header are the same sf-string, quotes included. `tag` separates the two directions and is inside the signed params. READ THE ROLES BEFORE COMPARING HEX: `seedHex` is a PRIVATE seed and `did` is the key DERIVED from it, while the identical-looking hex at keys[2].publicHex is that same byte string used as a PUBLIC key — a different DID, by construction.",
|
|
445
|
+
"seedHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
446
|
+
"did": "did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd",
|
|
447
|
+
"keys": [
|
|
448
|
+
{
|
|
449
|
+
"publicHex": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
450
|
+
"did": "did:key:z6MkeTG3bFFSLYVU7VqhgZxqr6YzpaGrQtFMh1uvqGy1vDnP",
|
|
451
|
+
"x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
452
|
+
"thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}",
|
|
453
|
+
"thumbprint": "ogRZbCR5KTrPFCAfuYmCMwj0w7Yuk3Lr6YWQWfpkbf0"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"publicHex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
|
457
|
+
"did": "did:key:z6MkwgaR63138bEEgad7uk993KMX54vBA6KTB4sFhCPnSB2e",
|
|
458
|
+
"x": "__________________________________________8",
|
|
459
|
+
"thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"__________________________________________8\"}",
|
|
460
|
+
"thumbprint": "5rFvj451YsRwcWuFV5946TD4InhZSbOza0q8jWz8X90"
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"publicHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
464
|
+
"did": "did:key:z6MkeTGwHmLmuCmgg4ABYhzWVh6ZX7hTwWt8gguAretUfc9c",
|
|
465
|
+
"x": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
|
|
466
|
+
"thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\"}",
|
|
467
|
+
"thumbprint": "P7IdLIpiTZiFaIoOSqbX3JrSyps3hvZ4Y2SieP96XIY"
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
"publicHex": "094119b513c9c595718eaba5a497a8e0ea1dd19bd5e043dad8b5f1ee9a893b64",
|
|
471
|
+
"did": "did:key:z6Mkf5PHmUf14MDpPoHgRVVNDEFSZ62ecAHhjSJGicWcjrno",
|
|
472
|
+
"x": "CUEZtRPJxZVxjqulpJeo4Ood0ZvV4EPa2LXx7pqJO2Q",
|
|
473
|
+
"thumbprintInput": "{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"CUEZtRPJxZVxjqulpJeo4Ood0ZvV4EPa2LXx7pqJO2Q\"}",
|
|
474
|
+
"thumbprint": "XS885FlMDO9EHzHe7ghOBrxMG-EzyAtWUPON3O8yQgQ"
|
|
475
|
+
}
|
|
476
|
+
],
|
|
477
|
+
"request": {
|
|
478
|
+
"authority": "example.com",
|
|
479
|
+
"created": 1754870400,
|
|
480
|
+
"expires": 1754870700,
|
|
481
|
+
"keyid": "1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y",
|
|
482
|
+
"alg": "ed25519",
|
|
483
|
+
"tag": "web-bot-auth",
|
|
484
|
+
"components": [
|
|
485
|
+
"@authority",
|
|
486
|
+
"signature-agent"
|
|
487
|
+
],
|
|
488
|
+
"signatureAgentUrl": "https://muretai.net/z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd",
|
|
489
|
+
"signatureAgent": "\"https://muretai.net/z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd\"",
|
|
490
|
+
"signatureParams": "(\"@authority\" \"signature-agent\");created=1754870400;expires=1754870700;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"web-bot-auth\"",
|
|
491
|
+
"signatureBase": "\"@authority\": example.com\n\"signature-agent\": \"https://muretai.net/z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd\"\n\"@signature-params\": (\"@authority\" \"signature-agent\");created=1754870400;expires=1754870700;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"web-bot-auth\"",
|
|
492
|
+
"signatureInput": "sig1=(\"@authority\" \"signature-agent\");created=1754870400;expires=1754870700;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"web-bot-auth\"",
|
|
493
|
+
"signature": "sig1=:4AUlLYDVmcMdzrlmW7PMafuKmfM1E1kkt94bnr+AeQuVl7siZl0+PK+cPeZ33lqQFEUk14ldvTxqNlzTC07UDA==:"
|
|
494
|
+
},
|
|
495
|
+
"directory": {
|
|
496
|
+
"authority": "example.com",
|
|
497
|
+
"created": 1754870400,
|
|
498
|
+
"expires": 1754877600,
|
|
499
|
+
"keyid": "1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y",
|
|
500
|
+
"alg": "ed25519",
|
|
501
|
+
"tag": "http-message-signatures-directory",
|
|
502
|
+
"components": [
|
|
503
|
+
"@authority"
|
|
504
|
+
],
|
|
505
|
+
"path": "/.well-known/http-message-signatures-directory",
|
|
506
|
+
"contentType": "application/http-message-signatures-directory+json",
|
|
507
|
+
"body": "{\"keys\":[{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg\"}]}",
|
|
508
|
+
"signatureParams": "(\"@authority\");created=1754870400;expires=1754877600;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"",
|
|
509
|
+
"signatureBase": "\"@authority\": example.com\n\"@signature-params\": (\"@authority\");created=1754870400;expires=1754877600;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"",
|
|
510
|
+
"signatureInput": "sig1=(\"@authority\");created=1754870400;expires=1754877600;keyid=\"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y\";alg=\"ed25519\";tag=\"http-message-signatures-directory\"",
|
|
511
|
+
"signature": "sig1=:xn3Gf4Niw6pEOIKZD9NC85a+AGbY6S7oBeBn77Li4vVm7TeFJHpeWqvws8n4JCIqIN1MXPjZAbOQmloCJd/3CQ==:"
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
"cardpub": [
|
|
515
|
+
{
|
|
516
|
+
"name": "relay-only",
|
|
517
|
+
"card": {
|
|
518
|
+
"did": "did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF",
|
|
519
|
+
"name": "Node",
|
|
520
|
+
"url": "",
|
|
521
|
+
"relay": "https://relay.example",
|
|
522
|
+
"enc_pub": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
523
|
+
},
|
|
524
|
+
"ts": 1784273681,
|
|
525
|
+
"envelopePayload": "{\"card\":{\"did\":\"did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF\",\"enc_pub\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"name\":\"Node\",\"relay\":\"https://relay.example\",\"url\":\"\"},\"ts\":1784273681,\"typ\":\"agentcard\",\"v\":1}",
|
|
526
|
+
"sig": "Ryrhh/k3UvsVHF4kR1gLOhinZMtKNWcQuvMHSWZ/UVLU34XJ0NmSXh3QyUiqpXuQdoU09F8DLgkbXEEPcJn0AA=="
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
"name": "with-url",
|
|
530
|
+
"card": {
|
|
531
|
+
"did": "did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF",
|
|
532
|
+
"name": "Node",
|
|
533
|
+
"url": "http://127.0.0.1:8001/"
|
|
534
|
+
},
|
|
535
|
+
"ts": 0,
|
|
536
|
+
"envelopePayload": "{\"card\":{\"did\":\"did:key:z6MkpFkurpyZgyna5SAfLpvdzp7W6cvdc1fn9YECrwv3AMbF\",\"name\":\"Node\",\"url\":\"http://127.0.0.1:8001/\"},\"ts\":0,\"typ\":\"agentcard\",\"v\":1}",
|
|
537
|
+
"sig": "7NNkIvHLZ8CgjlHXsTaGRYfmmKhKLpdaRGw0dX3ztbrY05wyaWBN+5RlM4vLPxeggSMb+VxYIQ0pgfcONK7zDQ=="
|
|
538
|
+
}
|
|
539
|
+
],
|
|
540
|
+
"epochNote": "NOT bytes, so not pinnable here — and the thing a client is most likely to get wrong. An `epoch` identifies a LISTENER, not a poll: sortable (creation-time+pid), STABLE for that listener's life, greater-wins, older gets HTTP 409. Minting a fresh epoch per poll makes a client supersede its own long-poll and drop mail. Specified in docs/SPECIFICATION.md §relay.",
|
|
541
|
+
"reject": {
|
|
542
|
+
"message": [
|
|
543
|
+
{
|
|
544
|
+
"name": "from-not-signer",
|
|
545
|
+
"category": "from-not-signer",
|
|
546
|
+
"mustReject": true,
|
|
547
|
+
"input": {
|
|
548
|
+
"from": "did:key:z6MkpgbpjhTBQSzhqCnLNNSnz42fSfBak34gYxoE2vqjKPSJ",
|
|
549
|
+
"to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
|
|
550
|
+
"messageId": "m1",
|
|
551
|
+
"contextId": "c1",
|
|
552
|
+
"timestamp": 1784273681,
|
|
553
|
+
"text": "pay the invoice",
|
|
554
|
+
"sig": "E/lo60PN4uJdh7nt2BYj3BZU1rFmsTv+/1WK8iuEJ/zmjn2W6X+ycD+Knvs8mGvJjaJ1b/fHNk+hfUHKtX/1CA=="
|
|
555
|
+
},
|
|
556
|
+
"note": "the signature is valid, but by a DIFFERENT key than `from` names. Derive the verifying key FROM `from` (with did:key the DID IS the key) and check against it — do not trust `from` as a label, and do not verify against a key from any other field."
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
"name": "bad-signature",
|
|
560
|
+
"category": "bad-signature",
|
|
561
|
+
"mustReject": true,
|
|
562
|
+
"input": {
|
|
563
|
+
"from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
|
|
564
|
+
"to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
|
|
565
|
+
"messageId": "m1",
|
|
566
|
+
"contextId": "c1",
|
|
567
|
+
"timestamp": 1784273681,
|
|
568
|
+
"text": "pay the invoice",
|
|
569
|
+
"sig": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
|
|
570
|
+
},
|
|
571
|
+
"note": "garbage signature bytes over an otherwise valid envelope — must not be displayed as signed."
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
"name": "tampered-text",
|
|
575
|
+
"category": "tampered-text",
|
|
576
|
+
"mustReject": true,
|
|
577
|
+
"input": {
|
|
578
|
+
"from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
|
|
579
|
+
"to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
|
|
580
|
+
"messageId": "m1",
|
|
581
|
+
"contextId": "c1",
|
|
582
|
+
"timestamp": 1784273681,
|
|
583
|
+
"text": "pay the ATTACKER",
|
|
584
|
+
"sig": "E/lo60PN4uJdh7nt2BYj3BZU1rFmsTv+/1WK8iuEJ/zmjn2W6X+ycD+Knvs8mGvJjaJ1b/fHNk+hfUHKtX/1CA=="
|
|
585
|
+
},
|
|
586
|
+
"note": "signed for one text, delivered with another. Recompute the canonical signing payload from the received fields — never trust a `sig` field without recomputing what it covers."
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
"name": "tampered-timestamp",
|
|
590
|
+
"category": "tampered-timestamp",
|
|
591
|
+
"mustReject": true,
|
|
592
|
+
"input": {
|
|
593
|
+
"from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
|
|
594
|
+
"to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
|
|
595
|
+
"messageId": "m1",
|
|
596
|
+
"contextId": "c1",
|
|
597
|
+
"timestamp": 1784273999,
|
|
598
|
+
"text": "pay the invoice",
|
|
599
|
+
"sig": "E/lo60PN4uJdh7nt2BYj3BZU1rFmsTv+/1WK8iuEJ/zmjn2W6X+ycD+Knvs8mGvJjaJ1b/fHNk+hfUHKtX/1CA=="
|
|
600
|
+
},
|
|
601
|
+
"note": "signed ts changed in transit (also proves timestamp is inside the signed payload)."
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
"name": "missing-sig",
|
|
605
|
+
"category": "missing-sig",
|
|
606
|
+
"mustReject": true,
|
|
607
|
+
"input": {
|
|
608
|
+
"from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
|
|
609
|
+
"to": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
|
|
610
|
+
"messageId": "m1",
|
|
611
|
+
"contextId": "c1",
|
|
612
|
+
"timestamp": 1784273681,
|
|
613
|
+
"text": "pay the invoice",
|
|
614
|
+
"sig": null
|
|
615
|
+
},
|
|
616
|
+
"note": "no signature at all — an unsigned message must never be shown as from a DID. `agent/inbox.verify` raises before any brain/log/display."
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
"name": "wrong-recipient",
|
|
620
|
+
"category": "wrong-recipient",
|
|
621
|
+
"mustReject": true,
|
|
622
|
+
"input": {
|
|
623
|
+
"from": "did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7",
|
|
624
|
+
"to": "did:key:z6Mkm6LsYZNUxC249DMDsHx5rZgMSuP3VNrqsCSk3nYZiGjP",
|
|
625
|
+
"messageId": "m1",
|
|
626
|
+
"contextId": "c1",
|
|
627
|
+
"timestamp": 1784273681,
|
|
628
|
+
"text": "pay the invoice",
|
|
629
|
+
"sig": "mbWMD6FBZevg6rxqAFKIbyxVNVXlL3hXvq+D+dhAFLAvCCLLhc15GQbWSPykZ7YtQzqJzAvigUCg5Ux7reCAAA=="
|
|
630
|
+
},
|
|
631
|
+
"recipientDid": "did:key:z6MkknhhwAGsRrcTTthPSsBbQtrNX6CMqt4poro5jdP6uCue",
|
|
632
|
+
"note": "validly signed, but `to` is not us. The signature verifies for the real recipient; a client must still refuse a message not addressed to it."
|
|
633
|
+
}
|
|
634
|
+
]
|
|
635
|
+
},
|
|
636
|
+
"rejectNote": "Each case MUST be rejected by your receiver (`mustReject`). `category` is language-neutral guidance, NOT core's -32xxx — your own error taxonomy is yours. message: verified with the key DERIVED FROM `from` (crypto.verify_envelope); invite: shared/invite.verify_invite, judged at the case's `checkNow`; claim: signature MUST verify AND a one-time nonce THIS device issued must be consumed before any trust is written. ONE case cannot be a static vector: `claim-spent-nonce` (a replayed claim whose nonce was already consumed) needs receiver state, so it is a REQUIRED client unit test, not pinned here — the same boundary as cryptobox's random-nonce seal."
|
|
637
|
+
}
|
package/examples/server.mjs
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
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_SIGNED_RATE / AGENT_ENTRY_SIGNED_RATE_TOTAL
|
|
37
|
+
* signed replies per minute per ACCOUNT and for the whole entry.
|
|
38
|
+
* Defaults are generous; LOWER THEM if your responder calls a model —
|
|
39
|
+
* the signature costs microseconds, the answer may not. 0 disables a tier.
|
|
36
40
|
* AGENT_ENTRY_GUEST "1" = GUEST MOUNT: coexist with a site that keeps its own front
|
|
37
41
|
* page. The entry serves its card paths and the POST door named by
|
|
38
42
|
* AGENT_ENTRY_BASE_URL (which must then carry that path, e.g.
|
|
@@ -143,6 +147,14 @@ try {
|
|
|
143
147
|
responder,
|
|
144
148
|
openDoor: true, // "you may contact me, no introduction"
|
|
145
149
|
anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
|
|
150
|
+
// The SIGNED lane's ceilings. Left unset they are the library defaults, which no
|
|
151
|
+
// conversational peer meets. LOWER THEM if this responder calls a model: verifying a
|
|
152
|
+
// signature costs microseconds, and what the ceiling actually protects is whatever you
|
|
153
|
+
// put behind `responder`.
|
|
154
|
+
...(process.env.AGENT_ENTRY_SIGNED_RATE
|
|
155
|
+
? { signedRatePerMin: Number(process.env.AGENT_ENTRY_SIGNED_RATE) } : {}),
|
|
156
|
+
...(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL
|
|
157
|
+
? { signedRatePerMinTotal: Number(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL) } : {}),
|
|
146
158
|
guest: process.env.AGENT_ENTRY_GUEST === '1',
|
|
147
159
|
wbaVerifiers,
|
|
148
160
|
});
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -54,6 +54,33 @@ export const CARD_SIG_REFRESH_S = 3600;
|
|
|
54
54
|
* wrote. Must match `ANON_RATE_PER_MIN` in examples/agent_entry_reference.py: one contract,
|
|
55
55
|
* two implementations, one bound. */
|
|
56
56
|
export const ANON_RATE_PER_MIN = 30;
|
|
57
|
+
/** Ceilings on the SIGNED lane: replies per minute per ACCOUNT, and per minute for the
|
|
58
|
+
* whole entry. Both default ON.
|
|
59
|
+
*
|
|
60
|
+
* WHY A SIGNED SENDER NEEDS A BOUND AT ALL. The reason not to have one used to be that a
|
|
61
|
+
* signed sender "is attributable, and every one of them is already in the ledger". Both
|
|
62
|
+
* clauses are true and neither is load-bearing: the ledger is never read back before the
|
|
63
|
+
* responder runs, so attribution is RECORDED and never ENFORCED — and attribution to a
|
|
64
|
+
* did:key minted thirty seconds ago and never reused is attribution to nothing, because
|
|
65
|
+
* holding one costs nothing. That is the correct, deliberate property of a permissionless
|
|
66
|
+
* door (this card TELLS strangers to mint one, with runnable code), so the bound has to
|
|
67
|
+
* come from somewhere else. Naming a caller is a precondition for limiting it, never a
|
|
68
|
+
* substitute for limiting it.
|
|
69
|
+
*
|
|
70
|
+
* WHY TWO TIERS. Verifying a signature is ~40 microseconds; what the ceiling protects is
|
|
71
|
+
* whatever the operator put behind `responder`, which may be a model call costing seconds
|
|
72
|
+
* and real money. Per-ACCOUNT (the T102-resolved account, so an owner's devices share one
|
|
73
|
+
* budget exactly as their ledger row does) stops one peer, honest or not, from taking the
|
|
74
|
+
* whole door. It CANNOT stop a flood from fresh keys — free identity defeats per-identity
|
|
75
|
+
* metering by definition — which is what the whole-entry ceiling is for. Ship both or
|
|
76
|
+
* neither; each one alone has an obvious hole.
|
|
77
|
+
*
|
|
78
|
+
* Defaults are deliberately generous: no conversational peer meets them, and a site whose
|
|
79
|
+
* responder calls a model should lower them. A reference implementation's defaults are the
|
|
80
|
+
* deployed posture of everyone who copies it. Must match examples/agent_entry_reference.py:
|
|
81
|
+
* one contract, two implementations, one bound. */
|
|
82
|
+
export const SIGNED_RATE_PER_MIN = 60;
|
|
83
|
+
export const SIGNED_RATE_PER_MIN_TOTAL = 600;
|
|
57
84
|
/** How many domains one card may advertise (agent/domainstore.MAX_CARD_DOMAINS, and the
|
|
58
85
|
* same ceiling shared/protocol.build_agent_card applies to a node's card). Every name
|
|
59
86
|
* listed is an outbound HTTPS fetch this entry asks strangers to make, so the cap bounds
|
|
@@ -94,8 +121,17 @@ export const AGENT_ENTRY_REL = 'https://muretai.net/rel/agent-entry';
|
|
|
94
121
|
* first, or ship no pointer. Verified live before this value was set (200 at the URL
|
|
95
122
|
* below, 404 at a control path under the same prefix); `entry-howto-resolves` in
|
|
96
123
|
* .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
|
-
|
|
124
|
+
* `FIRST_KNOCK_URL` in examples/agent_entry_reference.py.
|
|
125
|
+
*
|
|
126
|
+
* DEFAULT EMPTY, and the paragraph above is why: this file already calls empty "the safe
|
|
127
|
+
* default" and then shipped a vendor's docs host as the value, so every door built from it
|
|
128
|
+
* stamped that host into its own public card. A reference implementation names no host —
|
|
129
|
+
* the recipe travels IN the refusal (the contract suite strips every `http` value and
|
|
130
|
+
* requires what is left to still be a complete recipe), and the worked example now travels
|
|
131
|
+
* in the package itself as `conformance/`. Pass `howToUrl` to point at a page you operate;
|
|
132
|
+
* ship the page FIRST and verify it 200s, because a dangling pointer is the one failure
|
|
133
|
+
* measured here. */
|
|
134
|
+
export const FIRST_KNOCK_URL = '';
|
|
99
135
|
|
|
100
136
|
/** `Allow:` per RESOURCE, not per server. RFC 9110 §10.2.1 makes `Allow` a statement about
|
|
101
137
|
* the target resource, and §15.5.6 REQUIRES it on a 405 — a generic list is a wrong answer
|
|
@@ -173,7 +209,7 @@ export const ALLOW_MOUNT = 'GET, HEAD, POST, OPTIONS';
|
|
|
173
209
|
* field, string for string: the card and the refusal are one object, and two twins that
|
|
174
210
|
* teach a keyless visitor differently are two doors.
|
|
175
211
|
*/
|
|
176
|
-
export function signedEnvelopeRequirement(did) {
|
|
212
|
+
export function signedEnvelopeRequirement(did, endpoint = '', howTo = FIRST_KNOCK_URL) {
|
|
177
213
|
const requirement = {
|
|
178
214
|
scheme: SIGNED_ENVELOPE_SCHEME,
|
|
179
215
|
instruction: 'Generate an Ed25519 keypair NOW, locally, and knock again with it. You '
|
|
@@ -187,7 +223,11 @@ export function signedEnvelopeRequirement(did) {
|
|
|
187
223
|
+ 'a refusal: minting one is step 1, and it is a few lines of code. The COMPLETE '
|
|
188
224
|
+ 'request body to POST is exampleRequest below: copy it and fill in its five <...> '
|
|
189
225
|
+ 'blanks. The A2A envelope AROUND your signature is checked too, so send that shape '
|
|
190
|
-
+ 'and do not improvise one.'
|
|
226
|
+
+ 'and do not improvise one. KEEP THE PRIVATE HALF: write it to a file only you '
|
|
227
|
+
+ 'can read (mode 600). It is the whole of your identity - this site will address '
|
|
228
|
+
+ 'you by the did:key you derive from it, losing it makes you a stranger here, and '
|
|
229
|
+
+ 'the same seed is what later upgrades you into a full muretai node with this SAME '
|
|
230
|
+
+ 'address rather than a second one.',
|
|
191
231
|
in: 'params.message.metadata',
|
|
192
232
|
// THE CARRIER, not just the signature. `in` tells you where the six envelope fields go
|
|
193
233
|
// and says nothing about the message that carries them, and two proof-run agents read it
|
|
@@ -220,6 +260,17 @@ export function signedEnvelopeRequirement(did) {
|
|
|
220
260
|
},
|
|
221
261
|
},
|
|
222
262
|
recipient: did,
|
|
263
|
+
// WHERE to send it. Everything else in this object describes the MESSAGE — the six
|
|
264
|
+
// signed fields, the canonical bytes, the whole example body — and none of it said
|
|
265
|
+
// the address, so a first-time caller guessed. Measured 2026-08-19 (L1 dogfooding,
|
|
266
|
+
// fresh-user run): the guess was `/rpc`, the most plausible path for a JSON-RPC
|
|
267
|
+
// body, which on muretai.com is the RELAY transport. It reads `to`/`from`/`blob`/
|
|
268
|
+
// `sig` off the TOP level, finds none of them in an A2A envelope, and answers
|
|
269
|
+
// `{"error":"bad signature"}` — a routing mismatch reported as a crypto failure, at
|
|
270
|
+
// the exact moment the caller has the least context to tell the two apart. They
|
|
271
|
+
// went off to re-check canonicalization, base64 padding and clock skew; none of it
|
|
272
|
+
// was wrong. One field costs nothing and removes the guess.
|
|
273
|
+
endpoint,
|
|
223
274
|
identity: 'Derive your DID from the public key you just generated and send it as '
|
|
224
275
|
+ 'metadata.from: did:key:z + base58btc(0xed01 || <32-byte Ed25519 public key>)',
|
|
225
276
|
// RUN this, do not write the address by hand. Measured 2026-08-18: a real agent minted a
|
|
@@ -253,7 +304,7 @@ export function signedEnvelopeRequirement(did) {
|
|
|
253
304
|
// Emitted ONLY when it is known to resolve — an unresolvable pointer out-competes every
|
|
254
305
|
// field beside it (see FIRST_KNOCK_URL). Appended last so the object's other bytes and
|
|
255
306
|
// their positions do not move when a site turns the pointer off.
|
|
256
|
-
if (
|
|
307
|
+
if (howTo) requirement.howTo = howTo;
|
|
257
308
|
return requirement;
|
|
258
309
|
}
|
|
259
310
|
|
|
@@ -1446,6 +1497,35 @@ class RateBound {
|
|
|
1446
1497
|
}
|
|
1447
1498
|
}
|
|
1448
1499
|
|
|
1500
|
+
/** Per-ACCOUNT sliding windows, in a map that is itself bounded — because the keys are
|
|
1501
|
+
* free to mint, an unbounded map here would be the memory-growth vector the bound exists
|
|
1502
|
+
* to close. Eviction is oldest-first, the ledger's own discipline.
|
|
1503
|
+
*
|
|
1504
|
+
* Read the eviction honestly: a caller cycling fresh keys is evicted and re-admitted with
|
|
1505
|
+
* a clean window every time, so this tier alone stops NOTHING it was not already unable to
|
|
1506
|
+
* stop. That is not a flaw to fix here — it is why the whole-entry ceiling is not optional. */
|
|
1507
|
+
class AccountRateBounds {
|
|
1508
|
+
constructor(perMinute, maxKeys) {
|
|
1509
|
+
this.perMinute = perMinute;
|
|
1510
|
+
this.maxKeys = Math.max(1, Number(maxKeys) || 1);
|
|
1511
|
+
this.byAccount = new Map();
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** Consume one token for `account`. False when that account's window is full. */
|
|
1515
|
+
allow(account) {
|
|
1516
|
+
let bound = this.byAccount.get(account);
|
|
1517
|
+
if (!bound) {
|
|
1518
|
+
if (this.byAccount.size >= this.maxKeys) {
|
|
1519
|
+
const oldest = this.byAccount.keys().next().value;
|
|
1520
|
+
if (oldest !== undefined) this.byAccount.delete(oldest);
|
|
1521
|
+
}
|
|
1522
|
+
bound = new RateBound(this.perMinute);
|
|
1523
|
+
this.byAccount.set(account, bound);
|
|
1524
|
+
}
|
|
1525
|
+
return bound.allow();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1449
1529
|
/**
|
|
1450
1530
|
* An unpaired UTF-16 surrogate — a string with no UTF-8 encoding at all.
|
|
1451
1531
|
*
|
|
@@ -2138,9 +2218,20 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
2138
2218
|
* anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
|
|
2139
2219
|
* and the lane as a whole is capped at `anonRatePerMin` signed replies per
|
|
2140
2220
|
* minute — it is unauthenticated, so it must not be an unmetered signing
|
|
2141
|
-
* oracle.
|
|
2142
|
-
* and every one of them is already in the ledger.
|
|
2221
|
+
* oracle.
|
|
2143
2222
|
* anonRatePerMin anonymous replies per minute for the WHOLE agent entry (default 30).
|
|
2223
|
+
* signedRatePerMin / signedRatePerMinTotal
|
|
2224
|
+
* the SIGNED lane's ceilings: replies per minute per ACCOUNT (default 60)
|
|
2225
|
+
* and for the whole entry (default 600). Both ON by default; pass 0 or a
|
|
2226
|
+
* non-number to disable a tier. Checked AFTER the signature, so no
|
|
2227
|
+
* stranger can spend another account's budget, and BEFORE the ledger and
|
|
2228
|
+
* the responder, so a refused flood grows neither. See
|
|
2229
|
+
* SIGNED_RATE_PER_MIN for why being attributable is not being bounded.
|
|
2230
|
+
* howToUrl OPTIONAL page a keyless visitor is pointed at (`howTo` on the card and
|
|
2231
|
+
* the refusal). DEFAULT EMPTY and the field is then omitted entirely: a
|
|
2232
|
+
* reference implementation names no host, and the refusal is a complete
|
|
2233
|
+
* recipe without it. SHIP THE PAGE FIRST — a pointer that 404s
|
|
2234
|
+
* out-competes every field beside it and reads as terminal.
|
|
2144
2235
|
* wbaVerifiers OPTIONAL inbound Web Bot Auth (T107): a JWKS document {keys:[…]} of
|
|
2145
2236
|
* Ed25519 keys whose holders this entry should RECOGNISE — the body of
|
|
2146
2237
|
* a key directory you verified out of band. Absent (the default) the
|
|
@@ -2148,6 +2239,25 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
2148
2239
|
* Recognition only ever ADDS identity (env.wba_did, the wbaVisits
|
|
2149
2240
|
* count); it never changes verified, a ledger row, a rate lane or any
|
|
2150
2241
|
* refusal verdict.
|
|
2242
|
+
* observer OPTIONAL `(env) => void` — a WATCHER, called once per message with the
|
|
2243
|
+
* same frozen envelope the responder gets. It exists so that OBSERVING a
|
|
2244
|
+
* visit is not the same edit as ANSWERING one: wanting a counter should
|
|
2245
|
+
* not mean reaching into the code that decides what to say.
|
|
2246
|
+
*
|
|
2247
|
+
* IT CANNOT AFFECT ANYTHING. It is called AFTER the verdict is settled,
|
|
2248
|
+
* its return value is discarded, a thrown error is swallowed, and a
|
|
2249
|
+
* promise is never awaited — so a slow or broken watcher cannot delay,
|
|
2250
|
+
* fail, or change one byte of the signed reply. That is the whole contract
|
|
2251
|
+
* and it is not a formality: this door answers in ONE round trip with no
|
|
2252
|
+
* callback, and a watcher that dialled out on the hot path would make the
|
|
2253
|
+
* visitor's answer depend on somebody else's uptime.
|
|
2254
|
+
*
|
|
2255
|
+
* WHAT NOT TO PUT IN IT. The envelope carries `peer_did`/`owner_did`,
|
|
2256
|
+
* which a visitor handed you to transact with YOU. Forwarding a raw DID to
|
|
2257
|
+
* a third party shares a durable identifier its owner never offered them;
|
|
2258
|
+
* if you need a metric, send a salted, site-scoped digest and keep the DID
|
|
2259
|
+
* in your own store. This module ships no sink and names no vendor — the
|
|
2260
|
+
* slot is here so an adapter can live outside it.
|
|
2151
2261
|
*/
|
|
2152
2262
|
export function createAgentEntry({
|
|
2153
2263
|
seedHex,
|
|
@@ -2159,11 +2269,15 @@ export function createAgentEntry({
|
|
|
2159
2269
|
openDoor = true,
|
|
2160
2270
|
anonymousLane = false,
|
|
2161
2271
|
anonRatePerMin = ANON_RATE_PER_MIN,
|
|
2272
|
+
signedRatePerMin = SIGNED_RATE_PER_MIN,
|
|
2273
|
+
signedRatePerMinTotal = SIGNED_RATE_PER_MIN_TOTAL,
|
|
2162
2274
|
skills = [],
|
|
2163
2275
|
domains = null,
|
|
2164
2276
|
basePath = null,
|
|
2165
2277
|
guest = false,
|
|
2166
2278
|
maxAccounts = 50000,
|
|
2279
|
+
howToUrl = FIRST_KNOCK_URL,
|
|
2280
|
+
observer = null,
|
|
2167
2281
|
wbaVerifiers = null,
|
|
2168
2282
|
} = {}) {
|
|
2169
2283
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
@@ -2194,9 +2308,18 @@ export function createAgentEntry({
|
|
|
2194
2308
|
// bare domain: a name the domain's credential can never bind is not worth publishing.
|
|
2195
2309
|
const canonDomains = canonicalDomains(domains);
|
|
2196
2310
|
const did = didFromSeedHex(seedHex);
|
|
2311
|
+
// WHERE A VISITOR POSTS — derived from the PUBLISHED url, never from `mount`. The two
|
|
2312
|
+
// are different questions and the answers legitimately differ: `mount` is the path THIS
|
|
2313
|
+
// PROCESS matches (a prefix-stripping proxy makes it '' while the public address still
|
|
2314
|
+
// carries the path), and this is the address a stranger dials. Appending `mount` to a
|
|
2315
|
+
// url that already carries that same path published `https://h/support/support` on every
|
|
2316
|
+
// default path mount — a 404 signed into a public card, on the one field that exists to
|
|
2317
|
+
// stop a caller guessing. It stayed invisible because the only per-twin assertion ran at
|
|
2318
|
+
// a bare origin, where `mount` is '' and the wrong formula is accidentally right.
|
|
2319
|
+
const doorUrl = canonUrl + (canonicalMount(canonUrl) ? '' : '/');
|
|
2197
2320
|
// The terms of this door, built ONCE: the card publishes it (E1, before the knock) and
|
|
2198
2321
|
// the no-envelope refusal returns the same object (E2, after it).
|
|
2199
|
-
const requirement = signedEnvelopeRequirement(did);
|
|
2322
|
+
const requirement = signedEnvelopeRequirement(did, doorUrl, howToUrl);
|
|
2200
2323
|
|
|
2201
2324
|
const card = {
|
|
2202
2325
|
protocolVersion: PROTOCOL_VERSION,
|
|
@@ -2215,6 +2338,10 @@ export function createAgentEntry({
|
|
|
2215
2338
|
// entry's card. Omitted entirely when no domain was named — that is what keeps an
|
|
2216
2339
|
// already-deployed entry's published bytes unchanged.
|
|
2217
2340
|
if (canonDomains.length) card.domains = canonDomains;
|
|
2341
|
+
// Neutral key first, vendor key beside it for one release. See the securitySchemes block
|
|
2342
|
+
// below for why the old spelling stays: a consumer must learn the new name BEFORE
|
|
2343
|
+
// producers stop emitting the old one, never after.
|
|
2344
|
+
if (openDoor) card.agentEntry = { open_door: true };
|
|
2218
2345
|
if (openDoor) card.muretai = { open_door: true };
|
|
2219
2346
|
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
2220
2347
|
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
@@ -2241,15 +2368,28 @@ export function createAgentEntry({
|
|
|
2241
2368
|
// with the lock documented. The worked-example clause appears only when the page it
|
|
2242
2369
|
// names is known to resolve (see FIRST_KNOCK_URL); a 404 here would out-compete the
|
|
2243
2370
|
// block beside it.
|
|
2371
|
+
//
|
|
2372
|
+
// THE TERMS ARE PUBLISHED UNDER A NEUTRAL KEY, and under the vendor one BESIDE it for a
|
|
2373
|
+
// release. A standard whose core machine-readable object is KEYED BY A VENDOR is not a
|
|
2374
|
+
// standard; a standard that carries vendor BLOCKS is healthy — so the object moves to
|
|
2375
|
+
// `agentEntry` and `muretai` stays as a byte-identical alias until consumers have turned
|
|
2376
|
+
// over. Additive on purpose: removing the old spelling first would split doors from
|
|
2377
|
+
// visitors, and a reader who learned one name must keep finding the terms where they
|
|
2378
|
+
// looked. `muretai` is dropped at the next MAJOR, not at a minor. Settle this BEFORE a
|
|
2379
|
+
// second scheme exists: whatever ships next would otherwise land inside the vendor key
|
|
2380
|
+
// and entrench it at exactly the moment neutrality is the point.
|
|
2244
2381
|
card.securitySchemes = {
|
|
2245
2382
|
[SIGNED_ENVELOPE_SCHEME]: {
|
|
2246
2383
|
type: SIGNED_ENVELOPE_SCHEME,
|
|
2247
2384
|
description: 'Generate an Ed25519 keypair if you do not have one - no account, no '
|
|
2248
2385
|
+ 'registration, any crypto library will do - then sign every message/send with '
|
|
2249
2386
|
+ '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
|
|
2251
|
-
+ 'beside this line'
|
|
2252
|
-
+ (
|
|
2387
|
+
+ 'signed fields, the canonical bytes and the address to send to are in '
|
|
2388
|
+
+ '`agentEntry` beside this line'
|
|
2389
|
+
+ (howToUrl ? `; a worked example is at ${howToUrl}` : ''),
|
|
2390
|
+
agentEntry: requirement,
|
|
2391
|
+
// The vendor spelling, byte-identical, kept for one release so a consumer that
|
|
2392
|
+
// learned this name still finds the terms. Dropped at the next MAJOR.
|
|
2253
2393
|
muretai: requirement,
|
|
2254
2394
|
},
|
|
2255
2395
|
};
|
|
@@ -2271,6 +2411,14 @@ export function createAgentEntry({
|
|
|
2271
2411
|
const deviceOwner = new Map();
|
|
2272
2412
|
const replay = new ReplayGuard();
|
|
2273
2413
|
const anonRate = new RateBound(anonRatePerMin);
|
|
2414
|
+
// A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
|
|
2415
|
+
// rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
|
|
2416
|
+
// RateBound reads as refuse-everything, the opposite of what an operator passing 0 means.
|
|
2417
|
+
const perMin = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Number(n) : 0);
|
|
2418
|
+
const signedAccountRate = perMin(signedRatePerMin)
|
|
2419
|
+
? new AccountRateBounds(perMin(signedRatePerMin), maxAccounts) : null;
|
|
2420
|
+
const signedTotalRate = perMin(signedRatePerMinTotal)
|
|
2421
|
+
? new RateBound(perMin(signedRatePerMinTotal)) : null;
|
|
2274
2422
|
let sigEnvelope = null;
|
|
2275
2423
|
let sigMintedAt = 0;
|
|
2276
2424
|
|
|
@@ -2744,6 +2892,23 @@ export function createAgentEntry({
|
|
|
2744
2892
|
const account = acct.account;
|
|
2745
2893
|
const ownerDid = account !== from ? account : null;
|
|
2746
2894
|
|
|
2895
|
+
// 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a stranger
|
|
2896
|
+
// could spend somebody else's budget by naming them, and before `resolveAccount` an
|
|
2897
|
+
// owner's devices would each get their own. Here and not later: a refused flood must
|
|
2898
|
+
// grow neither the ledger nor whatever the responder costs.
|
|
2899
|
+
// PER-ACCOUNT FIRST, deliberately — one loud peer is then stopped by ITS OWN window
|
|
2900
|
+
// without drawing down the shared one, so it cannot starve everybody else on its way
|
|
2901
|
+
// to being refused. Neither refusal names its ceiling: a published number is a
|
|
2902
|
+
// calibration table telling a flood exactly how many keys to mint.
|
|
2903
|
+
if (signedAccountRate && !signedAccountRate.allow(account)) {
|
|
2904
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
2905
|
+
'you are sending faster than this door answers — slow down and retry');
|
|
2906
|
+
}
|
|
2907
|
+
if (signedTotalRate && !signedTotalRate.allow()) {
|
|
2908
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
2909
|
+
'this entry is at its ceiling right now — retry shortly');
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2747
2912
|
noteContact(account);
|
|
2748
2913
|
// T107: `wba_did` may legitimately differ from `peer_did` (the transport signer vs
|
|
2749
2914
|
// the message signer) — both facts are honest, and the schema says which is which.
|
|
@@ -2751,7 +2916,27 @@ export function createAgentEntry({
|
|
|
2751
2916
|
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from);
|
|
2752
2917
|
}
|
|
2753
2918
|
|
|
2919
|
+
/** Hand the envelope to the watcher, and make sure it can cost nothing.
|
|
2920
|
+
*
|
|
2921
|
+
* Called BEFORE the responder on purpose: observing that a visit HAPPENED must not
|
|
2922
|
+
* depend on the answer succeeding, or the one request worth counting — the one where
|
|
2923
|
+
* the site's own code threw — is the one that goes uncounted.
|
|
2924
|
+
*
|
|
2925
|
+
* Everything here is a refusal to let a watcher matter: the return value is discarded,
|
|
2926
|
+
* a synchronous throw is swallowed, and a returned promise is given a rejection handler
|
|
2927
|
+
* and then DROPPED rather than awaited. That last one is not tidiness — an unhandled
|
|
2928
|
+
* rejection can take a Node process down, so the watcher must not be able to end the
|
|
2929
|
+
* door by failing quietly in the background. */
|
|
2930
|
+
function observe(env) {
|
|
2931
|
+
if (typeof observer !== 'function') return;
|
|
2932
|
+
try {
|
|
2933
|
+
const r = observer(env);
|
|
2934
|
+
if (isThenable(r)) r.then(undefined, () => {});
|
|
2935
|
+
} catch { /* a watcher never changes what this door does */ }
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2754
2938
|
function respond(env, reqId, msg, toDid) {
|
|
2939
|
+
observe(env);
|
|
2755
2940
|
let answer;
|
|
2756
2941
|
try {
|
|
2757
2942
|
answer = responder(env);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"muretai-agent-entry.mjs",
|
|
12
|
+
"conformance/",
|
|
12
13
|
"examples/server.mjs",
|
|
13
14
|
"LICENSE",
|
|
14
15
|
"README.md"
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
"node": ">=20"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
21
|
+
"test": "node conformance/run.mjs",
|
|
20
22
|
"example": "node examples/server.mjs"
|
|
21
23
|
},
|
|
22
24
|
"keywords": [
|