@muretai/agent-entry 1.0.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/LICENSE +21 -0
- package/README.md +230 -0
- package/examples/server.mjs +109 -0
- package/muretai-agent-entry.mjs +1358 -0
- package/package.json +42 -0
|
@@ -0,0 +1,1358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web/agent-entry/muretai-agent-entry.mjs
|
|
3
|
+
* THE AGENT ENTRY — one dependency-free Node file that makes a website agent-reachable.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* Muretai's adoption bottleneck is that BOTH ends had to run a node. A site does not want
|
|
7
|
+
* a daemon, a key store or an inbox loop; it wants an endpoint. This module is that half:
|
|
8
|
+
* drop it on an origin you already have, and a visiting agent can (1) verify from your
|
|
9
|
+
* Agent Card that this DID really owns this origin, (2) POST a signed A2A message, and
|
|
10
|
+
* (3) get YOUR signed reply back in the same HTTP response. First contact IS account
|
|
11
|
+
* creation — there is no signup form, because the sender's did:key already is the account.
|
|
12
|
+
*
|
|
13
|
+
* Zero dependencies, forever: `node:crypto`, `node:http`, `node:buffer` only. No npm, no
|
|
14
|
+
* build step, no transpiler. Node 20+ (native ed25519 / x25519 / hkdfSync / chacha20-poly1305).
|
|
15
|
+
*
|
|
16
|
+
* THE BYTES ARE THE CONTRACT. Every signed payload here must be byte-identical to what
|
|
17
|
+
* Python's `shared/crypto.canonical` produces, or the signature is unverifiable and the only
|
|
18
|
+
* diagnostic anyone gets is "signature verification failed". The pinned bytes live in
|
|
19
|
+
* `testdata/wire_vectors.json`; `test_agent_entry_contract.py` Part 3 re-derives all of them
|
|
20
|
+
* through this file. If you change anything under CANONICAL JSON, run that suite first.
|
|
21
|
+
*
|
|
22
|
+
* import { createAgentEntry } from './muretai-agent-entry.mjs';
|
|
23
|
+
* createAgentEntry({ seedHex, name: 'Example Studio', baseUrl: 'https://studio.example',
|
|
24
|
+
* responder: (env) => `You said: ${env.text}` }).listen(8788);
|
|
25
|
+
*
|
|
26
|
+
* See examples/agent_entry_server.mjs for the ~50-line file a site actually copies.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createHash, createPrivateKey, createPublicKey, createCipheriv, createDecipheriv,
|
|
31
|
+
diffieHellman, hkdfSync, randomBytes, sign as nodeSign, verify as nodeVerify,
|
|
32
|
+
} from 'node:crypto';
|
|
33
|
+
import { createServer } from 'node:http';
|
|
34
|
+
import { Buffer } from 'node:buffer';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------- protocol constants
|
|
37
|
+
|
|
38
|
+
export const PROTOCOL_VERSION = '0.2';
|
|
39
|
+
/** `text` ceiling in UTF-8 BYTES (shared/protocol.MAX_TEXT_BYTES). Bytes, not characters:
|
|
40
|
+
* a limit in characters is not a limit on what anyone has to store. */
|
|
41
|
+
export const MAX_TEXT_BYTES = 64 * 1024;
|
|
42
|
+
/** HTTP body ceiling. Anything larger is refused with 413 BEFORE the JSON parser sees it. */
|
|
43
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
44
|
+
/** Accepted clock skew, seconds, either direction (agent/inbox.CLOCK_WINDOW). */
|
|
45
|
+
export const CLOCK_WINDOW_S = 300;
|
|
46
|
+
/** How long a messageId is remembered for replay refusal, seconds. */
|
|
47
|
+
export const REPLAY_TTL_S = 600;
|
|
48
|
+
/** The signed card envelope is re-minted at most this often (Inbox.CARD_SIG_REFRESH). */
|
|
49
|
+
export const CARD_SIG_REFRESH_S = 3600;
|
|
50
|
+
/** Signed replies per minute the ANONYMOUS lane may cost this agent entry, in total.
|
|
51
|
+
* Unauthenticated, so without a bound it is a signing oracle: a stranger spends an Ed25519
|
|
52
|
+
* signature (and a backend call) per request forever and nothing can attribute the cost.
|
|
53
|
+
* Per-ENTRY, not per-IP — behind a proxy the source address is whatever the last hop
|
|
54
|
+
* wrote. Must match `ANON_RATE_PER_MIN` in examples/agent_entry_reference.py: one contract,
|
|
55
|
+
* two implementations, one bound. */
|
|
56
|
+
export const ANON_RATE_PER_MIN = 30;
|
|
57
|
+
|
|
58
|
+
export const AGENT_CARD_PATH = '/.well-known/agent-card.json';
|
|
59
|
+
export const AGENT_CARD_PATH_LEGACY = '/.well-known/agent.json';
|
|
60
|
+
export const AGENT_CARD_SIG_PATH = '/.well-known/agent-card.sig.json';
|
|
61
|
+
|
|
62
|
+
const CARD_ENVELOPE_VERSION = 1;
|
|
63
|
+
const CARD_ENVELOPE_TYPE = 'agentcard';
|
|
64
|
+
|
|
65
|
+
/** JSON-RPC + Muretai L2 error objects, message strings included — a client greps these. */
|
|
66
|
+
export const ERRORS = {
|
|
67
|
+
PARSE_ERROR: { code: -32700, message: 'Parse error' },
|
|
68
|
+
INVALID_REQUEST: { code: -32600, message: 'Invalid Request' },
|
|
69
|
+
METHOD_NOT_FOUND: { code: -32601, message: 'Method not found' },
|
|
70
|
+
INVALID_PARAMS: { code: -32602, message: 'Invalid params' },
|
|
71
|
+
INTERNAL_ERROR: { code: -32603, message: 'Internal error' },
|
|
72
|
+
UNAUTHENTICATED: { code: -32001, message: 'Signature verification failed' },
|
|
73
|
+
REPLAY_REJECTED: { code: -32002, message: 'Replay or stale message' },
|
|
74
|
+
WRONG_RECIPIENT: { code: -32003, message: 'Message not addressed to me' },
|
|
75
|
+
RATE_LIMITED: { code: -32004, message: 'Rate limited' },
|
|
76
|
+
MESSAGE_TOO_LARGE: { code: -32005, message: 'Message text too large' },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ================================================================ CANONICAL JSON
|
|
80
|
+
//
|
|
81
|
+
// Reproduces, byte for byte:
|
|
82
|
+
// json.dumps(obj, sort_keys=True, separators=(",",":"), ensure_ascii=False,
|
|
83
|
+
// allow_nan=False).encode("utf-8")
|
|
84
|
+
//
|
|
85
|
+
// The four traps, each pinned by a case in testdata/wire_vectors.json:
|
|
86
|
+
// 1. KEY ORDER is by UNICODE CODE POINT. JavaScript's default string sort compares
|
|
87
|
+
// UTF-16 code UNITS, which disagrees for astral characters (U+1F600 sorts BEFORE
|
|
88
|
+
// U+FFFD by unit, AFTER it by code point). `codePointCompare` below is deliberate.
|
|
89
|
+
// 2. NON-ASCII STAYS LITERAL (ensure_ascii=False). JSON.stringify already does this,
|
|
90
|
+
// but most hand-rolled canonicalizers \u-escape and are then wrong for every
|
|
91
|
+
// Japanese message on the network.
|
|
92
|
+
// 3. Python's ESCAPE SET is exactly: the seven shorthands (" \ \b \f \n \r \t), every
|
|
93
|
+
// other control char < 0x20 as lowercase \u00xx — and NOTHING else. `/` and DEL
|
|
94
|
+
// (0x7F) are NOT escaped. Many JSON writers escape both; that is a silent break.
|
|
95
|
+
// 4. NUMBERS. Only integers inside +/-(2**53-1) and ordinary fractional floats are
|
|
96
|
+
// emitted; anything whose rendering differs between Python and JavaScript THROWS
|
|
97
|
+
// rather than producing bytes only Python can verify (see numberHazards in the
|
|
98
|
+
// vectors: 1.0, -0.0, 1e-07, 1e+16, 2**53+1 …).
|
|
99
|
+
|
|
100
|
+
const ESCAPES = new Map([
|
|
101
|
+
['"', '\\"'], ['\\', '\\\\'], ['\b', '\\b'], ['\f', '\\f'],
|
|
102
|
+
['\n', '\\n'], ['\r', '\\r'], ['\t', '\\t'],
|
|
103
|
+
]);
|
|
104
|
+
// eslint-disable-next-line no-control-regex
|
|
105
|
+
const NEEDS_ESCAPE = /[\u0000-\u001f"\\]/;
|
|
106
|
+
|
|
107
|
+
function encodeString(s) {
|
|
108
|
+
if (!NEEDS_ESCAPE.test(s)) return `"${s}"`;
|
|
109
|
+
let out = '"';
|
|
110
|
+
for (const ch of s) { // iterates by CODE POINT, not code unit
|
|
111
|
+
const shorthand = ESCAPES.get(ch);
|
|
112
|
+
if (shorthand !== undefined) { out += shorthand; continue; }
|
|
113
|
+
const cp = ch.codePointAt(0);
|
|
114
|
+
if (cp < 0x20) out += `\\u${cp.toString(16).padStart(4, '0')}`; // lowercase hex
|
|
115
|
+
else out += ch; // '/' and DEL included: NOT escaped
|
|
116
|
+
}
|
|
117
|
+
return out + '"';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function encodeNumber(n) {
|
|
121
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
122
|
+
// allow_nan=False. NaN/Infinity are not JSON (RFC 8259) and no two languages agree
|
|
123
|
+
// on a spelling — refuse to sign them rather than emit a token nobody can check.
|
|
124
|
+
throw new TypeError(`canonicalJSON: non-finite number (${n})`);
|
|
125
|
+
}
|
|
126
|
+
if (Number.isInteger(n)) {
|
|
127
|
+
if (!Number.isSafeInteger(n)) {
|
|
128
|
+
// Not a formatting mismatch — SILENT DATA CORRUPTION. Python has arbitrary
|
|
129
|
+
// precision; a JS Number rounds. Signed integers stay inside +/-(2**53-1).
|
|
130
|
+
throw new RangeError(`canonicalJSON: integer outside +/-(2**53-1) (${n})`);
|
|
131
|
+
}
|
|
132
|
+
return String(n); // -0 renders "0", same as Python's int 0
|
|
133
|
+
}
|
|
134
|
+
const rendered = String(n);
|
|
135
|
+
if (rendered.includes('e') || rendered.includes('E')) {
|
|
136
|
+
// Python zero-pads and always signs the exponent (1e-07); JS writes 1e-7. And the
|
|
137
|
+
// thresholds at which each switches to exponent notation differ (Python 1e16, JS 1e21).
|
|
138
|
+
throw new RangeError(`canonicalJSON: float needs exponent notation (${rendered}) — `
|
|
139
|
+
+ 'Python and JavaScript spell it differently; use an integer');
|
|
140
|
+
}
|
|
141
|
+
if (Math.abs(n) < 1e-4) {
|
|
142
|
+
// Python's repr switches to exponent below 1e-4 while JS still writes decimals.
|
|
143
|
+
throw new RangeError(`canonicalJSON: float too small to render identically (${rendered})`);
|
|
144
|
+
}
|
|
145
|
+
return rendered;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Compare two strings by UNICODE CODE POINT (Python's `str` order), not UTF-16 unit. */
|
|
149
|
+
function codePointCompare(a, b) {
|
|
150
|
+
if (a === b) return 0;
|
|
151
|
+
let i = 0, j = 0;
|
|
152
|
+
while (i < a.length && j < b.length) {
|
|
153
|
+
const ca = a.codePointAt(i), cb = b.codePointAt(j);
|
|
154
|
+
if (ca !== cb) return ca < cb ? -1 : 1;
|
|
155
|
+
i += ca > 0xffff ? 2 : 1;
|
|
156
|
+
j += cb > 0xffff ? 2 : 1;
|
|
157
|
+
}
|
|
158
|
+
if (i >= a.length && j < b.length) return -1; // a is a prefix of b
|
|
159
|
+
if (j >= b.length && i < a.length) return 1;
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function encodeValue(v) {
|
|
164
|
+
if (v === null) return 'null';
|
|
165
|
+
switch (typeof v) {
|
|
166
|
+
case 'string': return encodeString(v);
|
|
167
|
+
case 'number': return encodeNumber(v);
|
|
168
|
+
case 'boolean': return v ? 'true' : 'false';
|
|
169
|
+
case 'bigint':
|
|
170
|
+
// A BigInt would render exactly, but it can also exceed 2**53-1 silently on the
|
|
171
|
+
// way back in through JSON.parse. Refuse, like every other unrenderable number.
|
|
172
|
+
throw new TypeError('canonicalJSON: BigInt is not representable on this wire');
|
|
173
|
+
case 'object': break;
|
|
174
|
+
default:
|
|
175
|
+
throw new TypeError(`canonicalJSON: cannot encode ${typeof v}`);
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(v)) return `[${v.map(encodeValue).join(',')}]`;
|
|
178
|
+
const keys = Object.keys(v).sort(codePointCompare);
|
|
179
|
+
const parts = [];
|
|
180
|
+
for (const k of keys) {
|
|
181
|
+
const val = v[k];
|
|
182
|
+
if (val === undefined) {
|
|
183
|
+
// Python has no `undefined`: a key whose value is undefined would silently vanish
|
|
184
|
+
// from JSON.stringify and change the signed bytes. Say so instead.
|
|
185
|
+
throw new TypeError(`canonicalJSON: key ${JSON.stringify(k)} is undefined`);
|
|
186
|
+
}
|
|
187
|
+
parts.push(`${encodeString(k)}:${encodeValue(val)}`);
|
|
188
|
+
}
|
|
189
|
+
return `{${parts.join(',')}}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Canonical JSON STRING (UTF-8 when encoded) for `value`. Throws on anything whose
|
|
193
|
+
* bytes would differ from Python's. */
|
|
194
|
+
export function canonicalJSON(value) {
|
|
195
|
+
return encodeValue(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Canonical JSON as a UTF-8 Buffer — the bytes that actually get signed. */
|
|
199
|
+
export function canonicalBytes(value) {
|
|
200
|
+
const s = canonicalJSON(value);
|
|
201
|
+
assertEncodable(s);
|
|
202
|
+
return Buffer.from(s, 'utf8');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Refuse lone surrogates. Python's `.encode("utf-8")` RAISES on them; Node silently
|
|
206
|
+
* substitutes U+FFFD, which would sign different bytes than the sender believes. */
|
|
207
|
+
function assertEncodable(s) {
|
|
208
|
+
for (let i = 0; i < s.length; i++) {
|
|
209
|
+
const c = s.charCodeAt(i);
|
|
210
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
211
|
+
const next = s.charCodeAt(i + 1);
|
|
212
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) throw new TypeError('lone surrogate in payload');
|
|
213
|
+
i++;
|
|
214
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
215
|
+
throw new TypeError('lone surrogate in payload');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ================================================================ Ed25519 (node:crypto)
|
|
221
|
+
//
|
|
222
|
+
// Node wants DER, not raw bytes. These two prefixes are the whole trick:
|
|
223
|
+
// PKCS#8 private = 302e020100300506032b657004220420 || <32-byte seed>
|
|
224
|
+
// SPKI public = 302a300506032b6570032100 || <32-byte public key>
|
|
225
|
+
// (0x2b6570 is OID 1.3.101.112 = Ed25519; 0x2b656e is 1.3.101.110 = X25519.)
|
|
226
|
+
|
|
227
|
+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
228
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
229
|
+
const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
230
|
+
const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
231
|
+
|
|
232
|
+
function seedBuffer(seedHex) {
|
|
233
|
+
if (typeof seedHex !== 'string') throw new TypeError('seed must be a 64-char hex string');
|
|
234
|
+
const seed = Buffer.from(seedHex.trim(), 'hex');
|
|
235
|
+
if (seed.length !== 32) throw new TypeError('seed must be 32 bytes (64 hex chars)');
|
|
236
|
+
return seed;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ed25519PrivateKey(seedHex) {
|
|
240
|
+
return createPrivateKey({
|
|
241
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, seedBuffer(seedHex)]),
|
|
242
|
+
format: 'der', type: 'pkcs8',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ed25519PublicKey(publicRaw) {
|
|
247
|
+
return createPublicKey({
|
|
248
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, publicRaw]),
|
|
249
|
+
format: 'der', type: 'spki',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Raw 32-byte Ed25519 public key for a seed. */
|
|
254
|
+
export function publicKeyFromSeedHex(seedHex) {
|
|
255
|
+
const pub = createPublicKey(ed25519PrivateKey(seedHex));
|
|
256
|
+
return pub.export({ format: 'der', type: 'spki' }).subarray(ED25519_SPKI_PREFIX.length);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Raw Ed25519 signature over `message` (Buffer|string), as a Buffer. */
|
|
260
|
+
export function signBytes(seedHex, message) {
|
|
261
|
+
const m = Buffer.isBuffer(message) ? message : Buffer.from(String(message), 'utf8');
|
|
262
|
+
return nodeSign(null, m, ed25519PrivateKey(seedHex));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Verify a raw Ed25519 signature. Never throws — bad key/sig bytes answer false. */
|
|
266
|
+
export function verifyBytes(publicRaw, signature, message) {
|
|
267
|
+
try {
|
|
268
|
+
if (!Buffer.isBuffer(publicRaw) || publicRaw.length !== 32) return false;
|
|
269
|
+
if (!Buffer.isBuffer(signature) || signature.length !== 64) return false;
|
|
270
|
+
const m = Buffer.isBuffer(message) ? message : Buffer.from(String(message), 'utf8');
|
|
271
|
+
return nodeVerify(null, m, ed25519PublicKey(publicRaw), signature);
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A fresh 32-byte identity seed as hex. THIS IS THE PRIVATE KEY — never log or ship it. */
|
|
278
|
+
export function newSeedHex() {
|
|
279
|
+
return randomBytes(32).toString('hex');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A fresh message/correlation id (same shape as Python's uuid4().hex). */
|
|
283
|
+
export function newId() {
|
|
284
|
+
return randomBytes(16).toString('hex');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ================================================================ base58btc + did:key
|
|
288
|
+
|
|
289
|
+
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
290
|
+
const B58_INDEX = new Map([...B58].map((c, i) => [c, BigInt(i)]));
|
|
291
|
+
/** Every legitimate base58 here (a DID is ~48 chars) is far under this. The cap guards the
|
|
292
|
+
* O(n^2) bignum loop from an attacker-chosen `from` field — the decoder runs BEFORE any
|
|
293
|
+
* signature check, so an unbounded input is free CPU exhaustion (shared/crypto:184). */
|
|
294
|
+
const MAX_B58_LEN = 512;
|
|
295
|
+
const MULTICODEC_ED25519 = Buffer.from([0xed, 0x01]);
|
|
296
|
+
|
|
297
|
+
function b58encode(data) {
|
|
298
|
+
let n = 0n;
|
|
299
|
+
for (const b of data) n = (n << 8n) | BigInt(b);
|
|
300
|
+
let out = '';
|
|
301
|
+
while (n > 0n) {
|
|
302
|
+
const r = n % 58n;
|
|
303
|
+
n /= 58n;
|
|
304
|
+
out = B58[Number(r)] + out;
|
|
305
|
+
}
|
|
306
|
+
let pad = 0;
|
|
307
|
+
for (const b of data) { if (b === 0) pad++; else break; }
|
|
308
|
+
return '1'.repeat(pad) + out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function b58decode(s) {
|
|
312
|
+
if (typeof s !== 'string') throw new TypeError('base58: not a string');
|
|
313
|
+
if (s.length > MAX_B58_LEN) throw new RangeError('base58 input too long');
|
|
314
|
+
let n = 0n;
|
|
315
|
+
for (const ch of s) {
|
|
316
|
+
const v = B58_INDEX.get(ch);
|
|
317
|
+
if (v === undefined) throw new TypeError(`base58: bad character ${JSON.stringify(ch)}`);
|
|
318
|
+
n = n * 58n + v;
|
|
319
|
+
}
|
|
320
|
+
let hex = n.toString(16);
|
|
321
|
+
if (hex.length % 2) hex = '0' + hex;
|
|
322
|
+
const raw = n === 0n ? Buffer.alloc(0) : Buffer.from(hex, 'hex');
|
|
323
|
+
let pad = 0;
|
|
324
|
+
for (const ch of s) { if (ch === '1') pad++; else break; }
|
|
325
|
+
return Buffer.concat([Buffer.alloc(pad), raw]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 32-byte Ed25519 public key (hex or Buffer) -> `did:key:z…`. */
|
|
329
|
+
export function didFromPublicKeyHex(publicHex) {
|
|
330
|
+
const pub = Buffer.isBuffer(publicHex) ? publicHex : Buffer.from(publicHex, 'hex');
|
|
331
|
+
if (pub.length !== 32) throw new TypeError('an ed25519 public key is 32 bytes');
|
|
332
|
+
return 'did:key:z' + b58encode(Buffer.concat([MULTICODEC_ED25519, pub]));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `did:key:z…` -> 32-byte Ed25519 public key, hex. Enforces the 0xed01 multicodec and the
|
|
336
|
+
* 34-byte total: with did:key the DID IS the key, so this is the whole "key lookup". */
|
|
337
|
+
export function publicKeyHexFromDid(did) {
|
|
338
|
+
return publicKeyFromDid(did).toString('hex');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function publicKeyFromDid(did) {
|
|
342
|
+
if (typeof did !== 'string' || !did.startsWith('did:key:z')) {
|
|
343
|
+
throw new TypeError(`unsupported DID method: ${String(did).slice(0, 32)}`);
|
|
344
|
+
}
|
|
345
|
+
const raw = b58decode(did.slice('did:key:z'.length));
|
|
346
|
+
if (raw.length !== 34 || raw[0] !== 0xed || raw[1] !== 0x01) {
|
|
347
|
+
throw new TypeError('not an ed25519 did:key');
|
|
348
|
+
}
|
|
349
|
+
return raw.subarray(2);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The did:key a seed controls. */
|
|
353
|
+
export function didFromSeedHex(seedHex) {
|
|
354
|
+
return didFromPublicKeyHex(publicKeyFromSeedHex(seedHex));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ================================================================ the signing envelope
|
|
358
|
+
|
|
359
|
+
/** The SIX frozen signed fields, canonicalized (shared/crypto.signing_payload). Nothing
|
|
360
|
+
* else is signed: `replyTo`, `auto`, `group`, `vc` … all ride as UNSIGNED metadata.
|
|
361
|
+
* `timestamp` is passed through AS GIVEN — never coerced, because the type on the wire
|
|
362
|
+
* IS the type in the signed bytes (send ints; verify whatever arrived). */
|
|
363
|
+
export function signingPayload(fields) {
|
|
364
|
+
return canonicalJSON({
|
|
365
|
+
contextId: fields.contextId ?? null,
|
|
366
|
+
from: fields.from,
|
|
367
|
+
messageId: fields.messageId,
|
|
368
|
+
text: fields.text,
|
|
369
|
+
timestamp: fields.timestamp,
|
|
370
|
+
to: fields.to,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** base64 (standard alphabet, WITH padding) of the Ed25519 signature over the six fields. */
|
|
375
|
+
export function signEnvelope(seedHex, fields) {
|
|
376
|
+
if (!seedHex) throw new TypeError('signEnvelope: no seed (this agent entry cannot sign)');
|
|
377
|
+
const payload = signingPayload(fields);
|
|
378
|
+
assertEncodable(payload);
|
|
379
|
+
return signBytes(seedHex, Buffer.from(payload, 'utf8')).toString('base64');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Question 1 ONLY: does `sig` verify under the key DERIVED FROM `from`, over the six
|
|
383
|
+
* fields? Total and fail-closed — a malformed DID, bad base64, unrenderable number or
|
|
384
|
+
* short signature all answer false rather than throwing. */
|
|
385
|
+
export function verifyEnvelopeSignature(fields) {
|
|
386
|
+
try {
|
|
387
|
+
if (!fields || typeof fields !== 'object') return false;
|
|
388
|
+
// `from` (the key) and `sig` must be there; `to` may be the EMPTY STRING — that is how
|
|
389
|
+
// an anonymous-lane reply is addressed ("signed by me, to nobody in particular"), and
|
|
390
|
+
// refusing it here would make core's own walk-in answer read as unsigned.
|
|
391
|
+
if (!fields.from || !fields.sig || typeof fields.to !== 'string') return false;
|
|
392
|
+
const payload = signingPayload(fields);
|
|
393
|
+
assertEncodable(payload);
|
|
394
|
+
const sig = Buffer.from(String(fields.sig), 'base64');
|
|
395
|
+
if (sig.length !== 64) return false;
|
|
396
|
+
return verifyBytes(publicKeyFromDid(fields.from), sig, Buffer.from(payload, 'utf8'));
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Is this envelope an authentic statement ADDRESSED TO ME? Two questions, not one:
|
|
404
|
+
*
|
|
405
|
+
* 1. does `sig` verify under the key derived FROM `from`? With did:key the DID IS the
|
|
406
|
+
* key, so `from` is never taken as a label — that mistake is how a client ends up
|
|
407
|
+
* accepting a valid signature by a DIFFERENT identity than the one it displays
|
|
408
|
+
* (wire_vectors `reject.message/from-not-signer`, the crown-jewel case);
|
|
409
|
+
* 2. is `to` the recipient I am? A signature that verifies FOR SOMEONE ELSE is still a
|
|
410
|
+
* perfectly valid signature — it is just not my mail. `wire_vectors
|
|
411
|
+
* reject.message/wrong-recipient` is exactly that: `mustReject: true` even though
|
|
412
|
+
* the signature checks out, because in core the "to == me" half lives one layer up
|
|
413
|
+
* (agent/inbox.verify -> WRONG_RECIPIENT).
|
|
414
|
+
*
|
|
415
|
+
* A module-level function has no "me", so the recipient must be NAMED by the caller —
|
|
416
|
+
* `verifyEnvelope(fields, { recipientDid })`, or `recipientDid` on the fields object.
|
|
417
|
+
* An unnamed recipient is UNKNOWN, and unknown fails closed: an envelope nobody claims
|
|
418
|
+
* cannot be verified as theirs. When you deliberately want question 1 alone (auditing a
|
|
419
|
+
* stored message, say), call `verifyEnvelopeSignature`.
|
|
420
|
+
*
|
|
421
|
+
* Never throws.
|
|
422
|
+
*/
|
|
423
|
+
export function verifyEnvelope(fields, opts = {}) {
|
|
424
|
+
try {
|
|
425
|
+
if (!fields || typeof fields !== 'object') return false;
|
|
426
|
+
const recipient = opts.recipientDid ?? opts.me ?? fields.recipientDid ?? null;
|
|
427
|
+
if (typeof recipient !== 'string' || !recipient) return false;
|
|
428
|
+
if (fields.to !== recipient) return false;
|
|
429
|
+
return verifyEnvelopeSignature(fields);
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ================================================================ device-key binding v2 (T102)
|
|
436
|
+
//
|
|
437
|
+
// The ACCOUNT layer: a message may carry a countersigned DeviceKeyBinding v2 in
|
|
438
|
+
// metadata.binding proving its device DID belongs to an OWNER DID. This is the JS twin of
|
|
439
|
+
// shared/keybinding.verify_device_binding_v2 + the agent entry's account resolution, byte-pinned
|
|
440
|
+
// by testdata/wire_vectors.json `bindingV2`.
|
|
441
|
+
//
|
|
442
|
+
// Two signatures, over the SAME canonical bytes: the OWNER (root) signs, and the DEVICE
|
|
443
|
+
// countersigns — the countersignature is what stops a foreign owner claiming someone else's
|
|
444
|
+
// device. `typ` lives INSIDE the signed bytes (domain separation), and ts/validUntil are
|
|
445
|
+
// INTEGERS (a float's repr is not reproducible cross-language). Unlike the Python reference
|
|
446
|
+
// there is no "P-256 owner without a backend → unbound" branch: node:crypto verifies P-256
|
|
447
|
+
// natively, so a P-256 owner binding is fully checked here — the documented, expected
|
|
448
|
+
// asymmetry (the stdlib Python path treats the very same binding as unbound).
|
|
449
|
+
|
|
450
|
+
/** `typ` of the countersigned account binding (shared/keybinding.BINDING_V2_TYP). */
|
|
451
|
+
export const BINDING_V2_TYP = 'muretai/devicebinding/2';
|
|
452
|
+
|
|
453
|
+
// SPKI DER prefix for a P-256 public key carrying a COMPRESSED SEC1 point (33 bytes). OpenSSL
|
|
454
|
+
// (node:crypto) accepts compressed points, so the did:key point embeds directly — no
|
|
455
|
+
// decompression. 0x2a8648ce3d0201 = id-ecPublicKey, 0x2a8648ce3d030107 = prime256v1.
|
|
456
|
+
const P256_SPKI_PREFIX = Buffer.from(
|
|
457
|
+
'3039301306072a8648ce3d020106082a8648ce3d030107032200', 'hex');
|
|
458
|
+
|
|
459
|
+
/** did:key → { curve, key }: ('ed25519', 32-byte pubkey) or ('p256', 33-byte compressed
|
|
460
|
+
* point). Curve-agnostic sibling of `publicKeyFromDid` (which is ed25519-only, for the
|
|
461
|
+
* message envelope that is always ed25519). Throws on anything else. */
|
|
462
|
+
function decodeDidKey(did) {
|
|
463
|
+
if (typeof did !== 'string' || !did.startsWith('did:key:z')) {
|
|
464
|
+
throw new TypeError(`unsupported DID method: ${String(did).slice(0, 32)}`);
|
|
465
|
+
}
|
|
466
|
+
const raw = b58decode(did.slice('did:key:z'.length));
|
|
467
|
+
if (raw.length === 34 && raw[0] === 0xed && raw[1] === 0x01) {
|
|
468
|
+
return { curve: 'ed25519', key: raw.subarray(2) }; // 0xed01 multicodec
|
|
469
|
+
}
|
|
470
|
+
if (raw.length === 35 && raw[0] === 0x80 && raw[1] === 0x24) {
|
|
471
|
+
return { curve: 'p256', key: raw.subarray(2) }; // varint(0x1200) = p256-pub
|
|
472
|
+
}
|
|
473
|
+
throw new TypeError('unsupported did:key multicodec (not ed25519 or p256)');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Verify an ES256 signature over `message` for a 33-byte compressed P-256 point. Accepts
|
|
477
|
+
* both encodings clients emit (shared/crypto.p256_verify): raw r||s (64 bytes, WebCrypto /
|
|
478
|
+
* IEEE P1363) and ASN.1 DER (Secure Enclave / WebAuthn). Never throws. */
|
|
479
|
+
function p256Verify(compPoint, signature, message) {
|
|
480
|
+
try {
|
|
481
|
+
if (!Buffer.isBuffer(compPoint) || compPoint.length !== 33) return false;
|
|
482
|
+
const key = createPublicKey({
|
|
483
|
+
key: Buffer.concat([P256_SPKI_PREFIX, compPoint]), format: 'der', type: 'spki' });
|
|
484
|
+
if (signature.length === 64) {
|
|
485
|
+
return nodeVerify('sha256', message, { key, dsaEncoding: 'ieee-p1363' }, signature);
|
|
486
|
+
}
|
|
487
|
+
return nodeVerify('sha256', message, key, signature); // DER (Secure Enclave)
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Curve-dispatching signature verify against a did:key — the binding's owner may be
|
|
494
|
+
* ed25519 OR p256; the device is always ed25519. Total and fail-closed. */
|
|
495
|
+
function verifyDidSig(did, signature, message) {
|
|
496
|
+
try {
|
|
497
|
+
const { curve, key } = decodeDidKey(did);
|
|
498
|
+
if (curve === 'ed25519') return verifyBytes(key, signature, message);
|
|
499
|
+
if (curve === 'p256') return p256Verify(key, signature, message);
|
|
500
|
+
return false;
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Canonical bytes BOTH keys sign — exactly the five declared fields
|
|
507
|
+
* (shared/keybinding._binding_v2_payload). `canonicalBytes` sorts keys by code point, so
|
|
508
|
+
* the object order here is irrelevant; the emitted bytes are
|
|
509
|
+
* {"deviceDid":…,"rootDid":…,"ts":…,"typ":…,"validUntil":…}. */
|
|
510
|
+
function bindingV2Payload(rootDid, deviceDid, ts, validUntil) {
|
|
511
|
+
return canonicalBytes({ typ: BINDING_V2_TYP, rootDid, deviceDid, ts, validUntil });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Verify a v2 binding — the twin of shared/keybinding.verify_device_binding_v2. TOTAL on
|
|
516
|
+
* untrusted input (returns false, never throws). All must hold: typ matches; rootDid and
|
|
517
|
+
* deviceDid are non-empty strings; ts/validUntil are safe integers; `expectedDeviceDid`
|
|
518
|
+
* (when given) matches deviceDid (anti-copy pin); `now` given + validUntil non-zero → not
|
|
519
|
+
* expired; the OWNER signed the canonical five fields; the DEVICE countersigned the same.
|
|
520
|
+
*/
|
|
521
|
+
export function verifyDeviceBindingV2(binding, { now = null, expectedDeviceDid = null } = {}) {
|
|
522
|
+
try {
|
|
523
|
+
if (!binding || typeof binding !== 'object') return false;
|
|
524
|
+
if (binding.typ !== BINDING_V2_TYP) return false;
|
|
525
|
+
const { rootDid, deviceDid, ts, validUntil } = binding;
|
|
526
|
+
if (typeof rootDid !== 'string' || !rootDid) return false;
|
|
527
|
+
if (typeof deviceDid !== 'string' || !deviceDid) return false;
|
|
528
|
+
if (!Number.isSafeInteger(ts) || !Number.isSafeInteger(validUntil)) return false;
|
|
529
|
+
if (expectedDeviceDid !== null && deviceDid !== expectedDeviceDid) return false;
|
|
530
|
+
if (now !== null && validUntil !== 0 && now > validUntil) return false;
|
|
531
|
+
const sig = Buffer.from(String(binding.sig ?? ''), 'base64');
|
|
532
|
+
const deviceSig = Buffer.from(String(binding.deviceSig ?? ''), 'base64');
|
|
533
|
+
const payload = bindingV2Payload(rootDid, deviceDid, ts, validUntil);
|
|
534
|
+
return verifyDidSig(rootDid, sig, payload) && verifyDidSig(deviceDid, deviceSig, payload);
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ================================================================ signed Agent Card envelope
|
|
541
|
+
|
|
542
|
+
/** The canonical bytes a card envelope signs: {card, ts, typ, v} (shared/cardpub).
|
|
543
|
+
* `ts` MUST be an INTEGER epoch — a float `ts` renders through Python's repr and is,
|
|
544
|
+
* by construction, unverifiable outside Python. */
|
|
545
|
+
export function cardEnvelopePayload(card, ts) {
|
|
546
|
+
return canonicalJSON({ card, ts, typ: CARD_ENVELOPE_TYPE, v: CARD_ENVELOPE_VERSION });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Wrap `card` in the signed envelope served at /.well-known/agent-card.sig.json. */
|
|
550
|
+
export function makeCardEnvelope(seedHex, card, ts) {
|
|
551
|
+
if (!Number.isSafeInteger(ts)) {
|
|
552
|
+
throw new TypeError('card envelope ts must be an INTEGER epoch (a float is Python-only)');
|
|
553
|
+
}
|
|
554
|
+
const payload = cardEnvelopePayload(card, ts);
|
|
555
|
+
assertEncodable(payload);
|
|
556
|
+
return {
|
|
557
|
+
v: CARD_ENVELOPE_VERSION,
|
|
558
|
+
typ: CARD_ENVELOPE_TYPE,
|
|
559
|
+
card,
|
|
560
|
+
ts,
|
|
561
|
+
sig: signBytes(seedHex, Buffer.from(payload, 'utf8')).toString('base64'),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Verify a card envelope; returns the inner card or null. `expectedDid` is the
|
|
566
|
+
* anti-substitution check — a signature only proves "X signed X's card". */
|
|
567
|
+
export function verifyCardEnvelope(envelope, expectedDid = null) {
|
|
568
|
+
try {
|
|
569
|
+
if (!envelope || typeof envelope !== 'object') return null;
|
|
570
|
+
if (envelope.typ !== CARD_ENVELOPE_TYPE) return null;
|
|
571
|
+
const { card, ts, sig } = envelope;
|
|
572
|
+
if (!card || typeof card !== 'object' || !card.did || sig == null || ts == null) return null;
|
|
573
|
+
if (expectedDid !== null && card.did !== expectedDid) return null;
|
|
574
|
+
const raw = Buffer.from(String(sig), 'base64');
|
|
575
|
+
if (raw.length !== 64) return null;
|
|
576
|
+
const payload = cardEnvelopePayload(card, ts);
|
|
577
|
+
assertEncodable(payload);
|
|
578
|
+
return verifyBytes(publicKeyFromDid(card.did), raw, Buffer.from(payload, 'utf8'))
|
|
579
|
+
? card : null;
|
|
580
|
+
} catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ================================================================ cryptobox (X25519 + ChaCha20)
|
|
586
|
+
//
|
|
587
|
+
// STATIC-STATIC sealed box (shared/cryptobox.py). The X25519 key is a pure function of the
|
|
588
|
+
// SAME Ed25519 seed the agent already holds, so there is no second key to provision:
|
|
589
|
+
// x25519_private = sha256("agentnet-x25519:" || ed25519_seed)
|
|
590
|
+
// shared = X25519(my_private, their_public) (raw ECDH)
|
|
591
|
+
// key = HKDF-SHA256(shared, salt=32 zero bytes, info="agentnet-box-v1", 32)
|
|
592
|
+
// blob = base64(nonce[12] || ciphertext || tag[16])
|
|
593
|
+
// salt=None in Python's HKDF means "HashLen zero bytes", hence Buffer.alloc(32).
|
|
594
|
+
|
|
595
|
+
const BOX_INFO = Buffer.from('agentnet-box-v1', 'utf8');
|
|
596
|
+
const BOX_SALT = Buffer.alloc(32);
|
|
597
|
+
const NONCE_BYTES = 12;
|
|
598
|
+
const TAG_BYTES = 16;
|
|
599
|
+
|
|
600
|
+
function x25519PrivateRaw(seedHex) {
|
|
601
|
+
return createHash('sha256')
|
|
602
|
+
.update(Buffer.concat([Buffer.from('agentnet-x25519:', 'utf8'), seedBuffer(seedHex)]))
|
|
603
|
+
.digest();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function x25519PrivateKey(seedHex) {
|
|
607
|
+
return createPrivateKey({
|
|
608
|
+
key: Buffer.concat([X25519_PKCS8_PREFIX, x25519PrivateRaw(seedHex)]),
|
|
609
|
+
format: 'der', type: 'pkcs8',
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** The X25519 public key (hex) a peer needs to seal a box to this seed. Safe to publish. */
|
|
614
|
+
export function encPubHex(seedHex) {
|
|
615
|
+
const pub = createPublicKey(x25519PrivateKey(seedHex));
|
|
616
|
+
return pub.export({ format: 'der', type: 'spki' })
|
|
617
|
+
.subarray(X25519_SPKI_PREFIX.length).toString('hex');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function boxKey(seedHex, theirPubHex) {
|
|
621
|
+
const theirPub = Buffer.from(String(theirPubHex), 'hex');
|
|
622
|
+
if (theirPub.length !== 32) throw new TypeError('peer X25519 public key must be 32 bytes');
|
|
623
|
+
const shared = diffieHellman({
|
|
624
|
+
privateKey: x25519PrivateKey(seedHex),
|
|
625
|
+
publicKey: createPublicKey({
|
|
626
|
+
key: Buffer.concat([X25519_SPKI_PREFIX, theirPub]), format: 'der', type: 'spki',
|
|
627
|
+
}),
|
|
628
|
+
});
|
|
629
|
+
return Buffer.from(hkdfSync('sha256', shared, BOX_SALT, BOX_INFO, 32));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Encrypt to the holder of `theirPubHex`. Returns base64(nonce || ciphertext || tag).
|
|
633
|
+
* A fresh random nonce per call, so the output is never reproducible — which is why the
|
|
634
|
+
* wire vectors pin only the OPEN direction. */
|
|
635
|
+
export function seal(seedHex, theirPubHex, plaintext, ad = Buffer.alloc(0)) {
|
|
636
|
+
const pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), 'utf8');
|
|
637
|
+
const aad = Buffer.isBuffer(ad) ? ad : Buffer.from(String(ad), 'utf8');
|
|
638
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
639
|
+
const cipher = createCipheriv('chacha20-poly1305', boxKey(seedHex, theirPubHex), nonce,
|
|
640
|
+
{ authTagLength: TAG_BYTES });
|
|
641
|
+
if (aad.length) cipher.setAAD(aad, { plaintextLength: pt.length });
|
|
642
|
+
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
643
|
+
return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString('base64');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Decrypt a box sealed by the matching peer. Returns a Buffer, or **null on ANY failure**
|
|
647
|
+
* (bad base64, truncated blob, wrong key, AD mismatch, auth-tag failure) — the caller's
|
|
648
|
+
* verification path stays branch-simple, exactly like shared/cryptobox.open_box. */
|
|
649
|
+
export function openBox(seedHex, theirPubHex, blobB64, ad = Buffer.alloc(0)) {
|
|
650
|
+
try {
|
|
651
|
+
const raw = Buffer.from(String(blobB64), 'base64');
|
|
652
|
+
if (raw.length < NONCE_BYTES + TAG_BYTES) return null;
|
|
653
|
+
const nonce = raw.subarray(0, NONCE_BYTES);
|
|
654
|
+
const ct = raw.subarray(NONCE_BYTES, raw.length - TAG_BYTES);
|
|
655
|
+
const tag = raw.subarray(raw.length - TAG_BYTES);
|
|
656
|
+
const aad = Buffer.isBuffer(ad) ? ad : Buffer.from(String(ad), 'utf8');
|
|
657
|
+
const decipher = createDecipheriv('chacha20-poly1305', boxKey(seedHex, theirPubHex), nonce,
|
|
658
|
+
{ authTagLength: TAG_BYTES });
|
|
659
|
+
decipher.setAuthTag(tag);
|
|
660
|
+
if (aad.length) decipher.setAAD(aad, { plaintextLength: ct.length });
|
|
661
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
662
|
+
} catch {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ================================================================ reach-back through a relay
|
|
668
|
+
//
|
|
669
|
+
// The inline reply answers the visitor who is holding the HTTP connection. Everything the
|
|
670
|
+
// site wants to say LATER ("your booking is confirmed") goes the other way: sealed to the
|
|
671
|
+
// visitor's X25519 key and deposited at a relay, which stores and forwards it. The relay
|
|
672
|
+
// never sees plaintext — it only checks that the routing fields are signed.
|
|
673
|
+
|
|
674
|
+
/** Build the A2A message object (shared/protocol.Message.to_a2a) for `fields`. */
|
|
675
|
+
function toA2A({ role, text, messageId, contextId, timestamp, from, to, sig, replyTo }) {
|
|
676
|
+
const metadata = { timestamp, from, to, sig };
|
|
677
|
+
if (replyTo) metadata.replyTo = replyTo;
|
|
678
|
+
return {
|
|
679
|
+
kind: 'message',
|
|
680
|
+
role,
|
|
681
|
+
parts: [{ kind: 'text', text }],
|
|
682
|
+
messageId,
|
|
683
|
+
contextId: contextId ?? null,
|
|
684
|
+
metadata,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Seal a signed `message/send` request to `toDid` and deposit it at `relayUrl`.
|
|
690
|
+
*
|
|
691
|
+
* The deposit body is `{to, from, from_enc, id, blob, sig}` where
|
|
692
|
+
* `sig = Ed25519(to + "|" + from + "|" + id + "|" + blob)` over those UTF-8 bytes — NOT
|
|
693
|
+
* canonical JSON. It proves the routing fields and the opaque blob were not altered in
|
|
694
|
+
* transit while leaving the relay unable to read anything.
|
|
695
|
+
*
|
|
696
|
+
* Resolves `{status, queued, ...body}`; never throws for a non-2xx — the caller decides.
|
|
697
|
+
*/
|
|
698
|
+
export async function depositToRelay(relayUrl, { seedHex, toDid, toEncPub, text,
|
|
699
|
+
contextId = null, timestamp = null, auto = false } = {}) {
|
|
700
|
+
const from = didFromSeedHex(seedHex);
|
|
701
|
+
const messageId = newId();
|
|
702
|
+
const ts = timestamp ?? nowEpoch();
|
|
703
|
+
const sig = signEnvelope(seedHex, { from, to: toDid, messageId, contextId,
|
|
704
|
+
timestamp: ts, text });
|
|
705
|
+
const message = toA2A({ role: 'user', text, messageId, contextId, timestamp: ts,
|
|
706
|
+
from, to: toDid, sig });
|
|
707
|
+
if (auto) message.metadata.auto = true;
|
|
708
|
+
const rpc = { jsonrpc: '2.0', id: newId(), method: 'message/send', params: { message } };
|
|
709
|
+
// JSON.stringify (not canonical JSON) is correct for the SEALED body: only signed
|
|
710
|
+
// payloads need canonical form, and the AEAD tag already binds these bytes exactly.
|
|
711
|
+
const blob = seal(seedHex, toEncPub, JSON.stringify(rpc));
|
|
712
|
+
const id = newId();
|
|
713
|
+
const depositSig = signBytes(seedHex,
|
|
714
|
+
Buffer.from(`${toDid}|${from}|${id}|${blob}`, 'utf8')).toString('base64');
|
|
715
|
+
|
|
716
|
+
const res = await fetch(relayUrl.replace(/\/+$/, '') + '/send', {
|
|
717
|
+
method: 'POST',
|
|
718
|
+
headers: { 'Content-Type': 'application/json' },
|
|
719
|
+
body: JSON.stringify({ to: toDid, from, from_enc: encPubHex(seedHex), id, blob,
|
|
720
|
+
sig: depositSig }),
|
|
721
|
+
});
|
|
722
|
+
const raw = await res.text();
|
|
723
|
+
let body = {};
|
|
724
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { raw }; }
|
|
725
|
+
return { status: res.status, queued: body.queued === true, messageId, ...body };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ================================================================ the agent entry
|
|
729
|
+
|
|
730
|
+
function nowEpoch() {
|
|
731
|
+
return Math.floor(Date.now() / 1000);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
let OVERSIZE_SENTINEL = null;
|
|
735
|
+
/** A body one byte over the ceiling — the cheapest thing that makes `handlePost` answer 413.
|
|
736
|
+
* Shared and read-only: it is never parsed, only measured. */
|
|
737
|
+
function oversizeSentinel() {
|
|
738
|
+
if (OVERSIZE_SENTINEL === null) OVERSIZE_SENTINEL = Buffer.alloc(MAX_BODY_BYTES + 1);
|
|
739
|
+
return OVERSIZE_SENTINEL;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** messageId dedup with a TTL and a hard cap, so a stranger cannot grow it without bound. */
|
|
743
|
+
class ReplayGuard {
|
|
744
|
+
constructor(ttlSeconds = REPLAY_TTL_S, cap = 20000) {
|
|
745
|
+
this.ttl = ttlSeconds * 1000;
|
|
746
|
+
this.cap = cap;
|
|
747
|
+
this.seen = new Map(); // messageId -> expiry (ms). Insertion-ordered.
|
|
748
|
+
this.inserts = 0;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** True if this messageId is NEW (and remembers it); false if it is a replay. */
|
|
752
|
+
checkAndRemember(messageId) {
|
|
753
|
+
const now = Date.now();
|
|
754
|
+
const expiry = this.seen.get(messageId);
|
|
755
|
+
if (expiry !== undefined) {
|
|
756
|
+
if (expiry > now) return false; // still inside the window: a replay
|
|
757
|
+
this.seen.delete(messageId); // expired: it may be used again
|
|
758
|
+
}
|
|
759
|
+
this.seen.set(messageId, now + this.ttl);
|
|
760
|
+
if ((++this.inserts & 0xff) === 0) this.sweep(now);
|
|
761
|
+
while (this.seen.size > this.cap) {
|
|
762
|
+
// Oldest-first eviction. Dropping an entry can only ever make us ACCEPT an old
|
|
763
|
+
// duplicate — never reject a fresh message — so a full table degrades safely.
|
|
764
|
+
const oldest = this.seen.keys().next();
|
|
765
|
+
if (oldest.done) break;
|
|
766
|
+
this.seen.delete(oldest.value);
|
|
767
|
+
}
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
sweep(now = Date.now()) {
|
|
772
|
+
for (const [k, expiry] of this.seen) {
|
|
773
|
+
if (expiry > now) break; // insertion order == expiry order (fixed TTL)
|
|
774
|
+
this.seen.delete(k);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** A whole-agent entry sliding-window bound: at most `perMinute` grants in any 60s. Kept
|
|
780
|
+
* deliberately dumb — it can never hold more than `perMinute` timestamps, so the bound
|
|
781
|
+
* bounds its own bookkeeping, which is why it is the OUTERMOST guard on the anonymous
|
|
782
|
+
* lane (it also keeps a flood from growing the replay table). */
|
|
783
|
+
class RateBound {
|
|
784
|
+
constructor(perMinute = ANON_RATE_PER_MIN) {
|
|
785
|
+
this.perMinute = Math.max(0, Number(perMinute) || 0);
|
|
786
|
+
this.hits = [];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Consume one token. False when the window is full. */
|
|
790
|
+
allow() {
|
|
791
|
+
const now = Date.now();
|
|
792
|
+
this.hits = this.hits.filter((t) => now - t < 60000);
|
|
793
|
+
if (this.hits.length >= this.perMinute) return false;
|
|
794
|
+
this.hits.push(now);
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* The STRICT type check on the fields that end up inside the signed payload. Returns a
|
|
801
|
+
* reason string, or null when the shape is acceptable.
|
|
802
|
+
*
|
|
803
|
+
* WHY (measured divergence, not theory). The contract is "one agent entry, two
|
|
804
|
+
* implementations": the same bytes must get the same verdict. They did not. A NUMERIC text
|
|
805
|
+
* part was coerced to '' HERE and an account row was minted, while the Python reference
|
|
806
|
+
* raised and answered -32600 — the same POST created a customer on one deployment and was
|
|
807
|
+
* refused on the other, which is the double-book class for the booking flow this tier
|
|
808
|
+
* sells. Likewise a non-string `contextId` (the reproduced case was the float `1.0`):
|
|
809
|
+
* JavaScript renders it `1` and Python renders it `1.0`, so exactly one of them can verify
|
|
810
|
+
* the signature — and we would then ECHO it into our own signed reply.
|
|
811
|
+
*
|
|
812
|
+
* -32600 (Invalid Request) for all of them: a wrongly-typed field is a malformed request,
|
|
813
|
+
* not a failed signature. `examples/agent_entry_reference.py::_wire_shape_error` answers the
|
|
814
|
+
* same code for the same input, case for case.
|
|
815
|
+
*/
|
|
816
|
+
function wireShapeError(msg) {
|
|
817
|
+
const parts = msg.parts;
|
|
818
|
+
if (parts !== undefined && parts !== null && !Array.isArray(parts)) {
|
|
819
|
+
return 'parts must be an array';
|
|
820
|
+
}
|
|
821
|
+
for (const part of (Array.isArray(parts) ? parts : [])) {
|
|
822
|
+
if (!part || typeof part !== 'object' || part.kind !== 'text') continue;
|
|
823
|
+
// ABSENT reads as '' (Python's `.get("text", "")`); PRESENT-but-not-a-string is a
|
|
824
|
+
// refusal, never a coercion — coercing means signing a reply to text nobody wrote.
|
|
825
|
+
if ('text' in part && typeof part.text !== 'string') {
|
|
826
|
+
return 'a text part\'s `text` must be a string';
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (typeof msg.messageId !== 'string' || !msg.messageId) {
|
|
830
|
+
return 'messageId must be a non-empty string';
|
|
831
|
+
}
|
|
832
|
+
if (msg.contextId !== undefined && msg.contextId !== null
|
|
833
|
+
&& typeof msg.contextId !== 'string') {
|
|
834
|
+
return 'contextId must be a string or null';
|
|
835
|
+
}
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Every response carries these. An agent entry reads NO cookie, header credential or session —
|
|
840
|
+
* authority comes only from an Ed25519 signature inside the body — so `*` grants a browser
|
|
841
|
+
* agent exactly what curl already had, and nothing more. Never add Allow-Credentials. */
|
|
842
|
+
const CORS_HEADERS = {
|
|
843
|
+
'Access-Control-Allow-Origin': '*',
|
|
844
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
845
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
846
|
+
'Access-Control-Max-Age': '600',
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
function jsonResponse(status, obj, extraHeaders = {}) {
|
|
850
|
+
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
851
|
+
return {
|
|
852
|
+
status,
|
|
853
|
+
headers: {
|
|
854
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
855
|
+
'Content-Length': String(body.length),
|
|
856
|
+
...CORS_HEADERS,
|
|
857
|
+
...extraHeaders,
|
|
858
|
+
},
|
|
859
|
+
body,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function rpcError(id, error, data) {
|
|
864
|
+
const err = { ...error };
|
|
865
|
+
if (data) err.data = data;
|
|
866
|
+
return jsonResponse(200, { jsonrpc: '2.0', id: id ?? null, error: err });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function isThenable(v) {
|
|
870
|
+
return v !== null && typeof v === 'object' && typeof v.then === 'function';
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* createAgentEntry(opts) -> { did, card, ledger, handleRequest, handleRequestAsync, listen }
|
|
875
|
+
*
|
|
876
|
+
* seedHex the site's 32-byte identity seed (hex). THE PRIVATE KEY.
|
|
877
|
+
* name the public name on the Agent Card.
|
|
878
|
+
* baseUrl the base a visitor dials. It goes in the card's `url`, and the visitor
|
|
879
|
+
* REQUIRES card.url to name the origin+path it dialled (Outbox.card_binds_to)
|
|
880
|
+
* — that binding is what stops an attacker re-serving your signed card at
|
|
881
|
+
* their own host. Get it wrong and Path A verification fails, silently.
|
|
882
|
+
* responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
|
|
883
|
+
* openDoor advertise `muretai.open_door` (default true) — the flag that tells a
|
|
884
|
+
* visiting agent it may contact you without an introduction.
|
|
885
|
+
* anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
|
|
886
|
+
* and the lane as a whole is capped at `anonRatePerMin` signed replies per
|
|
887
|
+
* minute — it is unauthenticated, so it must not be an unmetered signing
|
|
888
|
+
* oracle. Signed senders are not rate-bound here: they are attributable,
|
|
889
|
+
* and every one of them is already in the ledger.
|
|
890
|
+
* anonRatePerMin anonymous replies per minute for the WHOLE agent entry (default 30).
|
|
891
|
+
*/
|
|
892
|
+
export function createAgentEntry({
|
|
893
|
+
seedHex,
|
|
894
|
+
name = 'Muretai AgentEntry',
|
|
895
|
+
baseUrl,
|
|
896
|
+
description = 'Signed agent-to-agent messaging. Send an A2A message and get a signed reply.',
|
|
897
|
+
version = '1',
|
|
898
|
+
responder = () => 'Thanks — a human will follow up.',
|
|
899
|
+
openDoor = true,
|
|
900
|
+
anonymousLane = false,
|
|
901
|
+
anonRatePerMin = ANON_RATE_PER_MIN,
|
|
902
|
+
skills = [],
|
|
903
|
+
maxAccounts = 50000,
|
|
904
|
+
} = {}) {
|
|
905
|
+
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
906
|
+
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
907
|
+
const did = didFromSeedHex(seedHex);
|
|
908
|
+
|
|
909
|
+
const card = {
|
|
910
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
911
|
+
name,
|
|
912
|
+
description,
|
|
913
|
+
url: baseUrl,
|
|
914
|
+
did,
|
|
915
|
+
version,
|
|
916
|
+
capabilities: { streaming: false, pushNotifications: false },
|
|
917
|
+
defaultInputModes: ['text/plain'],
|
|
918
|
+
defaultOutputModes: ['text/plain'],
|
|
919
|
+
skills,
|
|
920
|
+
};
|
|
921
|
+
if (openDoor) card.muretai = { open_door: true };
|
|
922
|
+
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
923
|
+
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
924
|
+
// reads is worse than advertising none — mail would queue at the relay forever.
|
|
925
|
+
|
|
926
|
+
const cardBytes = Buffer.from(JSON.stringify(card), 'utf8'); // identical bytes on both paths
|
|
927
|
+
// ACCOUNT DID -> {first_seen, last_seen, messages}. Keyed by the RESOLVED account (T102):
|
|
928
|
+
// the OWNER DID when a valid v2 binding rides along, else the device DID — so an owner's
|
|
929
|
+
// sibling devices are ONE customer row.
|
|
930
|
+
const ledger = new Map();
|
|
931
|
+
// device DID -> owner DID, the in-process TOFU pin (T102). The first VALID binding pins a
|
|
932
|
+
// device to its owner; a later binding for the same device naming a DIFFERENT owner is
|
|
933
|
+
// refused. Per-process on purpose for v1.5 — a real site PERSISTS this (and the fold), or
|
|
934
|
+
// the conflict rule resets to trust-on-first-use every restart.
|
|
935
|
+
const deviceOwner = new Map();
|
|
936
|
+
const replay = new ReplayGuard();
|
|
937
|
+
const anonRate = new RateBound(anonRatePerMin);
|
|
938
|
+
let sigEnvelope = null;
|
|
939
|
+
let sigMintedAt = 0;
|
|
940
|
+
|
|
941
|
+
/** The signed card, re-minted at most hourly. A CONSUMER REJECTS AN ENVELOPE OLDER THAN
|
|
942
|
+
* 6h (and one dated in the FUTURE), so this is a freshness window, not a cache tweak:
|
|
943
|
+
* without it a saved copy would still "prove" ownership to whoever holds the origin next. */
|
|
944
|
+
function cardEnvelopeBytes() {
|
|
945
|
+
const now = nowEpoch();
|
|
946
|
+
if (!sigEnvelope || now - sigMintedAt >= CARD_SIG_REFRESH_S) {
|
|
947
|
+
sigEnvelope = Buffer.from(JSON.stringify(makeCardEnvelope(seedHex, card, now)), 'utf8');
|
|
948
|
+
sigMintedAt = now;
|
|
949
|
+
}
|
|
950
|
+
return sigEnvelope;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function noteContact(accountDid) {
|
|
954
|
+
const now = nowEpoch();
|
|
955
|
+
const row = ledger.get(accountDid);
|
|
956
|
+
if (row) {
|
|
957
|
+
row.messages += 1;
|
|
958
|
+
row.last_seen = now;
|
|
959
|
+
return row;
|
|
960
|
+
}
|
|
961
|
+
// FIRST CONTACT IS ACCOUNT CREATION. There is no signup form: the sender proved control
|
|
962
|
+
// of a device key one line above, which is strictly more than an email link. The row is
|
|
963
|
+
// keyed by the ACCOUNT (the owner when bound), so sibling devices are one customer.
|
|
964
|
+
const fresh = { first_seen: now, last_seen: now, messages: 1 };
|
|
965
|
+
ledger.set(accountDid, fresh);
|
|
966
|
+
while (ledger.size > maxAccounts) {
|
|
967
|
+
const oldest = ledger.keys().next();
|
|
968
|
+
if (oldest.done) break;
|
|
969
|
+
ledger.delete(oldest.value);
|
|
970
|
+
}
|
|
971
|
+
return fresh;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** When a device that ALREADY has an unbound ledger row first proves its owner, move that
|
|
975
|
+
* row's history into the owner row — ONCE. Never the reverse: a later stripped binding
|
|
976
|
+
* resolves to the device DID and must not merge, or stripping a binding would become a
|
|
977
|
+
* way to read the owner's history. */
|
|
978
|
+
function foldDeviceIntoOwner(deviceDid, ownerDid) {
|
|
979
|
+
const devRow = ledger.get(deviceDid);
|
|
980
|
+
if (!devRow) return;
|
|
981
|
+
ledger.delete(deviceDid);
|
|
982
|
+
const ownerRow = ledger.get(ownerDid);
|
|
983
|
+
if (!ownerRow) { ledger.set(ownerDid, devRow); return; }
|
|
984
|
+
ownerRow.messages += devRow.messages || 0;
|
|
985
|
+
ownerRow.first_seen = Math.min(ownerRow.first_seen, devRow.first_seen);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* The account (owner) DID this message belongs to (T102) — the JS twin of
|
|
990
|
+
* examples/agent_entry_reference.py::_resolve_account and, one tier up,
|
|
991
|
+
* agent/inbox.py::_resolve_account. Returns { ok:true, account } or { ok:false, reason }.
|
|
992
|
+
* Absent binding → the device DID (`from`), byte-identical to today. Present binding → every
|
|
993
|
+
* check must hold or it FAILS CLOSED with the same UNAUTHENTICATED code and a distinct
|
|
994
|
+
* reason; never a silent downgrade to unbound, never a proven owner handed on unverified.
|
|
995
|
+
* The cheap structural pins produce the distinct reasons; the two signatures are left to
|
|
996
|
+
* `verifyDeviceBindingV2`, the one contract the Python reference re-implements.
|
|
997
|
+
*/
|
|
998
|
+
function resolveAccount(binding, from) {
|
|
999
|
+
if (binding === undefined || binding === null) return { ok: true, account: from };
|
|
1000
|
+
if (typeof binding !== 'object' || Array.isArray(binding)) {
|
|
1001
|
+
return { ok: false, reason: 'attached device binding is malformed' };
|
|
1002
|
+
}
|
|
1003
|
+
if (binding.typ !== BINDING_V2_TYP) {
|
|
1004
|
+
return { ok: false, reason: 'attached device binding has an unsupported typ' };
|
|
1005
|
+
}
|
|
1006
|
+
if (typeof binding.rootDid !== 'string' || !binding.rootDid) {
|
|
1007
|
+
return { ok: false, reason: 'attached device binding names no owner' };
|
|
1008
|
+
}
|
|
1009
|
+
if (binding.deviceDid !== from) {
|
|
1010
|
+
return { ok: false, reason: 'device binding does not name the sender' };
|
|
1011
|
+
}
|
|
1012
|
+
const { ts, validUntil, rootDid } = binding;
|
|
1013
|
+
if (!Number.isSafeInteger(ts) || !Number.isSafeInteger(validUntil)) {
|
|
1014
|
+
return { ok: false, reason: 'device binding timestamps must be integers' };
|
|
1015
|
+
}
|
|
1016
|
+
const now = nowEpoch();
|
|
1017
|
+
if (ts > now + CLOCK_WINDOW_S) {
|
|
1018
|
+
return { ok: false, reason: 'device binding ts is in the future' };
|
|
1019
|
+
}
|
|
1020
|
+
if (validUntil !== 0 && now > validUntil) {
|
|
1021
|
+
return { ok: false, reason: 'device binding has expired' };
|
|
1022
|
+
}
|
|
1023
|
+
if (!verifyDeviceBindingV2(binding, { now, expectedDeviceDid: from })) {
|
|
1024
|
+
return { ok: false, reason: 'device binding does not verify' };
|
|
1025
|
+
}
|
|
1026
|
+
const pinned = deviceOwner.get(from);
|
|
1027
|
+
if (pinned !== undefined && pinned !== rootDid) {
|
|
1028
|
+
return { ok: false, reason:
|
|
1029
|
+
'device is already bound to a different owner (a device DID is never re-owned '
|
|
1030
|
+
+ '— a new owner means a new device key)' };
|
|
1031
|
+
}
|
|
1032
|
+
if (pinned === undefined) {
|
|
1033
|
+
deviceOwner.set(from, rootDid);
|
|
1034
|
+
foldDeviceIntoOwner(from, rootDid);
|
|
1035
|
+
}
|
|
1036
|
+
return { ok: true, account: rootDid };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
|
|
1040
|
+
* code consumes this, so the key set must not drift: a webhook push, a drive-API read
|
|
1041
|
+
* and an agent entry callback all parse with ONE schema. */
|
|
1042
|
+
function backendEnvelope(msg, { verified, peerDid, ownerDid = null }) {
|
|
1043
|
+
const meta = msg.metadata || {};
|
|
1044
|
+
return {
|
|
1045
|
+
to_agent: name,
|
|
1046
|
+
to_did: did,
|
|
1047
|
+
direction: 'in',
|
|
1048
|
+
verified,
|
|
1049
|
+
peer_did: peerDid,
|
|
1050
|
+
// T102: the resolved ACCOUNT (owner) DID when a valid v2 binding proved this device
|
|
1051
|
+
// belongs to an owner, else null. `peer_did` STAYS the device that signed; sibling
|
|
1052
|
+
// devices share one owner_did, which is how a merchant reads them as one account.
|
|
1053
|
+
owner_did: ownerDid,
|
|
1054
|
+
peer_name: null,
|
|
1055
|
+
context_id: msg.contextId ?? null,
|
|
1056
|
+
text: messageText(msg),
|
|
1057
|
+
msg_id: msg.messageId ?? null,
|
|
1058
|
+
reply_to: meta.replyTo ?? null,
|
|
1059
|
+
wire_ts: meta.timestamp ?? null,
|
|
1060
|
+
auto: Boolean(meta.auto),
|
|
1061
|
+
coord: meta.coordination ?? null,
|
|
1062
|
+
deal: meta.deal ?? null,
|
|
1063
|
+
group: meta.group ?? null,
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function signedReply(reqId, { text, contextId, timestamp, toDid, replyTo }) {
|
|
1068
|
+
const messageId = newId();
|
|
1069
|
+
const ts = Number.isSafeInteger(timestamp) ? timestamp : nowEpoch();
|
|
1070
|
+
const ctx = contextId ?? null;
|
|
1071
|
+
const sig = signEnvelope(seedHex, { from: did, to: toDid, messageId, contextId: ctx,
|
|
1072
|
+
timestamp: ts, text });
|
|
1073
|
+
const message = toA2A({ role: 'agent', text, messageId, contextId: ctx, timestamp: ts,
|
|
1074
|
+
from: did, to: toDid, sig, replyTo });
|
|
1075
|
+
return jsonResponse(200, { jsonrpc: '2.0', id: reqId ?? null, result: message });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function finishReply(reqId, answer, { inbound, toDid }) {
|
|
1079
|
+
let text = answer;
|
|
1080
|
+
let contextId = inbound.contextId ?? null;
|
|
1081
|
+
let timestamp = null;
|
|
1082
|
+
if (answer && typeof answer === 'object') {
|
|
1083
|
+
text = answer.text;
|
|
1084
|
+
// The overrides exist so a test can prove a VISITOR refuses a cross-conversation or
|
|
1085
|
+
// stale reply. An honest agent entry never sets them.
|
|
1086
|
+
if ('contextId' in answer) contextId = answer.contextId;
|
|
1087
|
+
else if ('context_id' in answer) contextId = answer.context_id;
|
|
1088
|
+
if ('timestamp' in answer) timestamp = answer.timestamp;
|
|
1089
|
+
}
|
|
1090
|
+
if (typeof text !== 'string') text = String(text ?? '');
|
|
1091
|
+
return signedReply(reqId, { text, contextId, timestamp, toDid,
|
|
1092
|
+
replyTo: inbound.messageId });
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* The POST contract, in EXACTLY this order (agent/inbox.py::verify). The order is
|
|
1097
|
+
* load-bearing: an oversized message must cost the recipient nothing to refuse, so the
|
|
1098
|
+
* size checks come BEFORE any parsing or crypto — a check placed after the signature is
|
|
1099
|
+
* a check the attacker simply skips.
|
|
1100
|
+
*/
|
|
1101
|
+
function handlePost(bodyBuffer) {
|
|
1102
|
+
// 1. body over 1 MiB — refused WITHOUT parsing.
|
|
1103
|
+
if (bodyBuffer.length > MAX_BODY_BYTES) {
|
|
1104
|
+
return jsonResponse(413, { error: 'request body too large' });
|
|
1105
|
+
}
|
|
1106
|
+
// 2. unparseable or non-object JSON — a transport-level refusal, not a JSON-RPC one.
|
|
1107
|
+
let req;
|
|
1108
|
+
try {
|
|
1109
|
+
req = JSON.parse(bodyBuffer.toString('utf8'));
|
|
1110
|
+
} catch {
|
|
1111
|
+
return jsonResponse(400, { error: 'malformed JSON' });
|
|
1112
|
+
}
|
|
1113
|
+
if (!req || typeof req !== 'object' || Array.isArray(req)) {
|
|
1114
|
+
return jsonResponse(400, { error: 'JSON-RPC request must be an object' });
|
|
1115
|
+
}
|
|
1116
|
+
// 3. From here every refusal is HTTP 200 with a JSON-RPC error object.
|
|
1117
|
+
const reqId = req.id ?? null;
|
|
1118
|
+
if (typeof req.method === 'string' && req.method !== 'message/send') {
|
|
1119
|
+
return rpcError(reqId, ERRORS.METHOD_NOT_FOUND,
|
|
1120
|
+
`${req.method} — this agent entry serves message/send only`);
|
|
1121
|
+
}
|
|
1122
|
+
const msg = (req.params && typeof req.params === 'object') ? req.params.message : null;
|
|
1123
|
+
if (!msg || typeof msg !== 'object') {
|
|
1124
|
+
return rpcError(reqId, ERRORS.INVALID_PARAMS, 'params.message is required');
|
|
1125
|
+
}
|
|
1126
|
+
// 3a. wrongly-TYPED wire fields, on the raw object and before anything measures or
|
|
1127
|
+
// hashes it. These are the fields that end up inside a signed payload — theirs and,
|
|
1128
|
+
// for contextId, ours — so a coercion here is a signature over something the sender
|
|
1129
|
+
// did not say. Same check, same code, same order as the Python reference.
|
|
1130
|
+
const shape = wireShapeError(msg);
|
|
1131
|
+
if (shape !== null) return rpcError(reqId, ERRORS.INVALID_REQUEST, shape);
|
|
1132
|
+
|
|
1133
|
+
const meta = (msg.metadata && typeof msg.metadata === 'object') ? msg.metadata : {};
|
|
1134
|
+
const text = messageText(msg);
|
|
1135
|
+
|
|
1136
|
+
// 3b. text over the 64 KiB ceiling — BEFORE any crypto.
|
|
1137
|
+
const over = Buffer.byteLength(text, 'utf8') - MAX_TEXT_BYTES;
|
|
1138
|
+
if (over > 0) {
|
|
1139
|
+
return rpcError(reqId, ERRORS.MESSAGE_TOO_LARGE,
|
|
1140
|
+
`text is ${over} bytes over the ${MAX_TEXT_BYTES}-byte limit`);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const from = typeof meta.from === 'string' ? meta.from : null;
|
|
1144
|
+
const to = typeof meta.to === 'string' ? meta.to : null;
|
|
1145
|
+
const sig = typeof meta.sig === 'string' ? meta.sig : null;
|
|
1146
|
+
|
|
1147
|
+
// 4. no signing envelope. The anonymous lane accepts an inquiry that carries NO
|
|
1148
|
+
// envelope at all (a walk-in with no DID); a message that carries a PARTIAL one
|
|
1149
|
+
// (from/to present, sig stripped) is a downgrade attempt and is always refused.
|
|
1150
|
+
if (!from || !to || !sig) {
|
|
1151
|
+
const bare = !from && !to && !sig;
|
|
1152
|
+
if (!(anonymousLane && bare)) {
|
|
1153
|
+
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'missing signing envelope (from/to/sig)');
|
|
1154
|
+
}
|
|
1155
|
+
// Anonymous: answer, signed by us, addressed to nobody. NO ledger row — an
|
|
1156
|
+
// unauthenticated stranger must never be able to mint an account.
|
|
1157
|
+
//
|
|
1158
|
+
// It runs the SAME ladder as the signed lane, minus the checks that need a key: the
|
|
1159
|
+
// body cap, the shape gate and the text cap are the shared code above; the rate bound
|
|
1160
|
+
// and the dedup are here. Both refusals cost this agent entry no signature, which is the
|
|
1161
|
+
// whole point — the lane used to answer every unsigned repeat of ONE messageId with a
|
|
1162
|
+
// fresh signature, an unmetered signing oracle. Freshness is deliberately NOT checked:
|
|
1163
|
+
// an unsigned timestamp is a number the sender chose, so refusing an old one buys
|
|
1164
|
+
// nothing the dedup does not already buy.
|
|
1165
|
+
if (!anonRate.allow()) {
|
|
1166
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
1167
|
+
`the anonymous lane is limited to ${anonRatePerMin} replies per minute — `
|
|
1168
|
+
+ 'sign your message to lift the bound');
|
|
1169
|
+
}
|
|
1170
|
+
if (!replay.checkAndRemember(msg.messageId)) {
|
|
1171
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
1172
|
+
}
|
|
1173
|
+
return respond(backendEnvelope(msg, { verified: false, peerDid: null }),
|
|
1174
|
+
reqId, msg, '');
|
|
1175
|
+
}
|
|
1176
|
+
// 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
|
|
1177
|
+
// misaddressed message never reaches the base58 decoder.
|
|
1178
|
+
if (to !== did) {
|
|
1179
|
+
return rpcError(reqId, ERRORS.WRONG_RECIPIENT, `not addressed to me: ${to.slice(0, 24)}…`);
|
|
1180
|
+
}
|
|
1181
|
+
// 6. an INTEGER epoch, inside the clock window (both directions: a future timestamp is
|
|
1182
|
+
// as unusable as a stale one). Integer is the CONTRACT, not a preference: a float
|
|
1183
|
+
// renders through Python's repr and no other language reproduces those bytes, so a
|
|
1184
|
+
// fractional timestamp is a signature only one implementation could check. A STRING
|
|
1185
|
+
// timestamp lands here too — `Number.isSafeInteger('1786580417')` is false — and the
|
|
1186
|
+
// Python reference now answers the same -32002 instead of coercing it with float()
|
|
1187
|
+
// and accepting.
|
|
1188
|
+
//
|
|
1189
|
+
// Note what this check CANNOT see: `JSON.parse` destroys the int/float distinction,
|
|
1190
|
+
// so a body that wrote `1786580417.0` is already the Number 1786580417 here. That is
|
|
1191
|
+
// why the contract makes the canonical INTEGER spelling the thing the signature is
|
|
1192
|
+
// verified against (step 8 signs/verifies `timestamp: ts`, an integer Number): the
|
|
1193
|
+
// float-spelled sender then fails on BOTH implementations with -32001 rather than
|
|
1194
|
+
// being accepted by whichever one happened to keep the original bytes.
|
|
1195
|
+
const ts = meta.timestamp;
|
|
1196
|
+
if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
|
|
1197
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
|
|
1198
|
+
}
|
|
1199
|
+
// 7. duplicate messageId inside the replay window. (Its type was settled by the shape
|
|
1200
|
+
// gate: a non-string messageId never reaches here on either implementation.)
|
|
1201
|
+
const messageId = msg.messageId;
|
|
1202
|
+
if (!replay.checkAndRemember(messageId)) {
|
|
1203
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
1204
|
+
}
|
|
1205
|
+
// 8. the signature itself, under the key DERIVED FROM `from`.
|
|
1206
|
+
const fields = { from, to, messageId, contextId: msg.contextId ?? null,
|
|
1207
|
+
timestamp: ts, text, sig };
|
|
1208
|
+
if (!verifyEnvelope(fields, { recipientDid: did })) {
|
|
1209
|
+
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's device
|
|
1213
|
+
// DIDs to ONE account; a present-but-INVALID binding fails closed with the SAME
|
|
1214
|
+
// UNAUTHENTICATED code (never a silent downgrade to unbound). Absent → the device DID.
|
|
1215
|
+
const acct = resolveAccount(meta.binding, from);
|
|
1216
|
+
if (!acct.ok) return rpcError(reqId, ERRORS.UNAUTHENTICATED, acct.reason);
|
|
1217
|
+
const account = acct.account;
|
|
1218
|
+
const ownerDid = account !== from ? account : null;
|
|
1219
|
+
|
|
1220
|
+
noteContact(account);
|
|
1221
|
+
return respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid }),
|
|
1222
|
+
reqId, msg, from);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function respond(env, reqId, msg, toDid) {
|
|
1226
|
+
let answer;
|
|
1227
|
+
try {
|
|
1228
|
+
answer = responder(env);
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
return rpcError(reqId, ERRORS.INTERNAL_ERROR, `responder failed: ${e && e.message}`);
|
|
1231
|
+
}
|
|
1232
|
+
const inbound = { contextId: msg.contextId ?? null, messageId: msg.messageId ?? null };
|
|
1233
|
+
if (isThenable(answer)) {
|
|
1234
|
+
return answer.then(
|
|
1235
|
+
(v) => finishReply(reqId, v, { inbound, toDid }),
|
|
1236
|
+
(e) => rpcError(reqId, ERRORS.INTERNAL_ERROR, `responder failed: ${e && e.message}`));
|
|
1237
|
+
}
|
|
1238
|
+
return finishReply(reqId, answer, { inbound, toDid });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function route(method, path, bodyBuffer) {
|
|
1242
|
+
const pathname = String(path || '/').split('?')[0].split('#')[0];
|
|
1243
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1244
|
+
if (pathname === AGENT_CARD_PATH || pathname === AGENT_CARD_PATH_LEGACY) {
|
|
1245
|
+
// Byte-identical on both paths: the current A2A path and the legacy alias.
|
|
1246
|
+
return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
|
|
1247
|
+
}
|
|
1248
|
+
if (pathname === AGENT_CARD_SIG_PATH) {
|
|
1249
|
+
const env = cardEnvelopeBytes();
|
|
1250
|
+
return { status: 200, headers: cardHeaders(env.length), body: env };
|
|
1251
|
+
}
|
|
1252
|
+
if (pathname === '/') {
|
|
1253
|
+
const body = Buffer.from(
|
|
1254
|
+
`${name}\n\nThis origin is agent-reachable (Muretai agent entry).\n`
|
|
1255
|
+
+ `DID: ${did}\nCard: ${baseUrl.replace(/\/+$/, '')}${AGENT_CARD_PATH}\n`
|
|
1256
|
+
+ 'POST a signed A2A message/send request to / for a signed reply.\n', 'utf8');
|
|
1257
|
+
return { status: 200,
|
|
1258
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
|
1259
|
+
'Content-Length': String(body.length) },
|
|
1260
|
+
body };
|
|
1261
|
+
}
|
|
1262
|
+
return jsonResponse(404, { error: 'not found' });
|
|
1263
|
+
}
|
|
1264
|
+
if (method === 'POST') {
|
|
1265
|
+
// EXACTLY the root path. A POST anywhere else is not this contract.
|
|
1266
|
+
if (pathname !== '/') return jsonResponse(404, { error: 'not found' });
|
|
1267
|
+
return handlePost(bodyBuffer || Buffer.alloc(0));
|
|
1268
|
+
}
|
|
1269
|
+
if (method === 'OPTIONS') {
|
|
1270
|
+
return { status: 204,
|
|
1271
|
+
headers: { Allow: 'GET, POST, OPTIONS', 'Content-Length': '0', ...CORS_HEADERS },
|
|
1272
|
+
body: Buffer.alloc(0) };
|
|
1273
|
+
}
|
|
1274
|
+
return jsonResponse(405, { error: 'method not allowed' });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function cardHeaders(length) {
|
|
1278
|
+
return {
|
|
1279
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1280
|
+
'Content-Length': String(length),
|
|
1281
|
+
// The card and its envelope are public by design: a card is a self-assertion anyone
|
|
1282
|
+
// may fetch and re-verify, so there is nothing here to keep from a browser agent.
|
|
1283
|
+
...CORS_HEADERS,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** SYNCHRONOUS request handling: (method, path, headers, bodyBuffer) -> {status, headers, body}.
|
|
1288
|
+
* If `responder` returned a Promise, this answers -32603 rather than serializing
|
|
1289
|
+
* "[object Promise]" into a signed reply — use `handleRequestAsync` for an async responder. */
|
|
1290
|
+
function handleRequest(method, path, headers, bodyBuffer) {
|
|
1291
|
+
const out = route(method, path, bodyBuffer);
|
|
1292
|
+
if (isThenable(out)) {
|
|
1293
|
+
return rpcError(null, ERRORS.INTERNAL_ERROR,
|
|
1294
|
+
'responder is async — serve this agent entry through listen()/handleRequestAsync()');
|
|
1295
|
+
}
|
|
1296
|
+
return out;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** Same contract, awaiting an async responder. This is what `listen()` uses. */
|
|
1300
|
+
async function handleRequestAsync(method, path, headers, bodyBuffer) {
|
|
1301
|
+
return route(method, path, bodyBuffer);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Bind an HTTP server. Defaults to 127.0.0.1 ON PURPOSE: a demo agent entry that binds
|
|
1306
|
+
* 0.0.0.0 by accident is a private key answering the whole LAN. Pass a host explicitly
|
|
1307
|
+
* (behind a TLS terminator) to go public.
|
|
1308
|
+
*/
|
|
1309
|
+
function listen(port = 8788, host = '127.0.0.1', onReady) {
|
|
1310
|
+
const server = createServer((req, res) => {
|
|
1311
|
+
const chunks = [];
|
|
1312
|
+
let total = 0;
|
|
1313
|
+
let oversize = false;
|
|
1314
|
+
req.on('data', (chunk) => {
|
|
1315
|
+
total += chunk.length;
|
|
1316
|
+
if (total > MAX_BODY_BYTES) {
|
|
1317
|
+
// Stop BUFFERING immediately, but keep draining: answering mid-upload makes the
|
|
1318
|
+
// client see a connection reset instead of the 413 we are trying to tell it.
|
|
1319
|
+
oversize = true;
|
|
1320
|
+
if (total > 32 * MAX_BODY_BYTES) { req.destroy(); return; } // absurd: hang up
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
chunks.push(chunk);
|
|
1324
|
+
});
|
|
1325
|
+
req.on('error', () => { try { res.destroy(); } catch { /* already gone */ } });
|
|
1326
|
+
req.on('end', () => {
|
|
1327
|
+
// MAX_BODY_BYTES+1 bytes is all `handlePost` needs to make the same 413 decision,
|
|
1328
|
+
// so the size rule lives in ONE place instead of two that can drift. The sentinel
|
|
1329
|
+
// is allocated at most ONCE per process (and only if someone actually sends an
|
|
1330
|
+
// oversize body) — minting a fresh 1 MiB buffer per refusal would hand the
|
|
1331
|
+
// attacker the very allocation the 413 exists to refuse.
|
|
1332
|
+
const body = oversize ? oversizeSentinel() : Buffer.concat(chunks, total);
|
|
1333
|
+
Promise.resolve()
|
|
1334
|
+
.then(() => handleRequestAsync(req.method, req.url, req.headers, body))
|
|
1335
|
+
.catch((e) => rpcError(null, ERRORS.INTERNAL_ERROR, String(e && e.message)))
|
|
1336
|
+
.then(({ status, headers, body: out }) => {
|
|
1337
|
+
res.writeHead(status, headers);
|
|
1338
|
+
res.end(req.method === 'HEAD' ? undefined : out);
|
|
1339
|
+
})
|
|
1340
|
+
.catch(() => { try { res.destroy(); } catch { /* already gone */ } });
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
server.listen(port, host, () => { if (onReady) onReady(server); });
|
|
1344
|
+
return server;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
return { did, card, ledger, handleRequest, handleRequestAsync, listen,
|
|
1348
|
+
cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** The A2A text of a message: every `text` part, joined by newline (Message.from_a2a). */
|
|
1352
|
+
function messageText(msg) {
|
|
1353
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : [];
|
|
1354
|
+
return parts
|
|
1355
|
+
.filter((p) => p && typeof p === 'object' && p.kind === 'text')
|
|
1356
|
+
.map((p) => (typeof p.text === 'string' ? p.text : ''))
|
|
1357
|
+
.join('\n');
|
|
1358
|
+
}
|