@cello-protocol/daemon 0.0.181 → 0.0.183
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/dist/agent-id-migration.d.ts +9 -0
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +73 -1
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/agent-settings-keys.d.ts.map +1 -1
- package/dist/agent-settings-keys.js +18 -0
- package/dist/agent-settings-keys.js.map +1 -1
- package/dist/assignment-verify.d.ts +62 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +185 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +340 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.js +91 -49
- package/dist/bin/cello-daemon.js.map +1 -1
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +20 -0
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +351 -58
- package/dist/close-session-handler.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts +2 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +182 -9
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-encryption-status.d.ts +97 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +122 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +10 -0
- package/dist/content-park-client.d.ts.map +1 -1
- package/dist/content-park-client.js +84 -3
- package/dist/content-park-client.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +323 -21
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts +13 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +1168 -141
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-session-suspects.d.ts.map +1 -1
- package/dist/delivery-session-suspects.js +5 -0
- package/dist/delivery-session-suspects.js.map +1 -1
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +66 -3
- package/dist/directory-bootstrap.d.ts.map +1 -1
- package/dist/directory-bootstrap.js +133 -7
- package/dist/directory-bootstrap.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +22 -2
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +11 -3
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +119 -0
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-layer.d.ts +0 -2
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js.map +1 -1
- package/dist/inbound-sessions.d.ts +31 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +276 -16
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +34 -3
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/ipc-server.d.ts +5 -1
- package/dist/ipc-server.d.ts.map +1 -1
- package/dist/ipc-server.js +19 -3
- package/dist/ipc-server.js.map +1 -1
- package/dist/manifest-deps.d.ts +0 -11
- package/dist/manifest-deps.d.ts.map +1 -1
- package/dist/manifest-deps.js +28 -2
- package/dist/manifest-deps.js.map +1 -1
- package/dist/manifest-validity.d.ts +153 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +15 -0
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +31 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +217 -17
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +95 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +195 -4
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +72 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +69 -0
- package/dist/refusal-reasons.js.map +1 -0
- package/dist/register-handler.d.ts.map +1 -1
- package/dist/register-handler.js +76 -0
- package/dist/register-handler.js.map +1 -1
- package/dist/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +20 -1
- package/dist/registration-manager.js.map +1 -1
- package/dist/relay-only.d.ts +133 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +183 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/restart-seal-resolver.d.ts.map +1 -1
- package/dist/restart-seal-resolver.js +6 -0
- package/dist/restart-seal-resolver.js.map +1 -1
- package/dist/retry-queue.d.ts +49 -2
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +55 -10
- package/dist/retry-queue.js.map +1 -1
- package/dist/roster-freshness.d.ts +169 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +249 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +17 -1
- package/dist/seal-certificate-pull.d.ts.map +1 -1
- package/dist/seal-certificate-pull.js +37 -3
- package/dist/seal-certificate-pull.js.map +1 -1
- package/dist/seal-coordinator.d.ts +17 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +71 -0
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-failure-store.d.ts +104 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +126 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +35 -6
- package/dist/seal-flows.js.map +1 -1
- package/dist/seal-upgrade.d.ts +20 -5
- package/dist/seal-upgrade.d.ts.map +1 -1
- package/dist/seal-upgrade.js +28 -5
- package/dist/seal-upgrade.js.map +1 -1
- package/dist/session-assignment-parser.d.ts +41 -5
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +77 -14
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-ceremony.d.ts +35 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +97 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-connection-gater.d.ts +46 -7
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +163 -12
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-content-handlers.d.ts +12 -0
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +149 -8
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +653 -40
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +5938 -374
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts +27 -0
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +127 -6
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +177 -2
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +564 -16
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +338 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +478 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/signal-submission.d.ts.map +1 -1
- package/dist/signal-submission.js +10 -1
- package/dist/signal-submission.js.map +1 -1
- package/dist/signaling-connect.d.ts +18 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +206 -6
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +10 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +68 -0
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +83 -0
- package/dist/wire-content-hash.js.map +1 -1
- package/package.json +5 -5
|
@@ -31,7 +31,7 @@ var _a;
|
|
|
31
31
|
import { createHash } from "node:crypto";
|
|
32
32
|
import * as lp from "it-length-prefixed";
|
|
33
33
|
import { decode } from "cbor-x";
|
|
34
|
-
import { encodeCbor } from "@cello-protocol/protocol-types";
|
|
34
|
+
import { encodeCbor, decodeSealPayload } from "@cello-protocol/protocol-types";
|
|
35
35
|
import { evaluateRelayAck } from "./relay-receipt-store.js";
|
|
36
36
|
export const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
|
|
37
37
|
export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
|
|
@@ -51,11 +51,118 @@ export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
|
|
|
51
51
|
* ENUMERATED, never pattern-matched. A substring rule like `reason.includes("sealed")` would absorb
|
|
52
52
|
* a future reason nobody has considered — which is the same collapse in a new coat.
|
|
53
53
|
*/
|
|
54
|
-
export const TERMINAL_RELAY_REFUSALS = new Set([
|
|
54
|
+
export const TERMINAL_RELAY_REFUSALS = new Set([
|
|
55
|
+
"session_sealed",
|
|
56
|
+
"session_not_found",
|
|
57
|
+
/**
|
|
58
|
+
* `DOD-M15-TERMINAL-REASON-1` split `session_sealed` into named causes, and this set is one of
|
|
59
|
+
* THREE places keyed on the old literal — a rename on the relay silently made a terminal refusal
|
|
60
|
+
* non-terminal here, which is the 68-minute defect above reopened by a string change.
|
|
61
|
+
*
|
|
62
|
+
* `seal_refused`: a directory READ the seal and rejected it. Terminal in the strongest sense —
|
|
63
|
+
* there is no later, and no retry can change a merits verdict.
|
|
64
|
+
*
|
|
65
|
+
* `seal_in_progress` is deliberately ABSENT. A seal in flight may still succeed, and after
|
|
66
|
+
* `DOD-M15-TRANSPORT-TERMINAL-1` the session can return to `active` — treating it as terminal
|
|
67
|
+
* would retire a conversation that is about to seal normally.
|
|
68
|
+
*/
|
|
69
|
+
"seal_refused",
|
|
70
|
+
]);
|
|
55
71
|
/** True when the relay has ended this session and no later submit can succeed. */
|
|
56
72
|
export function isTerminalRelayRefusal(reason) {
|
|
57
73
|
return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
|
|
58
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
|
|
77
|
+
*
|
|
78
|
+
* We run several relays, so "move on to the next one" is always available — which is exactly why it
|
|
79
|
+
* needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
|
|
80
|
+
* what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
|
|
81
|
+
*
|
|
82
|
+
* ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
|
|
83
|
+
* rule would silently absorb a future reason nobody has considered.
|
|
84
|
+
*/
|
|
85
|
+
const RELAY_SIDE_REFUSALS = new Set([
|
|
86
|
+
/**
|
|
87
|
+
* The relay holds no directory public key, so it can verify nothing and is refusing everyone.
|
|
88
|
+
* That is this relay being misconfigured, not us being wrong — another relay is the right move,
|
|
89
|
+
* and it is the whole reason we run more than one.
|
|
90
|
+
*/
|
|
91
|
+
"online_token_no_directory_key",
|
|
92
|
+
]);
|
|
93
|
+
/**
|
|
94
|
+
* Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
|
|
95
|
+
*
|
|
96
|
+
* Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
|
|
97
|
+
* safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
|
|
98
|
+
* around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
|
|
99
|
+
* IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
|
|
100
|
+
* brings the same wall back on the next one.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyRelayAuthRefusal(reason, extra = {}) {
|
|
103
|
+
const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
|
|
104
|
+
let advice;
|
|
105
|
+
switch (reason) {
|
|
106
|
+
case "online_token_required":
|
|
107
|
+
advice = "This agent has no online token from a directory yet. It is issued when a directory " +
|
|
108
|
+
"marks the agent online, so this usually clears itself on the next directory connection. If " +
|
|
109
|
+
"it persists, the agent is not reaching any directory — check that first, not the relay.";
|
|
110
|
+
break;
|
|
111
|
+
case "online_token_expired":
|
|
112
|
+
advice = "The online token has expired and is refreshed on the next directory connection. If " +
|
|
113
|
+
"it keeps expiring, this machine's clock or its directory connection is the thing to look at.";
|
|
114
|
+
break;
|
|
115
|
+
case "online_token_signature_invalid":
|
|
116
|
+
case "online_token_malformed":
|
|
117
|
+
case "online_token_lifetime_too_long":
|
|
118
|
+
advice = "This relay would not accept the token this agent was issued. Most often the relay " +
|
|
119
|
+
"and the directory are not in the same consortium — check which directories this relay is " +
|
|
120
|
+
"configured to trust.";
|
|
121
|
+
break;
|
|
122
|
+
case "online_token_pubkey_mismatch":
|
|
123
|
+
advice = "The token names a different key from the one this agent signed with. That is an " +
|
|
124
|
+
"identity mix-up on this machine, not a relay problem.";
|
|
125
|
+
break;
|
|
126
|
+
case "online_token_no_directory_key":
|
|
127
|
+
advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
|
|
128
|
+
"refusing every agent. Its operator needs to configure one; another relay will work now.";
|
|
129
|
+
break;
|
|
130
|
+
case "slot_cap_exceeded":
|
|
131
|
+
advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
|
|
132
|
+
? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
|
|
133
|
+
"reservations on this relay, and none is idle enough to reclaim. That is almost always " +
|
|
134
|
+
"sessions that were never closed — close some and this clears. Moving to another relay " +
|
|
135
|
+
"would work now and hit the same wall there."
|
|
136
|
+
: "This agent already holds the most reservations one agent may hold on this relay. That is " +
|
|
137
|
+
"almost always sessions that were never closed — close some and this clears.";
|
|
138
|
+
break;
|
|
139
|
+
case "session_tuple_cap_exceeded":
|
|
140
|
+
advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
|
|
141
|
+
? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
|
|
142
|
+
`which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
|
|
143
|
+
"agents. Close some and try again — this is almost always conversations that were never " +
|
|
144
|
+
"closed rather than ones anybody is still using."
|
|
145
|
+
: "You already have the maximum number of concurrent conversations open with this " +
|
|
146
|
+
"counterparty. Close some and try again.";
|
|
147
|
+
break;
|
|
148
|
+
case "rate_limited":
|
|
149
|
+
advice = extra.retryAfterMs !== undefined
|
|
150
|
+
? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
|
|
151
|
+
: "This relay is throttling this agent; it clears on its own after the throttle window.";
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
|
|
155
|
+
"here and is reachable only over a direct connection.";
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
reason,
|
|
159
|
+
advice,
|
|
160
|
+
tryAnotherRelay,
|
|
161
|
+
...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
|
|
162
|
+
...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
|
|
163
|
+
...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
59
166
|
export const LEAF_KIND_MSG = 0x00;
|
|
60
167
|
/** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
|
|
61
168
|
export const LEAF_KIND_CTRL = 0x02;
|
|
@@ -151,6 +258,18 @@ export class AgentRelayClient {
|
|
|
151
258
|
#logger;
|
|
152
259
|
#receiptStore;
|
|
153
260
|
#sealLeafStore;
|
|
261
|
+
/** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
|
|
262
|
+
#onlineToken;
|
|
263
|
+
/**
|
|
264
|
+
* DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
|
|
265
|
+
* reaches neither the operator asking why their agent is unreachable nor the code deciding
|
|
266
|
+
* whether a different relay would do any better.
|
|
267
|
+
*/
|
|
268
|
+
#lastAuthRefusal = null;
|
|
269
|
+
/** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
|
|
270
|
+
getLastAuthRefusal() {
|
|
271
|
+
return this.#lastAuthRefusal;
|
|
272
|
+
}
|
|
154
273
|
#stream = null;
|
|
155
274
|
#connecting = null;
|
|
156
275
|
#closed = false;
|
|
@@ -166,6 +285,13 @@ export class AgentRelayClient {
|
|
|
166
285
|
// The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
|
|
167
286
|
// SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
|
|
168
287
|
#pendingStructure1 = null;
|
|
288
|
+
/**
|
|
289
|
+
* DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
|
|
290
|
+
* paired with its ack for the same reason that one is: the ack returns the relay's record, never
|
|
291
|
+
* ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
|
|
292
|
+
* a signature paired with the WRONG signed bytes is worse than no signature at all.
|
|
293
|
+
*/
|
|
294
|
+
#pendingSignature = null;
|
|
169
295
|
// The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
|
|
170
296
|
// #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
|
|
171
297
|
#pendingLeafKind = null;
|
|
@@ -189,6 +315,7 @@ export class AgentRelayClient {
|
|
|
189
315
|
this.#logger = opts.logger;
|
|
190
316
|
this.#receiptStore = opts.receiptStore;
|
|
191
317
|
this.#sealLeafStore = opts.sealLeafStore;
|
|
318
|
+
this.#onlineToken = opts.onlineToken;
|
|
192
319
|
}
|
|
193
320
|
/** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
|
|
194
321
|
get senderPubkeyHex() {
|
|
@@ -220,6 +347,29 @@ export class AgentRelayClient {
|
|
|
220
347
|
.then(() => undefined, () => undefined);
|
|
221
348
|
}
|
|
222
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
|
|
352
|
+
* recorded it.**
|
|
353
|
+
*
|
|
354
|
+
* `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
|
|
355
|
+
* and nobody can observe when it lands. That is correct for the witness relay, where the only
|
|
356
|
+
* requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
|
|
357
|
+
* because there the record is a precondition of an action we are about to take on another thread
|
|
358
|
+
* of the protocol — and losing that race denies a legitimate dial (review H1).
|
|
359
|
+
*
|
|
360
|
+
* Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
|
|
361
|
+
* same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
|
|
362
|
+
* is recorded, so calling this straight after `registerSession` waits for the record that call
|
|
363
|
+
* already queued rather than sending a second one.
|
|
364
|
+
*
|
|
365
|
+
* Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
|
|
366
|
+
* (a dial that might be denied still beats no dial at all).
|
|
367
|
+
*/
|
|
368
|
+
async recordAssignmentAndWait(node, sessionIdHex) {
|
|
369
|
+
const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
|
|
370
|
+
this.#submitChain = run.then(() => undefined, () => undefined);
|
|
371
|
+
return run.catch(() => false);
|
|
372
|
+
}
|
|
223
373
|
/**
|
|
224
374
|
* Present the directory-signed assignment to the relay. Idempotent
|
|
225
375
|
* (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
|
|
@@ -311,12 +461,25 @@ export class AgentRelayClient {
|
|
|
311
461
|
hasSessions() {
|
|
312
462
|
return this.#sessions.size > 0;
|
|
313
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Is THIS session already registered on this client?
|
|
466
|
+
*
|
|
467
|
+
* DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
|
|
468
|
+
* when the submit finishes, and "did I register it, or did I find it already there?" is the
|
|
469
|
+
* difference between releasing my own and **pulling a live one out from under a concurrent
|
|
470
|
+
* caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
|
|
471
|
+
* answer it: it is a count, and by then the id is in the set either way.
|
|
472
|
+
*/
|
|
473
|
+
hasSession(sessionIdHex) {
|
|
474
|
+
return this.#sessions.has(sessionIdHex);
|
|
475
|
+
}
|
|
314
476
|
/** Settle the one outstanding submit (if any) exactly once. */
|
|
315
477
|
#settlePending(r) {
|
|
316
478
|
const resolve = this.#pendingAck;
|
|
317
479
|
this.#pendingAck = null;
|
|
318
480
|
this.#pendingAckSessionHex = null;
|
|
319
481
|
this.#pendingStructure1 = null;
|
|
482
|
+
this.#pendingSignature = null;
|
|
320
483
|
this.#pendingLeafKind = null;
|
|
321
484
|
if (resolve)
|
|
322
485
|
resolve(r);
|
|
@@ -456,6 +619,8 @@ export class AgentRelayClient {
|
|
|
456
619
|
const s2 = frame["structure2_cbor"];
|
|
457
620
|
const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
|
|
458
621
|
const structure1Cbor = this.#pendingStructure1 ?? undefined;
|
|
622
|
+
// Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
|
|
623
|
+
const senderSignature = this.#pendingSignature ?? undefined;
|
|
459
624
|
// Verify the relay's signed ordering record and durably store the receipt BEFORE
|
|
460
625
|
// settling (which clears #pendingStructure1, the source of the content hash + session id). A
|
|
461
626
|
// signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
|
|
@@ -463,12 +628,24 @@ export class AgentRelayClient {
|
|
|
463
628
|
this.#settlePending(rejectSubmit
|
|
464
629
|
? { ok: false, reason: "relay_ack_signature_invalid" }
|
|
465
630
|
: seq >= 0
|
|
466
|
-
? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor }
|
|
631
|
+
? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
|
|
467
632
|
: { ok: false, reason: "relay_ack_malformed" });
|
|
468
633
|
}
|
|
469
634
|
else if (type === "hash_submit_error") {
|
|
470
635
|
const reason = typeof frame["reason"] === "string" ? frame["reason"] : "relay_rejected";
|
|
471
|
-
|
|
636
|
+
// Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
|
|
637
|
+
// what happened is how a refusal arrives as a bare code with no cause attached to it.
|
|
638
|
+
const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
|
|
639
|
+
// DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
|
|
640
|
+
// dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
|
|
641
|
+
const rawRetry = frame["retry_after_ms"];
|
|
642
|
+
const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
|
|
643
|
+
this.#settlePending({
|
|
644
|
+
ok: false,
|
|
645
|
+
reason,
|
|
646
|
+
...(detail ? { detail } : {}),
|
|
647
|
+
...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
|
|
648
|
+
});
|
|
472
649
|
}
|
|
473
650
|
else if (type === "assignment_ok") {
|
|
474
651
|
// The relay verified + recorded our client-presented assignment.
|
|
@@ -480,7 +657,23 @@ export class AgentRelayClient {
|
|
|
480
657
|
else if (type === "assignment_invalid") {
|
|
481
658
|
// The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
|
|
482
659
|
// consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
|
|
483
|
-
|
|
660
|
+
const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
|
|
661
|
+
this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
|
|
662
|
+
/**
|
|
663
|
+
* DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
|
|
664
|
+
*
|
|
665
|
+
* Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
|
|
666
|
+
* arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
|
|
667
|
+
* person, for the reason the order itself gives: nobody knows what sessions they have open, so
|
|
668
|
+
* whoever hits it believes they have none. Routed through the same classifier and onto the
|
|
669
|
+
* same surface as every other relay refusal.
|
|
670
|
+
*/
|
|
671
|
+
const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
|
|
672
|
+
const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
|
|
673
|
+
this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
|
|
674
|
+
...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
|
|
675
|
+
...(cap !== undefined ? { slotCap: cap } : {}),
|
|
676
|
+
});
|
|
484
677
|
const r = this.#pendingRecord;
|
|
485
678
|
this.#pendingRecord = null;
|
|
486
679
|
if (r)
|
|
@@ -540,6 +733,36 @@ export class AgentRelayClient {
|
|
|
540
733
|
});
|
|
541
734
|
}
|
|
542
735
|
}
|
|
736
|
+
else if (type === "relay_slot_reclaimed") {
|
|
737
|
+
/**
|
|
738
|
+
* DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
|
|
739
|
+
*
|
|
740
|
+
* The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
|
|
741
|
+
* here the frame fell off the end of this chain and was discarded in silence, which is the
|
|
742
|
+
* trap the order records in its own words: a refusal that only reaches the relay's log does
|
|
743
|
+
* not exist. From the agent's side the reservation simply stops working.
|
|
744
|
+
*
|
|
745
|
+
* Recorded as a refusal so it reaches `cello_status` through the same surface as every other
|
|
746
|
+
* relay refusal, with the same shape: a cause, and what to do about it.
|
|
747
|
+
*/
|
|
748
|
+
const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
|
|
749
|
+
const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
|
|
750
|
+
this.#lastAuthRefusal = {
|
|
751
|
+
reason: "slot_reclaimed",
|
|
752
|
+
advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
|
|
753
|
+
"had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
|
|
754
|
+
"automatically; a new session will take a fresh reservation.",
|
|
755
|
+
// Not the relay's fault and not ours — it was under pressure and we were the quietest. The
|
|
756
|
+
// client rebuilds against the same pool, so there is nothing to fail over from.
|
|
757
|
+
tryAnotherRelay: false,
|
|
758
|
+
};
|
|
759
|
+
this.#logger.warn("session.relay.slot_reclaimed", {
|
|
760
|
+
relayPeerId: this.#relayPeerId,
|
|
761
|
+
...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
|
|
762
|
+
impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
|
|
763
|
+
"rebuilt, this agent is reachable only over a direct connection.",
|
|
764
|
+
});
|
|
765
|
+
}
|
|
543
766
|
// session_interrupted / content_park_notify are out of scope here — session interruption
|
|
544
767
|
// is handled by the session node manager's dedicated relay-stream watcher.
|
|
545
768
|
}
|
|
@@ -551,6 +774,61 @@ export class AgentRelayClient {
|
|
|
551
774
|
async connect(node) {
|
|
552
775
|
return this.#ensureConnected(node);
|
|
553
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
|
|
779
|
+
*
|
|
780
|
+
* ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
|
|
781
|
+
* returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
|
|
782
|
+
* connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
|
|
783
|
+
* into a live session, plus the replacement standing receiver built behind it — and they share
|
|
784
|
+
* one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
|
|
785
|
+
* from the replacement receiver short-circuited on the session node's stream, sent nothing, and
|
|
786
|
+
* the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
|
|
787
|
+
* the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
|
|
788
|
+
* for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
|
|
789
|
+
* here reintroduces exactly that.
|
|
790
|
+
*
|
|
791
|
+
* So this always opens its own short-lived stream from `node`, and marks it
|
|
792
|
+
* `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
|
|
793
|
+
* stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
|
|
794
|
+
*/
|
|
795
|
+
async proveReservation(node) {
|
|
796
|
+
if (this.#closed)
|
|
797
|
+
return false;
|
|
798
|
+
for (const addr of this.#relayAddrs) {
|
|
799
|
+
try {
|
|
800
|
+
await node.dial(addr);
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
catch { /* try the next address */ }
|
|
804
|
+
}
|
|
805
|
+
let stream;
|
|
806
|
+
try {
|
|
807
|
+
stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
this.#logger.warn("session.relay.reservation_proof.failed", {
|
|
811
|
+
relayPeerId: this.#relayPeerId, reason: "stream", error: extractErrorMessage(err),
|
|
812
|
+
});
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
817
|
+
const ok = await this.#authenticate(stream, iter, "reservation");
|
|
818
|
+
this.#logger.info("session.relay.reservation_proof.result", {
|
|
819
|
+
relayPeerId: this.#relayPeerId,
|
|
820
|
+
nodePeerId: node.getPeerId(),
|
|
821
|
+
ok,
|
|
822
|
+
// DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
|
|
823
|
+
// the transport for what is usually a token or a cap.
|
|
824
|
+
...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
|
|
825
|
+
});
|
|
826
|
+
return ok;
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
await stream.close().catch(() => { });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
554
832
|
/** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
|
|
555
833
|
async #ensureConnected(node) {
|
|
556
834
|
if (this.#closed)
|
|
@@ -603,7 +881,7 @@ export class AgentRelayClient {
|
|
|
603
881
|
this.#startReader(stream, iter);
|
|
604
882
|
return true;
|
|
605
883
|
}
|
|
606
|
-
async #authenticate(stream, iter) {
|
|
884
|
+
async #authenticate(stream, iter, purpose) {
|
|
607
885
|
const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
|
|
608
886
|
if (challengeRes.done || challengeRes.value === undefined) {
|
|
609
887
|
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
|
|
@@ -627,8 +905,37 @@ export class AgentRelayClient {
|
|
|
627
905
|
return false;
|
|
628
906
|
}
|
|
629
907
|
const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
|
|
908
|
+
/**
|
|
909
|
+
* DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
|
|
910
|
+
* signaling reconnect and the one this client was built with is usually already gone.
|
|
911
|
+
*
|
|
912
|
+
* When there is none we send the auth anyway. Declining to try would replace a named refusal
|
|
913
|
+
* from the relay (`online_token_required`, which says what is wrong and what to do) with
|
|
914
|
+
* silence on both sides — and silence is what an operator reads as "the product is broken".
|
|
915
|
+
*/
|
|
916
|
+
const onlineToken = this.#onlineToken?.();
|
|
917
|
+
if (!onlineToken) {
|
|
918
|
+
this.#logger.warn("session.relay.auth.no_online_token", {
|
|
919
|
+
relayPeerId: this.#relayPeerId,
|
|
920
|
+
impact: "authenticating without the directory's online token. The relay will refuse this and " +
|
|
921
|
+
"will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
|
|
922
|
+
"over this relay. The usual cause is that no directory connection has been established " +
|
|
923
|
+
"yet; the next signaling connect issues a token and the receiver re-authenticates.",
|
|
924
|
+
});
|
|
925
|
+
}
|
|
630
926
|
try {
|
|
631
|
-
stream.send(lp.encode.single(encodeCbor({
|
|
927
|
+
stream.send(lp.encode.single(encodeCbor({
|
|
928
|
+
type: "relay_auth_response",
|
|
929
|
+
pubkey: this.#senderPubkey,
|
|
930
|
+
signature: authSig,
|
|
931
|
+
// DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
|
|
932
|
+
// stream as the agent's delivery target). `"reservation"` proves possession from THIS
|
|
933
|
+
// node's transport identity and nothing more — see proveReservation().
|
|
934
|
+
...(purpose ? { purpose } : {}),
|
|
935
|
+
// DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
|
|
936
|
+
// never parses them — a format it does not read is a format it cannot get wrong.
|
|
937
|
+
...(onlineToken ? { online_token: onlineToken } : {}),
|
|
938
|
+
})));
|
|
632
939
|
}
|
|
633
940
|
catch (err) {
|
|
634
941
|
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
|
|
@@ -648,12 +955,50 @@ export class AgentRelayClient {
|
|
|
648
955
|
return false;
|
|
649
956
|
}
|
|
650
957
|
if (ackFrame["type"] !== "relay_auth_ok") {
|
|
958
|
+
/**
|
|
959
|
+
* DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
|
|
960
|
+
*
|
|
961
|
+
* `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
|
|
962
|
+
* `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
|
|
963
|
+
* collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
|
|
964
|
+
* exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
|
|
965
|
+
* distinction the order that added those refusals set out to create, undone at the last hop.
|
|
966
|
+
*
|
|
967
|
+
* The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
|
|
968
|
+
* `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
|
|
969
|
+
* handshake raced and an immediate retry is the right move. One label for all three sends
|
|
970
|
+
* someone to look for a broken relay in all three cases.
|
|
971
|
+
*/
|
|
972
|
+
const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
|
|
973
|
+
const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
|
|
974
|
+
/**
|
|
975
|
+
* DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
|
|
976
|
+
*
|
|
977
|
+
* Everything below writes an excellent warn line into a file nobody opens. The operator who
|
|
978
|
+
* runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
|
|
979
|
+
* deciding whether another relay would help cannot read it either. So the classified refusal
|
|
980
|
+
* — reason, what to do about it, and whether to fail over — is stored where both can reach it.
|
|
981
|
+
*/
|
|
982
|
+
const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
|
|
983
|
+
const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
|
|
984
|
+
this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
|
|
985
|
+
...(slotsHeld !== undefined ? { slotsHeld } : {}),
|
|
986
|
+
...(slotCap !== undefined ? { slotCap } : {}),
|
|
987
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
988
|
+
});
|
|
651
989
|
this.#logger.warn("session.relay.auth.failed", {
|
|
652
990
|
relayPeerId: this.#relayPeerId,
|
|
653
|
-
reason: ackFrame["type"] === "relay_auth_failed" ? "auth_rejected" : "unexpected_frame",
|
|
991
|
+
reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
|
|
992
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
993
|
+
impact: relayReason === "rate_limited"
|
|
994
|
+
? "this relay is throttling us; it clears on its own after the stated window, and until it does this agent cannot reserve or witness here"
|
|
995
|
+
: "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
|
|
654
996
|
});
|
|
655
997
|
return false;
|
|
656
998
|
}
|
|
999
|
+
// DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
|
|
1000
|
+
// as the current state of a relay that has since started admitting us.
|
|
1001
|
+
this.#lastAuthRefusal = null;
|
|
657
1002
|
return true;
|
|
658
1003
|
}
|
|
659
1004
|
#startReader(stream, iter) {
|
|
@@ -729,18 +1074,132 @@ export class AgentRelayClient {
|
|
|
729
1074
|
* `final_message` and from `answered` — guards that could never fire while this method hardcoded
|
|
730
1075
|
* MSG for every caller. See `document-leaf-kind-on-the-wire.test.ts` for what that cost.
|
|
731
1076
|
*/
|
|
732
|
-
async submitMessageHash(node, sessionId, contentHash,
|
|
733
|
-
|
|
1077
|
+
async submitMessageHash(node, sessionId, contentHash,
|
|
1078
|
+
/**
|
|
1079
|
+
* REQUIRED — `DOD-M15-SEALWIRE-1` B2b-1 pass-2 F3. This default was the last one on the path,
|
|
1080
|
+
* and it is the reason a test passing `undefined` for `leafKind` looked correct: the value was
|
|
1081
|
+
* silently rebuilt as MESSAGE one hop below the parameter the fix had just made required. Its
|
|
1082
|
+
* one production caller always passes explicitly, so the default was dead and misleading.
|
|
1083
|
+
*/
|
|
1084
|
+
leafKind) {
|
|
1085
|
+
/**
|
|
1086
|
+
* `null`, and it is now IMPOSSIBLE to omit — see `submitLeaf`'s note on why the parameter is
|
|
1087
|
+
* required. A message leaf's content belongs to the operator and never reaches the relay.
|
|
1088
|
+
*/
|
|
1089
|
+
return this.submitLeaf(node, sessionId, contentHash, leafKind, null);
|
|
734
1090
|
}
|
|
735
1091
|
/**
|
|
736
1092
|
* Submit a leaf hash of a given kind (0x00 message / 0x02 control) to the relay. The SEAL
|
|
737
1093
|
* ctrl leaf rides this path: two distinct-sender ctrl leaves in the relay's
|
|
738
1094
|
* log trigger the directory's FROST notarization (relay `#maybeProcessSeal`).
|
|
1095
|
+
*
|
|
1096
|
+
* ─── `contentBytes` — `DOD-M15-SEALWIRE-1` bullets 3+4, THE SENDER LEG ───────────────────────
|
|
1097
|
+
*
|
|
1098
|
+
* The SEAL leaf's own payload, carried alongside its hash. Without it the directory holds a
|
|
1099
|
+
* SHA-256 pre-image and nothing else, so the client's SIGNED `final_root` — the one value in the
|
|
1100
|
+
* whole seal that the relay cannot produce — is unrecoverable, and every root check the directory
|
|
1101
|
+
* can make compares the relay against itself.
|
|
1102
|
+
*
|
|
1103
|
+
* ⚠️ THIS PARAMETER PUTS LEAF CONTENT ON THE RELAY, AND THE RELAY IS THE PARTY THIS PROTOCOL
|
|
1104
|
+
* EXISTS TO KEEP CONTENT AWAY FROM (INV-3: a forwarding relay sees ciphertext).
|
|
1105
|
+
*
|
|
1106
|
+
* It is safe for a SEAL ctrl leaf and for nothing else. The payload is `[session_id, final_root,
|
|
1107
|
+
* close_timestamp, "PENDING"]` and the relay already knows all four — it assigned the session,
|
|
1108
|
+
* built the tree the root comes from, and stamped the leaf. Nothing is disclosed. That reasoning
|
|
1109
|
+
* stops dead at the next leaf kind: a `msg` leaf's content is the operator's plaintext and a `doc`
|
|
1110
|
+
* leaf's is their document.
|
|
1111
|
+
*
|
|
1112
|
+
* So both directions are REFUSED rather than tidied, and refused HERE rather than at the relay:
|
|
1113
|
+
*
|
|
1114
|
+
* - content on a non-ctrl leaf → the relay would refuse the whole frame, but only after the
|
|
1115
|
+
* operator's words had already crossed the wire to the party that must not have them, and the
|
|
1116
|
+
* refusal would destroy their send rather than protect it.
|
|
1117
|
+
* - a ctrl leaf with NO payload → this was the actual defect. `submitSealLeaf` computed the
|
|
1118
|
+
* payload, hashed it, and had nowhere to put it, so it was dropped. The seal still succeeded,
|
|
1119
|
+
* the relay still acked, and three hops later the directory reported `not_carried` and blamed
|
|
1120
|
+
* the relay's build version — for a value the client never sent. Four reviewed legs shipped
|
|
1121
|
+
* over that silence. A dropped argument now fails on the machine that dropped it.
|
|
1122
|
+
*
|
|
1123
|
+
* ⚠️ REQUIRED, AND `| null` RATHER THAN `?` — THE TYPE IS THE GUARD.
|
|
1124
|
+
*
|
|
1125
|
+
* I first wrote this optional and covered it with tests. Then I ran the revert test that mattered:
|
|
1126
|
+
* drop the argument at the one call site that must pass it, exactly reproducing the original
|
|
1127
|
+
* defect. **All five new tests stayed green.** An optional parameter makes the defect a silent,
|
|
1128
|
+
* type-legal omission — which is precisely how it shipped through four reviews the first time.
|
|
1129
|
+
*
|
|
1130
|
+
* Required means the omission is a COMPILE ERROR, caught by the gate on the machine that made it,
|
|
1131
|
+
* before any test runs. Every caller must now say what this leaf carries, and `submitMessageHash`
|
|
1132
|
+
* says `null` in one visible place instead of by saying nothing at all.
|
|
739
1133
|
*/
|
|
740
|
-
async submitLeaf(node, sessionId, contentHash, leafKind) {
|
|
1134
|
+
async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes) {
|
|
1135
|
+
if (contentBytes !== null && leafKind !== LEAF_KIND_CTRL) {
|
|
1136
|
+
// Logged at ERROR and returned: a caller reaching this line is trying to hand the relay
|
|
1137
|
+
// operator content, and the log must carry it even if the caller swallows the result.
|
|
1138
|
+
this.#logger.error("session.relay.submit.content_not_permitted", {
|
|
1139
|
+
relayPeerId: this.#relayPeerId,
|
|
1140
|
+
leafKind,
|
|
1141
|
+
impact: "the submit was NOT sent. Only a SEAL ctrl leaf may carry its content to the relay; every other leaf kind's content belongs to the operator.",
|
|
1142
|
+
guidance: "Pass contentBytes only with LEAF_KIND_CTRL. If a new leaf kind genuinely needs to disclose its content to the relay, that is a protocol decision, not a call-site one.",
|
|
1143
|
+
});
|
|
1144
|
+
return { ok: false, reason: "content_not_permitted_for_leaf_kind" };
|
|
1145
|
+
}
|
|
1146
|
+
if (contentBytes === null && leafKind === LEAF_KIND_CTRL) {
|
|
1147
|
+
this.#logger.error("session.relay.submit.seal_payload_missing", {
|
|
1148
|
+
relayPeerId: this.#relayPeerId,
|
|
1149
|
+
impact: "the seal leaf was NOT sent. Sending it without its payload produces a certificate the directory cannot check against any participant's signed transcript — silently, and reported downstream as the RELAY being on an old build.",
|
|
1150
|
+
guidance: "A ctrl leaf on this path is a SEAL leaf; pass the encodeSealPayload bytes whose SHA-256(0x02 ‖ payload) is the contentHash argument.",
|
|
1151
|
+
});
|
|
1152
|
+
return { ok: false, reason: "seal_payload_not_carried" };
|
|
1153
|
+
}
|
|
1154
|
+
if (contentBytes !== null) {
|
|
1155
|
+
/**
|
|
1156
|
+
* ⚠️ THE BYTES MUST BE A SEAL PAYLOAD FOR THIS SESSION, AND THEY MUST HASH TO THE HASH BEING
|
|
1157
|
+
* SIGNED — review pass 2, MEDIUM-1 and HIGH-1. The kind check alone was not the property this
|
|
1158
|
+
* parameter's whole justification rests on.
|
|
1159
|
+
*
|
|
1160
|
+
* The justification is: *"the payload is [session_id, final_root, close_timestamp, "PENDING"]
|
|
1161
|
+
* and the relay already knows all four, so nothing is disclosed."* The code enforced
|
|
1162
|
+
* `leafKind === CTRL` and nothing else — so a caller passing a ctrl leaf with four kilobytes
|
|
1163
|
+
* of the operator's text would have transmitted it, and been refused only at the relay, AFTER
|
|
1164
|
+
* it crossed the wire to the party that must not have it. That is the precise harm the local
|
|
1165
|
+
* guard exists to prevent, and the relay learned this same lesson at its own review (H1 in
|
|
1166
|
+
* `relay-frames.ts`) one file over. I wrote the weaker version anyway.
|
|
1167
|
+
*
|
|
1168
|
+
* THE HASH BINDING IS THE MORE IMPORTANT HALF, and it closes a mutant that survived pass 1's
|
|
1169
|
+
* type hardening. Making the parameter required catches an OMITTED argument; it cannot catch a
|
|
1170
|
+
* SUBSTITUTED one. A caller that re-derives the payload instead of passing the one it hashed
|
|
1171
|
+
* — a second `encodeSealPayload` call, a fresh `Date.now()` — compiles, and the mismatch
|
|
1172
|
+
* surfaces at the directory as `seal_payload_unbound`, whose guidance reads *"someone between
|
|
1173
|
+
* them and here altered or fabricated the payload — the relay is the only party on that path.
|
|
1174
|
+
* Treat this as relay tampering, not a version mismatch."*
|
|
1175
|
+
*
|
|
1176
|
+
* **A client-side derivation slip would be published as a named accusation against a healthy
|
|
1177
|
+
* relay operator.** Checking it here makes it a local refusal on the machine that caused it.
|
|
1178
|
+
*/
|
|
1179
|
+
const rederived = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(contentBytes).digest());
|
|
1180
|
+
if (!Buffer.from(rederived).equals(Buffer.from(contentHash))) {
|
|
1181
|
+
this.#logger.error("session.relay.submit.seal_payload_unbound", {
|
|
1182
|
+
relayPeerId: this.#relayPeerId,
|
|
1183
|
+
impact: "the seal leaf was NOT sent. The payload does not hash to the content_hash this leaf signs, so the directory would have reported it as RELAY TAMPERING — a named accusation against a node that did nothing wrong.",
|
|
1184
|
+
guidance: "Pass the SAME bytes that produced contentHash. Re-deriving the payload at the call site produces a different close_timestamp and breaks the binding.",
|
|
1185
|
+
});
|
|
1186
|
+
return { ok: false, reason: "seal_payload_unbound" };
|
|
1187
|
+
}
|
|
1188
|
+
const decoded = decodeSealPayload(contentBytes);
|
|
1189
|
+
if (!decoded || !Buffer.from(decoded.session_id).equals(Buffer.from(sessionId))) {
|
|
1190
|
+
this.#logger.error("session.relay.submit.seal_payload_invalid", {
|
|
1191
|
+
relayPeerId: this.#relayPeerId,
|
|
1192
|
+
impact: "the submit was NOT sent. Only a SEAL payload for THIS session may be disclosed to the relay — arbitrary bytes on a ctrl leaf are still the operator's content, and a payload for another session is a replay.",
|
|
1193
|
+
guidance: decoded
|
|
1194
|
+
? "The payload names a different session than the one being submitted."
|
|
1195
|
+
: "The bytes are not a decodable SEAL payload. Build them with encodeSealPayload.",
|
|
1196
|
+
});
|
|
1197
|
+
return { ok: false, reason: "seal_payload_invalid" };
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
741
1200
|
// Chain on the prior submit so only one is outstanding at a time (FIFO). The ack
|
|
742
1201
|
// carries no session_id, so concurrent submits on one stream would be ambiguous.
|
|
743
|
-
const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind));
|
|
1202
|
+
const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes));
|
|
744
1203
|
// Keep the chain alive regardless of this submit's outcome.
|
|
745
1204
|
this.#submitChain = run.then(() => undefined, () => undefined);
|
|
746
1205
|
return run;
|
|
@@ -773,7 +1232,22 @@ export class AgentRelayClient {
|
|
|
773
1232
|
* and returned as-is — retrying those would be pointless traffic masking a real state.
|
|
774
1233
|
*/
|
|
775
1234
|
static #SESSION_NOT_FOUND_ATTEMPTS = 3;
|
|
776
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
|
|
1237
|
+
* window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
|
|
1238
|
+
* two waits clear any ordinary burst, and a third failure means something other than this
|
|
1239
|
+
* sender's own volume is going on, which the operator should hear about.
|
|
1240
|
+
*/
|
|
1241
|
+
static #RATE_LIMITED_ATTEMPTS = 3;
|
|
1242
|
+
/** Used only when the relay names no window (an older relay, or a malformed value). */
|
|
1243
|
+
static #RATE_LIMITED_FALLBACK_MS = 5_000;
|
|
1244
|
+
/**
|
|
1245
|
+
* Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
|
|
1246
|
+
* indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
|
|
1247
|
+
* answering `retry_after_ms: 3600000`. Past this the send fails and says so.
|
|
1248
|
+
*/
|
|
1249
|
+
static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
|
|
1250
|
+
async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
|
|
777
1251
|
const sessionIdHex = Buffer.from(sessionId).toString("hex");
|
|
778
1252
|
// Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
|
|
779
1253
|
//
|
|
@@ -791,7 +1265,7 @@ export class AgentRelayClient {
|
|
|
791
1265
|
// reported `session_not_found`, indistinguishable at the wire from the race — which is why
|
|
792
1266
|
// this must be discriminated on OUR state, not on the relay's reason string.
|
|
793
1267
|
const recordedBefore = this.#sessions.get(sessionIdHex)?.recorded === true;
|
|
794
|
-
let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind);
|
|
1268
|
+
let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
795
1269
|
for (let attempt = 1; attempt < _a.#SESSION_NOT_FOUND_ATTEMPTS
|
|
796
1270
|
&& !recordedBefore
|
|
797
1271
|
&& !result.ok
|
|
@@ -810,7 +1284,52 @@ export class AgentRelayClient {
|
|
|
810
1284
|
attempt,
|
|
811
1285
|
reason: result.reason,
|
|
812
1286
|
});
|
|
813
|
-
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind);
|
|
1287
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
|
|
1291
|
+
* 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
|
|
1292
|
+
*
|
|
1293
|
+
* Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
|
|
1294
|
+
* `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
|
|
1295
|
+
* witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
|
|
1296
|
+
* The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
|
|
1297
|
+
* missing it.
|
|
1298
|
+
*
|
|
1299
|
+
* This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
|
|
1300
|
+
* exactly when. So we wait it out here, where the wait is invisible, rather than handing the
|
|
1301
|
+
* agent an error for a condition that resolves in under a minute. Bounded: if the window is
|
|
1302
|
+
* absent or implausible we fall back to a fixed wait, and after
|
|
1303
|
+
* `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
|
|
1304
|
+
* the fallback, not the first move.
|
|
1305
|
+
*/
|
|
1306
|
+
for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
|
|
1307
|
+
&& !result.ok
|
|
1308
|
+
&& result.reason === "rate_limited"
|
|
1309
|
+
&& !this.#closed; attempt++) {
|
|
1310
|
+
const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
|
|
1311
|
+
this.#logger.info("session.relay.submit.throttled", {
|
|
1312
|
+
relayPeerId: this.#relayPeerId,
|
|
1313
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
1314
|
+
attempt,
|
|
1315
|
+
waitMs,
|
|
1316
|
+
retryAfterMsFromRelay: result.retry_after_ms,
|
|
1317
|
+
impact: "the relay is throttling this sender; waiting out its stated window and resubmitting — the message is NOT lost and the operator is not told, because this clears on its own",
|
|
1318
|
+
});
|
|
1319
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1320
|
+
if (this.#closed)
|
|
1321
|
+
break;
|
|
1322
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
1323
|
+
}
|
|
1324
|
+
if (!result.ok && result.reason === "rate_limited") {
|
|
1325
|
+
// Option 2, the fallback: it did not clear within our budget, so the caller must hear it
|
|
1326
|
+
// rather than be told the message was witnessed.
|
|
1327
|
+
this.#logger.warn("session.relay.submit.throttle_persisted", {
|
|
1328
|
+
relayPeerId: this.#relayPeerId,
|
|
1329
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
1330
|
+
attempts: _a.#RATE_LIMITED_ATTEMPTS,
|
|
1331
|
+
impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
|
|
1332
|
+
});
|
|
814
1333
|
}
|
|
815
1334
|
// The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
|
|
816
1335
|
// Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
|
|
@@ -825,7 +1344,7 @@ export class AgentRelayClient {
|
|
|
825
1344
|
}
|
|
826
1345
|
return result;
|
|
827
1346
|
}
|
|
828
|
-
async #doSubmitOnce(node, sessionId, contentHash, leafKind) {
|
|
1347
|
+
async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes) {
|
|
829
1348
|
if (this.#closed)
|
|
830
1349
|
return { ok: false, reason: "relay_client_closed" };
|
|
831
1350
|
if (!(await this.#ensureConnected(node)))
|
|
@@ -872,6 +1391,32 @@ export class AgentRelayClient {
|
|
|
872
1391
|
leaf_kind: leafKind,
|
|
873
1392
|
structure1_cbor: structure1,
|
|
874
1393
|
sender_signature: signature,
|
|
1394
|
+
/**
|
|
1395
|
+
* `DOD-M15-SEALWIRE-1` bullets 3+4 — the SEAL payload, and ONLY on a ctrl leaf.
|
|
1396
|
+
*
|
|
1397
|
+
* ⚠️ MY REASON FOR THE SPREAD WAS MEASURABLY WRONG, AND THE TRUE RISK IS THE OPPOSITE ONE —
|
|
1398
|
+
* review pass 2, MEDIUM-3, corrected rather than deleted.
|
|
1399
|
+
*
|
|
1400
|
+
* It said an explicit `content_bytes: undefined` *"encodes as a present CBOR key, and the
|
|
1401
|
+
* relay's guard refuses a present-but-unusable value by voiding the whole frame — that would
|
|
1402
|
+
* turn every ordinary message into a refused submit."* Measured through the production encoder:
|
|
1403
|
+
* the key IS emitted (0xf7), but it decodes back to `undefined`, so the relay's guard never
|
|
1404
|
+
* fires and the frame is **accepted with no payload**.
|
|
1405
|
+
*
|
|
1406
|
+
* So the mutation does not produce a loud federation-wide refusal. It produces a silent
|
|
1407
|
+
* `not_carried` at the directory — exactly the silent downgrade this whole unit exists to kill,
|
|
1408
|
+
* and a far worse outcome than the one I warned about. Writing the scarier consequence would
|
|
1409
|
+
* have sent the next reader hunting an availability bug instead of a mute one.
|
|
1410
|
+
*
|
|
1411
|
+
* The spread is still correct, and the ANCHOR test is what pins it: `"content_bytes" in frame`
|
|
1412
|
+
* is TRUE for the `undefined` mutant precisely because the key is present, so that assertion —
|
|
1413
|
+
* not the relay — is what catches this.
|
|
1414
|
+
*
|
|
1415
|
+
* `submitLeaf` has already established that this is set if and only if `leafKind` is ctrl, and
|
|
1416
|
+
* that the bytes are a SEAL payload for this session hashing to the signed `content_hash` —
|
|
1417
|
+
* every direction refused there, at ERROR, before anything reaches the wire.
|
|
1418
|
+
*/
|
|
1419
|
+
...(contentBytes !== null ? { content_bytes: contentBytes } : {}),
|
|
875
1420
|
});
|
|
876
1421
|
// Set the resolver synchronously (no await between the in-flight check and the set):
|
|
877
1422
|
// the submit chain guarantees no other submit runs concurrently, so #pendingAck is null.
|
|
@@ -882,6 +1427,7 @@ export class AgentRelayClient {
|
|
|
882
1427
|
// Remember this submit's sender-signed structure1_cbor so its ack can return the full
|
|
883
1428
|
// ordering record (the ack itself carries only the relay's structure2_cbor).
|
|
884
1429
|
this.#pendingStructure1 = structure1;
|
|
1430
|
+
this.#pendingSignature = signature;
|
|
885
1431
|
this.#pendingLeafKind = leafKind;
|
|
886
1432
|
try {
|
|
887
1433
|
stream.send(lp.encode.single(frame));
|
|
@@ -891,6 +1437,7 @@ export class AgentRelayClient {
|
|
|
891
1437
|
this.#pendingAck = null;
|
|
892
1438
|
this.#pendingAckSessionHex = null;
|
|
893
1439
|
this.#pendingStructure1 = null;
|
|
1440
|
+
this.#pendingSignature = null;
|
|
894
1441
|
this.#pendingLeafKind = null;
|
|
895
1442
|
}
|
|
896
1443
|
this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
|
|
@@ -913,6 +1460,7 @@ export class AgentRelayClient {
|
|
|
913
1460
|
this.#pendingAck = null;
|
|
914
1461
|
this.#pendingAckSessionHex = null;
|
|
915
1462
|
this.#pendingStructure1 = null;
|
|
1463
|
+
this.#pendingSignature = null;
|
|
916
1464
|
this.#pendingLeafKind = null;
|
|
917
1465
|
}
|
|
918
1466
|
}
|