@muretai/agent-entry 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -39
- package/conformance/receptor-check.mjs +904 -0
- package/diagrams/become.png +0 -0
- package/diagrams/desk.svg +17 -0
- package/diagrams/x402.svg +21 -0
- package/examples/server.mjs +8 -0
- package/muretai-agent-entry.mjs +323 -88
- package/package.json +8 -3
- package/scripts/distill/README.md +32 -0
- package/scripts/distill/distill.mjs +51 -0
- package/scripts/distill/fixtures.json +10 -0
- package/scripts/distill/lib.mjs +100 -0
- package/scripts/distill/loop.mjs +46 -0
- package/scripts/distill/measure.mjs +80 -0
- package/scripts/distill/record.mjs +90 -0
- package/scripts/distill/test.mjs +43 -0
- package/spec/skill-distill.md +219 -0
- package/spec/v1.md +20 -0
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 300" role="img">
|
|
3
|
+
<title>Keep WebMCP. Add a door beside it if the agent is alone.</title>
|
|
4
|
+
<rect width="740" height="300" fill="#fafafa"/>
|
|
5
|
+
<text x="24" y="26" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">A person in the tab keeps WebMCP. An agent alone can become a customer at the door beside it.</text>
|
|
6
|
+
<rect x="20" y="44" width="340" height="140" rx="10" fill="#111"/>
|
|
7
|
+
<text x="190" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#fff">A person is in the tab</text>
|
|
8
|
+
<text x="190" y="114" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#ddd">Keep WebMCP</text>
|
|
9
|
+
<text x="190" y="144" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#bbb">the page is theirs</text>
|
|
10
|
+
<rect x="380" y="44" width="340" height="140" rx="10" fill="#fff" stroke="#111"/>
|
|
11
|
+
<text x="550" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#111">The agent is alone</text>
|
|
12
|
+
<text x="550" y="114" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#555">Add a door beside WebMCP</text>
|
|
13
|
+
<text x="550" y="144" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#111">they can become a customer</text>
|
|
14
|
+
<rect x="20" y="200" width="700" height="80" rx="10" fill="#fff" stroke="#111"/>
|
|
15
|
+
<text x="370" y="232" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="700" fill="#111">GET /.well-known/agent-card.json 200</text>
|
|
16
|
+
<text x="370" y="260" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="700" fill="#111">POST / message/send 200 signed reply</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 280" role="img" aria-labelledby="t d">
|
|
2
|
+
<title id="t">x402 teaches with HTTP 402; Agent Entry teaches on GET 200</title>
|
|
3
|
+
<desc id="d">x402: request the resource, receive HTTP 402 and PAYMENT-REQUIRED, retry with PAYMENT-SIGNATURE. Agent Entry: GET the Agent Card as 200 before the knock, then POST already correct, answered 200. A refusal is JSON-RPC on HTTP 200, not HTTP 402.</desc>
|
|
4
|
+
<rect width="740" height="280" fill="#fafafa"/>
|
|
5
|
+
<rect x="20" y="20" width="340" height="240" rx="10" fill="#fff" stroke="#111"/>
|
|
6
|
+
<text x="190" y="48" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#111">x402 (HTTP)</text>
|
|
7
|
+
<text x="190" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">1. ask for the resource</text>
|
|
8
|
+
<text x="190" y="108" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">2. HTTP 402 + PAYMENT-REQUIRED</text>
|
|
9
|
+
<text x="190" y="136" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">3. retry with PAYMENT-SIGNATURE</text>
|
|
10
|
+
<text x="190" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">4. 200 if paid</text>
|
|
11
|
+
<text x="190" y="204" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">The error is how the client learns</text>
|
|
12
|
+
<text x="190" y="224" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">what to do next.</text>
|
|
13
|
+
<rect x="380" y="20" width="340" height="240" rx="10" fill="#111"/>
|
|
14
|
+
<text x="550" y="48" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#fff">Agent Entry</text>
|
|
15
|
+
<text x="550" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">1. GET agent-card.json (200)</text>
|
|
16
|
+
<text x="550" y="108" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">2. read how to sign (AE-8)</text>
|
|
17
|
+
<text x="550" y="136" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">3. POST message/send (200)</text>
|
|
18
|
+
<text x="550" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">4. signed reply, same request</text>
|
|
19
|
+
<text x="550" y="204" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#ddd">The POST is not a retry after an error.</text>
|
|
20
|
+
<text x="550" y="224" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#ddd">A refusal is JSON-RPC on HTTP 200.</text>
|
|
21
|
+
</svg>
|
package/examples/server.mjs
CHANGED
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
* https://example.com/agent) and answers NOTHING at `/` — no notice,
|
|
44
44
|
* no OPTIONS, no POST. Point your proxy at the door path and the
|
|
45
45
|
* well-known paths; the site keeps everything else, unchanged.
|
|
46
|
+
* AGENT_ENTRY_PREFER OPTIONAL: the site's own order of its ways in, as one JSON array
|
|
47
|
+
* (AE-30), e.g. '[{"kind":"page","when":"no-key"},"card"]' — "read on
|
|
48
|
+
* the page if you hold no key; otherwise the door". Published verbatim
|
|
49
|
+
* as `agentEntry.prefer`; an invalid list REFUSES to start.
|
|
46
50
|
* AGENT_ENTRY_WBA_JWKS OPTIONAL: a JWKS document {"keys":[…]} as one JSON string —
|
|
47
51
|
* the Web Bot Auth key directory (verified out of band) whose
|
|
48
52
|
* holders this entry should RECOGNISE on inbound requests. Off
|
|
@@ -156,6 +160,10 @@ try {
|
|
|
156
160
|
...(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL
|
|
157
161
|
? { signedRatePerMinTotal: Number(process.env.AGENT_ENTRY_SIGNED_RATE_TOTAL) } : {}),
|
|
158
162
|
guest: process.env.AGENT_ENTRY_GUEST === '1',
|
|
163
|
+
// AE-30: the site's own order of its ways in, a JSON array. Malformed JSON or an
|
|
164
|
+
// unknown kind/condition throws inside this try and the entry never starts — the same
|
|
165
|
+
// posture as a bad domain list: never publish a statement the operator did not make.
|
|
166
|
+
...(process.env.AGENT_ENTRY_PREFER ? { prefer: JSON.parse(process.env.AGENT_ENTRY_PREFER) } : {}),
|
|
159
167
|
wbaVerifiers,
|
|
160
168
|
});
|
|
161
169
|
} catch (err) {
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -761,7 +761,7 @@ export function signEnvelope(seedHex, fields) {
|
|
|
761
761
|
/** Question 1 ONLY: does `sig` verify under the key DERIVED FROM `from`, over the six
|
|
762
762
|
* fields? Total and fail-closed — a malformed DID, bad base64, unrenderable number or
|
|
763
763
|
* short signature all answer false rather than throwing. */
|
|
764
|
-
export function verifyEnvelopeSignature(fields) {
|
|
764
|
+
export function verifyEnvelopeSignature(fields, opts = {}) {
|
|
765
765
|
try {
|
|
766
766
|
if (!fields || typeof fields !== 'object') return false;
|
|
767
767
|
// `from` (the key) and `sig` must be there; `to` may be the EMPTY STRING — that is how
|
|
@@ -772,7 +772,11 @@ export function verifyEnvelopeSignature(fields) {
|
|
|
772
772
|
assertEncodable(payload);
|
|
773
773
|
const sig = Buffer.from(String(fields.sig), 'base64');
|
|
774
774
|
if (sig.length !== 64) return false;
|
|
775
|
-
|
|
775
|
+
// Payload `from` stays the root DID. `signerDid` is the verifying key when the
|
|
776
|
+
// sender enrolled a delegated op-key (T142 A2); omitted → `from` (the un-enrolled
|
|
777
|
+
// / this-door-reply case).
|
|
778
|
+
const signerDid = opts.signerDid || fields.from;
|
|
779
|
+
return verifyBytes(publicKeyFromDid(signerDid), sig, Buffer.from(payload, 'utf8'));
|
|
776
780
|
} catch {
|
|
777
781
|
return false;
|
|
778
782
|
}
|
|
@@ -805,12 +809,82 @@ export function verifyEnvelope(fields, opts = {}) {
|
|
|
805
809
|
const recipient = opts.recipientDid ?? opts.me ?? fields.recipientDid ?? null;
|
|
806
810
|
if (typeof recipient !== 'string' || !recipient) return false;
|
|
807
811
|
if (fields.to !== recipient) return false;
|
|
808
|
-
return verifyEnvelopeSignature(fields);
|
|
812
|
+
return verifyEnvelopeSignature(fields, opts);
|
|
809
813
|
} catch {
|
|
810
814
|
return false;
|
|
811
815
|
}
|
|
812
816
|
}
|
|
813
817
|
|
|
818
|
+
// ================================================================ KeyState (inline op-key, T142 A2)
|
|
819
|
+
//
|
|
820
|
+
// A persisted muretai identity enrolls a genesis KeyState at birth and signs
|
|
821
|
+
// messages with a delegated op-key while `from` stays the root DID. This door
|
|
822
|
+
// used to verify under `from` only, which refused every default-enrolled
|
|
823
|
+
// visitor. Resolve the op-key from a valid inline KeyState (root-signed, pin
|
|
824
|
+
// to the claimed `from`); a missing or invalid record falls back to `from`.
|
|
825
|
+
// No directory, no pin store — first-contact, same as the Python twin.
|
|
826
|
+
|
|
827
|
+
const KEYSTATE_TYP = 'muretai/keystate/1';
|
|
828
|
+
// Lockstep with shared/keystate._FIELDS_V1 / _signed_names: presence of
|
|
829
|
+
// encPubPqHash (even "") selects the T142 list. A V1-only list made every
|
|
830
|
+
// default-enrolled visitor fail verify and fall back to the root DID, so
|
|
831
|
+
// the op-signed envelope was -32001 at this door only (Python twin accepted).
|
|
832
|
+
const KEYSTATE_FIELDS_V1 = [
|
|
833
|
+
'typ', 'rootDid', 'epoch', 'rootKey', 'rootNextHash',
|
|
834
|
+
'opDid', 'opNextHash', 'encPub', 'encNextHash',
|
|
835
|
+
'guardiansHash', 'revokedOps', 'notBefore', 'notAfter', 'ts',
|
|
836
|
+
];
|
|
837
|
+
function keystateSignedNames(ks) {
|
|
838
|
+
if (ks && Object.prototype.hasOwnProperty.call(ks, 'encPubPqHash')) {
|
|
839
|
+
return KEYSTATE_FIELDS_V1.concat(['encPubPqHash']);
|
|
840
|
+
}
|
|
841
|
+
return KEYSTATE_FIELDS_V1;
|
|
842
|
+
}
|
|
843
|
+
const MAX_KEYSTATE_EPOCH = 2147483647; // 2**31 - 1, shared/keystate.MAX_EPOCH
|
|
844
|
+
|
|
845
|
+
export function verifyKeystate(ks, expectedRootDid, now) {
|
|
846
|
+
try {
|
|
847
|
+
if (!ks || typeof ks !== 'object') return false;
|
|
848
|
+
if (ks.typ !== KEYSTATE_TYP) return false;
|
|
849
|
+
const rootDid = ks.rootDid;
|
|
850
|
+
const epoch = ks.epoch;
|
|
851
|
+
if (!Number.isInteger(epoch) || epoch < 0 || epoch > MAX_KEYSTATE_EPOCH) return false;
|
|
852
|
+
if (expectedRootDid != null && rootDid !== expectedRootDid) return false;
|
|
853
|
+
const didKey = publicKeyHexFromDid(rootDid);
|
|
854
|
+
if (ks.rootKey !== didKey) return false;
|
|
855
|
+
const sig = Buffer.from(String(ks.sig), 'base64');
|
|
856
|
+
if (sig.length !== 64) return false;
|
|
857
|
+
const payloadObj = {};
|
|
858
|
+
for (const k of keystateSignedNames(ks)) {
|
|
859
|
+
payloadObj[k] = ks[k] === undefined ? null : ks[k];
|
|
860
|
+
}
|
|
861
|
+
const payload = canonicalJSON(payloadObj);
|
|
862
|
+
const pub = Buffer.from(String(ks.rootKey), 'hex');
|
|
863
|
+
if (pub.length !== 32) return false;
|
|
864
|
+
if (!verifyBytes(pub, sig, Buffer.from(payload, 'utf8'))) return false;
|
|
865
|
+
if (now != null) {
|
|
866
|
+
const nb = ks.notBefore || 0;
|
|
867
|
+
const na = ks.notAfter;
|
|
868
|
+
if (now < nb) return false;
|
|
869
|
+
if (na != null && now > na) return false;
|
|
870
|
+
}
|
|
871
|
+
return true;
|
|
872
|
+
} catch {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function resolveOpDid(rootDid, inlineKeystate, now) {
|
|
878
|
+
if (inlineKeystate && verifyKeystate(inlineKeystate, rootDid, now)) {
|
|
879
|
+
const burned = Array.isArray(inlineKeystate.revokedOps)
|
|
880
|
+
? inlineKeystate.revokedOps : [];
|
|
881
|
+
const op = inlineKeystate.opDid || rootDid;
|
|
882
|
+
if (burned.includes(op)) return rootDid;
|
|
883
|
+
return op;
|
|
884
|
+
}
|
|
885
|
+
return rootDid;
|
|
886
|
+
}
|
|
887
|
+
|
|
814
888
|
// ================================================================ Web Bot Auth (RFC 9421 subset, verify-only) — T107
|
|
815
889
|
//
|
|
816
890
|
// The INBOUND half only: did the holder of one of the keys this entry was GIVEN sign
|
|
@@ -1770,6 +1844,43 @@ function isThenable(v) {
|
|
|
1770
1844
|
return v !== null && typeof v === 'object' && typeof v.then === 'function';
|
|
1771
1845
|
}
|
|
1772
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* `then1(x, f)` — apply `f` to `x`, awaiting `x` only if it is a promise.
|
|
1849
|
+
*
|
|
1850
|
+
* This is what lets ONE verification ladder serve both the synchronous in-memory state and
|
|
1851
|
+
* an asynchronous external `store` (T105), instead of the ladder being written twice and
|
|
1852
|
+
* drifting. Writing it twice was the alternative considered and rejected: the ladder is the
|
|
1853
|
+
* security-critical path, its steps are ORDER-DEPENDENT, and two copies of an ordered
|
|
1854
|
+
* security argument is how one of them silently stops matching the other.
|
|
1855
|
+
*
|
|
1856
|
+
* The sync branch is not an optimisation — it is the contract. With no store, every step
|
|
1857
|
+
* returns a plain value, the ladder returns a plain value, and `handleRequest` (the
|
|
1858
|
+
* synchronous entry point) keeps working exactly as it always has.
|
|
1859
|
+
*/
|
|
1860
|
+
function then1(x, f) {
|
|
1861
|
+
return isThenable(x) ? x.then(f) : f(x);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Validate a caller-supplied `store` and return it unchanged.
|
|
1866
|
+
*
|
|
1867
|
+
* Checked ONCE at construction rather than per call, and it throws rather than filling in a
|
|
1868
|
+
* default: a store missing `seenMessage` would otherwise silently disable replay protection,
|
|
1869
|
+
* and the failure would appear as "the door works" until somebody replayed a message.
|
|
1870
|
+
*/
|
|
1871
|
+
function asStore(store) {
|
|
1872
|
+
const REQUIRED = ['seenMessage', 'getAccount', 'putAccount', 'getDeviceOwner',
|
|
1873
|
+
'putDeviceOwner'];
|
|
1874
|
+
const missing = REQUIRED.filter((m) => typeof store[m] !== 'function');
|
|
1875
|
+
if (missing.length) {
|
|
1876
|
+
throw new TypeError(
|
|
1877
|
+
`createAgentEntry: store is missing ${missing.join(', ')}. A store must implement all `
|
|
1878
|
+
+ `of ${REQUIRED.join(', ')} — a partial store would disable a security rule silently `
|
|
1879
|
+
+ 'rather than loudly.');
|
|
1880
|
+
}
|
|
1881
|
+
return store;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1773
1884
|
// ---------------------------------------------------------------- baseUrl canonicalisation
|
|
1774
1885
|
|
|
1775
1886
|
/** RFC 3986 `pchar` plus '/' — the only characters an accepted path may carry. Everything
|
|
@@ -2148,6 +2259,43 @@ function domainFix(candidate) {
|
|
|
2148
2259
|
* refuses every other bad value there. Truncating would start the entry with a claim that
|
|
2149
2260
|
* is USABLE and NOT WHAT THEY SAID.
|
|
2150
2261
|
*/
|
|
2262
|
+
/** The kinds a visitor can take into a site, and the conditions a site may attach. Kept
|
|
2263
|
+
* identical to the visitor side (Agent Web Router `parsePrefer`): a kind or condition one
|
|
2264
|
+
* side knows and the other does not is a declaration one side silently drops. */
|
|
2265
|
+
export const PREFER_KINDS = ['page', 'card', 'mcp'];
|
|
2266
|
+
export const PREFER_WHEN = ['person', 'alone', 'key', 'no-key', 'token', 'browser'];
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* The exact `agentEntry.prefer` this entry may publish, or a TypeError (AE-30).
|
|
2270
|
+
*
|
|
2271
|
+
* The site's own order of its ways in — "read on the page if you have no key, then the
|
|
2272
|
+
* door", say. VALIDATED, NEVER REWRITTEN: this goes on a SIGNED card, and a card that says
|
|
2273
|
+
* something the operator did not write is a worse card than none, so an unknown kind, an
|
|
2274
|
+
* unknown condition or a stray key refuses the whole declaration instead of trimming it —
|
|
2275
|
+
* the same posture as `canonicalDomains`. `null`/`undefined` means "not configured", and
|
|
2276
|
+
* then no `prefer` key is published at all, which is what keeps an already-deployed
|
|
2277
|
+
* entry's bytes unchanged.
|
|
2278
|
+
*/
|
|
2279
|
+
export function validatePrefer(prefer) {
|
|
2280
|
+
if (prefer == null) return null;
|
|
2281
|
+
if (!Array.isArray(prefer) || prefer.length === 0) {
|
|
2282
|
+
throw new TypeError('agentEntry.prefer must be a non-empty array of "page" | "card" | "mcp" or {kind, when}');
|
|
2283
|
+
}
|
|
2284
|
+
for (const e of prefer) {
|
|
2285
|
+
if (typeof e === 'string') {
|
|
2286
|
+
if (!PREFER_KINDS.includes(e)) throw new TypeError(`agentEntry.prefer: unknown kind ${JSON.stringify(e)}`);
|
|
2287
|
+
continue;
|
|
2288
|
+
}
|
|
2289
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) throw new TypeError('agentEntry.prefer: an entry must be a kind or {kind, when}');
|
|
2290
|
+
const keys = Object.keys(e);
|
|
2291
|
+
if (!PREFER_KINDS.includes(e.kind)) throw new TypeError(`agentEntry.prefer: unknown kind ${JSON.stringify(e.kind)}`);
|
|
2292
|
+
if ('when' in e && !PREFER_WHEN.includes(e.when)) throw new TypeError(`agentEntry.prefer: unknown condition ${JSON.stringify(e.when)}`);
|
|
2293
|
+
const stray = keys.filter((k) => k !== 'kind' && k !== 'when');
|
|
2294
|
+
if (stray.length) throw new TypeError(`agentEntry.prefer: unexpected key(s) ${stray.join(', ')}`);
|
|
2295
|
+
}
|
|
2296
|
+
return prefer;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2151
2299
|
export function canonicalDomains(domains, { warn = true } = {}) {
|
|
2152
2300
|
if (domains === undefined || domains === null) return [];
|
|
2153
2301
|
if (!Array.isArray(domains)) {
|
|
@@ -2269,6 +2417,11 @@ export function canonicalMount(canonUrl, basePath) {
|
|
|
2269
2417
|
* responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
|
|
2270
2418
|
* openDoor advertise `muretai.open_door` (default true) — the flag that tells a
|
|
2271
2419
|
* visiting agent it may contact you without an introduction.
|
|
2420
|
+
* prefer OPTIONAL: the site's own order of its ways in, published as
|
|
2421
|
+
* `agentEntry.prefer` (AE-30) — e.g. `[{kind:'page', when:'no-key'}, 'card']`
|
|
2422
|
+
* says "read on the page if you hold no key; otherwise the door". Validated
|
|
2423
|
+
* by `validatePrefer`; an invalid list throws, so the entry never starts
|
|
2424
|
+
* with a statement the operator did not make. Absent = no key published.
|
|
2272
2425
|
* anonymousLane also accept UNSIGNED inquiries (default false). They create no account,
|
|
2273
2426
|
* and the lane as a whole is capped at `anonRatePerMin` signed replies per
|
|
2274
2427
|
* minute — it is unauthenticated, so it must not be an unmetered signing
|
|
@@ -2330,6 +2483,7 @@ export function createAgentEntry({
|
|
|
2330
2483
|
version = '1',
|
|
2331
2484
|
responder = () => 'Thanks — a human will follow up.',
|
|
2332
2485
|
openDoor = true,
|
|
2486
|
+
prefer = null,
|
|
2333
2487
|
anonymousLane = false,
|
|
2334
2488
|
anonRatePerMin = ANON_RATE_PER_MIN,
|
|
2335
2489
|
signedRatePerMin = SIGNED_RATE_PER_MIN,
|
|
@@ -2342,6 +2496,7 @@ export function createAgentEntry({
|
|
|
2342
2496
|
howToUrl = FIRST_KNOCK_URL,
|
|
2343
2497
|
observer = null,
|
|
2344
2498
|
wbaVerifiers = null,
|
|
2499
|
+
store = null,
|
|
2345
2500
|
} = {}) {
|
|
2346
2501
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
2347
2502
|
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
@@ -2404,7 +2559,10 @@ export function createAgentEntry({
|
|
|
2404
2559
|
// Neutral key first, vendor key beside it for one release. See the securitySchemes block
|
|
2405
2560
|
// below for why the old spelling stays: a consumer must learn the new name BEFORE
|
|
2406
2561
|
// producers stop emitting the old one, never after.
|
|
2407
|
-
|
|
2562
|
+
// AE-30: the site's order rides on the NEUTRAL key only; the alias stays `open_door`
|
|
2563
|
+
// alone, so an old consumer that compares the two aliases byte for byte keeps passing.
|
|
2564
|
+
const canonPrefer = validatePrefer(prefer);
|
|
2565
|
+
if (openDoor) card.agentEntry = { open_door: true, ...(canonPrefer ? { prefer: canonPrefer } : {}) };
|
|
2408
2566
|
if (openDoor) card.muretai = { open_door: true };
|
|
2409
2567
|
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
2410
2568
|
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
@@ -2485,6 +2643,53 @@ export function createAgentEntry({
|
|
|
2485
2643
|
// Unlike the ledger it is READ on every message, so only a real store can carry it.
|
|
2486
2644
|
const deviceOwner = new Map();
|
|
2487
2645
|
const replay = new ReplayGuard();
|
|
2646
|
+
|
|
2647
|
+
// ---------------------------------------------------------------- the state seam (T105)
|
|
2648
|
+
//
|
|
2649
|
+
// WHY. The three structures above live in CLOSURE MEMORY, and a serverless instance keeps
|
|
2650
|
+
// none of them between requests. The round-trip shape suits those platforms perfectly —
|
|
2651
|
+
// one signed POST in, one signed reply out, nothing to keep awake — so what blocks them is
|
|
2652
|
+
// not the shape but the STATE. Losing `replay` re-opens every message inside the freshness
|
|
2653
|
+
// window to replay; losing `deviceOwner` resets the no-re-ownership rule to
|
|
2654
|
+
// trust-on-first-use at every cold start.
|
|
2655
|
+
//
|
|
2656
|
+
// `store` is an OPTIONAL duck-typed object whose five methods may each return a value or a
|
|
2657
|
+
// promise:
|
|
2658
|
+
//
|
|
2659
|
+
// seenMessage(messageId, ttlSeconds) -> bool TRUE when the id was NEW (and is now
|
|
2660
|
+
// remembered). The test and the remember
|
|
2661
|
+
// MUST be atomic in the backing store.
|
|
2662
|
+
// getAccount(did) -> row | null
|
|
2663
|
+
// putAccount(did, row) -> void row === null DELETES the row.
|
|
2664
|
+
// getDeviceOwner(deviceDid)-> ownerDid | null
|
|
2665
|
+
// putDeviceOwner(dev, own) -> void
|
|
2666
|
+
//
|
|
2667
|
+
// Rate counters are deliberately NOT in this interface, and that is the approved design,
|
|
2668
|
+
// not an omission: a bound that costs a store write per request is its own denial of
|
|
2669
|
+
// service, and losing one fails open for a single minute — a bounded loss, unlike a lost
|
|
2670
|
+
// replay set or a lost pin.
|
|
2671
|
+
//
|
|
2672
|
+
// WITH NO STORE the default below is SYNCHRONOUS and wraps exactly the Maps above, so the
|
|
2673
|
+
// no-store path stays byte-identical and `handleRequest` (the sync entry point) keeps
|
|
2674
|
+
// working. With a store the message path returns a promise, which `route()` already
|
|
2675
|
+
// propagates and `handleRequestAsync` already awaits.
|
|
2676
|
+
const memoryStore = {
|
|
2677
|
+
seenMessage: (messageId) => replay.checkAndRemember(messageId),
|
|
2678
|
+
getAccount: (did) => ledger.get(did) ?? null,
|
|
2679
|
+
putAccount: (did, row) => {
|
|
2680
|
+
if (row === null) { ledger.delete(did); return; }
|
|
2681
|
+
ledger.set(did, row);
|
|
2682
|
+
while (ledger.size > maxAccounts) {
|
|
2683
|
+
const oldest = ledger.keys().next();
|
|
2684
|
+
if (oldest.done) break;
|
|
2685
|
+
ledger.delete(oldest.value);
|
|
2686
|
+
}
|
|
2687
|
+
},
|
|
2688
|
+
getDeviceOwner: (deviceDid) => deviceOwner.get(deviceDid) ?? null,
|
|
2689
|
+
putDeviceOwner: (deviceDid, ownerDid) => { deviceOwner.set(deviceDid, ownerDid); },
|
|
2690
|
+
};
|
|
2691
|
+
const state = store ? asStore(store) : memoryStore;
|
|
2692
|
+
const storeIsExternal = Boolean(store);
|
|
2488
2693
|
const anonRate = new RateBound(anonRatePerMin);
|
|
2489
2694
|
// A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
|
|
2490
2695
|
// rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
|
|
@@ -2680,25 +2885,24 @@ export function createAgentEntry({
|
|
|
2680
2885
|
return sigEnvelope;
|
|
2681
2886
|
}
|
|
2682
2887
|
|
|
2888
|
+
/** Record contact from an account. Returns the row (or a promise of it, with a store). */
|
|
2683
2889
|
function noteContact(accountDid) {
|
|
2684
2890
|
const now = nowEpoch();
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
const
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
}
|
|
2701
|
-
return fresh;
|
|
2891
|
+
return then1(state.getAccount(accountDid), (row) => {
|
|
2892
|
+
if (row) {
|
|
2893
|
+
// A store hands back a COPY, so the row must be written home again. The in-memory
|
|
2894
|
+
// store hands back the live object and the write is a no-op re-set of the same
|
|
2895
|
+
// reference — one code path, correct for both.
|
|
2896
|
+
const updated = { ...row, messages: (row.messages || 0) + 1, last_seen: now };
|
|
2897
|
+
return then1(state.putAccount(accountDid, updated), () => updated);
|
|
2898
|
+
}
|
|
2899
|
+
// FIRST CONTACT IS ACCOUNT CREATION. There is no signup form: the sender proved
|
|
2900
|
+
// control of a device key one line above, which is strictly more than an email link.
|
|
2901
|
+
// The row is keyed by the ACCOUNT (the owner when bound), so sibling devices are one
|
|
2902
|
+
// customer.
|
|
2903
|
+
const fresh = { first_seen: now, last_seen: now, messages: 1 };
|
|
2904
|
+
return then1(state.putAccount(accountDid, fresh), () => fresh);
|
|
2905
|
+
});
|
|
2702
2906
|
}
|
|
2703
2907
|
|
|
2704
2908
|
/** When a device that ALREADY has an unbound ledger row first proves its owner, move that
|
|
@@ -2706,13 +2910,19 @@ export function createAgentEntry({
|
|
|
2706
2910
|
* resolves to the device DID and must not merge, or stripping a binding would become a
|
|
2707
2911
|
* way to read the owner's history. */
|
|
2708
2912
|
function foldDeviceIntoOwner(deviceDid, ownerDid) {
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2913
|
+
return then1(state.getAccount(deviceDid), (devRow) => {
|
|
2914
|
+
if (!devRow) return undefined;
|
|
2915
|
+
return then1(state.putAccount(deviceDid, null), () =>
|
|
2916
|
+
then1(state.getAccount(ownerDid), (ownerRow) => {
|
|
2917
|
+
if (!ownerRow) return state.putAccount(ownerDid, devRow);
|
|
2918
|
+
const merged = {
|
|
2919
|
+
...ownerRow,
|
|
2920
|
+
messages: (ownerRow.messages || 0) + (devRow.messages || 0),
|
|
2921
|
+
first_seen: Math.min(ownerRow.first_seen, devRow.first_seen),
|
|
2922
|
+
};
|
|
2923
|
+
return state.putAccount(ownerDid, merged);
|
|
2924
|
+
}));
|
|
2925
|
+
});
|
|
2716
2926
|
}
|
|
2717
2927
|
|
|
2718
2928
|
/**
|
|
@@ -2760,17 +2970,18 @@ export function createAgentEntry({
|
|
|
2760
2970
|
if (!verifyDeviceBindingV2(binding, { now, expectedDeviceDid: from })) {
|
|
2761
2971
|
return { ok: false, reason: 'device binding does not verify' };
|
|
2762
2972
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2973
|
+
return then1(state.getDeviceOwner(from), (pinned) => {
|
|
2974
|
+
if (pinned !== null && pinned !== undefined && pinned !== rootDid) {
|
|
2975
|
+
return { ok: false, reason:
|
|
2976
|
+
'device is already bound to a different owner (a device DID is never re-owned '
|
|
2977
|
+
+ '— a new owner means a new device key)' };
|
|
2978
|
+
}
|
|
2979
|
+
if (pinned === null || pinned === undefined) {
|
|
2980
|
+
return then1(state.putDeviceOwner(from, rootDid), () =>
|
|
2981
|
+
then1(foldDeviceIntoOwner(from, rootDid), () => ({ ok: true, account: rootDid })));
|
|
2982
|
+
}
|
|
2983
|
+
return { ok: true, account: rootDid };
|
|
2984
|
+
});
|
|
2774
2985
|
}
|
|
2775
2986
|
|
|
2776
2987
|
/** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
|
|
@@ -3051,16 +3262,19 @@ export function createAgentEntry({
|
|
|
3051
3262
|
`the anonymous lane is limited to ${anonRatePerMin} replies per minute — `
|
|
3052
3263
|
+ 'sign your message to lift the bound');
|
|
3053
3264
|
}
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3265
|
+
return then1(state.seenMessage(msg.messageId, REPLAY_TTL_S), (fresh) => {
|
|
3266
|
+
if (!fresh) {
|
|
3267
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED,
|
|
3268
|
+
'duplicate messageId (replay) detected');
|
|
3269
|
+
}
|
|
3270
|
+
// T107: the interesting case — an anonymous inquiry whose TRANSPORT a known key
|
|
3271
|
+
// signed. `verified` STAYS false (the WBA signature covers @authority +
|
|
3272
|
+
// signature-agent, not the text), no ledger row is minted (the header set is a
|
|
3273
|
+
// bearer credential and replayable while it lives), and the anon rate bound
|
|
3274
|
+
// above already applied. Identify, don't enrol.
|
|
3275
|
+
return respond(backendEnvelope(msg, { verified: false, peerDid: null,
|
|
3276
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, '');
|
|
3277
|
+
});
|
|
3064
3278
|
}
|
|
3065
3279
|
// 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
|
|
3066
3280
|
// misaddressed message never reaches the base58 decoder.
|
|
@@ -3085,12 +3299,13 @@ export function createAgentEntry({
|
|
|
3085
3299
|
if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
|
|
3086
3300
|
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
|
|
3087
3301
|
}
|
|
3088
|
-
// 7. the signature itself
|
|
3089
|
-
//
|
|
3302
|
+
// 7. the signature itself. Payload `from` is the root DID; the verifying key is
|
|
3303
|
+
// the delegated op-key when a valid inline KeyState is attached (T142 A2).
|
|
3090
3304
|
const messageId = msg.messageId;
|
|
3091
3305
|
const fields = { from, to, messageId, contextId: msg.contextId ?? null,
|
|
3092
3306
|
timestamp: ts, text, sig };
|
|
3093
|
-
|
|
3307
|
+
const signerDid = resolveOpDid(from, meta.keystate, ts);
|
|
3308
|
+
if (!verifyEnvelope(fields, { recipientDid: did, signerDid })) {
|
|
3094
3309
|
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
|
|
3095
3310
|
}
|
|
3096
3311
|
// 8. duplicate messageId inside the replay window — AFTER the verify, and the ORDER is the
|
|
@@ -3102,40 +3317,54 @@ export function createAgentEntry({
|
|
|
3102
3317
|
// rule the signed lane's ceiling already follows one step below, for the same reason; it
|
|
3103
3318
|
// was simply never applied here. The cost of the swap is one Ed25519 verify spent on a
|
|
3104
3319
|
// replayed VALID message, which an attacker must first have obtained.
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3320
|
+
return then1(state.seenMessage(messageId, REPLAY_TTL_S), (fresh) => {
|
|
3321
|
+
if (!fresh) {
|
|
3322
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's
|
|
3326
|
+
// device DIDs to ONE account; a present-but-INVALID binding fails closed with the
|
|
3327
|
+
// SAME UNAUTHENTICATED code (never a silent downgrade to unbound). Absent → the
|
|
3328
|
+
// device DID.
|
|
3329
|
+
return then1(resolveAccount(meta.binding, from), (acct) => {
|
|
3330
|
+
if (!acct.ok) return rpcError(reqId, ERRORS.UNAUTHENTICATED, acct.reason);
|
|
3331
|
+
const account = acct.account;
|
|
3332
|
+
const ownerDid = account !== from ? account : null;
|
|
3333
|
+
|
|
3334
|
+
// 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a
|
|
3335
|
+
// stranger could spend somebody else's budget by naming them, and before
|
|
3336
|
+
// `resolveAccount` an owner's devices would each get their own. Here and not
|
|
3337
|
+
// later: a refused flood must grow neither the ledger nor whatever the
|
|
3338
|
+
// responder costs.
|
|
3339
|
+
// PER-ACCOUNT FIRST, deliberately — one loud peer is then stopped by ITS OWN
|
|
3340
|
+
// window without drawing down the shared one, so it cannot starve everybody
|
|
3341
|
+
// else on its way to being refused. Neither refusal names its ceiling: a
|
|
3342
|
+
// published number is a calibration table telling a flood exactly how many keys
|
|
3343
|
+
// to mint.
|
|
3344
|
+
//
|
|
3345
|
+
// These two bounds stay IN PROCESS even with an external store, and that is the
|
|
3346
|
+
// approved design rather than an omission: a ceiling that costs a store write
|
|
3347
|
+
// per request is its own denial of service, and losing a counter fails open for
|
|
3348
|
+
// one minute — bounded, unlike a lost replay set or a lost device pin. A
|
|
3349
|
+
// serverless deployment therefore gets its ceiling per instance; put a real one
|
|
3350
|
+
// at the edge if that matters.
|
|
3351
|
+
if (signedAccountRate && !signedAccountRate.allow(account)) {
|
|
3352
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
3353
|
+
'you are sending faster than this door answers — slow down and retry');
|
|
3354
|
+
}
|
|
3355
|
+
if (signedTotalRate && !signedTotalRate.allow()) {
|
|
3356
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
3357
|
+
'this entry is at its ceiling right now — retry shortly');
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
return then1(noteContact(account), () =>
|
|
3361
|
+
// T107: `wba_did` may legitimately differ from `peer_did` (the transport signer
|
|
3362
|
+
// vs the message signer) — both facts are honest, and the schema says which is
|
|
3363
|
+
// which.
|
|
3364
|
+
respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
|
|
3365
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from));
|
|
3366
|
+
});
|
|
3367
|
+
});
|
|
3139
3368
|
}
|
|
3140
3369
|
|
|
3141
3370
|
/** Hand the envelope to the watcher, and make sure it can cost nothing.
|
|
@@ -3373,8 +3602,14 @@ export function createAgentEntry({
|
|
|
3373
3602
|
function handleRequest(method, path, headers, bodyBuffer) {
|
|
3374
3603
|
const out = route(method, path, bodyBuffer, headers);
|
|
3375
3604
|
if (isThenable(out)) {
|
|
3376
|
-
|
|
3377
|
-
|
|
3605
|
+
// Two different causes, and an operator can only fix the one they are told about.
|
|
3606
|
+
// A `store` makes EVERY message path async by construction, so saying "responder is
|
|
3607
|
+
// async" to somebody who passed a synchronous responder and a KV store would send
|
|
3608
|
+
// them looking in the wrong place entirely.
|
|
3609
|
+
return rpcError(null, ERRORS.INTERNAL_ERROR, storeIsExternal
|
|
3610
|
+
? 'this entry has an external store, so every message is answered asynchronously — '
|
|
3611
|
+
+ 'serve it through listen()/handleRequestAsync()'
|
|
3612
|
+
: 'responder is async — serve this agent entry through listen()/handleRequestAsync()');
|
|
3378
3613
|
}
|
|
3379
3614
|
return out;
|
|
3380
3615
|
}
|