@cello-protocol/client 0.0.2

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.
Files changed (53) hide show
  1. package/dist/agent-hash-queue.d.ts +206 -0
  2. package/dist/agent-hash-queue.d.ts.map +1 -0
  3. package/dist/agent-hash-queue.js +380 -0
  4. package/dist/agent-hash-queue.js.map +1 -0
  5. package/dist/backup-key-derivation.d.ts +37 -0
  6. package/dist/backup-key-derivation.d.ts.map +1 -0
  7. package/dist/backup-key-derivation.js +48 -0
  8. package/dist/backup-key-derivation.js.map +1 -0
  9. package/dist/client-backup.d.ts +144 -0
  10. package/dist/client-backup.d.ts.map +1 -0
  11. package/dist/client-backup.js +273 -0
  12. package/dist/client-backup.js.map +1 -0
  13. package/dist/client.d.ts +249 -0
  14. package/dist/client.d.ts.map +1 -0
  15. package/dist/client.js +4664 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/connection-policy.d.ts +163 -0
  18. package/dist/connection-policy.d.ts.map +1 -0
  19. package/dist/connection-policy.js +248 -0
  20. package/dist/connection-policy.js.map +1 -0
  21. package/dist/db-key-derivation.d.ts +26 -0
  22. package/dist/db-key-derivation.d.ts.map +1 -0
  23. package/dist/db-key-derivation.js +37 -0
  24. package/dist/db-key-derivation.js.map +1 -0
  25. package/dist/encrypted-file-signing-key-provider.d.ts +92 -0
  26. package/dist/encrypted-file-signing-key-provider.d.ts.map +1 -0
  27. package/dist/encrypted-file-signing-key-provider.js +251 -0
  28. package/dist/encrypted-file-signing-key-provider.js.map +1 -0
  29. package/dist/index.d.ts +13 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +8 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/mcp-server.d.ts +270 -0
  34. package/dist/mcp-server.d.ts.map +1 -0
  35. package/dist/mcp-server.js +1155 -0
  36. package/dist/mcp-server.js.map +1 -0
  37. package/dist/network-directory-node.d.ts +85 -0
  38. package/dist/network-directory-node.d.ts.map +1 -0
  39. package/dist/network-directory-node.js +584 -0
  40. package/dist/network-directory-node.js.map +1 -0
  41. package/dist/s3-cloud-storage-provider.d.ts +54 -0
  42. package/dist/s3-cloud-storage-provider.d.ts.map +1 -0
  43. package/dist/s3-cloud-storage-provider.js +78 -0
  44. package/dist/s3-cloud-storage-provider.js.map +1 -0
  45. package/dist/sqlcipher-client-store.d.ts +68 -0
  46. package/dist/sqlcipher-client-store.d.ts.map +1 -0
  47. package/dist/sqlcipher-client-store.js +382 -0
  48. package/dist/sqlcipher-client-store.js.map +1 -0
  49. package/dist/types.d.ts +408 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +7 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +48 -0
package/dist/client.js ADDED
@@ -0,0 +1,4664 @@
1
+ /**
2
+ * CELLO Client — client.ts (MSG-002, SESSION-002)
3
+ *
4
+ * CelloClientImpl: peer registry, send path, inbound stream handler,
5
+ * and receive queue for the M0 one-shot message exchange protocol.
6
+ * SESSION-002 additions: receiveSessionAssignment, listSessions.
7
+ *
8
+ * PSEUDOCODE (Phase P):
9
+ *
10
+ * send(peerPubkeyHex, content):
11
+ * 1. Look up peerPubkeyHex → peer_not_connected if absent
12
+ * 2. buildEnvelope(content, keyProvider, Date.now()) → content_too_large if rejected
13
+ * 3. serializeEnvelope → bytes
14
+ * 4. node.newStream(peerId, CELLO_PROTOCOL_ID):
15
+ * - structured error → peer_unreachable or connection_lost
16
+ * 5. stream.send(lp.encode.single(bytes))
17
+ * 6. stream.close() — half-close write side
18
+ * 7. Drain read side (for await lp.decode(stream)):
19
+ * - clean EOF → delivered:true
20
+ * - stream.status === 'reset' → remote_rejected
21
+ * - transport error → connection_lost
22
+ *
23
+ * inbound handler (stream):
24
+ * 1. AbortController with 5s timeout
25
+ * 2. Read one LP frame via lp.decode(stream) — abort if timeout fires
26
+ * 3. deserializeEnvelope(payload) → malformed_envelope + stream.abort on error
27
+ * 4. validateEnvelope(envelope) → stream.abort on error
28
+ * 5. enqueue to receiveQueue keyed by sender_pubkey hex
29
+ * 6. stream.close() — clean close signals delivered:true to sender
30
+ *
31
+ * sendRaw(peerPubkeyHex, bytes) [internal, exposed for tests]:
32
+ * Open stream, write raw bytes as single LP frame, await close type.
33
+ * Used by tests to inject tampered envelopes.
34
+ *
35
+ * receiveSessionAssignment(assignment, myPubkey):
36
+ * SESSION-002 AC-002, AC-003, AC-004, AC-005, SI-003
37
+ * SESSION-004 changes: replace M1 Ed25519 verify with FROST verify path.
38
+ *
39
+ * SESSION-004 pseudocode (Phase P rev2):
40
+ * RFC 9591 (FROST), RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
41
+ *
42
+ * 1. Check signature_type (SESSION-004 SI-003, AC-003):
43
+ * if assignment.signature_type === 'single':
44
+ * return { ok:false, reason:"unsupported_signature_type" }
45
+ * // Hard cut — even if the single-key sig itself verifies (SI-003 is absolute)
46
+ * // No session record is created (SI-003). No I/O is attempted.
47
+ *
48
+ * 2. Determine role and verification key (SESSION-004 AC-007):
49
+ * isInitiator = (Buffer.from(myPubkey).equals(Buffer.from(pubA)))
50
+ * if isInitiator:
51
+ * // CRITICAL-1 FIX: initiator MUST have a thresholdSigner injected.
52
+ * // There is NO fallback to assignment.signer_pubkey — that is frame-provided
53
+ * // and attacker-controlled. Absence of a signer is a hard error.
54
+ * if this.#thresholdSigner is null:
55
+ * return { ok:false, reason:"frost_signer_not_configured" }
56
+ * verifyKey = this.#thresholdSigner.getPrimaryPubkey() // HIGH-4: on IThresholdSigner
57
+ * else:
58
+ * // Counterparty: use signer_pubkey embedded in the frame (A's primary_pubkey)
59
+ * // TypeScript discriminated union guarantees signer_pubkey is present for 'frost' frames
60
+ * verifyKey = assignment.signer_pubkey // guaranteed non-undefined by type system
61
+ *
62
+ * 3. Compute genesis_prev_root (same as M1):
63
+ * genesis_prev_root = computeGenesisPrevRoot(pubA, pubB, session_id, session_timestamp)
64
+ *
65
+ * 4. Build session establishment TBS (HIGH-5: uses buildSessionEstablishmentTbs from protocol-types):
66
+ * tbs = buildSessionEstablishmentTbs(session_id, pubA, pubB, genesis_prev_root, session_timestamp)
67
+ *
68
+ * 5. Verify FROST signature (SESSION-004 AC-002, SI-001):
69
+ * // Use FrostThresholdSigner.verifySignature() which handles framing internally:
70
+ * // framedMsg = <context>\0<tbs> (domain separation per CONTEXT.md)
71
+ * // OR use ed25519_FROST.verify(sig, frameMessage(context, tbs), verifyKey) directly
72
+ * // The client imports ed25519_FROST from @noble/curves/ed25519 (not from @cello-protocol/crypto)
73
+ * // to keep the verify path independent from any signer state.
74
+ * framedMsg = frameMessage(CONTEXT_SESSION_ESTABLISHMENT, tbs) // context\0tbs
75
+ * isValid = ed25519_FROST.verify(assignment.directory_signature, framedMsg, verifyKey)
76
+ * if !isValid:
77
+ * return { ok:false, reason:"frost_signature_invalid" }
78
+ * // Never accept a tampered signature (SI-001 absolute)
79
+ *
80
+ * 6-9. (same as M1: content handler, relay auth, counterparty dial, store session)
81
+ *
82
+ * IMPORTANT NOTE on CelloClientImpl constructor and createClient factory changes:
83
+ * - Add optional #thresholdSigner: IThresholdSigner | null field
84
+ * - createClient accepts optional thresholdSigner in opts
85
+ * - No @cello-protocol/directory import — IThresholdSigner comes from @cello-protocol/crypto
86
+ *
87
+ * IMPORTANT NOTE on ReceiveAssignmentResult (IMPORTANT-9):
88
+ * Add 'frost_signature_invalid' | 'unsupported_signature_type' | 'frost_signer_not_configured'
89
+ * to the reason union in types.ts
90
+ *
91
+ * 1. Build TBS = CBOR([session_id, participant_a.pubkey, participant_b.pubkey, session_timestamp])
92
+ * 2. Verify Ed25519(TBS, assignment.directory_pubkey, assignment.directory_signature) [M1, REMOVED in M2]
93
+ * → { ok:false, reason:"directory_signature_invalid" } if fails
94
+ * 3. Determine counterparty: if myPubkey == participant_a.pubkey then counterparty = B, else A
95
+ * 4. Compute genesis_prev_root = computeGenesisPrevRoot(pubA, pubB, session_id, session_timestamp)
96
+ * per FIPS 180-4 / SESSION-002
97
+ * 5. Register /cello/content/1.0.0 handler on node (if not yet registered)
98
+ * 6. Dial relay on /cello/relay/1.0.0, complete challenge-response auth:
99
+ * a. Read relay_auth_challenge frame
100
+ * b. Compute authMsg = SHA-256("CELLO-RELAY-AUTH-v1" || nonce || myPubkey) [RFC 8032, FIPS 180-4]
101
+ * c. Sign authMsg with keyProvider → signature
102
+ * d. Send relay_auth_response{pubkey, signature}
103
+ * → { ok:false, reason:"relay_auth_failed" } or "relay_auth_error" on failure
104
+ * 7. Dial counterparty on /cello/content/1.0.0
105
+ * → { ok:false, reason:"dial_counterparty_failed" } if unreachable
106
+ * 8. Store SessionRecord with status:"active", last_seen_seq:0
107
+ * 9. Return { ok:true, sessionId }
108
+ */
109
+ import { createHash } from "node:crypto";
110
+ import { Encoder, decode } from "cbor-x";
111
+ import * as lp from "it-length-prefixed";
112
+ import { buildEnvelope, serializeEnvelope, deserializeEnvelope, validateEnvelope, computeGenesisPrevRoot, encodeSealPayload, buildSessionEstablishmentTbs, buildSealTbs, decodeConnectionPackage, validateConnectionPackage, } from "@cello-protocol/protocol-types";
113
+ import { verify, buildMerkleTree, merkleRoot, verifyFrostSignature, CONTEXT_SESSION_ESTABLISHMENT, mlDsaKeygen, mlDsaVerify, FileMlDsaKeyProvider } from "@cello-protocol/crypto";
114
+ import { CELLO_PROTOCOL_ID, CELLO_CONTENT_PROTOCOL_ID } from "@cello-protocol/transport";
115
+ import { NetworkDirectoryNode, runNetworkDkg } from "./network-directory-node.js";
116
+ const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
117
+ const AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
118
+ const SIGNALING_PROTOCOL_ID = "/cello/signaling/1.0.0";
119
+ const AUTH_DOMAIN_DIR = "CELLO-DIR-AUTH-v1";
120
+ const DEFAULT_INITIATE_TIMEOUT_MS = 30_000;
121
+ const CBOR_ENC = new Encoder({ tagUint8Array: false });
122
+ function toU8(v) {
123
+ if (v instanceof Uint8Array)
124
+ return v;
125
+ if (Buffer.isBuffer(v))
126
+ return new Uint8Array(v);
127
+ // Uint8ArrayList (it-length-prefixed v10) has a .slice() method
128
+ if (typeof v.slice === "function") {
129
+ return v.slice();
130
+ }
131
+ throw new Error(`expected bytes, got ${typeof v}`);
132
+ }
133
+ function toU8Safe(v) {
134
+ if (v instanceof Uint8Array)
135
+ return v;
136
+ if (Buffer.isBuffer(v))
137
+ return new Uint8Array(v);
138
+ return null;
139
+ }
140
+ const PENDING_CONTENT_BOUND = 256;
141
+ // ─── SESSION-006 reconnect constants ─────────────────────────────────────────
142
+ /** Default reconnect timeout: 60 seconds per SESSION-006 AC-003. */
143
+ const DEFAULT_RECONNECT_TIMEOUT_MS = 60_000;
144
+ /** Initial backoff interval in ms (exponential: 200, 400, 800, …). */
145
+ const RECONNECT_INITIAL_BACKOFF_MS = 200;
146
+ /** Maximum backoff cap to avoid excessively long waits. */
147
+ const RECONNECT_MAX_BACKOFF_MS = 5_000;
148
+ /** Timeout for each individual relay auth read (challenge or ack). */
149
+ const RELAY_AUTH_TIMEOUT_MS = 5_000;
150
+ /**
151
+ * SESSION-005: default timeout waiting for directory seal_verified + client FROST ceremony + session_sealed.
152
+ * After bilateral SEAL exchange, if no session_sealed arrives within this window,
153
+ * cello_close_session returns seal_type: 'bilateral'.
154
+ */
155
+ const DEFAULT_SEAL_FROST_TIMEOUT_MS = 15_000;
156
+ /** Race an async iterator's next() call against a timeout. */
157
+ async function nextWithTimeout(iter, timeoutMs) {
158
+ return Promise.race([
159
+ iter.next(),
160
+ new Promise((_, reject) => setTimeout(() => reject(new Error("auth_timeout")), timeoutMs)),
161
+ ]);
162
+ }
163
+ // ─── CelloClientImpl ─────────────────────────────────────────────────────────
164
+ class CelloClientImpl {
165
+ #node;
166
+ #keyProvider;
167
+ #contentGraceMs;
168
+ /** SESSION-006: max ms to attempt relay reconnect before giving up. */
169
+ #reconnectTimeoutMs;
170
+ /** SESSION-005: optional FROST threshold signer for seal ceremony coordination. */
171
+ #thresholdSigner;
172
+ /** SESSION-005: seal-frost-timeout in ms (default 15s). */
173
+ #sealFrostTimeoutMs;
174
+ #myPubkeyHex = null;
175
+ // peer_pubkey_hex → PeerEntry
176
+ #peers = new Map();
177
+ // sender_pubkey_hex → FIFO queue of received envelopes
178
+ #receiveQueues = new Map();
179
+ // ordered arrival list for peekAll()
180
+ #arrivalLog = [];
181
+ // Optional callback invoked after each successful inbound enqueue
182
+ #onMessageQueued;
183
+ // Callback for inbound session assignments (participant B role). MCP-002.
184
+ #onSessionAssignmentHandler;
185
+ // session_id_hex → SessionRecord (SESSION-002)
186
+ #sessions = new Map();
187
+ // track whether content handler has been registered on this node
188
+ #contentHandlerRegistered = false;
189
+ // ─── MSG-004 per-session state ──────────────────────────────────────────────
190
+ // session_id_hex → persistent relay stream
191
+ #relayStreams = new Map();
192
+ // session_id_hex → Promise<void> chain for outbound serialization
193
+ #outboundQueues = new Map();
194
+ // session_id_hex → pending ack resolver (sequence_number → ack data)
195
+ #pendingAckResolvers = new Map();
196
+ // session_id_hex → highest seq# received from relay (tracks relay delivery order independently
197
+ // of cross-check completion; used for sequence gap/replay detection)
198
+ #relayRecvSeq = new Map();
199
+ // session_id_hex → fully-ready cross-checks keyed by seqNum, awaiting in-order processing
200
+ #readyQueue = new Map();
201
+ // session_id_hex → pending S2 entries keyed by content_hash_hex
202
+ #pendingS2 = new Map();
203
+ // session_id_hex → pending content entries keyed by content_hash_hex (counterparty-sent content)
204
+ #pendingContent = new Map();
205
+ // session_id_hex → own-send pre-buffered content keyed by content_hash_hex
206
+ // Kept separate from #pendingContent to avoid collision when both sides send identical bytes.
207
+ #ownPendingContent = new Map();
208
+ // session_id_hex → set of content_hash_hex values from tampered frames (declared hash ≠ computed)
209
+ // If a subsequent S2 arrives claiming the same hash, it immediately desync's (content_hash_mismatch).
210
+ #tamperedContentClaims = new Map();
211
+ // session_id_hex → own_echo_resolvers (sequence_number → resolve fn)
212
+ #ownEchoResolvers = new Map();
213
+ // session_id_hex → FIFO queue of ReceivedMessage (for receiveMessage)
214
+ #sessionMessageQueues = new Map();
215
+ // FIFO arrival order across all sessions: { sessionIdHex, message }
216
+ #anyMessageQueue = [];
217
+ // SESSION-007: wake resolvers for receiveSessionMessageAsync (per-session) and receiveMessageAsync (any-session)
218
+ #receiveWaiters = new Map();
219
+ #receiveAnyWaiters = new Set();
220
+ // session_id_hex → directory signaling stream (SESSION-003)
221
+ #directoryStreams = new Map();
222
+ // SESSION-006: track whether a reconnect loop is already running per session
223
+ #reconnectInProgress = new Set();
224
+ // ─── ADAPTER-003: persistent directory signaling stream ────────────────────
225
+ /** Configured directory endpoint (required for initiateSession). ADAPTER-003. */
226
+ #directoryEndpoint;
227
+ /** Single persistent signaling stream shared across all session_request outbound calls
228
+ * and inbound session_assignment / session_sealed events. ADAPTER-003. */
229
+ #persistentSignalingStream = null;
230
+ #persistentSignalingIter = null;
231
+ /** Pending resolver for the in-flight session_request → session_assignment/error.
232
+ * At most one session_request is in-flight at a time per signaling stream.
233
+ * Receives the raw decoded CBOR frame (session_assignment or session_request_error). */
234
+ #pendingSessionRequestResolve = null;
235
+ /** In-flight promise for #openPersistentSignalingStream — prevents concurrent open attempts. */
236
+ #openingSignalingStream = null;
237
+ // session_id_hex → Promise: set when the initiator SEAL echo is expected (SESSION-003)
238
+ // Allows non-initiator auto-response to know when its own SEAL echo confirms the seal
239
+ #sealInitiatedSessions = new Set();
240
+ // session_id_hex: set when THIS client received seal_verified and ran the FROST ceremony.
241
+ // Guards #handleFrostSealed to use #myPrimaryPubkey for verification (anti-substitution).
242
+ // Distinct from #sealInitiatedSessions: a concurrent-close counterparty can call
243
+ // initiateSessionSeal but is NOT the FROST ceremony participant.
244
+ #frostCeremonyParticipant = new Set();
245
+ // SESSION-005: session_id_hex → resolve fn for seal-frost-timeout Promise
246
+ // The Promise resolves when session_sealed arrives; if it times out first, seal_type = 'bilateral'.
247
+ #sealFrostResolvers = new Map();
248
+ // PERSIST-014: session_id_hex → resolve callback for pending gap-fill requests
249
+ #pendingGapFillResolvers = new Map();
250
+ // SESSION-005: session_id_hex → { leafCount, timestamp } from seal_verified frame.
251
+ // Stored so #handleFrostSealed can use the authoritative values even if local_tree_leaves
252
+ // is incomplete due to a sequence_causal_inconsistency desync race.
253
+ #sealVerifiedData = new Map();
254
+ // SESSION-005: track the primary_pubkey for this client (set after bootstrapKeyShares)
255
+ #myPrimaryPubkey = null;
256
+ // ─── REG-001: Registration state ────────────────────────────────────────────
257
+ /** Cached registration state — set after a successful register() call. */
258
+ #registrationState = null;
259
+ /** Pending resolver for register_success / register_error from directory. */
260
+ #pendingRegisterResolve = null;
261
+ /** Pending resolver for dkg_ready from directory (part of register flow). */
262
+ #pendingDkgReadyResolve = null;
263
+ /** Pending resolver for seal_unilateral_confirmed / seal_unilateral_too_early (PERSIST-015). */
264
+ #pendingUnilateralSealResolve = null;
265
+ /** Optional path for persisting the ML-DSA keypair (FileMlDsaKeyProvider). REG-001 AC-010. */
266
+ #mlDsaKeyFile;
267
+ /** ML-DSA key provider stored after successful register() call. Used to sign ConnectionPackages. CELLO-MCP-003. */
268
+ #mlDsaProvider = null;
269
+ // ─── CONNREQ-002: Connection state ────────────────────────────────────────────
270
+ /** connection_id → ClientConnectionRecord */
271
+ #connections = new Map();
272
+ /** counterparty_pubkey_hex → connection_id (for fast lookup by peer) */
273
+ #connectionsByPeer = new Map();
274
+ /** Connection policy for evaluating inbound connection_request_inbound frames. Mutable via setPolicy(). */
275
+ #connectionPolicy;
276
+ /** Overall connection timeout in ms (default 300s). Injected for tests. */
277
+ #connectionTimeoutMs;
278
+ /** Round 2 silence timeout in ms (default 120s). Injected for tests. */
279
+ #round2TimeoutMs;
280
+ /** If true, expose _evaluateCallCount on the instance for test assertions. */
281
+ #trackEvaluateCount;
282
+ /** Whitelist: sender pubkeys that bypass evaluateConnectionPackage. */
283
+ #whitelist;
284
+ /** Callback fired when an inbound connection_request_inbound is queued for agent review. */
285
+ #onConnectionPendingReview;
286
+ /** DB-003: if true, attempt cross-check of sender's ml_dsa_pubkey on inbound requests. */
287
+ #crossCheckDirectoryOnInbound;
288
+ /** DB-003: peers whose connection was accepted without successful cross-check. */
289
+ #profileUncheckedPeers = new Set();
290
+ /** Counter incremented each time evaluateConnectionPackage is called (trackEvaluateCount=true). */
291
+ _evaluateCallCount = 0;
292
+ /** Callback fired when a connection_established event arrives. */
293
+ #onConnectionEstablishedHandler;
294
+ /** Callback fired when a disclosure_request_inbound arrives. */
295
+ #onDisclosureRequestedHandler;
296
+ /**
297
+ * CONNREQ-003: Multi-slot resolver map for concurrent outbound connection requests.
298
+ * Keyed by target pubkey hex — one slot per unique target.
299
+ * Replaces the former single-slot #pendingConnectionRequestResolve field.
300
+ * Resolves when connection_established, connection_rejected, connection_insufficient,
301
+ * connection_request_error, or disclosure_request_inbound arrives for that target.
302
+ *
303
+ * Pseudocode (Phase P):
304
+ * requestConnection(target):
305
+ * if map.has(target) → log warn "connection.request.duplicate" → return error immediately
306
+ * mint correlationId ; log info "connection.request.sent"
307
+ * map.set(target, resolve)
308
+ * await outcome / timeout
309
+ * map.delete(target)
310
+ * on established: log info "connection.established"
311
+ * on error: log error "connection.request.failed"
312
+ *
313
+ * signalingReader(connection_established):
314
+ * target = frame.counterparty_pubkey
315
+ * resolve = map.get(target) ; map.delete(target) ; resolve(frame)
316
+ * (if no resolver: B's side — fires #onConnectionEstablishedHandler)
317
+ */
318
+ #pendingConnectionRequestResolvers = new Map();
319
+ /**
320
+ * CONNREQ-003: TEST-ONLY escape hatch exposing resolver map size for memory-leak assertions.
321
+ * Read by connreq-003 tests via (client as any)._pendingConnectionRequestResolverCount.
322
+ */
323
+ get _pendingConnectionRequestResolverCount() {
324
+ return this.#pendingConnectionRequestResolvers.size;
325
+ }
326
+ /**
327
+ * CONNREQ-003: Promise queue for concurrent awaitConnectionRequest() callers.
328
+ * Each entry is the resolve fn from one awaitConnectionRequest() call.
329
+ * When an inbound request arrives, the first queued resolver is called (FIFO).
330
+ * Replaces the polling-based single-caller pattern for the inbound side.
331
+ *
332
+ * Pseudocode (Phase P):
333
+ * awaitConnectionRequest(timeoutMs):
334
+ * if queue not empty: shift → return immediately with pending_review item
335
+ * create Promise, push resolve fn to #pendingAwaitConnectionRequestResolvers
336
+ * race against timeout
337
+ * on item arrival: first resolver in queue receives the item (FIFO)
338
+ *
339
+ * #handleInboundConnectionRequest (after queuing to #pendingReviewQueue):
340
+ * if #pendingAwaitConnectionRequestResolvers.length > 0:
341
+ * resolver = shift ; resolver(item) ; return (don't add to #pendingReviewQueue)
342
+ * else: add to #pendingReviewQueue as before
343
+ */
344
+ #pendingAwaitConnectionRequestResolvers = [];
345
+ /** Pending disclosure_response → resolver (connection_request_id → resolve for cello_respond_to_disclosure_request) */
346
+ #pendingDisclosureResolvers = new Map();
347
+ /**
348
+ * In-process pending connection requests (for B's side).
349
+ * connection_request_id → state tracking for responding/requesting disclosure.
350
+ */
351
+ #pendingInboundRequests = new Map();
352
+ /**
353
+ * FIFO queue of inbound connection requests that need agent review.
354
+ * Populated by #handleInboundConnectionRequest when verdict is pending_agent_review.
355
+ * Drained by awaitConnectionRequest(). CELLO-MCP-003.
356
+ */
357
+ #pendingReviewQueue = [];
358
+ /**
359
+ * Tracks which connection_request_ids have been decided (accepted, rejected, or requested disclosure).
360
+ * Used to prevent double-decisions. CELLO-MCP-003.
361
+ */
362
+ #decidedRequests = new Set();
363
+ // ─── PERSIST-014: Logger (injected, defaults to no-op) ───────────────────────
364
+ #logger;
365
+ constructor(node, keyProvider, onMessageQueued, contentGraceMs = 30_000, reconnectTimeoutMs = DEFAULT_RECONNECT_TIMEOUT_MS, thresholdSigner, sealFrostTimeoutMs = DEFAULT_SEAL_FROST_TIMEOUT_MS, directoryEndpoint = null, mlDsaKeyFile, connectionPolicy, connectionTimeoutMs = 300_000, round2TimeoutMs = 120_000, trackEvaluateCount = false, whitelist = [], onConnectionPendingReview, crossCheckDirectoryOnInbound = false, logger) {
366
+ this.#node = node;
367
+ this.#keyProvider = keyProvider;
368
+ this.#onMessageQueued = onMessageQueued;
369
+ this.#contentGraceMs = contentGraceMs;
370
+ this.#reconnectTimeoutMs = reconnectTimeoutMs;
371
+ this.#thresholdSigner = thresholdSigner;
372
+ this.#sealFrostTimeoutMs = sealFrostTimeoutMs;
373
+ this.#directoryEndpoint = directoryEndpoint;
374
+ this.#mlDsaKeyFile = mlDsaKeyFile;
375
+ this.#connectionPolicy = connectionPolicy;
376
+ this.#connectionTimeoutMs = connectionTimeoutMs;
377
+ this.#round2TimeoutMs = round2TimeoutMs;
378
+ this.#trackEvaluateCount = trackEvaluateCount;
379
+ this.#whitelist = whitelist;
380
+ this.#onConnectionPendingReview = onConnectionPendingReview;
381
+ this.#crossCheckDirectoryOnInbound = crossCheckDirectoryOnInbound;
382
+ this.#logger = logger ?? { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
383
+ }
384
+ /**
385
+ * SESSION-005: Set the FROST primary_pubkey for this client.
386
+ * Call after bootstrapKeyShares to register the group public key.
387
+ * Used for verifying incoming session_sealed FROST signatures when this client
388
+ * is the seal initiator.
389
+ */
390
+ setPrimaryPubkey(primaryPubkey) {
391
+ this.#myPrimaryPubkey = new Uint8Array(primaryPubkey);
392
+ }
393
+ addPeer(peerPubkeyHex, peerId, multiaddrs) {
394
+ this.#peers.set(peerPubkeyHex, { peerId, multiaddrs, connected: true });
395
+ }
396
+ async send(peerPubkeyHex, content) {
397
+ // Step 1: registry lookup
398
+ const entry = this.#peers.get(peerPubkeyHex);
399
+ if (!entry) {
400
+ return { delivered: false, reason: "peer_not_connected" };
401
+ }
402
+ // Step 2: build envelope — catches content_too_large before any I/O
403
+ const buildResult = await buildEnvelope(content, this.#keyProvider, Date.now());
404
+ if (!buildResult.ok) {
405
+ if (buildResult.error.reason === "content_too_large") {
406
+ return { delivered: false, reason: "content_too_large" };
407
+ }
408
+ return { delivered: false, reason: "connection_lost" };
409
+ }
410
+ // Step 3: serialize
411
+ const bytes = serializeEnvelope(buildResult.envelope);
412
+ return this.#sendBytes(entry.peerId, bytes, buildResult.envelope.content_hash);
413
+ }
414
+ // Internal test escape: open a raw stream directly to peer without building an envelope.
415
+ // Used by AC-012 to write truncated/malformed bytes.
416
+ async openRawStream(peerPubkeyHex) {
417
+ const entry = this.#peers.get(peerPubkeyHex);
418
+ if (!entry)
419
+ throw new Error(`peer_not_connected: ${peerPubkeyHex}`);
420
+ return this.#node.newStream(entry.peerId, CELLO_PROTOCOL_ID);
421
+ }
422
+ // Internal test escape: open a raw content protocol stream to peer by peerId string.
423
+ // Used by AC-003 to inject tampered content frames directly.
424
+ async openContentStreamByPeerId(peerId) {
425
+ return this.#node.newStream(peerId, CELLO_CONTENT_PROTOCOL_ID);
426
+ }
427
+ // Internal test escape: directly feed a leaf_deliver frame into the relay stream handler.
428
+ // Used by AC-004 through AC-008 to inject adversarial frames without a compromised relay.
429
+ injectLeafDeliver(sessionIdHex, frame) {
430
+ const myPubkeyHex = this.#myPubkeyHex;
431
+ if (!myPubkeyHex)
432
+ throw new Error("injectLeafDeliver: client not yet authenticated (no session registered)");
433
+ this.#handleInboundLeafDeliver(sessionIdHex, frame, myPubkeyHex);
434
+ }
435
+ // Internal test escape: inject a minimal session record directly into #sessions.
436
+ // Bypasses receiveSessionAssignment (which requires a real relay) for unit tests
437
+ // that need to test session_sealed, FROST verification, etc. without a relay.
438
+ // TEST-ONLY: only intended for use in unit tests; not exposed in the CelloClient type.
439
+ injectTestSession(sessionIdHex, sessionId, myPubkeyHex, directoryPubkey, status = "active", opts) {
440
+ const genesis_prev_root = new Uint8Array(32);
441
+ const counterpartyPubkey = new Uint8Array(32);
442
+ const record = {
443
+ session_id: sessionId,
444
+ counterparty_pubkey: counterpartyPubkey,
445
+ counterparty_peer_id: "",
446
+ counterparty_multiaddrs: [],
447
+ relay_endpoint: { peer_id: "", multiaddrs: [] },
448
+ directory_endpoint: { peer_id: "", multiaddrs: [] },
449
+ directory_pubkey: directoryPubkey,
450
+ genesis_prev_root,
451
+ last_seen_seq: 0,
452
+ last_sent_seq: 0,
453
+ status,
454
+ local_tree_leaves: [],
455
+ next_expected_seq: 1,
456
+ desynchronized: false,
457
+ };
458
+ this.#sessions.set(sessionIdHex, record);
459
+ if (!this.#myPubkeyHex) {
460
+ this.#myPubkeyHex = myPubkeyHex;
461
+ }
462
+ // M-001: mark as seal-initiated and FROST ceremony participant so #handleFrostSealed
463
+ // uses own primary_pubkey for verification.
464
+ if (opts?.isInitiator) {
465
+ this.#sealInitiatedSessions.add(sessionIdHex);
466
+ this.#frostCeremonyParticipant.add(sessionIdHex);
467
+ }
468
+ }
469
+ // Internal test escape: directly feed a session_sealed, session_seal_rejected,
470
+ // seal_verified, or session_frost_sealed frame into the directory stream handler —
471
+ // bypasses the real directory signaling stream so tests can inject adversarial frames.
472
+ injectDirectoryFrame(sessionIdHex, frame) {
473
+ const session = this.#sessions.get(sessionIdHex);
474
+ if (!session)
475
+ throw new Error(`injectDirectoryFrame: session not found: ${sessionIdHex}`);
476
+ if (frame["type"] === "session_sealed") {
477
+ this.#handleDirectorySessionSealed(sessionIdHex, frame, session.directory_pubkey);
478
+ }
479
+ else if (frame["type"] === "session_seal_rejected") {
480
+ this.#handleDirectorySessionSealRejected(sessionIdHex, frame);
481
+ }
482
+ else if (frame["type"] === "seal_rejected_tree_mismatch") {
483
+ this.#handleSealRejectedTreeMismatch(sessionIdHex, frame);
484
+ }
485
+ else if (frame["type"] === "seal_unilateral_confirmed") {
486
+ this.#handleSealUnilateralConfirmed(sessionIdHex, frame);
487
+ }
488
+ else if (frame["type"] === "seal_unilateral_notification") {
489
+ this.#handleSealUnilateralNotification(sessionIdHex, frame);
490
+ }
491
+ else if (frame["type"] === "seal_verified") {
492
+ void this.#handleSealVerified(sessionIdHex, frame);
493
+ }
494
+ else if (frame["type"] === "session_frost_sealed") {
495
+ this.#handleSessionFrostSealed(sessionIdHex, frame);
496
+ }
497
+ }
498
+ // Internal: open stream, write LP-framed bytes, await close type.
499
+ // Exposed as sendRaw for test injection of tampered envelopes.
500
+ async sendRaw(peerPubkeyHex, bytes) {
501
+ const entry = this.#peers.get(peerPubkeyHex);
502
+ if (!entry) {
503
+ return { delivered: false, reason: "peer_not_connected" };
504
+ }
505
+ return this.#sendBytes(entry.peerId, bytes, undefined);
506
+ }
507
+ async #sendBytes(peerId, bytes, contentHash) {
508
+ // Step 4: open stream
509
+ let stream;
510
+ try {
511
+ stream = await this.#node.newStream(peerId, CELLO_PROTOCOL_ID);
512
+ }
513
+ catch (err) {
514
+ // node_stopped → transport issue; connection_lost from newStream means no prior
515
+ // connection to this peer (= unreachable); protocol error also = unreachable
516
+ const reason = isStructuredError(err, "node_stopped") ? "transport_not_started"
517
+ : "peer_unreachable";
518
+ return { delivered: false, reason };
519
+ }
520
+ try {
521
+ // Step 5: write LP-framed bytes
522
+ stream.send(lp.encode.single(bytes));
523
+ // Step 6: half-close write side
524
+ await stream.close();
525
+ // Step 7: drain read side — the close type tells us the outcome
526
+ try {
527
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
528
+ for await (const _ of lp.decode(stream)) {
529
+ // Receiver never sends data — drain any unexpected bytes and discard
530
+ }
531
+ }
532
+ catch {
533
+ // Read side error — check stream status to classify
534
+ }
535
+ if (stream.status === "reset" || stream.status === "aborted") {
536
+ return { delivered: false, reason: "remote_rejected" };
537
+ }
538
+ const hashHex = contentHash
539
+ ? Buffer.from(contentHash).toString("hex")
540
+ : "";
541
+ return { delivered: true, contentHash: hashHex };
542
+ }
543
+ catch (err) {
544
+ return { delivered: false, reason: mapSendError(err) };
545
+ }
546
+ }
547
+ // ─── SESSION-002 ─────────────────────────────────────────────────────────────
548
+ /**
549
+ * Process a SessionAssignment pushed by the directory.
550
+ * SESSION-002 AC-002, AC-003, AC-004, AC-005, SI-003.
551
+ *
552
+ * Crypto refs:
553
+ * Ed25519 verification: RFC 8032
554
+ * SHA-256: FIPS 180-4
555
+ */
556
+ async receiveSessionAssignment(assignment, myPubkey) {
557
+ const { session_id, session_timestamp } = assignment;
558
+ const pubA = assignment.participant_a.pubkey;
559
+ const pubB = assignment.participant_b.pubkey;
560
+ // SESSION-004 Step 1: Check signature_type (SI-003, AC-003)
561
+ // M1 'single' frames are hard-refused in M2 — even if the single-key sig verifies.
562
+ if (assignment.signature_type === "single") {
563
+ return { ok: false, reason: "unsupported_signature_type" };
564
+ }
565
+ // SESSION-004 Step 2: Determine role and verification key (AC-007)
566
+ const myPubkeyHex = Buffer.from(myPubkey).toString("hex");
567
+ const pubAHex = Buffer.from(pubA).toString("hex");
568
+ const isInitiator = myPubkeyHex === pubAHex;
569
+ let verifyKey;
570
+ if (isInitiator) {
571
+ // CRITICAL-1: initiator MUST have a thresholdSigner injected.
572
+ // Falling back to assignment.signer_pubkey (frame-provided) would be attacker-controlled.
573
+ if (!this.#thresholdSigner) {
574
+ return { ok: false, reason: "frost_signer_not_configured" };
575
+ }
576
+ verifyKey = this.#thresholdSigner.getPrimaryPubkey();
577
+ }
578
+ else {
579
+ // Counterparty (B): use signer_pubkey from the frame (A's primary_pubkey).
580
+ // TypeScript discriminated union guarantees signer_pubkey is present for 'frost' frames.
581
+ verifyKey = assignment.signer_pubkey;
582
+ }
583
+ // SESSION-004 Step 3: Build TBS and verify FROST signature (AC-002, SI-001)
584
+ // TBS = canonical CBOR([session_id, pubA, pubB, genesis_prev_root, session_timestamp])
585
+ // buildSessionEstablishmentTbs imported from protocol-types (HIGH-5: single source of truth)
586
+ const genesis_prev_root_for_tbs = computeGenesisPrevRoot(pubA, pubB, session_id, session_timestamp);
587
+ const tbs = buildSessionEstablishmentTbs(session_id, pubA, pubB, genesis_prev_root_for_tbs, session_timestamp);
588
+ // Verify FROST signature with domain separation (context\0tbs framing)
589
+ if (!verifyFrostSignature(assignment.directory_signature, tbs, CONTEXT_SESSION_ESTABLISHMENT, verifyKey)) {
590
+ return { ok: false, reason: "frost_signature_invalid" };
591
+ }
592
+ // Step 4: Determine counterparty
593
+ const counterparty = isInitiator ? assignment.participant_b : assignment.participant_a;
594
+ // genesis_prev_root was already computed above for TBS — reuse it
595
+ const genesis_prev_root = genesis_prev_root_for_tbs;
596
+ // Step 4: Register content protocol handler on this node (if not already registered).
597
+ // Set the flag before awaiting handle() to prevent concurrent calls from both registering.
598
+ if (!this.#contentHandlerRegistered) {
599
+ this.#contentHandlerRegistered = true;
600
+ try {
601
+ await this.#node.handle(CELLO_CONTENT_PROTOCOL_ID, (stream) => {
602
+ void this.#handleContentStream(stream);
603
+ });
604
+ }
605
+ catch {
606
+ // Already registered by a concurrent call — safe to ignore.
607
+ }
608
+ }
609
+ // Step 5: Dial relay on /cello/relay/1.0.0 and complete challenge-response auth (AC-003)
610
+ const relayPeerId = assignment.relay_endpoint.peer_id;
611
+ const relayMultiaddr = assignment.relay_endpoint.multiaddrs[0];
612
+ if (relayMultiaddr) {
613
+ try {
614
+ await this.#node.dial(relayMultiaddr);
615
+ }
616
+ catch {
617
+ // Connection may already exist — proceed
618
+ }
619
+ }
620
+ let relayStream;
621
+ try {
622
+ relayStream = await this.#node.newStream(relayPeerId, RELAY_PROTOCOL_ID);
623
+ }
624
+ catch {
625
+ return { ok: false, reason: "relay_auth_error" };
626
+ }
627
+ // Auth challenge-response
628
+ // Read relay_auth_challenge, respond with relay_auth_response
629
+ // Signature: Ed25519(SHA-256("CELLO-RELAY-AUTH-v1" || nonce || myPubkey), keyProvider) per RFC 8032, FIPS 180-4
630
+ let relayIter;
631
+ try {
632
+ const authResult = await this.#performRelayAuth(relayStream, myPubkey);
633
+ if (!authResult.ok) {
634
+ return { ok: false, reason: authResult.reason };
635
+ }
636
+ relayIter = authResult.iter;
637
+ }
638
+ catch {
639
+ return { ok: false, reason: "relay_auth_error" };
640
+ }
641
+ // Step 6: Dial counterparty on /cello/content/1.0.0 (AC-004)
642
+ // Best-effort: counterparty may not yet be listening. Session is stored as active
643
+ // regardless — the content stream will be re-established on first message.
644
+ try {
645
+ const counterpartyMultiaddr = counterparty.multiaddrs[0];
646
+ if (counterpartyMultiaddr) {
647
+ try {
648
+ await this.#node.dial(counterpartyMultiaddr);
649
+ }
650
+ catch {
651
+ // Already connected or not yet reachable — proceed
652
+ }
653
+ }
654
+ const contentStream = await this.#node.newStream(counterparty.peer_id, CELLO_CONTENT_PROTOCOL_ID);
655
+ // Close gracefully — content stream will be re-established per message in M1
656
+ contentStream.close().catch(() => { });
657
+ }
658
+ catch {
659
+ // Counterparty not yet listening — store session as active anyway.
660
+ // Content connection will be established when first message is sent.
661
+ }
662
+ // Step 7: Store session record (AC-004)
663
+ const sessionIdHex = Buffer.from(session_id).toString("hex");
664
+ const record = {
665
+ session_id,
666
+ counterparty_pubkey: counterparty.pubkey,
667
+ counterparty_peer_id: counterparty.peer_id,
668
+ counterparty_multiaddrs: counterparty.multiaddrs,
669
+ relay_endpoint: {
670
+ peer_id: assignment.relay_endpoint.peer_id,
671
+ multiaddrs: assignment.relay_endpoint.multiaddrs,
672
+ },
673
+ directory_endpoint: {
674
+ peer_id: assignment.directory_endpoint.peer_id,
675
+ multiaddrs: assignment.directory_endpoint.multiaddrs,
676
+ },
677
+ directory_pubkey: assignment.directory_pubkey,
678
+ genesis_prev_root,
679
+ last_seen_seq: 0,
680
+ last_sent_seq: 0,
681
+ status: "active",
682
+ local_tree_leaves: [],
683
+ next_expected_seq: 1,
684
+ desynchronized: false,
685
+ };
686
+ this.#sessions.set(sessionIdHex, record);
687
+ // Store the relay stream and start the persistent reader loop (MSG-004)
688
+ this.#relayStreams.set(sessionIdHex, relayStream);
689
+ this.#relayRecvSeq.set(sessionIdHex, 0);
690
+ this.#readyQueue.set(sessionIdHex, new Map());
691
+ this.#pendingS2.set(sessionIdHex, new Map());
692
+ this.#pendingContent.set(sessionIdHex, new Map());
693
+ this.#ownPendingContent.set(sessionIdHex, new Map());
694
+ this.#tamperedContentClaims.set(sessionIdHex, new Set());
695
+ this.#ownEchoResolvers.set(sessionIdHex, new Map());
696
+ this.#sessionMessageQueues.set(sessionIdHex, []);
697
+ // Cache myPubkeyHex for the stream reader (same key across all sessions on this client)
698
+ if (!this.#myPubkeyHex)
699
+ this.#myPubkeyHex = myPubkeyHex;
700
+ void this.#runRelayStreamReader(sessionIdHex, relayStream, myPubkeyHex, relayIter);
701
+ // Fire inbound session handler if this client is participant B (session was initiated
702
+ // by a remote peer). MCP-002: cello_await_session uses this to populate its queue.
703
+ // myPubkeyHex !== pubAHex means we are B (the non-initiator).
704
+ if (myPubkeyHex !== pubAHex && this.#onSessionAssignmentHandler) {
705
+ this.#onSessionAssignmentHandler({
706
+ sessionIdHex: sessionIdHex,
707
+ counterpartyPubkeyHex: Buffer.from(counterparty.pubkey).toString("hex"),
708
+ genesisPrevRootHex: Buffer.from(genesis_prev_root).toString("hex"),
709
+ });
710
+ }
711
+ // ADAPTER-003: if the persistent signaling stream is already open (e.g. opened by
712
+ // initiateSession), it handles session_sealed/seal_rejected events for all sessions.
713
+ // Only open a per-session stream when the persistent stream is not available.
714
+ if (!this.#persistentSignalingStream) {
715
+ void this.#connectDirectorySignalingStream(sessionIdHex, assignment, myPubkey);
716
+ }
717
+ return { ok: true, sessionId: session_id };
718
+ }
719
+ listSessions() {
720
+ return Array.from(this.#sessions.values());
721
+ }
722
+ // ─── MSG-004 implementation ──────────────────────────────────────────────────
723
+ async sendMessage(sessionIdHex, content) {
724
+ // Per-session outbound serialization queue: next send not started until echo received
725
+ const prev = this.#outboundQueues.get(sessionIdHex) ?? Promise.resolve();
726
+ let release;
727
+ const next = new Promise((r) => { release = r; });
728
+ this.#outboundQueues.set(sessionIdHex, prev.then(() => next));
729
+ await prev;
730
+ try {
731
+ return await this.#sendMessageLocked(sessionIdHex, content);
732
+ }
733
+ finally {
734
+ release();
735
+ }
736
+ }
737
+ async #sendMessageLocked(sessionIdHex, content) {
738
+ const session = this.#sessions.get(sessionIdHex);
739
+ if (!session)
740
+ return { ok: false, reason: "session_not_found" };
741
+ if (session.desynchronized)
742
+ return { ok: false, reason: "session_desynchronized" };
743
+ if (session.status === "sealing" || session.status === "sealed" || session.status === "seal_deferred")
744
+ return { ok: false, reason: "session_sealed" };
745
+ // SESSION-006 AC-001/AC-003: transport_lost means relay stream is gone or being reconnected
746
+ if (session.status === "transport_lost")
747
+ return { ok: false, reason: "transport_unavailable" };
748
+ const relayStream = this.#relayStreams.get(sessionIdHex);
749
+ if (!relayStream || relayStream.status !== "open") {
750
+ return { ok: false, reason: "transport_unavailable" };
751
+ }
752
+ // content_hash = SHA-256(0x00 || content) per MERKLE-001
753
+ const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x00])).update(content).digest());
754
+ // Build Structure 1 TBS: [1, content_hash, myPubkey, session_id, last_seen_seq, timestamp]
755
+ const myPubkeyHex = this.#myPubkeyHex;
756
+ const myPubkeyBytes = Buffer.from(myPubkeyHex, "hex");
757
+ const tbs = CBOR_ENC.encode([
758
+ 1,
759
+ contentHash,
760
+ myPubkeyBytes,
761
+ session.session_id,
762
+ session.last_seen_seq,
763
+ Date.now(),
764
+ ]);
765
+ const signature = await this.#keyProvider.sign(tbs);
766
+ // Submit hash_submit to relay on the persistent relay stream
767
+ const hashSubmitFrame = CBOR_ENC.encode({
768
+ type: "hash_submit",
769
+ session_id: session.session_id,
770
+ leaf_kind: 0x00,
771
+ structure1_cbor: tbs,
772
+ sender_signature: signature,
773
+ });
774
+ // Pre-buffer own content in a separate map so the echo cross-check finds it immediately.
775
+ // The sender won't receive a content_frame for its own messages (it sent to counterparty,
776
+ // not to itself). Using a separate map from #pendingContent avoids collision when both
777
+ // participants send identical byte payloads in the same session.
778
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
779
+ this.#ownPendingContent.get(sessionIdHex)?.set(contentHashHex, {
780
+ content_bytes: content,
781
+ arrived_at: Date.now(),
782
+ });
783
+ // Set up ack resolver before sending to avoid race with fast relay.
784
+ // The outbound queue guarantees at most one in-flight send per session.
785
+ // Guard here so a queue bug causes an immediate throw rather than a silent orphan.
786
+ if (this.#pendingAckResolvers.has(sessionIdHex)) {
787
+ throw new Error(`[cello-client] ack resolver already set for session ${sessionIdHex}; outbound queue invariant violated`);
788
+ }
789
+ let ackResolve;
790
+ const ackPromise = new Promise((r) => { ackResolve = r; });
791
+ this.#pendingAckResolvers.set(sessionIdHex, ackResolve);
792
+ try {
793
+ relayStream.send(lp.encode.single(hashSubmitFrame));
794
+ }
795
+ catch {
796
+ this.#pendingAckResolvers.delete(sessionIdHex);
797
+ this.#ownPendingContent.get(sessionIdHex)?.delete(contentHashHex);
798
+ return { ok: false, reason: "transport_unavailable" };
799
+ }
800
+ const ack = await ackPromise;
801
+ if (!ack.ok) {
802
+ return { ok: false, reason: "relay_rejected" };
803
+ }
804
+ const mySeq = ack.sequence_number;
805
+ // Send content to counterparty on /cello/content/1.0.0
806
+ // Best-effort: if content path fails, the receiver's 30s grace timer will desync
807
+ const sess2 = this.#sessions.get(sessionIdHex);
808
+ if (sess2 && !sess2.desynchronized) {
809
+ void this.#sendContentFrame(sess2, content, contentHash);
810
+ }
811
+ // Wait for our own echoed leaf_deliver (unblocks when crossCheckDelivery fires echo_resolve)
812
+ await this.#waitForOwnEcho(sessionIdHex, mySeq);
813
+ // Re-check desync: desync() fires the resolver to unblock, but send must still fail
814
+ const sess3 = this.#sessions.get(sessionIdHex);
815
+ if (!sess3 || sess3.desynchronized)
816
+ return { ok: false, reason: "session_desynchronized" };
817
+ return { ok: true };
818
+ }
819
+ async #sendContentFrame(session, content, contentHash) {
820
+ const counterpartyPeerId = session.counterparty_peer_id;
821
+ try {
822
+ // Dial counterparty if not connected
823
+ const multiaddr = session.counterparty_multiaddrs[0];
824
+ if (multiaddr) {
825
+ try {
826
+ await this.#node.dial(multiaddr);
827
+ }
828
+ catch { /* already connected */ }
829
+ }
830
+ const contentStream = await this.#node.newStream(counterpartyPeerId, CELLO_CONTENT_PROTOCOL_ID);
831
+ const frame = CBOR_ENC.encode({
832
+ type: "content_frame",
833
+ session_id: session.session_id,
834
+ content_hash: contentHash,
835
+ content_bytes: content,
836
+ });
837
+ contentStream.send(lp.encode.single(frame));
838
+ await contentStream.close();
839
+ }
840
+ catch {
841
+ // Content path failure is silent; 30s grace timer fires if receiver doesn't get content
842
+ }
843
+ }
844
+ async #waitForOwnEcho(sessionIdHex, seqNum) {
845
+ return new Promise((resolve) => {
846
+ const resolvers = this.#ownEchoResolvers.get(sessionIdHex);
847
+ if (resolvers) {
848
+ resolvers.set(seqNum, resolve);
849
+ }
850
+ else {
851
+ resolve(); // session was closed
852
+ }
853
+ });
854
+ }
855
+ receiveMessage(sessionIdHex) {
856
+ const queue = this.#sessionMessageQueues.get(sessionIdHex);
857
+ if (!queue || queue.length === 0)
858
+ return null;
859
+ return queue.shift();
860
+ }
861
+ receiveAnyMessage() {
862
+ return this.#anyMessageQueue.shift() ?? null;
863
+ }
864
+ // ─── SESSION-007: async blocking receive ─────────────────────────────────────
865
+ //
866
+ // Pseudocode for receiveMessageAsync(timeoutMs):
867
+ // 1. Check #anyMessageQueue — if non-empty, dequeue and return immediately.
868
+ // 2. Register a wake resolver in #receiveAnyWaiters.
869
+ // 3. Race: wait for wakeUp() signal vs. setTimeout(timeoutMs).
870
+ // 4. On wake: dequeue from #anyMessageQueue. Also dequeue from per-session queue.
871
+ // 5. On timeout: return { type: 'timeout' }.
872
+ //
873
+ // Pseudocode for receiveSessionMessageAsync(sessionIdHex, timeoutMs):
874
+ // 1. Check #sessionMessageQueues[sessionIdHex] — if non-empty, dequeue and return immediately.
875
+ // 2. Register a wake resolver in #receiveWaiters[sessionIdHex].
876
+ // 3. Race: wait for wakeUp() signal vs. setTimeout(timeoutMs).
877
+ // 4. On wake: dequeue from #sessionMessageQueues. If empty (spurious wake), repeat from step 2.
878
+ // 5. On timeout: clean up resolver, return null.
879
+ // 6. On return: compute otherSessionsPending, log session.receive.pending_hint if non-empty.
880
+ //
881
+ // Pseudocode for #wakeReceiveWaiters(sessionIdHex):
882
+ // 1. Fire all resolvers in #receiveWaiters[sessionIdHex].
883
+ // 2. Fire all resolvers in #receiveAnyWaiters.
884
+ // (Resolvers clear themselves on fire to avoid double-wake.)
885
+ //
886
+ // Pseudocode for #computeOtherSessionsPending(excludeSessionIdHex):
887
+ // 1. For each entry in #sessionMessageQueues, skip excluded session.
888
+ // 2. Collect sessionIdHex values where queue.length > 0.
889
+ // 3. Return collected array.
890
+ //
891
+ // Pseudocode for #enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp):
892
+ // 1. Log session.sealed.received with {sessionId, sealedRoot (hex), closeTimestamp, checkpointStatus, correlationId}.
893
+ // 2. Build lifecycle event: { type: "session_sealed", sessionIdHex, sealedRoot, closeTimestamp, checkpointStatus: "pending" }.
894
+ // 3. Enqueue to #sessionMessageQueues[sessionIdHex] (create queue if missing).
895
+ // 4. Enqueue to #anyMessageQueue.
896
+ // 5. Call #wakeReceiveWaiters(sessionIdHex).
897
+ #computeOtherSessionsPending(excludeSessionIdHex) {
898
+ const pending = [];
899
+ for (const [sid, queue] of this.#sessionMessageQueues.entries()) {
900
+ if (sid !== excludeSessionIdHex && queue.length > 0) {
901
+ pending.push(sid);
902
+ }
903
+ }
904
+ return pending;
905
+ }
906
+ #wakeReceiveWaiters(sessionIdHex) {
907
+ // Wake per-session waiters
908
+ const sessionWaiters = this.#receiveWaiters.get(sessionIdHex);
909
+ if (sessionWaiters) {
910
+ for (const resolve of sessionWaiters)
911
+ resolve();
912
+ sessionWaiters.clear();
913
+ }
914
+ // Wake any-session waiters
915
+ for (const resolve of this.#receiveAnyWaiters)
916
+ resolve();
917
+ this.#receiveAnyWaiters.clear();
918
+ }
919
+ async receiveSessionMessageAsync(sessionIdHex, timeoutMs) {
920
+ const deadline = Date.now() + timeoutMs;
921
+ while (true) {
922
+ // Check queue first (fast path: already has messages)
923
+ const queue = this.#sessionMessageQueues.get(sessionIdHex);
924
+ if (queue && queue.length > 0) {
925
+ const item = queue.shift();
926
+ // Remove from #anyMessageQueue as well to keep in sync
927
+ const idx = this.#anyMessageQueue.findIndex((e) => e.sessionIdHex === sessionIdHex && e.message === item);
928
+ if (idx !== -1)
929
+ this.#anyMessageQueue.splice(idx, 1);
930
+ // Attach otherSessionsPending
931
+ const pending = this.#computeOtherSessionsPending(sessionIdHex);
932
+ const result = pending.length > 0 ? { ...item, otherSessionsPending: pending } : item;
933
+ if (pending.length > 0) {
934
+ this.#logger.info("session.receive.pending_hint", {
935
+ currentSessionId: sessionIdHex,
936
+ pendingSessionCount: pending.length,
937
+ correlationId: sessionIdHex,
938
+ });
939
+ }
940
+ return result;
941
+ }
942
+ const remaining = deadline - Date.now();
943
+ if (remaining <= 0)
944
+ return null;
945
+ // Register wake resolver and race against timeout
946
+ await new Promise((resolve) => {
947
+ let set = this.#receiveWaiters.get(sessionIdHex);
948
+ if (!set) {
949
+ set = new Set();
950
+ this.#receiveWaiters.set(sessionIdHex, set);
951
+ }
952
+ set.add(resolve);
953
+ setTimeout(() => {
954
+ // Remove resolver if timeout fires before wake
955
+ this.#receiveWaiters.get(sessionIdHex)?.delete(resolve);
956
+ resolve();
957
+ }, remaining);
958
+ });
959
+ }
960
+ }
961
+ async receiveMessageAsync(timeoutMs) {
962
+ const deadline = Date.now() + timeoutMs;
963
+ while (true) {
964
+ // Check any-session queue first (fast path)
965
+ if (this.#anyMessageQueue.length > 0) {
966
+ const entry = this.#anyMessageQueue.shift();
967
+ // Remove from per-session queue as well
968
+ const perSession = this.#sessionMessageQueues.get(entry.sessionIdHex);
969
+ if (perSession) {
970
+ const idx = perSession.indexOf(entry.message);
971
+ if (idx !== -1)
972
+ perSession.splice(idx, 1);
973
+ }
974
+ // Attach otherSessionsPending
975
+ const pending = this.#computeOtherSessionsPending(entry.sessionIdHex);
976
+ const result = pending.length > 0
977
+ ? { ...entry.message, sessionIdHex: entry.sessionIdHex, otherSessionsPending: pending }
978
+ : { ...entry.message, sessionIdHex: entry.sessionIdHex };
979
+ if (pending.length > 0) {
980
+ this.#logger.info("session.receive.pending_hint", {
981
+ currentSessionId: entry.sessionIdHex,
982
+ pendingSessionCount: pending.length,
983
+ correlationId: entry.sessionIdHex,
984
+ });
985
+ }
986
+ return result;
987
+ }
988
+ const remaining = deadline - Date.now();
989
+ if (remaining <= 0)
990
+ return { type: "timeout" };
991
+ // Register wake resolver and race against timeout
992
+ await new Promise((resolve) => {
993
+ this.#receiveAnyWaiters.add(resolve);
994
+ setTimeout(() => {
995
+ this.#receiveAnyWaiters.delete(resolve);
996
+ resolve();
997
+ }, remaining);
998
+ });
999
+ }
1000
+ }
1001
+ #enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp) {
1002
+ // Use sessionIdHex as correlationId — minted at session initiation, unique per session flow.
1003
+ const correlationId = sessionIdHex;
1004
+ this.#logger.info("session.sealed.received", {
1005
+ sessionId: sessionIdHex,
1006
+ sealedRoot: Buffer.from(sealedRoot).toString("hex"),
1007
+ closeTimestamp,
1008
+ checkpointStatus: "pending",
1009
+ correlationId,
1010
+ });
1011
+ const lifecycleEvent = {
1012
+ type: "session_sealed",
1013
+ sessionIdHex,
1014
+ sealedRoot: new Uint8Array(sealedRoot),
1015
+ closeTimestamp,
1016
+ checkpointStatus: "pending",
1017
+ };
1018
+ let queue = this.#sessionMessageQueues.get(sessionIdHex);
1019
+ if (!queue) {
1020
+ queue = [];
1021
+ this.#sessionMessageQueues.set(sessionIdHex, queue);
1022
+ }
1023
+ queue.push(lifecycleEvent);
1024
+ this.#anyMessageQueue.push({ sessionIdHex, message: lifecycleEvent });
1025
+ this.#wakeReceiveWaiters(sessionIdHex);
1026
+ }
1027
+ // ─── SESSION-003: seal ceremony ──────────────────────────────────────────────
1028
+ async initiateSessionSeal(sessionIdHex) {
1029
+ const session = this.#sessions.get(sessionIdHex);
1030
+ if (!session)
1031
+ return { ok: false, reason: "session_not_found" };
1032
+ if (session.status !== "active")
1033
+ return { ok: false, reason: "session_not_active" };
1034
+ session.status = "sealing";
1035
+ this.#sealInitiatedSessions.add(sessionIdHex);
1036
+ const result = await this.#submitSealLeaf(sessionIdHex, session, "initiator");
1037
+ if (!result.ok)
1038
+ return result;
1039
+ // SESSION-005: if a threshold signer is configured, wait for the FROST seal ceremony.
1040
+ // The directory runs verification and FROST ceremony; if it doesn't reply within
1041
+ // sealFrostTimeoutMs, this is a bilateral seal (directory unreachable).
1042
+ // Without a threshold signer (M1 compatibility), return immediately —
1043
+ // the M1 single-key seal notification will arrive asynchronously.
1044
+ if (this.#thresholdSigner) {
1045
+ const sealReceived = new Promise((resolve) => {
1046
+ this.#sealFrostResolvers.set(sessionIdHex, resolve);
1047
+ });
1048
+ const timeout = new Promise((resolve) => setTimeout(resolve, this.#sealFrostTimeoutMs));
1049
+ await Promise.race([sealReceived, timeout]);
1050
+ // Clean up resolver
1051
+ this.#sealFrostResolvers.delete(sessionIdHex);
1052
+ // Check if session_sealed arrived (status would be 'sealed' by now)
1053
+ const sess = this.#sessions.get(sessionIdHex);
1054
+ if (sess && sess.status === "sealing") {
1055
+ // Timeout elapsed without session_sealed — bilateral fallback (DB-001)
1056
+ sess.status = "seal_deferred";
1057
+ sess.seal_type = "bilateral";
1058
+ // M-003: store the verified timestamp so #handleSessionFrostSealed can reconstruct
1059
+ // the exact TBS if the directory later completes the deferred FROST ceremony.
1060
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
1061
+ if (sealVerifiedEntry) {
1062
+ sess.close_timestamp = sealVerifiedEntry.timestamp;
1063
+ }
1064
+ }
1065
+ }
1066
+ return { ok: true };
1067
+ }
1068
+ // PERSIST-015: send seal_unilateral to the directory after delivery_grace_seconds elapses.
1069
+ async initiateUnilateralSeal(sessionIdHex) {
1070
+ const session = this.#sessions.get(sessionIdHex);
1071
+ if (!session)
1072
+ return { ok: false, reason: "session_not_found" };
1073
+ if (session.status !== "active" && session.status !== "sealing") {
1074
+ return { ok: false, reason: "session_not_active" };
1075
+ }
1076
+ if (!this.#persistentSignalingStream) {
1077
+ const opened = await this.#openPersistentSignalingStream();
1078
+ if (!opened || !this.#persistentSignalingStream) {
1079
+ return { ok: false, reason: "directory_unreachable" };
1080
+ }
1081
+ }
1082
+ const localRoot = this.#computeLocalRoot(session) ?? session.genesis_prev_root;
1083
+ const reportedSeq = session.next_expected_seq - 1;
1084
+ const frame = CBOR_ENC.encode({
1085
+ type: "seal_unilateral",
1086
+ session_id: session.session_id,
1087
+ reported_root: localRoot,
1088
+ reported_seq: reportedSeq,
1089
+ });
1090
+ this.#persistentSignalingStream.send(lp.encode.single(frame));
1091
+ const UNILATERAL_TIMEOUT_MS = 15_000;
1092
+ let timeoutHandle;
1093
+ const responseFrame = await Promise.race([
1094
+ new Promise((resolve) => {
1095
+ this.#pendingUnilateralSealResolve = resolve;
1096
+ }),
1097
+ new Promise((resolve) => {
1098
+ timeoutHandle = setTimeout(() => {
1099
+ this.#pendingUnilateralSealResolve = null;
1100
+ resolve({ type: "seal_unilateral_error", reason: "timeout" });
1101
+ }, UNILATERAL_TIMEOUT_MS);
1102
+ }),
1103
+ ]);
1104
+ clearTimeout(timeoutHandle);
1105
+ if (responseFrame["type"] === "seal_unilateral_confirmed") {
1106
+ const sealedRootRaw = responseFrame["sealed_root"];
1107
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1108
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw)
1109
+ : new Uint8Array(32);
1110
+ const sealedAt = typeof responseFrame["sealed_at"] === "number" ? responseFrame["sealed_at"] : Date.now();
1111
+ return { ok: true, sealed_root: sealedRoot, sealed_at: sealedAt };
1112
+ }
1113
+ if (responseFrame["type"] === "seal_unilateral_too_early") {
1114
+ const remainingSeconds = typeof responseFrame["remaining_seconds"] === "number"
1115
+ ? responseFrame["remaining_seconds"] : 0;
1116
+ return { ok: false, reason: "too_early", remaining_seconds: remainingSeconds };
1117
+ }
1118
+ return { ok: false, reason: responseFrame["reason"] ?? "unknown" };
1119
+ }
1120
+ async #submitSealLeaf(sessionIdHex, session, _role) {
1121
+ const relayStream = this.#relayStreams.get(sessionIdHex);
1122
+ if (!relayStream || relayStream.status !== "open") {
1123
+ return { ok: false, reason: "transport_unavailable" };
1124
+ }
1125
+ // Compute current local tree root (R_tail for initiator, root-after-initiator-SEAL for responder)
1126
+ const finalRoot = session.local_tree_leaves.length === 0
1127
+ ? session.genesis_prev_root
1128
+ : (() => {
1129
+ const inputs = session.local_tree_leaves.map(l => ({
1130
+ kind: l.kind,
1131
+ data: l.s2_cbor,
1132
+ }));
1133
+ return merkleRoot(buildMerkleTree(inputs));
1134
+ })();
1135
+ const close_timestamp = Date.now();
1136
+ const sealPayload = encodeSealPayload({
1137
+ session_id: session.session_id,
1138
+ final_root: finalRoot,
1139
+ close_timestamp,
1140
+ attestation: "PENDING",
1141
+ });
1142
+ // content_hash = SHA-256(0x02 || seal_payload) — ctrl leaf kind byte is 0x02
1143
+ const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x02])).update(sealPayload).digest());
1144
+ const myPubkeyHex = this.#myPubkeyHex;
1145
+ const myPubkeyBytes = Buffer.from(myPubkeyHex, "hex");
1146
+ const tbs = CBOR_ENC.encode([
1147
+ 1,
1148
+ contentHash,
1149
+ myPubkeyBytes,
1150
+ session.session_id,
1151
+ session.last_seen_seq,
1152
+ close_timestamp,
1153
+ ]);
1154
+ const signature = await this.#keyProvider.sign(tbs);
1155
+ const hashSubmitFrame = CBOR_ENC.encode({
1156
+ type: "hash_submit",
1157
+ session_id: session.session_id,
1158
+ leaf_kind: 0x02,
1159
+ structure1_cbor: tbs,
1160
+ sender_signature: signature,
1161
+ });
1162
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
1163
+ this.#ownPendingContent.get(sessionIdHex)?.set(contentHashHex, {
1164
+ content_bytes: sealPayload,
1165
+ arrived_at: Date.now(),
1166
+ });
1167
+ if (this.#pendingAckResolvers.has(sessionIdHex)) {
1168
+ return { ok: false, reason: "ack_resolver_conflict" };
1169
+ }
1170
+ let ackResolve;
1171
+ const ackPromise = new Promise((r) => { ackResolve = r; });
1172
+ this.#pendingAckResolvers.set(sessionIdHex, ackResolve);
1173
+ try {
1174
+ relayStream.send(lp.encode.single(hashSubmitFrame));
1175
+ }
1176
+ catch {
1177
+ this.#pendingAckResolvers.delete(sessionIdHex);
1178
+ this.#ownPendingContent.get(sessionIdHex)?.delete(contentHashHex);
1179
+ return { ok: false, reason: "transport_unavailable" };
1180
+ }
1181
+ const ack = await ackPromise;
1182
+ if (!ack.ok)
1183
+ return { ok: false, reason: "relay_rejected" };
1184
+ const mySeq = ack.sequence_number;
1185
+ // Send SEAL payload as content_frame to counterparty so they can cross-check
1186
+ const sess2 = this.#sessions.get(sessionIdHex);
1187
+ if (sess2 && !sess2.desynchronized) {
1188
+ void this.#sendContentFrame(sess2, sealPayload, contentHash);
1189
+ }
1190
+ // Wait for own echo
1191
+ await this.#waitForOwnEcho(sessionIdHex, mySeq);
1192
+ const sess3 = this.#sessions.get(sessionIdHex);
1193
+ if (!sess3 || sess3.desynchronized)
1194
+ return { ok: false, reason: "session_desynchronized" };
1195
+ return { ok: true };
1196
+ }
1197
+ // ─── Directory signaling stream (SESSION-003) ────────────────────────────────
1198
+ async #connectDirectorySignalingStream(sessionIdHex, assignment, myPubkey) {
1199
+ const dirPeerId = assignment.directory_endpoint.peer_id;
1200
+ const dirMultiaddr = assignment.directory_endpoint.multiaddrs[0];
1201
+ if (!dirPeerId)
1202
+ return; // no directory endpoint — skip (test env may not have one)
1203
+ try {
1204
+ if (dirMultiaddr) {
1205
+ try {
1206
+ await this.#node.dial(dirMultiaddr);
1207
+ }
1208
+ catch { /* already connected */ }
1209
+ }
1210
+ const SIGNALING_PROTOCOL_ID = "/cello/signaling/1.0.0";
1211
+ const AUTH_DOMAIN_DIR = "CELLO-DIR-AUTH-v1";
1212
+ let dirStream;
1213
+ try {
1214
+ dirStream = await this.#node.newStream(dirPeerId, SIGNALING_PROTOCOL_ID);
1215
+ }
1216
+ catch {
1217
+ return; // directory not reachable — session still active, sealed notification just won't arrive
1218
+ }
1219
+ this.#directoryStreams.set(sessionIdHex, dirStream);
1220
+ // Directory signaling auth: read challenge, sign, respond
1221
+ const iter = lp.decode(dirStream)[Symbol.asyncIterator]();
1222
+ const { value: challengeRaw, done } = await iter.next();
1223
+ if (done || challengeRaw === undefined) {
1224
+ dirStream.abort(new Error("dir_auth_error"));
1225
+ return;
1226
+ }
1227
+ let challengeFrame;
1228
+ try {
1229
+ challengeFrame = decode(toU8(challengeRaw));
1230
+ }
1231
+ catch {
1232
+ dirStream.abort(new Error("dir_auth_error"));
1233
+ return;
1234
+ }
1235
+ if (challengeFrame["type"] !== "signaling_auth_challenge") {
1236
+ dirStream.abort(new Error("dir_auth_error"));
1237
+ return;
1238
+ }
1239
+ const nonce = toU8(challengeFrame["nonce"]);
1240
+ const domain = Buffer.from(AUTH_DOMAIN_DIR, "utf8");
1241
+ const authMsg = new Uint8Array(Buffer.concat([domain, nonce, myPubkey]));
1242
+ const msgHash = new Uint8Array(createHash("sha256").update(authMsg).digest());
1243
+ const sig = await this.#keyProvider.sign(msgHash);
1244
+ const authResponseFrame = CBOR_ENC.encode({
1245
+ type: "signaling_auth_response",
1246
+ pubkey: myPubkey,
1247
+ signature: sig,
1248
+ });
1249
+ dirStream.send(lp.encode.single(authResponseFrame));
1250
+ // ADAPTER-003: consume signaling_auth_ok so iter is in sync for session_sealed frames
1251
+ const { value: ackRaw2, done: ackDone2 } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
1252
+ if (ackDone2 || ackRaw2 === undefined) {
1253
+ dirStream.abort(new Error("dir_auth_error"));
1254
+ return;
1255
+ }
1256
+ let ackFrame2;
1257
+ try {
1258
+ ackFrame2 = decode(toU8(ackRaw2));
1259
+ }
1260
+ catch {
1261
+ dirStream.abort(new Error("dir_auth_error"));
1262
+ return;
1263
+ }
1264
+ if (ackFrame2["type"] !== "signaling_auth_ok") {
1265
+ dirStream.abort(new Error("dir_auth_error"));
1266
+ return;
1267
+ }
1268
+ void this.#runDirectoryStreamReader(sessionIdHex, dirStream, assignment.directory_pubkey, iter);
1269
+ }
1270
+ catch {
1271
+ // Directory connection failure — session still active
1272
+ }
1273
+ }
1274
+ async #runDirectoryStreamReader(sessionIdHex, stream, directoryPubkey, iter) {
1275
+ try {
1276
+ while (true) {
1277
+ let result;
1278
+ try {
1279
+ result = await iter.next();
1280
+ }
1281
+ catch {
1282
+ break;
1283
+ }
1284
+ if (result.done || result.value === undefined)
1285
+ break;
1286
+ let frame;
1287
+ try {
1288
+ frame = decode(toU8(result.value));
1289
+ }
1290
+ catch {
1291
+ continue;
1292
+ }
1293
+ if (frame["type"] === "session_sealed") {
1294
+ this.#handleDirectorySessionSealed(sessionIdHex, frame, directoryPubkey);
1295
+ }
1296
+ else if (frame["type"] === "session_seal_rejected") {
1297
+ this.#handleDirectorySessionSealRejected(sessionIdHex, frame);
1298
+ }
1299
+ else if (frame["type"] === "seal_rejected_tree_mismatch") {
1300
+ this.#handleSealRejectedTreeMismatch(sessionIdHex, frame);
1301
+ }
1302
+ else if (frame["type"] === "seal_unilateral_confirmed") {
1303
+ this.#handleSealUnilateralConfirmed(sessionIdHex, frame);
1304
+ }
1305
+ else if (frame["type"] === "seal_unilateral_notification") {
1306
+ this.#handleSealUnilateralNotification(sessionIdHex, frame);
1307
+ }
1308
+ else if (frame["type"] === "seal_verified") {
1309
+ void this.#handleSealVerified(sessionIdHex, frame);
1310
+ }
1311
+ else if (frame["type"] === "session_frost_sealed") {
1312
+ this.#handleSessionFrostSealed(sessionIdHex, frame);
1313
+ }
1314
+ }
1315
+ }
1316
+ catch { /* stream closed */ }
1317
+ if (this.#directoryStreams.get(sessionIdHex) === stream) {
1318
+ this.#directoryStreams.delete(sessionIdHex);
1319
+ }
1320
+ }
1321
+ #handleDirectorySessionSealed(sessionIdHex, frame, directoryPubkey) {
1322
+ const session = this.#sessions.get(sessionIdHex);
1323
+ if (!session)
1324
+ return;
1325
+ const signatureType = frame["signature_type"];
1326
+ // If this client has a threshold signer (M2 mode), enforce FROST-only.
1327
+ if (this.#thresholdSigner) {
1328
+ // SI-003: reject M1-era single-key seal notarizations in M2 mode
1329
+ if (signatureType === "single") {
1330
+ console.warn(`[cello-client] unsupported_signature_type: single on session ${sessionIdHex}`);
1331
+ return;
1332
+ }
1333
+ if (signatureType !== "frost") {
1334
+ // Unknown signature_type — ignore
1335
+ return;
1336
+ }
1337
+ this.#handleFrostSealed(sessionIdHex, frame, session);
1338
+ }
1339
+ else {
1340
+ // M1 compatibility mode: no threshold signer — verify directory_signature
1341
+ this.#handleSingleSealed(sessionIdHex, frame, directoryPubkey, session);
1342
+ }
1343
+ }
1344
+ /** No-threshold-signer path: handles both 'single' (Ed25519 dir sig) and 'frost' seal frames. */
1345
+ #handleSingleSealed(sessionIdHex, frame, directoryPubkey, session) {
1346
+ const signatureType = frame["signature_type"];
1347
+ const sealedRootRaw = frame["sealed_root"];
1348
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1349
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
1350
+ const ctRaw = frame["close_timestamp"];
1351
+ const closeTimestamp = typeof ctRaw === "number" ? ctRaw : typeof ctRaw === "bigint" ? Number(ctRaw) : null;
1352
+ const sidRaw = frame["session_id"];
1353
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
1354
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
1355
+ if (!sealedRoot || sealedRoot.length !== 32)
1356
+ return;
1357
+ if (closeTimestamp === null)
1358
+ return;
1359
+ if (!sessionId)
1360
+ return;
1361
+ if (signatureType === "frost") {
1362
+ // FROST seal received by an M1 client (no threshold signer).
1363
+ // Verify using the signer_pubkey embedded in the frame (initiator's primary_pubkey).
1364
+ const frostSigRaw = frame["frost_signature"];
1365
+ const frostSig = frostSigRaw instanceof Uint8Array ? frostSigRaw
1366
+ : Buffer.isBuffer(frostSigRaw) ? new Uint8Array(frostSigRaw) : null;
1367
+ const signerPubkeyRaw = frame["signer_pubkey"];
1368
+ const signerPubkey = signerPubkeyRaw instanceof Uint8Array ? signerPubkeyRaw
1369
+ : Buffer.isBuffer(signerPubkeyRaw) ? new Uint8Array(signerPubkeyRaw) : null;
1370
+ if (!frostSig || frostSig.length !== 64)
1371
+ return;
1372
+ if (!signerPubkey || signerPubkey.length !== 32)
1373
+ return;
1374
+ // Use sealVerifiedData if available (responder also gets seal_verified when M2 initiator),
1375
+ // else fall back to local_tree_leaves.length.
1376
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
1377
+ const leafCount = sealVerifiedEntry?.leafCount ?? session.local_tree_leaves.length;
1378
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, closeTimestamp);
1379
+ if (!verifyFrostSignature(frostSig, tbs, "cello-frost-seal-v1", signerPubkey)) {
1380
+ console.warn(`[cello-client] frost_signature_invalid on session_sealed: ${sessionIdHex}`);
1381
+ return;
1382
+ }
1383
+ session.status = "sealed";
1384
+ session.sealed_root = sealedRoot;
1385
+ session.frost_signature = frostSig;
1386
+ session.signer_pubkey = signerPubkey;
1387
+ session.seal_type = "frost";
1388
+ session.close_timestamp = closeTimestamp;
1389
+ this.#sealVerifiedData.delete(sessionIdHex);
1390
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1391
+ this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
1392
+ }
1393
+ else {
1394
+ // M1 single-key: verify directory_signature against pinned directory pubkey
1395
+ const dirSigRaw = frame["directory_signature"];
1396
+ const dirSig = dirSigRaw instanceof Uint8Array ? dirSigRaw
1397
+ : Buffer.isBuffer(dirSigRaw) ? new Uint8Array(dirSigRaw) : null;
1398
+ if (!dirSig || dirSig.length !== 64)
1399
+ return;
1400
+ // SI-005 (M1): verify directory signature against pinned directory pubkey
1401
+ const tbs = CBOR_ENC.encode([
1402
+ sessionId,
1403
+ sealedRoot,
1404
+ closeTimestamp > 0xffffffff ? BigInt(closeTimestamp) : closeTimestamp,
1405
+ ]);
1406
+ if (!verify(directoryPubkey, tbs, dirSig)) {
1407
+ console.warn(`[cello-client] directory_signature_invalid on session_sealed: ${sessionIdHex}`);
1408
+ return;
1409
+ }
1410
+ session.status = "sealed";
1411
+ session.sealed_root = sealedRoot;
1412
+ session.directory_signature = dirSig;
1413
+ session.close_timestamp = closeTimestamp;
1414
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1415
+ this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
1416
+ }
1417
+ // Resolve the seal-frost-timeout waiter
1418
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
1419
+ }
1420
+ /** M2 FROST seal verification. */
1421
+ #handleFrostSealed(sessionIdHex, frame, session) {
1422
+ const sealedRootRaw = frame["sealed_root"];
1423
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1424
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
1425
+ const frostSigRaw = frame["frost_signature"];
1426
+ const frostSig = frostSigRaw instanceof Uint8Array ? frostSigRaw
1427
+ : Buffer.isBuffer(frostSigRaw) ? new Uint8Array(frostSigRaw) : null;
1428
+ const signerPubkeyRaw = frame["signer_pubkey"];
1429
+ const signerPubkey = signerPubkeyRaw instanceof Uint8Array ? signerPubkeyRaw
1430
+ : Buffer.isBuffer(signerPubkeyRaw) ? new Uint8Array(signerPubkeyRaw) : null;
1431
+ const ctRaw = frame["close_timestamp"];
1432
+ const closeTimestamp = typeof ctRaw === "number" ? ctRaw : typeof ctRaw === "bigint" ? Number(ctRaw) : null;
1433
+ const sidRaw = frame["session_id"];
1434
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
1435
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
1436
+ const leafCountRaw = frame["leaf_count"];
1437
+ // Prefer stored sealVerifiedData so we use the same leafCount that was used during
1438
+ // the FROST ceremony, even if local_tree_leaves is incomplete due to a desync race.
1439
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
1440
+ const leafCount = typeof leafCountRaw === "number" ? leafCountRaw
1441
+ : (sealVerifiedEntry?.leafCount ?? session.local_tree_leaves.length);
1442
+ const resolvedCloseTimestamp = closeTimestamp ?? sealVerifiedEntry?.timestamp ?? null;
1443
+ if (!sealedRoot || sealedRoot.length !== 32)
1444
+ return;
1445
+ if (!frostSig || frostSig.length !== 64)
1446
+ return;
1447
+ if (!signerPubkey || signerPubkey.length !== 32)
1448
+ return;
1449
+ if (resolvedCloseTimestamp === null)
1450
+ return;
1451
+ if (!sessionId)
1452
+ return;
1453
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, resolvedCloseTimestamp);
1454
+ // Determine verification key.
1455
+ // Use #myPrimaryPubkey only if this client ran the FROST ceremony (received seal_verified).
1456
+ // #frostCeremonyParticipant is set by #handleSealVerified before the ceremony runs —
1457
+ // a concurrent-close counterparty (in #sealInitiatedSessions but NOT #frostCeremonyParticipant)
1458
+ // must use signerPubkey from the frame (the actual initiator's key).
1459
+ const isFrostInitiator = this.#frostCeremonyParticipant.has(sessionIdHex);
1460
+ let verifyKey;
1461
+ if (isFrostInitiator) {
1462
+ if (!this.#myPrimaryPubkey) {
1463
+ console.warn(`[cello-client] no primary_pubkey set on initiator for session ${sessionIdHex}`);
1464
+ return;
1465
+ }
1466
+ verifyKey = this.#myPrimaryPubkey;
1467
+ }
1468
+ else {
1469
+ verifyKey = signerPubkey;
1470
+ }
1471
+ // SI-001: verify FROST signature before transitioning to sealed.
1472
+ if (!this.#thresholdSigner.verifySignature(frostSig, tbs, "cello-frost-seal-v1", verifyKey)) {
1473
+ console.warn(`[cello-client] seal_signature_invalid on session ${sessionIdHex}`);
1474
+ return;
1475
+ }
1476
+ session.status = "sealed";
1477
+ session.sealed_root = sealedRoot;
1478
+ session.frost_signature = frostSig;
1479
+ session.signer_pubkey = signerPubkey;
1480
+ session.seal_type = "frost";
1481
+ session.close_timestamp = resolvedCloseTimestamp;
1482
+ this.#sealVerifiedData.delete(sessionIdHex);
1483
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1484
+ this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, resolvedCloseTimestamp);
1485
+ // Resolve the seal-frost-timeout waiter so initiateSessionSeal returns promptly
1486
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
1487
+ }
1488
+ #handleDirectorySessionSealRejected(sessionIdHex, _frame) {
1489
+ const session = this.#sessions.get(sessionIdHex);
1490
+ if (!session)
1491
+ return;
1492
+ session.status = "seal_rejected";
1493
+ // Also resolve the seal-frost-timeout waiter so initiateSessionSeal doesn't wait for the timeout
1494
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
1495
+ }
1496
+ /**
1497
+ * PERSIST-014: Handle seal_rejected_tree_mismatch from the directory.
1498
+ * Determines if this client is the behind party and initiates gap-fill reconciliation.
1499
+ */
1500
+ #handleSealRejectedTreeMismatch(sessionIdHex, frame) {
1501
+ const session = this.#sessions.get(sessionIdHex);
1502
+ if (!session)
1503
+ return;
1504
+ const partyASequence = typeof frame["party_a_sequence"] === "number" ? frame["party_a_sequence"] : 0;
1505
+ const partyBSequence = typeof frame["party_b_sequence"] === "number" ? frame["party_b_sequence"] : 0;
1506
+ // Determine this client's local sequence (highest seq in its Merkle tree).
1507
+ // next_expected_seq is 1-indexed: the next seq the relay will assign, so local highest = next - 1.
1508
+ const mySequence = session.next_expected_seq - 1;
1509
+ const aheadSequence = Math.max(partyASequence, partyBSequence);
1510
+ if (mySequence >= aheadSequence) {
1511
+ // We are NOT the behind party — wait for the behind party to reconcile and retry
1512
+ return;
1513
+ }
1514
+ // We are the behind party — initiate gap-fill reconciliation
1515
+ const gapSize = aheadSequence - mySequence;
1516
+ const correlationId = Buffer.from(session.session_id).toString("hex") + "-" + Date.now().toString(36);
1517
+ this.#logger.info("session.reconciliation.started", {
1518
+ sessionId: sessionIdHex,
1519
+ gapSize,
1520
+ fromSequence: mySequence,
1521
+ toSequence: aheadSequence,
1522
+ correlationId,
1523
+ });
1524
+ void this.#performGapFillReconciliation(sessionIdHex, mySequence, aheadSequence, correlationId);
1525
+ }
1526
+ /**
1527
+ * PERSIST-014: Request gap-fill leaves from the relay, verify each, advance tree, retry seal.
1528
+ */
1529
+ async #performGapFillReconciliation(sessionIdHex, fromSeq, toSeq, correlationId) {
1530
+ const session = this.#sessions.get(sessionIdHex);
1531
+ if (!session)
1532
+ return;
1533
+ const startMs = Date.now();
1534
+ const gapSize = toSeq - fromSeq;
1535
+ // Send gap_fill_request to relay
1536
+ const relayStream = this.#relayStreams.get(sessionIdHex);
1537
+ if (!relayStream || relayStream.status !== "open") {
1538
+ this.#logger.error("session.gap.fill.failed", {
1539
+ sessionId: sessionIdHex,
1540
+ reason: "relay_stream_unavailable",
1541
+ correlationId,
1542
+ });
1543
+ return;
1544
+ }
1545
+ const gapFillRequestFrame = CBOR_ENC.encode({
1546
+ type: "gap_fill_request",
1547
+ session_id: session.session_id,
1548
+ from_seq: fromSeq,
1549
+ to_seq: toSeq,
1550
+ });
1551
+ // Register a resolver before sending so there's no race with an immediate response
1552
+ const responsePromise = new Promise((resolve) => {
1553
+ this.#pendingGapFillResolvers.set(sessionIdHex, resolve);
1554
+ });
1555
+ try {
1556
+ relayStream.send(lp.encode.single(gapFillRequestFrame));
1557
+ }
1558
+ catch {
1559
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
1560
+ this.#logger.error("session.gap.fill.failed", {
1561
+ sessionId: sessionIdHex,
1562
+ reason: "relay_send_failed",
1563
+ correlationId,
1564
+ });
1565
+ return;
1566
+ }
1567
+ // Wait for gap_fill_response or gap_fill_error from the reader loop
1568
+ const responseResult = await responsePromise;
1569
+ if (!responseResult.ok) {
1570
+ this.#logger.error("session.gap.fill.failed", {
1571
+ sessionId: sessionIdHex,
1572
+ reason: responseResult.reason,
1573
+ correlationId,
1574
+ });
1575
+ return;
1576
+ }
1577
+ // SI-002: verify each leaf's Ed25519 signature before advancing the tree
1578
+ const gapLeaves = responseResult.leaves;
1579
+ for (let i = 0; i < gapLeaves.length; i++) {
1580
+ const leaf = gapLeaves[i];
1581
+ const senderPubkey = leaf["sender_pubkey"] instanceof Uint8Array ? leaf["sender_pubkey"]
1582
+ : Buffer.isBuffer(leaf["sender_pubkey"]) ? new Uint8Array(leaf["sender_pubkey"]) : null;
1583
+ const structure1Cbor = leaf["structure1_cbor"] instanceof Uint8Array ? leaf["structure1_cbor"]
1584
+ : Buffer.isBuffer(leaf["structure1_cbor"]) ? new Uint8Array(leaf["structure1_cbor"]) : null;
1585
+ const senderSignature = leaf["sender_signature"] instanceof Uint8Array ? leaf["sender_signature"]
1586
+ : Buffer.isBuffer(leaf["sender_signature"]) ? new Uint8Array(leaf["sender_signature"]) : null;
1587
+ if (!senderPubkey || !structure1Cbor || !senderSignature) {
1588
+ this.#logger.warn("session.gap.fill.leaf.invalid", {
1589
+ sessionId: sessionIdHex,
1590
+ leafIndex: i,
1591
+ reason: "missing_fields",
1592
+ correlationId,
1593
+ });
1594
+ return;
1595
+ }
1596
+ if (!verify(senderPubkey, structure1Cbor, senderSignature)) {
1597
+ this.#logger.warn("session.gap.fill.leaf.invalid", {
1598
+ sessionId: sessionIdHex,
1599
+ leafIndex: i,
1600
+ reason: "signature_invalid",
1601
+ correlationId,
1602
+ });
1603
+ return;
1604
+ }
1605
+ }
1606
+ // Advance next_expected_seq to reflect newly received leaves
1607
+ const latestSeq = gapLeaves.reduce((max, l) => {
1608
+ const seq = typeof l["sequence_number"] === "number" ? l["sequence_number"] : max;
1609
+ return Math.max(max, seq);
1610
+ }, session.next_expected_seq - 1);
1611
+ session.next_expected_seq = latestSeq + 1;
1612
+ this.#logger.info("session.reconciliation.completed", {
1613
+ sessionId: sessionIdHex,
1614
+ gapSize,
1615
+ durationMs: Date.now() - startMs,
1616
+ correlationId,
1617
+ });
1618
+ // Retry the seal attempt with the now-advanced tree
1619
+ // Re-send a seal_attempt frame with the updated local sequence so the directory
1620
+ // can re-compare roots. The directory will ack if both parties now agree.
1621
+ const localRoot = this.#computeLocalRoot(session);
1622
+ const dirStream = this.#directoryStreams.get(sessionIdHex);
1623
+ if (localRoot && dirStream && dirStream.status === "open") {
1624
+ const sealAttemptFrame = CBOR_ENC.encode({
1625
+ type: "seal_attempt",
1626
+ session_id: session.session_id,
1627
+ reported_root: localRoot,
1628
+ reported_seq: session.next_expected_seq - 1,
1629
+ });
1630
+ try {
1631
+ dirStream.send(lp.encode.single(sealAttemptFrame));
1632
+ }
1633
+ catch {
1634
+ this.#logger.error("session.gap.fill.failed", {
1635
+ sessionId: sessionIdHex,
1636
+ reason: "seal_retry_send_failed",
1637
+ correlationId,
1638
+ });
1639
+ }
1640
+ }
1641
+ }
1642
+ /**
1643
+ * PERSIST-014: Handle gap_fill_response from the relay.
1644
+ * Resolves the pending reconciliation promise with the received leaves.
1645
+ */
1646
+ #handleGapFillResponse(sessionIdHex, frame) {
1647
+ const resolver = this.#pendingGapFillResolvers.get(sessionIdHex);
1648
+ if (!resolver)
1649
+ return;
1650
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
1651
+ const leavesRaw = Array.isArray(frame["leaves"]) ? frame["leaves"] : [];
1652
+ resolver({ ok: true, leaves: leavesRaw });
1653
+ }
1654
+ /**
1655
+ * PERSIST-015: Handle seal_unilateral_confirmed from the directory.
1656
+ * The submitting party receives this when the unilateral seal succeeds.
1657
+ */
1658
+ #handleSealUnilateralConfirmed(sessionIdHex, frame) {
1659
+ const session = this.#sessions.get(sessionIdHex);
1660
+ if (!session)
1661
+ return;
1662
+ const sealedRootRaw = frame["sealed_root"];
1663
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1664
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
1665
+ session.status = "sealed";
1666
+ if (sealedRoot)
1667
+ session.sealed_root = sealedRoot;
1668
+ session.seal_type = "unilateral";
1669
+ session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
1670
+ const correlationId = Buffer.from(session.session_id).toString("hex");
1671
+ this.#logger.info("session.sealed", {
1672
+ sessionId: sessionIdHex,
1673
+ sealType: "UNILATERAL",
1674
+ rootHash: sealedRoot ? Buffer.from(sealedRoot).toString("hex") : "unknown",
1675
+ correlationId,
1676
+ });
1677
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1678
+ if (sealedRoot) {
1679
+ this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, session.close_timestamp ?? Date.now());
1680
+ }
1681
+ // Resolve the FROST ceremony waiter only if a bilateral seal was in-flight for this session.
1682
+ // The unilateral and FROST paths are mutually exclusive once the session seals — resolving an
1683
+ // absent FROST waiter is harmless (map miss returns undefined), but resolving a present one
1684
+ // spuriously would confuse the bilateral seal flow. Guard on whether the session was actually
1685
+ // in sealing state via the FROST path before the unilateral confirmation arrived.
1686
+ if (this.#sealInitiatedSessions.has(sessionIdHex)) {
1687
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
1688
+ }
1689
+ }
1690
+ /**
1691
+ * PERSIST-015: Handle seal_unilateral_notification from the directory.
1692
+ * The absent party receives this on reconnect — verifies sealed root against local state.
1693
+ */
1694
+ #handleSealUnilateralNotification(sessionIdHex, frame) {
1695
+ let session = this.#sessions.get(sessionIdHex);
1696
+ if (!session) {
1697
+ // Absent party reconnecting after session was sealed without them — create a minimal
1698
+ // sealed session record so the notification is observable via listSessions().
1699
+ const sessionIdRaw = frame["session_id"];
1700
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
1701
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw)
1702
+ : Buffer.from(sessionIdHex, "hex");
1703
+ // Build the stub as sealed from the start — no transient "active" state visible to readers.
1704
+ // Fields like counterparty_pubkey and directory_pubkey are zeroed because the absent party
1705
+ // does not have session state; #computeLocalRoot handles the empty-leaves case explicitly.
1706
+ const stub = {
1707
+ session_id: sessionId,
1708
+ counterparty_pubkey: new Uint8Array(32),
1709
+ counterparty_peer_id: "",
1710
+ counterparty_multiaddrs: [],
1711
+ relay_endpoint: { peer_id: "", multiaddrs: [] },
1712
+ directory_endpoint: { peer_id: "", multiaddrs: [] },
1713
+ directory_pubkey: new Uint8Array(32),
1714
+ genesis_prev_root: new Uint8Array(32),
1715
+ last_seen_seq: 0,
1716
+ last_sent_seq: 0,
1717
+ status: "sealed",
1718
+ seal_type: "unilateral",
1719
+ local_tree_leaves: [],
1720
+ next_expected_seq: 1,
1721
+ desynchronized: false,
1722
+ };
1723
+ this.#sessions.set(sessionIdHex, stub);
1724
+ session = stub;
1725
+ }
1726
+ const sealedRootRaw = frame["sealed_root"];
1727
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1728
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
1729
+ session.status = "sealed";
1730
+ if (sealedRoot)
1731
+ session.sealed_root = sealedRoot;
1732
+ session.seal_type = "unilateral";
1733
+ session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
1734
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1735
+ if (sealedRoot) {
1736
+ this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, session.close_timestamp);
1737
+ }
1738
+ // AC-004: Verify sealed root against local Merkle state
1739
+ const localRoot = this.#computeLocalRoot(session);
1740
+ if (localRoot == null) {
1741
+ // Cannot verify — no local leaves received yet; log distinctly rather than as mismatch
1742
+ this.#logger.info("session.unilateral.no.local.state", {
1743
+ sessionId: sessionIdHex,
1744
+ correlationId: sessionIdHex,
1745
+ });
1746
+ return;
1747
+ }
1748
+ const match = sealedRoot != null && Buffer.from(localRoot).equals(Buffer.from(sealedRoot));
1749
+ if (match) {
1750
+ this.#logger.info("session.unilateral.verified", {
1751
+ sessionId: sessionIdHex,
1752
+ match: true,
1753
+ correlationId: sessionIdHex,
1754
+ });
1755
+ }
1756
+ else {
1757
+ this.#logger.warn("session.unilateral.mismatch", {
1758
+ sessionId: sessionIdHex,
1759
+ localRoot: Buffer.from(localRoot).toString("hex"),
1760
+ sealedRoot: sealedRoot ? Buffer.from(sealedRoot).toString("hex") : "null",
1761
+ correlationId: sessionIdHex,
1762
+ });
1763
+ }
1764
+ }
1765
+ /**
1766
+ * PERSIST-015: Compute the local Merkle root from the session's accepted leaves.
1767
+ */
1768
+ #computeLocalRoot(session) {
1769
+ if (!session.local_tree_leaves || session.local_tree_leaves.length === 0)
1770
+ return null;
1771
+ const leafInputs = session.local_tree_leaves.map((l) => ({
1772
+ kind: l.kind,
1773
+ data: l.s2_cbor,
1774
+ }));
1775
+ const tree = buildMerkleTree(leafInputs);
1776
+ return merkleRoot(tree);
1777
+ }
1778
+ /**
1779
+ * SESSION-005: Handle seal_verified event from the directory.
1780
+ * The directory has verified the Merkle tree; the initiator must now coordinate
1781
+ * the FROST ceremony and return the combined signature.
1782
+ */
1783
+ async #handleSealVerified(sessionIdHex, frame) {
1784
+ const session = this.#sessions.get(sessionIdHex);
1785
+ if (!session)
1786
+ return;
1787
+ if (!this.#thresholdSigner)
1788
+ return; // no FROST signer — bilateral only
1789
+ const sealedRootRaw = frame["sealed_root"];
1790
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1791
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
1792
+ const sidRaw = frame["session_id"];
1793
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
1794
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
1795
+ const leafCountRaw = frame["leaf_count"];
1796
+ const leafCount = typeof leafCountRaw === "number" ? leafCountRaw : null;
1797
+ const tsRaw = frame["timestamp"];
1798
+ const timestamp = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "bigint" ? Number(tsRaw) : null;
1799
+ if (!sealedRoot || !sessionId || leafCount === null || timestamp === null)
1800
+ return;
1801
+ // Store for #handleFrostSealed so it can use the authoritative leafCount/timestamp
1802
+ // even if local_tree_leaves is incomplete due to a desync race.
1803
+ this.#sealVerifiedData.set(sessionIdHex, { leafCount, timestamp });
1804
+ // Mark this client as the FROST ceremony participant so #handleFrostSealed uses
1805
+ // #myPrimaryPubkey for verification (anti-substitution guard).
1806
+ this.#frostCeremonyParticipant.add(sessionIdHex);
1807
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, timestamp);
1808
+ // Participate in the FROST seal ceremony as coordinator
1809
+ const ceremonyId = `seal:${sessionIdHex}`;
1810
+ let result;
1811
+ try {
1812
+ result = await this.#thresholdSigner.participateInCeremony(ceremonyId, tbs, "cello-frost-seal-v1");
1813
+ }
1814
+ catch {
1815
+ // Ceremony failed — bilateral fallback; do not send seal_frost_signature
1816
+ return;
1817
+ }
1818
+ if (!result.ok) {
1819
+ // DB-002: ceremony failed (threshold not met) — bilateral fallback
1820
+ return;
1821
+ }
1822
+ // Send seal_frost_signature to directory.
1823
+ // Prefer the per-session directory stream; fall back to the persistent signaling stream
1824
+ // (which is used when receiveSessionAssignment detects a persistent stream is already open).
1825
+ const dirStream = this.#directoryStreams.get(sessionIdHex);
1826
+ const sendStream = (dirStream && dirStream.status === "open") ? dirStream : this.#persistentSignalingStream;
1827
+ if (!sendStream)
1828
+ return;
1829
+ const sealFrostSigFrame = CBOR_ENC.encode({
1830
+ type: "seal_frost_signature",
1831
+ session_id: sessionId,
1832
+ frost_signature: result.signature,
1833
+ });
1834
+ try {
1835
+ sendStream.send(lp.encode.single(sealFrostSigFrame));
1836
+ }
1837
+ catch {
1838
+ // Stream closed — bilateral fallback
1839
+ }
1840
+ }
1841
+ /**
1842
+ * Handle ceremony_request from the directory.
1843
+ * The directory sends this when a session_request requires a FROST ceremony but
1844
+ * the directory is not the coordinator. The client runs participateInCeremony
1845
+ * and sends back a ceremony_result with the combined signature.
1846
+ */
1847
+ async #handleCeremonyRequest(stream, frame) {
1848
+ if (!this.#thresholdSigner)
1849
+ return;
1850
+ const ceremonyId = frame["ceremony_id"];
1851
+ const tbsRaw = frame["tbs"];
1852
+ const tbs = tbsRaw instanceof Uint8Array ? tbsRaw
1853
+ : Buffer.isBuffer(tbsRaw) ? new Uint8Array(tbsRaw) : null;
1854
+ const context = frame["context"];
1855
+ if (!ceremonyId || !tbs || !context)
1856
+ return;
1857
+ try {
1858
+ const result = await this.#thresholdSigner.participateInCeremony(ceremonyId, tbs, context);
1859
+ const sig = result.ok ? result.signature : null;
1860
+ stream.send(lp.encode.single(CBOR_ENC.encode({
1861
+ type: "ceremony_result",
1862
+ ceremony_id: ceremonyId,
1863
+ signature: sig ? new Uint8Array(sig) : null,
1864
+ })));
1865
+ }
1866
+ catch {
1867
+ stream.send(lp.encode.single(CBOR_ENC.encode({
1868
+ type: "ceremony_result",
1869
+ ceremony_id: ceremonyId,
1870
+ signature: null,
1871
+ })));
1872
+ }
1873
+ }
1874
+ /**
1875
+ * SESSION-005: Handle session_frost_sealed event — deferred FROST seal completed.
1876
+ * Sent by the directory when a previously deferred seal ceremony completes.
1877
+ * Updates the session from seal_deferred/bilateral to sealed/frost.
1878
+ */
1879
+ #handleSessionFrostSealed(sessionIdHex, frame) {
1880
+ const session = this.#sessions.get(sessionIdHex);
1881
+ if (!session)
1882
+ return;
1883
+ const sealedRootRaw = frame["sealed_root"];
1884
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
1885
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
1886
+ const frostSigRaw = frame["frost_signature"];
1887
+ const frostSig = frostSigRaw instanceof Uint8Array ? frostSigRaw
1888
+ : Buffer.isBuffer(frostSigRaw) ? new Uint8Array(frostSigRaw) : null;
1889
+ const signerPubkeyRaw = frame["signer_pubkey"];
1890
+ const signerPubkey = signerPubkeyRaw instanceof Uint8Array ? signerPubkeyRaw
1891
+ : Buffer.isBuffer(signerPubkeyRaw) ? new Uint8Array(signerPubkeyRaw) : null;
1892
+ const sidRaw = frame["session_id"];
1893
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
1894
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
1895
+ if (!sealedRoot || frostSig === null || !signerPubkey || !sessionId)
1896
+ return;
1897
+ if (!frostSig || frostSig.length !== 64)
1898
+ return;
1899
+ if (!signerPubkey || signerPubkey.length !== 32)
1900
+ return;
1901
+ if (!this.#thresholdSigner)
1902
+ return;
1903
+ // Prefer stored sealVerifiedData leafCount (same as #handleFrostSealed) so verification
1904
+ // uses the count from the FROST ceremony even if local_tree_leaves is incomplete.
1905
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
1906
+ const leafCount = sealVerifiedEntry?.leafCount ?? session.local_tree_leaves.length;
1907
+ // M-003: close_timestamp must be set (stored during bilateral fallback from seal_verified).
1908
+ // Without it we cannot reconstruct the exact TBS and verification would be unsound.
1909
+ const closeTimestamp = session.close_timestamp ?? sealVerifiedEntry?.timestamp;
1910
+ if (closeTimestamp === undefined) {
1911
+ console.warn(`[cello-client] session_frost_sealed: no close_timestamp for ${sessionIdHex}, cannot verify TBS`);
1912
+ return;
1913
+ }
1914
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, closeTimestamp);
1915
+ const isFrostInitiator = this.#frostCeremonyParticipant.has(sessionIdHex);
1916
+ let verifyKey;
1917
+ if (isFrostInitiator) {
1918
+ if (!this.#myPrimaryPubkey)
1919
+ return;
1920
+ verifyKey = this.#myPrimaryPubkey;
1921
+ }
1922
+ else {
1923
+ verifyKey = signerPubkey;
1924
+ }
1925
+ if (!this.#thresholdSigner.verifySignature(frostSig, tbs, "cello-frost-seal-v1", verifyKey)) {
1926
+ console.warn(`[cello-client] session_frost_sealed: seal_signature_invalid on session ${sessionIdHex}`);
1927
+ return;
1928
+ }
1929
+ // AC-004: update session from bilateral to frost
1930
+ session.status = "sealed";
1931
+ session.sealed_root = sealedRoot;
1932
+ session.frost_signature = frostSig;
1933
+ session.signer_pubkey = signerPubkey;
1934
+ session.seal_type = "frost";
1935
+ }
1936
+ closeSession(sessionIdHex) {
1937
+ this.#sessions.delete(sessionIdHex);
1938
+ const ackResolve = this.#pendingAckResolvers.get(sessionIdHex);
1939
+ if (ackResolve) {
1940
+ this.#pendingAckResolvers.delete(sessionIdHex);
1941
+ ackResolve({ ok: false, reason: "session_closed" });
1942
+ }
1943
+ this.#relayRecvSeq.delete(sessionIdHex);
1944
+ this.#readyQueue.delete(sessionIdHex);
1945
+ this.#pendingS2.delete(sessionIdHex);
1946
+ this.#pendingContent.delete(sessionIdHex);
1947
+ this.#ownPendingContent.delete(sessionIdHex);
1948
+ this.#tamperedContentClaims.delete(sessionIdHex);
1949
+ this.#ownEchoResolvers.delete(sessionIdHex);
1950
+ this.#sessionMessageQueues.delete(sessionIdHex);
1951
+ this.#outboundQueues.delete(sessionIdHex);
1952
+ this.#sealInitiatedSessions.delete(sessionIdHex);
1953
+ this.#frostCeremonyParticipant.delete(sessionIdHex);
1954
+ // Resolve seal-frost-timeout waiter so initiateSessionSeal doesn't hang
1955
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
1956
+ this.#sealFrostResolvers.delete(sessionIdHex);
1957
+ this.#sealVerifiedData.delete(sessionIdHex);
1958
+ const stream = this.#relayStreams.get(sessionIdHex);
1959
+ if (stream) {
1960
+ this.#relayStreams.delete(sessionIdHex);
1961
+ stream.abort(new Error("session_closed"));
1962
+ }
1963
+ const dirStream = this.#directoryStreams.get(sessionIdHex);
1964
+ if (dirStream) {
1965
+ this.#directoryStreams.delete(sessionIdHex);
1966
+ dirStream.abort(new Error("session_closed"));
1967
+ }
1968
+ }
1969
+ // ─── Relay stream reader (MSG-004) ───────────────────────────────────────────
1970
+ async #runRelayStreamReader(sessionIdHex, stream, myPubkeyHex, iter) {
1971
+ // Use the iter created by #performRelayAuth so there is never a second lp.decode
1972
+ // iterator on this stream. If iter is absent (future callers), create one now.
1973
+ const source = iter ?? lp.decode(stream)[Symbol.asyncIterator]();
1974
+ try {
1975
+ while (true) {
1976
+ let result;
1977
+ try {
1978
+ result = await source.next();
1979
+ }
1980
+ catch {
1981
+ break;
1982
+ }
1983
+ if (result.done || result.value === undefined)
1984
+ break;
1985
+ const bytes = toU8(result.value);
1986
+ let frame;
1987
+ try {
1988
+ frame = decode(bytes);
1989
+ }
1990
+ catch {
1991
+ // CBOR decode failure = transport corruption (not adversarial injection — the relay
1992
+ // stream is authenticated). We skip the frame and let the sequence-gap check in
1993
+ // #handleInboundLeafDeliver detect the missing sequence number on the next valid frame.
1994
+ continue;
1995
+ }
1996
+ if (frame["type"] === "hash_submit_ack") {
1997
+ const resolve = this.#pendingAckResolvers.get(sessionIdHex);
1998
+ if (resolve) {
1999
+ this.#pendingAckResolvers.delete(sessionIdHex);
2000
+ const seqNum = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : 0;
2001
+ resolve({ ok: true, sequence_number: seqNum });
2002
+ }
2003
+ }
2004
+ else if (frame["type"] === "hash_submit_error") {
2005
+ const resolve = this.#pendingAckResolvers.get(sessionIdHex);
2006
+ if (resolve) {
2007
+ this.#pendingAckResolvers.delete(sessionIdHex);
2008
+ resolve({ ok: false, reason: String(frame["reason"] ?? "unknown") });
2009
+ }
2010
+ }
2011
+ else if (frame["type"] === "leaf_deliver") {
2012
+ const deliverSessionIdRaw = frame["session_id"];
2013
+ const deliverSessionId = deliverSessionIdRaw instanceof Uint8Array ? deliverSessionIdRaw
2014
+ : Buffer.isBuffer(deliverSessionIdRaw) ? new Uint8Array(deliverSessionIdRaw) : null;
2015
+ const targetSessionHex = deliverSessionId
2016
+ ? Buffer.from(deliverSessionId).toString("hex")
2017
+ : sessionIdHex;
2018
+ this.#handleInboundLeafDeliver(targetSessionHex, frame, myPubkeyHex);
2019
+ }
2020
+ else if (frame["type"] === "gap_fill_response") {
2021
+ // PERSIST-014: reconciliation response — verify leaves, advance tree, retry seal
2022
+ this.#handleGapFillResponse(sessionIdHex, frame);
2023
+ }
2024
+ else if (frame["type"] === "gap_fill_error") {
2025
+ // PERSIST-014: relay could not serve gap-fill leaves
2026
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
2027
+ const pendingReconciliation = this.#pendingGapFillResolvers.get(sessionIdHex);
2028
+ if (pendingReconciliation) {
2029
+ this.#pendingGapFillResolvers.delete(sessionIdHex);
2030
+ pendingReconciliation({ ok: false, reason });
2031
+ }
2032
+ }
2033
+ }
2034
+ }
2035
+ catch {
2036
+ // Stream closed — relay disconnected (SESSION-006 DB-001)
2037
+ }
2038
+ // Relay stream disconnected: mark relay stream null so sendMessage returns transport_unavailable
2039
+ if (this.#relayStreams.get(sessionIdHex) === stream) {
2040
+ this.#relayStreams.delete(sessionIdHex);
2041
+ }
2042
+ // SESSION-006 AC-001: transition session to transport_lost and unblock pending sends
2043
+ this.#onRelayDisconnect(sessionIdHex, myPubkeyHex);
2044
+ }
2045
+ /**
2046
+ * SESSION-006 AC-001: Called when the relay stream closes unexpectedly.
2047
+ *
2048
+ * Pseudocode (Phase P):
2049
+ * 1. If session not found or already desynchronized → nothing to do
2050
+ * 2. If session already transport_lost → already handling (reconnect in progress)
2051
+ * 3. Mark session status = "transport_lost"
2052
+ * 4. Unblock any pending ack resolver with transport_unavailable
2053
+ * 5. Start reconnect loop (returns immediately, runs asynchronously)
2054
+ */
2055
+ #onRelayDisconnect(sessionIdHex, myPubkeyHex) {
2056
+ const session = this.#sessions.get(sessionIdHex);
2057
+ if (!session)
2058
+ return;
2059
+ if (session.desynchronized)
2060
+ return;
2061
+ if (session.status === "transport_lost")
2062
+ return; // already reconnecting
2063
+ // Sealing/sealed/seal_rejected/seal_deferred: relay stream closing is expected; do not clobber status.
2064
+ // In SESSION-003/005, the relay triggers processSeal → confirmSeal which destroys the relay session.
2065
+ // The client's sealing→sealed transition is driven by the directory signaling stream, not relay.
2066
+ if (session.status === "sealing" || session.status === "sealed" || session.status === "seal_rejected" || session.status === "seal_deferred")
2067
+ return;
2068
+ // Mark session transport_lost (SESSION-006 AC-001)
2069
+ session.status = "transport_lost";
2070
+ // Unblock any waiting sendMessage calls with transport_unavailable
2071
+ const ackResolve = this.#pendingAckResolvers.get(sessionIdHex);
2072
+ if (ackResolve) {
2073
+ this.#pendingAckResolvers.delete(sessionIdHex);
2074
+ ackResolve({ ok: false, reason: "transport_unavailable" });
2075
+ }
2076
+ // Start reconnect loop asynchronously (SESSION-006 AC-002, DB-001, AC-003)
2077
+ void this.#reconnectRelayStream(sessionIdHex, myPubkeyHex);
2078
+ }
2079
+ /**
2080
+ * SESSION-006 reconnect loop with exponential backoff.
2081
+ *
2082
+ * Pseudocode (Phase P):
2083
+ * deadline = now + reconnectTimeoutMs
2084
+ * backoff = RECONNECT_INITIAL_BACKOFF_MS
2085
+ * while now < deadline:
2086
+ * try:
2087
+ * newStream = dial relay endpoint from cached SessionAssignment
2088
+ * complete "CELLO-RELAY-AUTH-v1" challenge-response
2089
+ * await relay_auth_ok
2090
+ * relay sends queued leaf_deliver frames (already implemented in NODE-002)
2091
+ * update relayStreams[sessionIdHex] = newStream
2092
+ * start new #runRelayStreamReader loop (passing auth iter)
2093
+ * mark session status = "active"
2094
+ * return
2095
+ * catch:
2096
+ * wait backoff ms
2097
+ * backoff = min(backoff * 2, RECONNECT_MAX_BACKOFF_MS)
2098
+ * // timeout elapsed
2099
+ * session stays "transport_lost" permanently (AC-003)
2100
+ */
2101
+ async #reconnectRelayStream(sessionIdHex, myPubkeyHex) {
2102
+ // Guard: only one reconnect loop per session at a time
2103
+ if (this.#reconnectInProgress.has(sessionIdHex))
2104
+ return;
2105
+ this.#reconnectInProgress.add(sessionIdHex);
2106
+ const deadline = Date.now() + this.#reconnectTimeoutMs;
2107
+ let backoff = RECONNECT_INITIAL_BACKOFF_MS;
2108
+ try {
2109
+ while (Date.now() < deadline) {
2110
+ const session = this.#sessions.get(sessionIdHex);
2111
+ // Stop if session was closed, desynchronized, or sealed during reconnect
2112
+ if (!session || session.desynchronized)
2113
+ return;
2114
+ if (session.status !== "transport_lost")
2115
+ return;
2116
+ try {
2117
+ const relayPeerId = session.relay_endpoint.peer_id;
2118
+ const relayMultiaddr = session.relay_endpoint.multiaddrs[0];
2119
+ if (relayMultiaddr) {
2120
+ try {
2121
+ await this.#node.dial(relayMultiaddr);
2122
+ }
2123
+ catch { /* already connected or not yet reachable */ }
2124
+ }
2125
+ let newStream;
2126
+ try {
2127
+ newStream = await this.#node.newStream(relayPeerId, RELAY_PROTOCOL_ID);
2128
+ }
2129
+ catch {
2130
+ throw new Error("relay_unreachable");
2131
+ }
2132
+ // Complete relay auth challenge-response (CELLO-RELAY-AUTH-v1)
2133
+ const myPubkey = Buffer.from(myPubkeyHex, "hex");
2134
+ const authResult = await this.#performRelayAuth(newStream, myPubkey);
2135
+ if (!authResult.ok) {
2136
+ newStream.abort(new Error("auth_failed"));
2137
+ throw new Error("relay_auth_failed");
2138
+ }
2139
+ // Re-check session state after async auth
2140
+ const sessionAfterAuth = this.#sessions.get(sessionIdHex);
2141
+ if (!sessionAfterAuth || sessionAfterAuth.desynchronized) {
2142
+ newStream.abort(new Error("session_gone"));
2143
+ return;
2144
+ }
2145
+ // Reconnect succeeded: install new stream and resume
2146
+ this.#relayStreams.set(sessionIdHex, newStream);
2147
+ sessionAfterAuth.status = "active";
2148
+ // Start the new reader loop (authResult.iter is the continuation iterator)
2149
+ void this.#runRelayStreamReader(sessionIdHex, newStream, myPubkeyHex, authResult.iter);
2150
+ return;
2151
+ }
2152
+ catch {
2153
+ // Reconnect attempt failed — wait with exponential backoff
2154
+ const remaining = deadline - Date.now();
2155
+ if (remaining <= 0)
2156
+ break;
2157
+ const waitMs = Math.min(backoff, remaining);
2158
+ await new Promise((r) => setTimeout(r, waitMs));
2159
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_BACKOFF_MS);
2160
+ }
2161
+ }
2162
+ // Timeout elapsed: session remains transport_lost permanently (AC-003)
2163
+ // No further state change needed — session.status is already "transport_lost"
2164
+ }
2165
+ finally {
2166
+ this.#reconnectInProgress.delete(sessionIdHex);
2167
+ }
2168
+ }
2169
+ // Internal test escape: forcibly close the relay stream for a session to simulate disconnect.
2170
+ // SESSION-006: used by AC-001, AC-002, DB-001 tests.
2171
+ injectRelayDisconnect(sessionIdHex) {
2172
+ const stream = this.#relayStreams.get(sessionIdHex);
2173
+ const myPubkeyHex = this.#myPubkeyHex;
2174
+ if (!myPubkeyHex)
2175
+ return;
2176
+ // Abort the stream if it exists (this will cause #runRelayStreamReader to exit)
2177
+ if (stream) {
2178
+ this.#relayStreams.delete(sessionIdHex);
2179
+ try {
2180
+ stream.abort(new Error("test_inject_disconnect"));
2181
+ }
2182
+ catch { /* ignore */ }
2183
+ }
2184
+ // Directly trigger the disconnect handler
2185
+ this.#onRelayDisconnect(sessionIdHex, myPubkeyHex);
2186
+ }
2187
+ #handleInboundLeafDeliver(sessionIdHex, frame, myPubkeyHex) {
2188
+ const session = this.#sessions.get(sessionIdHex);
2189
+ if (!session || session.desynchronized)
2190
+ return;
2191
+ // Decode Structure 2 CBOR
2192
+ const s2CborRaw = frame["structure2_cbor"];
2193
+ const s2Cbor = s2CborRaw instanceof Uint8Array ? s2CborRaw
2194
+ : Buffer.isBuffer(s2CborRaw) ? new Uint8Array(s2CborRaw) : null;
2195
+ if (!s2Cbor) {
2196
+ this.#desync(sessionIdHex, "structure2_malformed");
2197
+ return;
2198
+ }
2199
+ let s2Arr;
2200
+ try {
2201
+ const decoded = decode(s2Cbor);
2202
+ if (!Array.isArray(decoded) || decoded.length !== 6) {
2203
+ this.#desync(sessionIdHex, "structure2_malformed");
2204
+ return;
2205
+ }
2206
+ s2Arr = decoded;
2207
+ }
2208
+ catch {
2209
+ this.#desync(sessionIdHex, "structure2_malformed");
2210
+ return;
2211
+ }
2212
+ // Extract Structure 2 fields: [seq, sender_pubkey, content_hash, sender_sig, scan_result, prev_root]
2213
+ const seqNum = typeof s2Arr[0] === "number" ? s2Arr[0] : null;
2214
+ if (seqNum === null) {
2215
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
2216
+ return;
2217
+ }
2218
+ const senderPubkey = toU8Safe(s2Arr[1]);
2219
+ const contentHash = toU8Safe(s2Arr[2]);
2220
+ const senderSig = toU8Safe(s2Arr[3]);
2221
+ // s2Arr[4] = scan_result sentinel (ignored in M1)
2222
+ const prevRoot = toU8Safe(s2Arr[5]);
2223
+ if (!senderPubkey || senderPubkey.length !== 32) {
2224
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
2225
+ return;
2226
+ }
2227
+ if (!contentHash || contentHash.length !== 32) {
2228
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
2229
+ return;
2230
+ }
2231
+ if (!senderSig || senderSig.length !== 64) {
2232
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
2233
+ return;
2234
+ }
2235
+ if (!prevRoot || prevRoot.length !== 32) {
2236
+ this.#desync(sessionIdHex, "structure2_fields_invalid");
2237
+ return;
2238
+ }
2239
+ const s2 = {
2240
+ sequence_number: seqNum,
2241
+ sender_pubkey: senderPubkey,
2242
+ content_hash: contentHash,
2243
+ sender_signature: senderSig,
2244
+ scan_result: { score: null, verdict: "unscanned", model_hash: new Uint8Array(32) },
2245
+ prev_root: prevRoot,
2246
+ };
2247
+ // Sequence check against relay-delivery counter (independent of cross-check completion).
2248
+ // The relay sends leaves in strict monotonic order; any deviation is an integrity violation.
2249
+ const relayRecvSeq = this.#relayRecvSeq.get(sessionIdHex) ?? 0;
2250
+ if (seqNum <= relayRecvSeq) {
2251
+ this.#desync(sessionIdHex, "sequence_replay");
2252
+ return;
2253
+ }
2254
+ if (seqNum > relayRecvSeq + 1) {
2255
+ this.#desync(sessionIdHex, "sequence_gap");
2256
+ return;
2257
+ }
2258
+ this.#relayRecvSeq.set(sessionIdHex, seqNum);
2259
+ // Decode Structure 1 from frame.structure1_cbor for sig verify and causal check
2260
+ const s1CborRaw = frame["structure1_cbor"];
2261
+ const s1Cbor = s1CborRaw instanceof Uint8Array ? s1CborRaw
2262
+ : Buffer.isBuffer(s1CborRaw) ? new Uint8Array(s1CborRaw) : null;
2263
+ if (!s1Cbor) {
2264
+ this.#desync(sessionIdHex, "structure1_malformed");
2265
+ return;
2266
+ }
2267
+ let s1Fields = null;
2268
+ try {
2269
+ const s1Decoded = decode(s1Cbor);
2270
+ if (Array.isArray(s1Decoded) && s1Decoded.length === 6) {
2271
+ const lss = s1Decoded[4];
2272
+ const ts = s1Decoded[5];
2273
+ if (typeof lss === "number" && (typeof ts === "number" || typeof ts === "bigint")) {
2274
+ s1Fields = { last_seen_seq: lss, timestamp: ts };
2275
+ }
2276
+ }
2277
+ }
2278
+ catch { /* fall through */ }
2279
+ if (!s1Fields) {
2280
+ this.#desync(sessionIdHex, "structure1_malformed");
2281
+ return;
2282
+ }
2283
+ // Verify Ed25519 signature over the exact Structure 1 CBOR bytes the sender signed.
2284
+ // We must NOT re-encode from decoded fields — cbor-x may change timestamp representation
2285
+ // (e.g. number→float64 vs the original uint64), breaking signature verification.
2286
+ // The relay stores and forwards the original structure1_cbor unchanged, so s1Cbor
2287
+ // here is byte-identical to what the sender signed.
2288
+ if (!verify(senderPubkey, s1Cbor, s2.sender_signature)) {
2289
+ this.#desync(sessionIdHex, "signature_verification_failed");
2290
+ return;
2291
+ }
2292
+ // Determine if this is our own send
2293
+ const senderHex = Buffer.from(senderPubkey).toString("hex");
2294
+ const isOwnSend = senderHex === myPubkeyHex;
2295
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
2296
+ const leafKind = typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : 0x00;
2297
+ // Check if a tampered content frame arrived earlier claiming this hash.
2298
+ // A tampered frame has declared_hash = contentHashHex but bytes that don't verify —
2299
+ // the content path already flagged this as a mismatch attempt.
2300
+ const tamperedClaims = this.#tamperedContentClaims.get(sessionIdHex);
2301
+ if (tamperedClaims?.has(contentHashHex)) {
2302
+ tamperedClaims.delete(contentHashHex);
2303
+ this.#desync(sessionIdHex, "content_hash_mismatch");
2304
+ return;
2305
+ }
2306
+ // Check if matching content already arrived.
2307
+ // Own-send echoes look up #ownPendingContent (pre-buffered by #sendMessageLocked).
2308
+ // Counterparty sends look up #pendingContent (content arrived via content path).
2309
+ // Keeping them separate avoids collision when both sides send identical byte payloads.
2310
+ const ownPendingContent = isOwnSend ? this.#ownPendingContent.get(sessionIdHex) : undefined;
2311
+ const pendingContent = this.#pendingContent.get(sessionIdHex);
2312
+ const contentEntry = isOwnSend
2313
+ ? ownPendingContent?.get(contentHashHex)
2314
+ : pendingContent?.get(contentHashHex);
2315
+ // Retrieve echo resolver now (before cross-check where seq is consumed)
2316
+ let echoResolve;
2317
+ if (isOwnSend) {
2318
+ const resolvers = this.#ownEchoResolvers.get(sessionIdHex);
2319
+ echoResolve = resolvers?.get(seqNum);
2320
+ resolvers?.delete(seqNum);
2321
+ }
2322
+ if (contentEntry) {
2323
+ if (isOwnSend) {
2324
+ ownPendingContent.delete(contentHashHex);
2325
+ }
2326
+ else {
2327
+ pendingContent.delete(contentHashHex);
2328
+ }
2329
+ this.#crossCheckDelivery(sessionIdHex, s2, s2Cbor, s1Fields, leafKind, contentEntry.content_bytes, isOwnSend, echoResolve);
2330
+ }
2331
+ else {
2332
+ // S2 arrived before content — buffer and start 30s grace timer
2333
+ const timerHandle = setTimeout(() => {
2334
+ const ps2Map = this.#pendingS2.get(sessionIdHex);
2335
+ if (ps2Map?.has(contentHashHex)) {
2336
+ ps2Map.delete(contentHashHex);
2337
+ this.#desync(sessionIdHex, "content_missing");
2338
+ }
2339
+ }, this.#contentGraceMs);
2340
+ const entry = {
2341
+ s2,
2342
+ s2_cbor: s2Cbor,
2343
+ s1_fields: s1Fields,
2344
+ leaf_kind: leafKind,
2345
+ sequence_number: seqNum,
2346
+ content_hash: contentHash,
2347
+ is_own_send: isOwnSend,
2348
+ arrived_at: Date.now(),
2349
+ timer_handle: timerHandle,
2350
+ echo_resolve: echoResolve,
2351
+ };
2352
+ this.#pendingS2.get(sessionIdHex)?.set(contentHashHex, entry);
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Called when BOTH S2 (relay path) and content (content path) have arrived for a leaf.
2357
+ * Enqueues the entry in the ready queue keyed by seqNum, then drains in-order.
2358
+ * This defers prevRoot and causal checks until all prior leaves are confirmed, avoiding
2359
+ * false prev_root_mismatch when content for seq N-1 hasn't arrived yet.
2360
+ */
2361
+ #crossCheckDelivery(sessionIdHex, s2, s2Cbor, s1Fields, leafKind, contentBytes, isOwnSend, echoResolve) {
2362
+ const session = this.#sessions.get(sessionIdHex);
2363
+ if (!session || session.desynchronized)
2364
+ return;
2365
+ const readyQ = this.#readyQueue.get(sessionIdHex);
2366
+ if (!readyQ)
2367
+ return;
2368
+ readyQ.set(s2.sequence_number, { s2, s2_cbor: s2Cbor, s1_fields: s1Fields, leaf_kind: leafKind, content_bytes: contentBytes, is_own_send: isOwnSend, echo_resolve: echoResolve });
2369
+ this.#drainReadyQueue(sessionIdHex);
2370
+ }
2371
+ #drainReadyQueue(sessionIdHex) {
2372
+ const session = this.#sessions.get(sessionIdHex);
2373
+ const readyQ = this.#readyQueue.get(sessionIdHex);
2374
+ if (!session || !readyQ || session.desynchronized)
2375
+ return;
2376
+ while (true) {
2377
+ // next_expected_seq is the next seqNum to process (starts at 1)
2378
+ const nextSeq = session.next_expected_seq;
2379
+ const entry = readyQ.get(nextSeq);
2380
+ if (!entry)
2381
+ break; // not ready yet — wait for content to arrive
2382
+ readyQ.delete(nextSeq);
2383
+ const { s2, s2_cbor, s1_fields, leaf_kind, content_bytes, is_own_send, echo_resolve } = entry;
2384
+ // Verify prev_root now that local_tree_leaves has all leaves 1..(nextSeq-1)
2385
+ const expectedPrevRoot = session.local_tree_leaves.length === 0
2386
+ ? session.genesis_prev_root
2387
+ : (() => {
2388
+ const inputs = session.local_tree_leaves.map(l => ({
2389
+ kind: l.kind,
2390
+ data: l.s2_cbor,
2391
+ }));
2392
+ return merkleRoot(buildMerkleTree(inputs));
2393
+ })();
2394
+ if (Buffer.compare(Buffer.from(s2.prev_root), Buffer.from(expectedPrevRoot)) !== 0) {
2395
+ this.#desync(sessionIdHex, "prev_root_mismatch");
2396
+ return;
2397
+ }
2398
+ // Causal chain check per MSG-004 AC-008: sender's last_seen_seq = sender's highest
2399
+ // observed counterparty seq. Receiver checks: that value can't exceed the receiver's
2400
+ // own highest echoed seq (last_sent_seq), since the sender can only have seen leaves
2401
+ // the receiver actually sent.
2402
+ // Skip this check for own-send echoes: the claim "I've seen N of B's leaves" was
2403
+ // made at send-time when we knew our own view of B's sends. We trust our own sends;
2404
+ // there's no injection risk here, and applying the check would fire a false desync
2405
+ // when our own SEAL echo arrives before all counterparty echoes complete.
2406
+ if (!is_own_send && s1_fields.last_seen_seq > session.last_sent_seq) {
2407
+ this.#desync(sessionIdHex, "sequence_causal_inconsistency");
2408
+ return;
2409
+ }
2410
+ const kind = leaf_kind === 0x02 ? "ctrl" : "msg";
2411
+ // Append to local tree
2412
+ session.local_tree_leaves.push({ kind, s2_cbor });
2413
+ session.next_expected_seq += 1;
2414
+ // Compute leaf hash: SHA-256(leaf_kind_byte || s2_cbor) per MERKLE-001
2415
+ const leafHash = new Uint8Array(createHash("sha256").update(new Uint8Array([leaf_kind])).update(s2_cbor).digest());
2416
+ if (is_own_send) {
2417
+ // Own-send echo: fire the send lock release and advance own-seq tracker.
2418
+ // Do NOT enqueue into receiveMessage queues — callers don't "receive" their own sends.
2419
+ session.last_sent_seq = s2.sequence_number;
2420
+ echo_resolve?.();
2421
+ }
2422
+ else {
2423
+ // last_seen_seq = highest counterparty relay seq confirmed on this session.
2424
+ // Per MSG-004: TBS last_seen_seq must equal the highest relay seq from the *counterparty*,
2425
+ // so the causal-chain check at seal can verify we couldn't have seen msgs that don't exist.
2426
+ session.last_seen_seq = s2.sequence_number;
2427
+ if (kind === "ctrl" && session.status === "active") {
2428
+ // SESSION-003: non-initiator auto-response to counterparty's SEAL leaf.
2429
+ // Transition to sealing and submit our own SEAL leaf asynchronously.
2430
+ session.status = "sealing";
2431
+ void this.#submitSealLeaf(sessionIdHex, session, "responder").then((result) => {
2432
+ if (!result.ok) {
2433
+ const s = this.#sessions.get(sessionIdHex);
2434
+ if (s && s.status === "sealing")
2435
+ s.status = "seal_rejected";
2436
+ }
2437
+ });
2438
+ }
2439
+ else {
2440
+ // Counterparty message: enqueue for receiveMessage callers.
2441
+ const msg = {
2442
+ type: "message",
2443
+ content: content_bytes,
2444
+ senderPubkey: s2.sender_pubkey,
2445
+ sequenceNumber: s2.sequence_number,
2446
+ leafHash,
2447
+ };
2448
+ this.#sessionMessageQueues.get(sessionIdHex)?.push(msg);
2449
+ this.#anyMessageQueue.push({ sessionIdHex, message: msg });
2450
+ // SESSION-007: wake any blocked receiveSessionMessageAsync / receiveMessageAsync callers.
2451
+ this.#wakeReceiveWaiters(sessionIdHex);
2452
+ }
2453
+ }
2454
+ }
2455
+ }
2456
+ #desync(sessionIdHex, reason) {
2457
+ const session = this.#sessions.get(sessionIdHex);
2458
+ if (!session)
2459
+ return;
2460
+ session.desynchronized = true;
2461
+ // Cancel pending S2 timers AND fire any migrated echo resolvers
2462
+ const ps2 = this.#pendingS2.get(sessionIdHex);
2463
+ if (ps2) {
2464
+ for (const entry of ps2.values()) {
2465
+ clearTimeout(entry.timer_handle);
2466
+ entry.echo_resolve?.();
2467
+ }
2468
+ ps2.clear();
2469
+ }
2470
+ // Fire remaining own_echo_resolvers (unblock waiting sendMessage calls)
2471
+ const resolvers = this.#ownEchoResolvers.get(sessionIdHex);
2472
+ if (resolvers) {
2473
+ for (const resolve of resolvers.values()) {
2474
+ resolve();
2475
+ }
2476
+ resolvers.clear();
2477
+ }
2478
+ this.#pendingContent.get(sessionIdHex)?.clear();
2479
+ this.#ownPendingContent.get(sessionIdHex)?.clear();
2480
+ this.#relayRecvSeq.delete(sessionIdHex);
2481
+ this.#readyQueue.get(sessionIdHex)?.clear();
2482
+ // Unblock any pending ack resolver
2483
+ const ackResolve = this.#pendingAckResolvers.get(sessionIdHex);
2484
+ if (ackResolve) {
2485
+ this.#pendingAckResolvers.delete(sessionIdHex);
2486
+ ackResolve({ ok: false, reason });
2487
+ }
2488
+ console.warn(`[cello-client] session_desynchronized: ${sessionIdHex} reason=${reason}`);
2489
+ }
2490
+ // ─── Content frame handler (MSG-004) ─────────────────────────────────────────
2491
+ async #handleContentStream(stream) {
2492
+ let payload;
2493
+ try {
2494
+ for await (const chunk of lp.decode(stream)) {
2495
+ payload = toU8(chunk);
2496
+ break; // one frame per stream
2497
+ }
2498
+ }
2499
+ catch {
2500
+ stream.abort(new Error("content_stream_error"));
2501
+ return;
2502
+ }
2503
+ if (!payload) {
2504
+ stream.close().catch(() => { });
2505
+ return;
2506
+ }
2507
+ let frame;
2508
+ try {
2509
+ frame = decode(payload);
2510
+ }
2511
+ catch {
2512
+ stream.close().catch(() => { });
2513
+ return;
2514
+ }
2515
+ if (frame["type"] !== "content_frame") {
2516
+ stream.close().catch(() => { });
2517
+ return;
2518
+ }
2519
+ const sessionIdRaw = frame["session_id"];
2520
+ const sessionIdBytes = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
2521
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
2522
+ if (!sessionIdBytes) {
2523
+ stream.close().catch(() => { });
2524
+ return;
2525
+ }
2526
+ const sessionIdHex = Buffer.from(sessionIdBytes).toString("hex");
2527
+ const session = this.#sessions.get(sessionIdHex);
2528
+ if (!session || session.desynchronized) {
2529
+ stream.close().catch(() => { });
2530
+ return;
2531
+ }
2532
+ const contentBytesRaw = frame["content_bytes"];
2533
+ const contentBytes = contentBytesRaw instanceof Uint8Array ? contentBytesRaw
2534
+ : Buffer.isBuffer(contentBytesRaw) ? new Uint8Array(contentBytesRaw) : null;
2535
+ if (!contentBytes) {
2536
+ stream.close().catch(() => { });
2537
+ return;
2538
+ }
2539
+ const declaredHashRaw = frame["content_hash"];
2540
+ const declaredHash = declaredHashRaw instanceof Uint8Array ? declaredHashRaw
2541
+ : Buffer.isBuffer(declaredHashRaw) ? new Uint8Array(declaredHashRaw) : null;
2542
+ if (!declaredHash || declaredHash.length !== 32) {
2543
+ stream.close().catch(() => { });
2544
+ return;
2545
+ }
2546
+ const declaredHashHex = Buffer.from(declaredHash).toString("hex");
2547
+ // If S2 is already buffered, we know the leaf_kind and can verify the hash immediately.
2548
+ // leaf_kind may be 0x00 (msg) or 0x02 (ctrl/SEAL) — the sender uses it in SHA-256(kind||bytes).
2549
+ // If S2 has not yet arrived, we cannot know the leaf_kind and must defer verification:
2550
+ // store the content and let the S2-arrival path (which has leaf_kind) verify it then.
2551
+ const ps2MapEarly = this.#pendingS2.get(sessionIdHex);
2552
+ const s2EntryEarly = ps2MapEarly?.get(declaredHashHex);
2553
+ if (s2EntryEarly) {
2554
+ // S2 already buffered — verify hash immediately using known leaf_kind.
2555
+ const recomputed = new Uint8Array(createHash("sha256").update(new Uint8Array([s2EntryEarly.leaf_kind])).update(contentBytes).digest());
2556
+ if (Buffer.compare(Buffer.from(recomputed), Buffer.from(declaredHash)) !== 0) {
2557
+ clearTimeout(s2EntryEarly.timer_handle);
2558
+ ps2MapEarly.delete(declaredHashHex);
2559
+ this.#desync(sessionIdHex, "content_hash_mismatch");
2560
+ stream.close().catch(() => { });
2561
+ return;
2562
+ }
2563
+ }
2564
+ else {
2565
+ // S2 not yet buffered — try both possible leaf_kind values: 0x00 (msg) and 0x02 (ctrl/SEAL).
2566
+ // If neither hash matches the declared hash, the frame is tampered; flag it for desync
2567
+ // when the corresponding S2 arrives.
2568
+ const msgHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest());
2569
+ const ctrlHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x02])).update(contentBytes).digest());
2570
+ const matchesMsg = Buffer.compare(Buffer.from(msgHash), Buffer.from(declaredHash)) === 0;
2571
+ const matchesCtrl = Buffer.compare(Buffer.from(ctrlHash), Buffer.from(declaredHash)) === 0;
2572
+ if (!matchesMsg && !matchesCtrl) {
2573
+ this.#tamperedContentClaims.get(sessionIdHex)?.add(declaredHashHex);
2574
+ stream.close().catch(() => { });
2575
+ return;
2576
+ }
2577
+ }
2578
+ const contentHashHex = declaredHashHex;
2579
+ // Check if matching S2 is already buffered
2580
+ const ps2Map = this.#pendingS2.get(sessionIdHex);
2581
+ const s2Entry = ps2Map?.get(contentHashHex);
2582
+ if (s2Entry) {
2583
+ ps2Map.delete(contentHashHex);
2584
+ clearTimeout(s2Entry.timer_handle);
2585
+ this.#crossCheckDelivery(sessionIdHex, s2Entry.s2, s2Entry.s2_cbor, s2Entry.s1_fields, s2Entry.leaf_kind, contentBytes, s2Entry.is_own_send, s2Entry.echo_resolve);
2586
+ }
2587
+ else {
2588
+ // Content arrived before S2 — buffer it (no timer per AC-010)
2589
+ const pending = this.#pendingContent.get(sessionIdHex);
2590
+ if (pending) {
2591
+ if (pending.size >= PENDING_CONTENT_BOUND) {
2592
+ // Evict oldest entry (FIFO) per pseudocode M-1 fix
2593
+ const firstKey = pending.keys().next().value;
2594
+ if (firstKey !== undefined)
2595
+ pending.delete(firstKey);
2596
+ }
2597
+ pending.set(contentHashHex, { content_bytes: contentBytes, arrived_at: Date.now() });
2598
+ console.debug(`[cello-client] content_without_hash: session=${sessionIdHex} hash=${contentHashHex}`);
2599
+ }
2600
+ }
2601
+ stream.close().catch(() => { });
2602
+ }
2603
+ /**
2604
+ * Complete relay challenge-response auth on an open stream.
2605
+ * Returns ok:true plus the stream iterator on success, ok:false with reason on rejection.
2606
+ * The caller MUST pass the returned iterator to #runRelayStreamReader — creating a second
2607
+ * lp.decode iterator on the same stream causes frame-stealing between the two readers.
2608
+ *
2609
+ * Auth signature: Ed25519(SHA-256("CELLO-RELAY-AUTH-v1" || nonce || pubkey), privkey)
2610
+ * per RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
2611
+ *
2612
+ * Protocol: relay sends relay_auth_challenge immediately on connect.
2613
+ * We read it, sign the nonce, send relay_auth_response.
2614
+ * On auth failure, relay sends relay_auth_failed then aborts the stream.
2615
+ * On success, relay sends relay_auth_ok then waits for hash_submit frames.
2616
+ * Protocol: challenge → response → relay_auth_ok | relay_auth_failed
2617
+ */
2618
+ async #performRelayAuth(stream, myPubkey) {
2619
+ // Create exactly ONE iterator for this stream's lifetime — never create a second one.
2620
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
2621
+ // Read challenge frame — bounded by RELAY_AUTH_TIMEOUT_MS to prevent indefinite hang
2622
+ // if a misbehaving relay sends the challenge but never sends relay_auth_ok.
2623
+ const { value: challengeRaw, done } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
2624
+ if (done || challengeRaw === undefined) {
2625
+ return { ok: false, reason: "relay_auth_error" };
2626
+ }
2627
+ const challengeBytes = toU8(challengeRaw);
2628
+ let challenge;
2629
+ try {
2630
+ challenge = decode(challengeBytes);
2631
+ }
2632
+ catch {
2633
+ return { ok: false, reason: "relay_auth_error" };
2634
+ }
2635
+ if (challenge["type"] !== "relay_auth_challenge") {
2636
+ return { ok: false, reason: "relay_auth_error" };
2637
+ }
2638
+ const nonce = toU8(challenge["nonce"]);
2639
+ if (nonce.length !== 32) {
2640
+ return { ok: false, reason: "relay_auth_error" };
2641
+ }
2642
+ // Build and sign auth message
2643
+ const domain = Buffer.from(AUTH_DOMAIN, "utf8");
2644
+ const authMsg = new Uint8Array(Buffer.concat([domain, nonce, myPubkey]));
2645
+ const msgHash = new Uint8Array(createHash("sha256").update(authMsg).digest());
2646
+ const signature = await this.#keyProvider.sign(msgHash);
2647
+ // Send response
2648
+ const responseFrame = CBOR_ENC.encode({
2649
+ type: "relay_auth_response",
2650
+ pubkey: myPubkey,
2651
+ signature,
2652
+ });
2653
+ stream.send(lp.encode.single(responseFrame));
2654
+ // Read relay_auth_ok or relay_auth_failed — bounded by RELAY_AUTH_TIMEOUT_MS.
2655
+ // A relay that sends the challenge but stalls before the ack would otherwise hang here
2656
+ // indefinitely; the timeout causes #performRelayAuth to throw, which the outer reconnect
2657
+ // loop's catch block handles via backoff/deadline logic.
2658
+ const { value: ackRaw, done: ackDone } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
2659
+ if (ackDone || ackRaw === undefined) {
2660
+ return { ok: false, reason: "relay_auth_error" };
2661
+ }
2662
+ let ackFrame;
2663
+ try {
2664
+ ackFrame = decode(toU8(ackRaw));
2665
+ }
2666
+ catch {
2667
+ return { ok: false, reason: "relay_auth_error" };
2668
+ }
2669
+ if (ackFrame["type"] === "relay_auth_failed") {
2670
+ return { ok: false, reason: "relay_auth_failed" };
2671
+ }
2672
+ if (ackFrame["type"] !== "relay_auth_ok") {
2673
+ return { ok: false, reason: "relay_auth_error" };
2674
+ }
2675
+ return { ok: true, iter };
2676
+ }
2677
+ // ─── MSG-002 handlers ─────────────────────────────────────────────────────────
2678
+ async registerHandler() {
2679
+ await this.#node.handle(CELLO_PROTOCOL_ID, (stream) => {
2680
+ void this.#handleInbound(stream);
2681
+ });
2682
+ // ADAPTER-003: if a directory endpoint is configured, pre-authenticate now.
2683
+ // This registers this client's stream with the directory so the directory can
2684
+ // deliver inbound session_assignment frames (participant B role) without waiting
2685
+ // for this client to call initiateSession first.
2686
+ // Awaited here so that by the time registerHandler returns, the stream is established
2687
+ // and the directory knows this client is reachable.
2688
+ // Best-effort: failure is non-fatal (stream will be re-opened on first initiateSession call).
2689
+ if (this.#directoryEndpoint && !this.#persistentSignalingStream) {
2690
+ await this.#openPersistentSignalingStream().catch(() => {
2691
+ // Ignore failure — stream will be opened lazily on first initiateSession call
2692
+ });
2693
+ }
2694
+ }
2695
+ // ─── REG-001: Agent registration ─────────────────────────────────────────────
2696
+ /**
2697
+ * Register this agent with the directory.
2698
+ *
2699
+ * REG-001 Phase P pseudocode:
2700
+ * 1. If already registered, return { error: 'already_registered' }
2701
+ * 2. Generate (or load) ML-DSA-44 keypair (NIST FIPS 204)
2702
+ * - If #mlDsaKeyFile is set: FileMlDsaKeyProvider.load(path) — persists with 0o600
2703
+ * - Otherwise: mlDsaKeygen() → InMemoryMlDsaKeyProvider
2704
+ * 3. Open (or reuse) persistent signaling stream (auth handled inside)
2705
+ * 4. Get myPubkeyHex (Ed25519 K_local)
2706
+ * 5. Send register_request { phone_stub, k_local_pubkey, ml_dsa_pubkey } on signaling stream
2707
+ * 5a. Await dkg_ready { epochId, participants, threshold } (routed by signaling reader)
2708
+ * 5b. Run real FROST DKG ceremony over /cello/frost/1.0.0 streams (RFC 9591)
2709
+ * - Create NetworkDirectoryNode for each directory peer
2710
+ * - runNetworkDkg(agentPubkey, { threshold, participants, directoryNodes })
2711
+ * - Stores client share via storeDkgResult in frost-threshold-signer
2712
+ * 5c. Send dkg_complete { primary_pubkey } on signaling stream
2713
+ * 6. Await register_success or register_error (routed by #runPersistentSignalingReader)
2714
+ * - register_error → return { error: reason }
2715
+ * - register_success → build RegistrationState and cache it
2716
+ * 7. Return RegistrationState
2717
+ *
2718
+ * Crypto refs: NIST FIPS 204 (ML-DSA-44), RFC 9591 (FROST), FIPS 180-4 (SHA-256)
2719
+ */
2720
+ async register(phoneStub, preAuthToken) {
2721
+ // Step 1: already registered — return error
2722
+ if (this.#registrationState) {
2723
+ return { error: "already_registered" };
2724
+ }
2725
+ // Step 2: generate or load ML-DSA-44 keypair (NIST FIPS 204)
2726
+ const mlDsaProvider = this.#mlDsaKeyFile
2727
+ ? await FileMlDsaKeyProvider.load(this.#mlDsaKeyFile)
2728
+ : await mlDsaKeygen();
2729
+ const mlDsaPubkey = await mlDsaProvider.getPublicKey();
2730
+ const mlDsaPubkeyHex = Buffer.from(mlDsaPubkey).toString("hex");
2731
+ // Step 3: open persistent signaling stream (handles auth including auth_ok wait)
2732
+ const opened = await this.#openPersistentSignalingStream();
2733
+ if (!opened || !this.#persistentSignalingStream) {
2734
+ return { error: "directory_unreachable" };
2735
+ }
2736
+ // Step 4: get K_local pubkey hex
2737
+ if (!this.#myPubkeyHex) {
2738
+ const pubkey = await this.#keyProvider.getPublicKey();
2739
+ this.#myPubkeyHex = Buffer.from(pubkey).toString("hex");
2740
+ }
2741
+ const kLocalPubkeyHex = this.#myPubkeyHex;
2742
+ // Step 5: send register_request
2743
+ const regRequestFrame = CBOR_ENC.encode({
2744
+ type: "register_request",
2745
+ phone_stub: phoneStub,
2746
+ k_local_pubkey: kLocalPubkeyHex,
2747
+ ml_dsa_pubkey: mlDsaPubkeyHex,
2748
+ });
2749
+ this.#persistentSignalingStream.send(lp.encode.single(regRequestFrame));
2750
+ // Step 5a: await dkg_ready from directory (routed by #runPersistentSignalingReader)
2751
+ const DKG_READY_TIMEOUT_MS = 15_000;
2752
+ let dkgReadyTimeoutHandle;
2753
+ const dkgReadyFrame = await Promise.race([
2754
+ new Promise((resolve) => {
2755
+ this.#pendingDkgReadyResolve = resolve;
2756
+ }),
2757
+ new Promise((resolve) => {
2758
+ dkgReadyTimeoutHandle = setTimeout(() => {
2759
+ this.#pendingDkgReadyResolve = null;
2760
+ resolve({ type: "register_error", reason: "timeout" });
2761
+ }, DKG_READY_TIMEOUT_MS);
2762
+ }),
2763
+ ]);
2764
+ clearTimeout(dkgReadyTimeoutHandle);
2765
+ if (dkgReadyFrame["type"] !== "dkg_ready") {
2766
+ const reason = dkgReadyFrame["reason"] ?? "unknown";
2767
+ // already_registered: directory skips dkg_ready and sends register_error directly.
2768
+ // Reconstruct RegistrationState from profile fields in the error frame.
2769
+ if (reason === "already_registered" &&
2770
+ dkgReadyFrame["agent_id"] &&
2771
+ dkgReadyFrame["primary_pubkey"]) {
2772
+ const state = {
2773
+ agent_id: dkgReadyFrame["agent_id"],
2774
+ primary_pubkey: dkgReadyFrame["primary_pubkey"],
2775
+ ml_dsa_pubkey: dkgReadyFrame["ml_dsa_pubkey"] ?? mlDsaPubkeyHex,
2776
+ // registered_at is set to now — the already_registered frame does not carry the
2777
+ // original timestamp. Callers must not treat this as the canonical registration time.
2778
+ registered_at: Date.now(),
2779
+ status: "active",
2780
+ };
2781
+ this.#registrationState = state;
2782
+ this.#mlDsaProvider = mlDsaProvider;
2783
+ return state;
2784
+ }
2785
+ return { error: reason };
2786
+ }
2787
+ // Step 5b: run real FROST DKG over /cello/frost/1.0.0 (RFC 9591)
2788
+ const epochId = dkgReadyFrame["epochId"];
2789
+ const participants = dkgReadyFrame["participants"];
2790
+ const threshold = dkgReadyFrame["threshold"];
2791
+ if (!this.#directoryEndpoint) {
2792
+ return { error: "directory_unreachable" };
2793
+ }
2794
+ const dirNode = new NetworkDirectoryNode({
2795
+ id: this.#directoryEndpoint.peer_id,
2796
+ node: this.#node,
2797
+ directoryPeerId: this.#directoryEndpoint.peer_id,
2798
+ directoryMultiaddrs: this.#directoryEndpoint.multiaddrs,
2799
+ });
2800
+ // epochId is noted here for tracing; runNetworkDkg derives its own epochId internally
2801
+ void epochId;
2802
+ const kLocalPubkeyBytes = Buffer.from(kLocalPubkeyHex, "hex");
2803
+ let dkgPrimaryPubkeyHex;
2804
+ try {
2805
+ const dkgResult = await runNetworkDkg(kLocalPubkeyBytes, {
2806
+ threshold,
2807
+ participants,
2808
+ directoryNodes: [dirNode],
2809
+ preAuthToken,
2810
+ });
2811
+ dkgPrimaryPubkeyHex = Buffer.from(dkgResult.primaryPubkey).toString("hex");
2812
+ // Store the threshold signer so ceremony_request frames can be handled.
2813
+ // The signer holds the client's local FROST share and the directory as a stub.
2814
+ this.#thresholdSigner = dkgResult.signer;
2815
+ // SESSION-005: update primary_pubkey so seal verification uses the DKG-derived key.
2816
+ this.#myPrimaryPubkey = new Uint8Array(dkgResult.primaryPubkey);
2817
+ }
2818
+ catch {
2819
+ return { error: "dkg_failed" };
2820
+ }
2821
+ // Step 5c: send dkg_complete with the agreed primary_pubkey
2822
+ const dkgCompleteFrame = CBOR_ENC.encode({
2823
+ type: "dkg_complete",
2824
+ primary_pubkey: dkgPrimaryPubkeyHex,
2825
+ });
2826
+ this.#persistentSignalingStream.send(lp.encode.single(dkgCompleteFrame));
2827
+ // Step 6: await register_success or register_error (routed by #runPersistentSignalingReader)
2828
+ const REGISTER_TIMEOUT_MS = 15_000;
2829
+ let timeoutHandle;
2830
+ const responseWithTimeout = await Promise.race([
2831
+ new Promise((resolve) => {
2832
+ this.#pendingRegisterResolve = resolve;
2833
+ }),
2834
+ new Promise((resolve) => {
2835
+ timeoutHandle = setTimeout(() => {
2836
+ this.#pendingRegisterResolve = null;
2837
+ resolve({ type: "register_error", reason: "timeout" });
2838
+ }, REGISTER_TIMEOUT_MS);
2839
+ }),
2840
+ ]);
2841
+ clearTimeout(timeoutHandle);
2842
+ if (responseWithTimeout["type"] !== "register_success") {
2843
+ const reason = responseWithTimeout["reason"] ?? "unknown";
2844
+ // already_registered: directory persisted the profile from a prior session.
2845
+ // Reconstruct RegistrationState from the profile data included in the error frame
2846
+ // so the agent can continue without a new DKG ceremony.
2847
+ if (reason === "already_registered" &&
2848
+ responseWithTimeout["agent_id"] &&
2849
+ responseWithTimeout["primary_pubkey"]) {
2850
+ const state = {
2851
+ agent_id: responseWithTimeout["agent_id"],
2852
+ primary_pubkey: responseWithTimeout["primary_pubkey"],
2853
+ ml_dsa_pubkey: responseWithTimeout["ml_dsa_pubkey"] ?? mlDsaPubkeyHex,
2854
+ registered_at: Date.now(),
2855
+ status: "active",
2856
+ };
2857
+ this.#registrationState = state;
2858
+ this.#mlDsaProvider = mlDsaProvider;
2859
+ return state;
2860
+ }
2861
+ return { error: reason };
2862
+ }
2863
+ // Step 7: build RegistrationState and cache
2864
+ const agentId = responseWithTimeout["agent_id"];
2865
+ const primaryPubkey = responseWithTimeout["primary_pubkey"];
2866
+ const state = {
2867
+ agent_id: agentId,
2868
+ primary_pubkey: primaryPubkey,
2869
+ ml_dsa_pubkey: mlDsaPubkeyHex,
2870
+ registered_at: Date.now(),
2871
+ status: "active",
2872
+ };
2873
+ this.#registrationState = state;
2874
+ // Store the ML-DSA key provider so MCP tools can build ConnectionPackages (CELLO-MCP-003).
2875
+ this.#mlDsaProvider = mlDsaProvider;
2876
+ return state;
2877
+ }
2878
+ async #handleInbound(stream) {
2879
+ // Read one LP frame, with a 5s wall-clock timeout as a safety net.
2880
+ // DecoderOptions has no signal field — timeout is enforced by racing the
2881
+ // read promise against a timer that aborts the stream externally.
2882
+ let payload;
2883
+ let timeoutFired = false;
2884
+ const readFrame = async () => {
2885
+ for await (const chunk of lp.decode(stream)) {
2886
+ payload = chunk.slice();
2887
+ return; // got one frame
2888
+ }
2889
+ };
2890
+ let timerId;
2891
+ const timeout = new Promise((_, reject) => {
2892
+ timerId = setTimeout(() => {
2893
+ timeoutFired = true;
2894
+ reject(new Error("truncated_frame: read timeout"));
2895
+ }, 5_000);
2896
+ });
2897
+ try {
2898
+ await Promise.race([readFrame(), timeout]);
2899
+ clearTimeout(timerId);
2900
+ }
2901
+ catch {
2902
+ clearTimeout(timerId);
2903
+ stream.abort(new Error(timeoutFired ? "truncated_frame: read timeout" : "truncated_frame: stream error"));
2904
+ return;
2905
+ }
2906
+ if (!payload) {
2907
+ stream.abort(new Error("truncated_frame: no frame received"));
2908
+ return;
2909
+ }
2910
+ // CBOR parse
2911
+ const deserResult = deserializeEnvelope(payload);
2912
+ if (!deserResult.ok) {
2913
+ stream.abort(new Error(`malformed_envelope: ${deserResult.error.reason}`));
2914
+ return;
2915
+ }
2916
+ // Full validation: struct → content_hash recompute → signature
2917
+ const validateResult = validateEnvelope(deserResult.envelope);
2918
+ if (!validateResult.ok) {
2919
+ stream.abort(new Error(`validation_failed: ${validateResult.error.reason}`));
2920
+ return;
2921
+ }
2922
+ // Enqueue
2923
+ const senderHex = Buffer.from(deserResult.envelope.sender_pubkey).toString("hex");
2924
+ const received = {
2925
+ content: deserResult.envelope.content,
2926
+ senderPubkey: deserResult.envelope.sender_pubkey,
2927
+ contentHash: deserResult.envelope.content_hash,
2928
+ timestamp: deserResult.envelope.timestamp,
2929
+ };
2930
+ if (!this.#receiveQueues.has(senderHex)) {
2931
+ this.#receiveQueues.set(senderHex, []);
2932
+ }
2933
+ this.#receiveQueues.get(senderHex).push(received);
2934
+ this.#arrivalLog.push({ senderPubkeyHex: senderHex, envelope: received });
2935
+ this.#onMessageQueued?.(senderHex);
2936
+ // Clean close — signals delivered:true to sender
2937
+ await stream.close().catch(() => { });
2938
+ }
2939
+ receive(senderPubkeyHex) {
2940
+ const queue = this.#receiveQueues.get(senderPubkeyHex);
2941
+ if (!queue || queue.length === 0)
2942
+ return null;
2943
+ return queue.shift();
2944
+ }
2945
+ peekAll() {
2946
+ return [...this.#arrivalLog];
2947
+ }
2948
+ onSessionAssignment(handler) {
2949
+ this.#onSessionAssignmentHandler = handler;
2950
+ }
2951
+ // ─── CONNREQ-002: Connection methods ──────────────────────────────────────────
2952
+ /**
2953
+ * Register a handler for connection_established events.
2954
+ * CONNREQ-002: fires on both the sender and the target when a connection is created.
2955
+ */
2956
+ onConnectionEstablished(handler) {
2957
+ this.#onConnectionEstablishedHandler = handler;
2958
+ }
2959
+ /**
2960
+ * Register a handler for disclosure_request_inbound events (Round 2 notification for sender).
2961
+ * CONNREQ-002: fires on the sender when the target requests more disclosure.
2962
+ */
2963
+ onDisclosureRequested(handler) {
2964
+ this.#onDisclosureRequestedHandler = handler;
2965
+ }
2966
+ /**
2967
+ * Return all active connection records for this client.
2968
+ * CONNREQ-002: used by the MCP tool layer and tests.
2969
+ */
2970
+ listConnections() {
2971
+ return [...this.#connections.values()];
2972
+ }
2973
+ /**
2974
+ * Return the cached registration state, or null if not yet registered.
2975
+ * CELLO-MCP-003.
2976
+ */
2977
+ getRegistrationState() {
2978
+ return this.#registrationState;
2979
+ }
2980
+ /**
2981
+ * Return the ML-DSA key provider stored after successful register(), or null.
2982
+ * Used by the MCP server to build ConnectionPackages. CELLO-MCP-003.
2983
+ * SI-001: This returns the key PROVIDER (sign/getPublicKey), not raw secret bytes.
2984
+ */
2985
+ getMlDsaProvider() {
2986
+ return this.#mlDsaProvider;
2987
+ }
2988
+ /**
2989
+ * Set the connection policy. Replaces any previously configured policy.
2990
+ * CELLO-MCP-003.
2991
+ */
2992
+ setPolicy(policy) {
2993
+ this.#connectionPolicy = policy;
2994
+ }
2995
+ /**
2996
+ * Return the current connection policy. Returns default open/deterministic if none configured.
2997
+ * CELLO-MCP-003.
2998
+ */
2999
+ getPolicy() {
3000
+ return this.#connectionPolicy ?? { mode: "open", review_mode: "deterministic", requirements: [] };
3001
+ }
3002
+ /** Return the configured directory peer ID, or null if no directory endpoint was provided. */
3003
+ getDirectoryPeerId() {
3004
+ return this.#directoryEndpoint?.peer_id ?? null;
3005
+ }
3006
+ /**
3007
+ * Check if a connection exists with the given counterparty pubkey.
3008
+ * Returns the connection_id if found, null otherwise.
3009
+ * CELLO-MCP-003.
3010
+ */
3011
+ hasConnection(counterpartyPubkeyHex) {
3012
+ return this.#connectionsByPeer.get(counterpartyPubkeyHex) ?? null;
3013
+ }
3014
+ /**
3015
+ * Accept a pending inbound connection request (inference review mode).
3016
+ * Sends connection_response { verdict: 'accept' } to the directory.
3017
+ * The directory then pushes connection_established to both parties.
3018
+ * CELLO-MCP-003.
3019
+ */
3020
+ async acceptConnection(connectionRequestId) {
3021
+ const pending = this.#pendingInboundRequests.get(connectionRequestId);
3022
+ if (!pending) {
3023
+ if (this.#decidedRequests.has(connectionRequestId)) {
3024
+ return { error: { reason: "already_decided" } };
3025
+ }
3026
+ return { error: { reason: "no_pending_request" } };
3027
+ }
3028
+ if (this.#decidedRequests.has(connectionRequestId)) {
3029
+ return { error: { reason: "already_decided" } };
3030
+ }
3031
+ // Mark as decided before sending to prevent races
3032
+ this.#decidedRequests.add(connectionRequestId);
3033
+ this.#pendingInboundRequests.delete(connectionRequestId);
3034
+ if (!this.#persistentSignalingStream) {
3035
+ // Stream gone — still mark as decided
3036
+ return { error: { reason: "no_pending_request" } };
3037
+ }
3038
+ const responseFrame = CBOR_ENC.encode({
3039
+ type: "connection_response",
3040
+ connection_request_id: connectionRequestId,
3041
+ verdict: "accept",
3042
+ });
3043
+ try {
3044
+ this.#persistentSignalingStream.send(lp.encode.single(responseFrame));
3045
+ }
3046
+ catch {
3047
+ return { error: { reason: "no_pending_request" } };
3048
+ }
3049
+ // Wait for connection_established to arrive via the signaling reader loop.
3050
+ // The signaling reader will store the connection record and fire onConnectionEstablished.
3051
+ // Poll #connections until the connection appears (or timeout).
3052
+ const deadline = Date.now() + this.#connectionTimeoutMs;
3053
+ while (Date.now() < deadline) {
3054
+ const connectionId = this.#connectionsByPeer.get(pending.from_pubkey);
3055
+ if (connectionId) {
3056
+ return { accepted: true, connection_id: connectionId };
3057
+ }
3058
+ const remaining = deadline - Date.now();
3059
+ if (remaining <= 0)
3060
+ break;
3061
+ await new Promise((resolve) => setTimeout(resolve, Math.min(20, remaining)));
3062
+ }
3063
+ return { error: { reason: "no_pending_request" } };
3064
+ }
3065
+ /**
3066
+ * Reject a pending inbound connection request (inference review mode).
3067
+ * Sends connection_response { verdict: 'reject' } to the directory.
3068
+ * CELLO-MCP-003.
3069
+ */
3070
+ async rejectConnection(connectionRequestId, reason) {
3071
+ const pending = this.#pendingInboundRequests.get(connectionRequestId);
3072
+ if (!pending) {
3073
+ if (this.#decidedRequests.has(connectionRequestId)) {
3074
+ return { error: { reason: "already_decided" } };
3075
+ }
3076
+ return { error: { reason: "no_pending_request" } };
3077
+ }
3078
+ if (this.#decidedRequests.has(connectionRequestId)) {
3079
+ return { error: { reason: "already_decided" } };
3080
+ }
3081
+ // Mark as decided
3082
+ this.#decidedRequests.add(connectionRequestId);
3083
+ this.#pendingInboundRequests.delete(connectionRequestId);
3084
+ if (!this.#persistentSignalingStream) {
3085
+ return { rejected: true };
3086
+ }
3087
+ const payload = {
3088
+ type: "connection_response",
3089
+ connection_request_id: connectionRequestId,
3090
+ verdict: "reject",
3091
+ };
3092
+ if (reason !== undefined)
3093
+ payload["reason"] = reason;
3094
+ const responseFrame = CBOR_ENC.encode(payload);
3095
+ try {
3096
+ this.#persistentSignalingStream.send(lp.encode.single(responseFrame));
3097
+ }
3098
+ catch { /* stream closed */ }
3099
+ return { rejected: true };
3100
+ }
3101
+ /**
3102
+ * Request more disclosure from sender (Round 2 initiation by target).
3103
+ * Only valid when in Round 1 pending state.
3104
+ * CELLO-MCP-003.
3105
+ */
3106
+ async requestMoreDisclosure(connectionRequestId, requestedItems) {
3107
+ const pending = this.#pendingInboundRequests.get(connectionRequestId);
3108
+ if (!pending) {
3109
+ if (this.#decidedRequests.has(connectionRequestId)) {
3110
+ return { error: { reason: "already_decided" } };
3111
+ }
3112
+ return { error: { reason: "no_pending_request" } };
3113
+ }
3114
+ if (this.#decidedRequests.has(connectionRequestId)) {
3115
+ return { error: { reason: "already_decided" } };
3116
+ }
3117
+ if (pending.round >= 2) {
3118
+ return { error: { reason: "max_rounds_reached" } };
3119
+ }
3120
+ // Advance to Round 2
3121
+ pending.round = 2;
3122
+ if (!this.#persistentSignalingStream) {
3123
+ return { error: { reason: "no_pending_request" } };
3124
+ }
3125
+ const disclosureFrame = CBOR_ENC.encode({
3126
+ type: "disclosure_request",
3127
+ connection_request_id: connectionRequestId,
3128
+ requested_items: requestedItems,
3129
+ });
3130
+ try {
3131
+ this.#persistentSignalingStream.send(lp.encode.single(disclosureFrame));
3132
+ }
3133
+ catch {
3134
+ return { error: { reason: "no_pending_request" } };
3135
+ }
3136
+ return { request_sent: true };
3137
+ }
3138
+ /**
3139
+ * Block until an inbound connection request arrives for agent review,
3140
+ * or until timeoutMs elapses. CELLO-MCP-003.
3141
+ *
3142
+ * CONNREQ-003: Supports multiple concurrent callers via Promise queue
3143
+ * (#pendingAwaitConnectionRequestResolvers). Each caller gets its own Promise.
3144
+ * When a new inbound request arrives, the first waiting resolver is called (FIFO).
3145
+ * If #pendingReviewQueue already has items, the caller returns immediately.
3146
+ *
3147
+ * Pseudocode (Phase P):
3148
+ * 1. if #pendingReviewQueue.length > 0: shift item and return immediately
3149
+ * 2. create Promise with timeout
3150
+ * 3. push resolve fn to #pendingAwaitConnectionRequestResolvers
3151
+ * 4. await Promise.race([itemArrived, timeout])
3152
+ * 5. on arrival: return pending_review ; on timeout: return { type: 'timeout' }
3153
+ */
3154
+ async awaitConnectionRequest(timeoutMs = 30_000) {
3155
+ // Fast path: if items already queued, return immediately (no Promise overhead)
3156
+ if (this.#pendingReviewQueue.length > 0) {
3157
+ const item = this.#pendingReviewQueue.shift();
3158
+ return {
3159
+ type: "pending_review",
3160
+ connection_request_id: item.connection_request_id,
3161
+ from_pubkey: item.from_pubkey,
3162
+ report: item.report,
3163
+ };
3164
+ }
3165
+ let resolveItem;
3166
+ const itemPromise = new Promise((resolve) => {
3167
+ resolveItem = resolve;
3168
+ });
3169
+ this.#pendingAwaitConnectionRequestResolvers.push(resolveItem);
3170
+ const result = { item: null };
3171
+ let timedOut = false;
3172
+ await Promise.race([
3173
+ itemPromise.then((i) => { result.item = i; }),
3174
+ new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, timeoutMs)),
3175
+ ]);
3176
+ // On timeout: remove our resolver from the queue if it hasn't been consumed yet
3177
+ if (timedOut) {
3178
+ const idx = this.#pendingAwaitConnectionRequestResolvers.indexOf(resolveItem);
3179
+ if (idx !== -1) {
3180
+ this.#pendingAwaitConnectionRequestResolvers.splice(idx, 1);
3181
+ }
3182
+ return { type: "timeout" };
3183
+ }
3184
+ const item = result.item;
3185
+ if (!item) {
3186
+ return { type: "timeout" };
3187
+ }
3188
+ return {
3189
+ type: "pending_review",
3190
+ connection_request_id: item.connection_request_id,
3191
+ from_pubkey: item.from_pubkey,
3192
+ report: item.report,
3193
+ };
3194
+ }
3195
+ /**
3196
+ * Send a connection_request to target B and wait for a final outcome.
3197
+ * Returns:
3198
+ * { result: 'established', connection_id } — connection created
3199
+ * { result: 'rejected', reason } — target rejected
3200
+ * { result: 'insufficient', unmet_requirements } — target found requirements unmet
3201
+ * { result: 'disclosure_requested', connection_request_id, requested_items } — Round 2 request (unblocks sender)
3202
+ * { result: 'timeout' } — no response within connectionTimeoutMs
3203
+ * { result: 'error', reason } — directory-level error (not_registered, target_not_found, etc.)
3204
+ */
3205
+ async cello_request_connection(opts) {
3206
+ const targetPubkeyHex = opts.target_pubkey;
3207
+ // CONNREQ-003 AC-002: reject duplicate concurrent requests to same target immediately.
3208
+ // A second call for the same target while the first is still in flight would overwrite
3209
+ // the first resolver, losing it. Instead, return an error immediately.
3210
+ if (this.#pendingConnectionRequestResolvers.has(targetPubkeyHex)) {
3211
+ this.#logger.warn("connection.request.duplicate", { targetPubkeyHex });
3212
+ return { result: "error", reason: "connection_request_in_flight" };
3213
+ }
3214
+ // CONNREQ-003: Reserve this target's slot in the map BEFORE any async work.
3215
+ // This is the earliest possible moment — before stream-open and before the
3216
+ // frame is sent — so that a concurrent duplicate call (AC-002) is rejected
3217
+ // even if the stream open is still in progress.
3218
+ let resolveOutcome;
3219
+ const outcomePromise = new Promise((resolve) => {
3220
+ resolveOutcome = resolve;
3221
+ });
3222
+ this.#pendingConnectionRequestResolvers.set(targetPubkeyHex, resolveOutcome);
3223
+ // Ensure the persistent signaling stream is open
3224
+ if (!this.#persistentSignalingStream) {
3225
+ const opened = await this.#openPersistentSignalingStream();
3226
+ if (!opened) {
3227
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
3228
+ return { result: "error", reason: "directory_unreachable" };
3229
+ }
3230
+ }
3231
+ // Mint a correlationId for this outbound connection request flow.
3232
+ // Threaded through all log events in this flow (CONNREQ-003 observability ACs).
3233
+ const correlationId = `connreq-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
3234
+ this.#logger.info("connection.request.sent", { targetPubkeyHex, correlationId });
3235
+ // Build the frame
3236
+ const frameBytes = CBOR_ENC.encode({
3237
+ type: "connection_request",
3238
+ target_pubkey: targetPubkeyHex,
3239
+ package_cbor: opts.package_cbor,
3240
+ });
3241
+ try {
3242
+ this.#persistentSignalingStream.send(lp.encode.single(frameBytes));
3243
+ }
3244
+ catch {
3245
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
3246
+ return { result: "error", reason: "directory_unreachable" };
3247
+ }
3248
+ // Race: outcome vs connectionTimeoutMs
3249
+ // DB-001: timeout cleans this slot without affecting concurrent requests to other targets.
3250
+ let frame = null;
3251
+ let timedOut = false;
3252
+ await Promise.race([
3253
+ outcomePromise.then((f) => { frame = f; }),
3254
+ new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, this.#connectionTimeoutMs)),
3255
+ ]);
3256
+ // Clean up this target's resolver slot on timeout
3257
+ // (on successful resolution, the signaling reader already deleted it)
3258
+ if (this.#pendingConnectionRequestResolvers.get(targetPubkeyHex) === resolveOutcome) {
3259
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyHex);
3260
+ }
3261
+ if (timedOut || !frame) {
3262
+ return { result: "timeout" };
3263
+ }
3264
+ const type = frame["type"];
3265
+ if (type === "connection_established") {
3266
+ const connectionId = frame["connection_id"];
3267
+ const counterpartyPubkey = frame["counterparty_pubkey"];
3268
+ // Store connection record locally
3269
+ const record = {
3270
+ connection_id: connectionId,
3271
+ counterparty_pubkey: counterpartyPubkey,
3272
+ counterparty_primary_pubkey: "",
3273
+ counterparty_ml_dsa_pubkey: "",
3274
+ established_at: Date.now(),
3275
+ status: "active",
3276
+ };
3277
+ this.#connections.set(connectionId, record);
3278
+ this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
3279
+ this.#logger.info("connection.established", {
3280
+ connectionId,
3281
+ counterpartyPubkeyHex: counterpartyPubkey,
3282
+ correlationId,
3283
+ });
3284
+ return { result: "established", connection_id: connectionId };
3285
+ }
3286
+ if (type === "connection_rejected") {
3287
+ return { result: "rejected", reason: frame["reason"] ?? "rejected" };
3288
+ }
3289
+ if (type === "connection_insufficient") {
3290
+ return { result: "insufficient", unmet_requirements: frame["unmet_requirements"] ?? [] };
3291
+ }
3292
+ if (type === "connection_request_error") {
3293
+ const reason = frame["reason"] ?? "unknown";
3294
+ // already_connected: hydrate the existing connection so the caller can proceed.
3295
+ if (reason === "already_connected" && frame["connection_id"]) {
3296
+ const connectionId = frame["connection_id"];
3297
+ if (!this.#connections.has(connectionId)) {
3298
+ const record = {
3299
+ connection_id: connectionId,
3300
+ counterparty_pubkey: targetPubkeyHex,
3301
+ counterparty_primary_pubkey: "",
3302
+ counterparty_ml_dsa_pubkey: "",
3303
+ established_at: Date.now(),
3304
+ status: "active",
3305
+ };
3306
+ this.#connections.set(connectionId, record);
3307
+ this.#connectionsByPeer.set(targetPubkeyHex, connectionId);
3308
+ }
3309
+ this.#logger.info("connection.established", {
3310
+ connectionId,
3311
+ counterpartyPubkeyHex: targetPubkeyHex,
3312
+ correlationId,
3313
+ });
3314
+ return { result: "established", connection_id: connectionId };
3315
+ }
3316
+ this.#logger.error("connection.request.failed", { targetPubkeyHex, reason, correlationId });
3317
+ return { result: "error", reason };
3318
+ }
3319
+ if (type === "disclosure_request_inbound") {
3320
+ return {
3321
+ result: "disclosure_requested",
3322
+ connection_request_id: frame["connection_request_id"],
3323
+ requested_items: frame["requested_items"] ?? [],
3324
+ };
3325
+ }
3326
+ return { result: "error", reason: "unknown" };
3327
+ }
3328
+ /**
3329
+ * Respond to a disclosure request from the target (Round 2 sender side).
3330
+ * Called after cello_request_connection returns { result: 'disclosure_requested' }.
3331
+ * Sends disclosure_response and waits for the final connection outcome.
3332
+ */
3333
+ async cello_respond_to_disclosure_request(opts) {
3334
+ if (!this.#persistentSignalingStream) {
3335
+ return { result: "error", reason: "directory_unreachable" };
3336
+ }
3337
+ const frameBytes = CBOR_ENC.encode({
3338
+ type: "disclosure_response",
3339
+ connection_request_id: opts.connection_request_id,
3340
+ package_cbor: opts.package_cbor,
3341
+ });
3342
+ let resolveOutcome;
3343
+ const outcomePromise = new Promise((resolve) => {
3344
+ resolveOutcome = resolve;
3345
+ });
3346
+ this.#pendingDisclosureResolvers.set(opts.connection_request_id, resolveOutcome);
3347
+ try {
3348
+ this.#persistentSignalingStream.send(lp.encode.single(frameBytes));
3349
+ }
3350
+ catch {
3351
+ this.#pendingDisclosureResolvers.delete(opts.connection_request_id);
3352
+ return { result: "error", reason: "directory_unreachable" };
3353
+ }
3354
+ let frame = null;
3355
+ let timedOut = false;
3356
+ await Promise.race([
3357
+ outcomePromise.then((f) => { frame = f; }),
3358
+ new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, this.#connectionTimeoutMs)),
3359
+ ]);
3360
+ this.#pendingDisclosureResolvers.delete(opts.connection_request_id);
3361
+ if (timedOut || !frame) {
3362
+ return { result: "timeout" };
3363
+ }
3364
+ const type = frame["type"];
3365
+ if (type === "connection_established") {
3366
+ const connectionId = frame["connection_id"];
3367
+ const counterpartyPubkey = frame["counterparty_pubkey"];
3368
+ const record = {
3369
+ connection_id: connectionId,
3370
+ counterparty_pubkey: counterpartyPubkey,
3371
+ counterparty_primary_pubkey: "",
3372
+ counterparty_ml_dsa_pubkey: "",
3373
+ established_at: Date.now(),
3374
+ status: "active",
3375
+ };
3376
+ this.#connections.set(connectionId, record);
3377
+ this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
3378
+ return { result: "established", connection_id: connectionId };
3379
+ }
3380
+ if (type === "connection_rejected") {
3381
+ return { result: "rejected", reason: frame["reason"] ?? "rejected" };
3382
+ }
3383
+ if (type === "connection_insufficient") {
3384
+ return { result: "insufficient", unmet_requirements: frame["unmet_requirements"] ?? [] };
3385
+ }
3386
+ return { result: "error", reason: "unknown" };
3387
+ }
3388
+ /**
3389
+ * Request more disclosure from the sender (Round 2, target side).
3390
+ * Returns { error: 'max_rounds_reached' } if Round 2 is already in flight.
3391
+ */
3392
+ async cello_request_more_disclosure(opts) {
3393
+ const pending = this.#pendingInboundRequests.get(opts.connection_request_id);
3394
+ if (!pending) {
3395
+ return { error: "max_rounds_reached" }; // no such request or already completed
3396
+ }
3397
+ if (pending.round >= 2) {
3398
+ return { error: "max_rounds_reached" };
3399
+ }
3400
+ // Advance to Round 2 state
3401
+ pending.round = 2;
3402
+ if (!this.#persistentSignalingStream) {
3403
+ return { error: "max_rounds_reached" }; // stream gone
3404
+ }
3405
+ const frameBytes = CBOR_ENC.encode({
3406
+ type: "disclosure_request",
3407
+ connection_request_id: opts.connection_request_id,
3408
+ requested_items: opts.requested_items,
3409
+ });
3410
+ try {
3411
+ this.#persistentSignalingStream.send(lp.encode.single(frameBytes));
3412
+ }
3413
+ catch {
3414
+ return { error: "max_rounds_reached" };
3415
+ }
3416
+ return { ok: true };
3417
+ }
3418
+ /**
3419
+ * Reconnect the persistent directory signaling stream and re-authenticate.
3420
+ * Called after a client reconnects and wants the directory to deliver queued
3421
+ * connection requests (CONNREQ-002 DB-001).
3422
+ */
3423
+ async reconnectDirectory() {
3424
+ // Clear existing stream state to force a re-open
3425
+ if (this.#persistentSignalingStream) {
3426
+ try {
3427
+ this.#persistentSignalingStream.abort(new Error("reconnect"));
3428
+ }
3429
+ catch { }
3430
+ this.#persistentSignalingStream = null;
3431
+ this.#persistentSignalingIter = null;
3432
+ }
3433
+ const opened = await this.#openPersistentSignalingStream();
3434
+ return opened;
3435
+ }
3436
+ /**
3437
+ * FEDERATION-003 AC-004: Look up a relay's registered public key from the directory.
3438
+ *
3439
+ * Opens a one-shot signaling stream, authenticates, sends relay_pubkey_request,
3440
+ * and returns the public_key_hex for the given relayId.
3441
+ *
3442
+ * DB-002: If the directory is unreachable, returns undefined. The caller is responsible
3443
+ * for retry logic (the hash submission stays in the pending queue).
3444
+ *
3445
+ * Pseudocode (Phase P):
3446
+ * 1. Require directoryEndpoint — return undefined if not configured.
3447
+ * 2. Dial directory and open signaling stream.
3448
+ * 3. Authenticate: read signaling_auth_challenge, sign SHA-256("CELLO-DIR-AUTH-v1" || nonce || pubkey),
3449
+ * send signaling_auth_response. Read signaling_auth_ok. RFC 8032, FIPS 180-4.
3450
+ * 4. Send relay_pubkey_request { type, relay_id }.
3451
+ * 5. Read relay_pubkey_response { type, public_key_hex } or relay_pubkey_error { type, reason }.
3452
+ * 6. On relay_pubkey_response: return public_key_hex.
3453
+ * 7. On relay_pubkey_error or any failure: return undefined.
3454
+ *
3455
+ * @param relayId - hex encoding of the relay's Ed25519 public key
3456
+ * @returns the relay's registered public_key_hex, or undefined if not found / unreachable
3457
+ */
3458
+ async getRelayPublicKey(relayId) {
3459
+ if (!this.#directoryEndpoint)
3460
+ return undefined;
3461
+ const dirPeerId = this.#directoryEndpoint.peer_id;
3462
+ const dirMultiaddr = this.#directoryEndpoint.multiaddrs[0];
3463
+ try {
3464
+ if (dirMultiaddr) {
3465
+ try {
3466
+ await this.#node.dial(dirMultiaddr);
3467
+ }
3468
+ catch { /* already connected */ }
3469
+ }
3470
+ let sigStream;
3471
+ try {
3472
+ sigStream = await this.#node.newStream(dirPeerId, SIGNALING_PROTOCOL_ID);
3473
+ }
3474
+ catch (err) {
3475
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: err instanceof Error ? err.message : "stream_open_failed" });
3476
+ return undefined;
3477
+ }
3478
+ const iter = lp.decode(sigStream)[Symbol.asyncIterator]();
3479
+ // Auth challenge
3480
+ const { value: challengeRaw, done } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
3481
+ if (done || challengeRaw === undefined) {
3482
+ sigStream.abort(new Error("dir_auth_error"));
3483
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "no_auth_challenge" });
3484
+ return undefined;
3485
+ }
3486
+ let challengeFrame;
3487
+ try {
3488
+ challengeFrame = decode(toU8(challengeRaw));
3489
+ }
3490
+ catch {
3491
+ sigStream.abort(new Error("dir_auth_error"));
3492
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "decode_failed" });
3493
+ return undefined;
3494
+ }
3495
+ if (challengeFrame["type"] !== "signaling_auth_challenge") {
3496
+ sigStream.abort(new Error("dir_auth_error"));
3497
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "unexpected_frame" });
3498
+ return undefined;
3499
+ }
3500
+ const nonce = toU8(challengeFrame["nonce"]);
3501
+ if (!this.#myPubkeyHex) {
3502
+ const pubkey = await this.#keyProvider.getPublicKey();
3503
+ this.#myPubkeyHex = Buffer.from(pubkey).toString("hex");
3504
+ }
3505
+ const myPubkey = Buffer.from(this.#myPubkeyHex, "hex");
3506
+ const domain = Buffer.from(AUTH_DOMAIN_DIR, "utf8");
3507
+ const authMsg = new Uint8Array(Buffer.concat([domain, nonce, myPubkey]));
3508
+ const msgHash = new Uint8Array(createHash("sha256").update(authMsg).digest());
3509
+ const sig = await this.#keyProvider.sign(msgHash);
3510
+ sigStream.send(lp.encode.single(CBOR_ENC.encode({
3511
+ type: "signaling_auth_response",
3512
+ pubkey: myPubkey,
3513
+ signature: sig,
3514
+ })));
3515
+ // Auth ok
3516
+ const { value: ackRaw, done: ackDone } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
3517
+ if (ackDone || ackRaw === undefined) {
3518
+ sigStream.abort(new Error("dir_auth_error"));
3519
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "no_auth_ack" });
3520
+ return undefined;
3521
+ }
3522
+ let ackFrame;
3523
+ try {
3524
+ ackFrame = decode(toU8(ackRaw));
3525
+ }
3526
+ catch {
3527
+ sigStream.abort(new Error("dir_auth_error"));
3528
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "decode_failed" });
3529
+ return undefined;
3530
+ }
3531
+ if (ackFrame["type"] !== "signaling_auth_ok") {
3532
+ sigStream.abort(new Error("dir_auth_error"));
3533
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "auth_failed" });
3534
+ return undefined;
3535
+ }
3536
+ // Send relay_pubkey_request
3537
+ sigStream.send(lp.encode.single(CBOR_ENC.encode({
3538
+ type: "relay_pubkey_request",
3539
+ relay_id: relayId,
3540
+ })));
3541
+ // Read relay_pubkey_response or relay_pubkey_error
3542
+ const { value: respRaw, done: respDone } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
3543
+ if (respDone || respRaw === undefined) {
3544
+ sigStream.abort(new Error("no_response"));
3545
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "no_response" });
3546
+ return undefined;
3547
+ }
3548
+ let respFrame;
3549
+ try {
3550
+ respFrame = decode(toU8(respRaw));
3551
+ }
3552
+ catch {
3553
+ sigStream.abort(new Error("decode_failed"));
3554
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: "decode_failed" });
3555
+ return undefined;
3556
+ }
3557
+ sigStream.close().catch(() => { });
3558
+ if (respFrame["type"] === "relay_pubkey_response") {
3559
+ return respFrame["public_key_hex"];
3560
+ }
3561
+ // relay_pubkey_error (not_found) or unexpected type
3562
+ const errorReason = respFrame["reason"] ?? "not_found";
3563
+ if (errorReason !== "not_found") {
3564
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: errorReason });
3565
+ }
3566
+ return undefined;
3567
+ }
3568
+ catch (err) {
3569
+ this.#logger.warn("relay.pubkey.lookup.failed", { relayId, reason: err instanceof Error ? err.message : "unknown" });
3570
+ return undefined;
3571
+ }
3572
+ }
3573
+ /**
3574
+ * TEST-ONLY escape hatch: inject a pending inbound connection request into state.
3575
+ * Used by AC-010 to test max_rounds_reached without going through the full flow.
3576
+ */
3577
+ _injectPendingConnectionRequest(opts) {
3578
+ this.#pendingInboundRequests.set(opts.connection_request_id, {
3579
+ connection_request_id: opts.connection_request_id,
3580
+ from_pubkey: opts.from_pubkey,
3581
+ package_cbor: opts.package_cbor,
3582
+ round: opts.round,
3583
+ });
3584
+ }
3585
+ /**
3586
+ * CONNREQ-003 TEST-ONLY escape hatch: route a synthetic connection outcome frame
3587
+ * through the same resolver logic as the signaling stream reader.
3588
+ *
3589
+ * Used by SI-001 adversarial test to inject a connection_established frame for
3590
+ * target B while C's resolver is still pending, verifying that B's frame does NOT
3591
+ * resolve C's resolver (Map-keyed routing isolation guarantee).
3592
+ *
3593
+ * Accepts the same frame types that the signaling reader handles for connection
3594
+ * outcomes: connection_established, connection_rejected, connection_insufficient,
3595
+ * connection_request_error, disclosure_request_inbound.
3596
+ *
3597
+ * Does not store a connection record (connection_established frames injected in
3598
+ * tests bypass the full connection lifecycle — they test resolver routing only).
3599
+ */
3600
+ _injectConnectionFrame(frame) {
3601
+ const type = frame["type"];
3602
+ if (type === "connection_established") {
3603
+ const counterpartyPubkey = frame["counterparty_pubkey"];
3604
+ const resolve = this.#pendingConnectionRequestResolvers.get(counterpartyPubkey);
3605
+ if (resolve) {
3606
+ this.#pendingConnectionRequestResolvers.delete(counterpartyPubkey);
3607
+ resolve(frame);
3608
+ }
3609
+ }
3610
+ else if (type === "disclosure_request_inbound") {
3611
+ const targetPubkeyForDisclosure = frame["from_pubkey"];
3612
+ const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForDisclosure);
3613
+ if (resolve) {
3614
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyForDisclosure);
3615
+ resolve(frame);
3616
+ }
3617
+ }
3618
+ else {
3619
+ // connection_rejected, connection_insufficient, connection_request_error
3620
+ const targetPubkeyForError = frame["target_pubkey"];
3621
+ if (targetPubkeyForError && this.#pendingConnectionRequestResolvers.has(targetPubkeyForError)) {
3622
+ const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForError);
3623
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyForError);
3624
+ resolve(frame);
3625
+ }
3626
+ else if (this.#pendingConnectionRequestResolvers.size === 1) {
3627
+ const [singleKey, singleResolve] = this.#pendingConnectionRequestResolvers.entries().next().value;
3628
+ this.#pendingConnectionRequestResolvers.delete(singleKey);
3629
+ singleResolve(frame);
3630
+ }
3631
+ }
3632
+ }
3633
+ // ─── CONNREQ-002: B-side inbound request handler ─────────────────────────────
3634
+ /**
3635
+ * Handle an inbound connection_request_inbound frame (B's side).
3636
+ * CONNREQ-002 pseudocode (connection-request.ts story):
3637
+ * 1. Check whitelist — if sender whitelisted, accept without policy evaluation
3638
+ * 2. Decode + validate the ConnectionPackage (decodeConnectionPackage + validateConnectionPackage)
3639
+ * 3. Build DirectoryContext from the frame-provided sender_registered_at / sender_is_provisional
3640
+ * 4. Call evaluateConnectionPackage (increment _evaluateCallCount if trackEvaluateCount)
3641
+ * 5a. auto_accept → send connection_response { verdict: 'accept' }
3642
+ * 5b. auto_reject → send connection_response { verdict: 'reject', reason }
3643
+ * 5c. auto_insufficient → send connection_response { verdict: 'insufficient', unmet_requirements }
3644
+ * 5d. pending_agent_review → fire onConnectionPendingReview handler; store in #pendingInboundRequests;
3645
+ * optionally start round2TimeoutMs timer that auto-rejects on expiry
3646
+ */
3647
+ async #handleInboundConnectionRequest(frame) {
3648
+ if (!this.#persistentSignalingStream)
3649
+ return;
3650
+ const connectionRequestId = frame["connection_request_id"];
3651
+ const fromPubkey = frame["from_pubkey"];
3652
+ const packageCborRaw = frame["package_cbor"];
3653
+ const packageCbor = packageCborRaw instanceof Uint8Array ? packageCborRaw
3654
+ : Buffer.isBuffer(packageCborRaw) ? new Uint8Array(packageCborRaw) : null;
3655
+ if (!connectionRequestId || !fromPubkey || !packageCbor)
3656
+ return;
3657
+ const senderRegisteredAt = typeof frame["sender_registered_at"] === "number"
3658
+ ? frame["sender_registered_at"] : 0;
3659
+ const senderIsProvisional = frame["sender_is_provisional"] === true;
3660
+ let verdict = "reject";
3661
+ let rejectReason;
3662
+ let unmetRequirements;
3663
+ const isWhitelisted = this.#whitelist.includes(fromPubkey);
3664
+ if (isWhitelisted) {
3665
+ verdict = "accept";
3666
+ }
3667
+ else if (!this.#connectionPolicy) {
3668
+ // No policy configured — default to accept
3669
+ verdict = "accept";
3670
+ }
3671
+ else {
3672
+ // Decode and validate the package
3673
+ let pkg;
3674
+ try {
3675
+ pkg = decodeConnectionPackage(packageCbor);
3676
+ }
3677
+ catch {
3678
+ verdict = "reject";
3679
+ rejectReason = "package_decode_failed";
3680
+ pkg = null;
3681
+ }
3682
+ if (pkg !== null) {
3683
+ const fromPubkeyBytes = Buffer.from(fromPubkey, "hex");
3684
+ const validatedPackage = validateConnectionPackage(pkg, fromPubkeyBytes, Date.now(), mlDsaVerify);
3685
+ if (!validatedPackage.valid) {
3686
+ verdict = "reject";
3687
+ rejectReason = validatedPackage.reason;
3688
+ }
3689
+ else {
3690
+ const context = {
3691
+ registered_at: senderRegisteredAt,
3692
+ is_provisional: senderIsProvisional,
3693
+ conversation_count: 0,
3694
+ clean_close_rate: 0,
3695
+ };
3696
+ if (this.#trackEvaluateCount) {
3697
+ this._evaluateCallCount++;
3698
+ }
3699
+ const { evaluateConnectionPackage } = await import("./connection-policy.js");
3700
+ const report = evaluateConnectionPackage(validatedPackage, this.#connectionPolicy, context, Date.now());
3701
+ if (report.verdict === "auto_accept") {
3702
+ verdict = "accept";
3703
+ }
3704
+ else if (report.verdict === "auto_reject") {
3705
+ verdict = "reject";
3706
+ rejectReason = report.reason;
3707
+ }
3708
+ else if (report.verdict === "auto_insufficient") {
3709
+ if (this.#round2TimeoutMs > 0) {
3710
+ // Round 2 enabled: send disclosure_request to ask for more
3711
+ this.#pendingInboundRequests.set(connectionRequestId, {
3712
+ connection_request_id: connectionRequestId,
3713
+ from_pubkey: fromPubkey,
3714
+ package_cbor: packageCbor,
3715
+ round: 1,
3716
+ });
3717
+ const disclosureFrame = CBOR_ENC.encode({
3718
+ type: "disclosure_request",
3719
+ connection_request_id: connectionRequestId,
3720
+ requested_items: report.unmet_requirements.map((u) => ({
3721
+ type: u.signal_type,
3722
+ condition: u.condition,
3723
+ })),
3724
+ });
3725
+ try {
3726
+ this.#persistentSignalingStream.send(lp.encode.single(disclosureFrame));
3727
+ }
3728
+ catch { /* stream closed */ }
3729
+ setTimeout(() => {
3730
+ const stillPending = this.#pendingInboundRequests.get(connectionRequestId);
3731
+ if (stillPending) {
3732
+ this.#pendingInboundRequests.delete(connectionRequestId);
3733
+ if (this.#persistentSignalingStream) {
3734
+ const timeoutFrame = CBOR_ENC.encode({
3735
+ type: "connection_response",
3736
+ connection_request_id: connectionRequestId,
3737
+ verdict: "reject",
3738
+ reason: "disclosure_timeout",
3739
+ });
3740
+ try {
3741
+ this.#persistentSignalingStream.send(lp.encode.single(timeoutFrame));
3742
+ }
3743
+ catch { /* stream closed */ }
3744
+ }
3745
+ }
3746
+ }, this.#round2TimeoutMs);
3747
+ return;
3748
+ }
3749
+ verdict = "insufficient";
3750
+ unmetRequirements = report.unmet_requirements;
3751
+ }
3752
+ else {
3753
+ // pending_agent_review: store for agent review and fire callback
3754
+ this.#pendingInboundRequests.set(connectionRequestId, {
3755
+ connection_request_id: connectionRequestId,
3756
+ from_pubkey: fromPubkey,
3757
+ package_cbor: packageCbor,
3758
+ round: 1,
3759
+ });
3760
+ const reviewItem = {
3761
+ connection_request_id: connectionRequestId,
3762
+ from_pubkey: fromPubkey,
3763
+ report,
3764
+ package_cbor: packageCbor,
3765
+ sender_registered_at: senderRegisteredAt,
3766
+ sender_is_provisional: senderIsProvisional,
3767
+ };
3768
+ // CONNREQ-003: Deliver to the first waiting awaitConnectionRequest() caller (FIFO),
3769
+ // or enqueue in #pendingReviewQueue if no callers are waiting.
3770
+ const awaitResolver = this.#pendingAwaitConnectionRequestResolvers.shift();
3771
+ if (awaitResolver) {
3772
+ // Direct delivery to waiting caller — do not add to #pendingReviewQueue
3773
+ awaitResolver({
3774
+ connection_request_id: connectionRequestId,
3775
+ from_pubkey: fromPubkey,
3776
+ report,
3777
+ });
3778
+ }
3779
+ else {
3780
+ // No caller waiting — enqueue for future awaitConnectionRequest() poll
3781
+ this.#pendingReviewQueue.push(reviewItem);
3782
+ }
3783
+ if (this.#onConnectionPendingReview) {
3784
+ this.#onConnectionPendingReview({
3785
+ type: "connection_request_inbound",
3786
+ from_pubkey: fromPubkey,
3787
+ connection_request_id: connectionRequestId,
3788
+ package_cbor: packageCbor,
3789
+ sender_registered_at: senderRegisteredAt,
3790
+ sender_is_provisional: senderIsProvisional,
3791
+ });
3792
+ }
3793
+ // Start round2TimeoutMs auto-reject timer
3794
+ if (this.#round2TimeoutMs > 0) {
3795
+ setTimeout(() => {
3796
+ const stillPending = this.#pendingInboundRequests.get(connectionRequestId);
3797
+ if (stillPending) {
3798
+ this.#pendingInboundRequests.delete(connectionRequestId);
3799
+ if (this.#persistentSignalingStream) {
3800
+ const responseFrame = CBOR_ENC.encode({
3801
+ type: "connection_response",
3802
+ connection_request_id: connectionRequestId,
3803
+ verdict: "reject",
3804
+ reason: "disclosure_timeout",
3805
+ });
3806
+ try {
3807
+ this.#persistentSignalingStream.send(lp.encode.single(responseFrame));
3808
+ }
3809
+ catch { /* stream closed */ }
3810
+ }
3811
+ }
3812
+ }, this.#round2TimeoutMs);
3813
+ }
3814
+ return; // do not send a connection_response yet
3815
+ }
3816
+ } // end of validatedPackage.valid else-branch
3817
+ }
3818
+ else {
3819
+ // pkg was null (decode failed) — verdict/rejectReason already set above
3820
+ }
3821
+ }
3822
+ // DB-003: cross-check logic — mark as unchecked when enabled (profile-query not yet implemented)
3823
+ if (verdict === "accept" && this.#crossCheckDirectoryOnInbound) {
3824
+ this.#profileUncheckedPeers.add(fromPubkey);
3825
+ }
3826
+ // Send connection_response to directory
3827
+ const responsePayload = {
3828
+ type: "connection_response",
3829
+ connection_request_id: connectionRequestId,
3830
+ verdict,
3831
+ };
3832
+ if (rejectReason !== undefined)
3833
+ responsePayload["reason"] = rejectReason;
3834
+ if (unmetRequirements !== undefined)
3835
+ responsePayload["unmet_requirements"] = unmetRequirements;
3836
+ const responseFrame = CBOR_ENC.encode(responsePayload);
3837
+ try {
3838
+ this.#persistentSignalingStream.send(lp.encode.single(responseFrame));
3839
+ }
3840
+ catch { /* stream closed — response lost */ }
3841
+ }
3842
+ /**
3843
+ * Handle a disclosure_response_inbound frame (B's side, Round 2).
3844
+ * Re-evaluates the updated package and sends final connection_response.
3845
+ */
3846
+ async #handleDisclosureResponse(frame) {
3847
+ if (!this.#persistentSignalingStream)
3848
+ return;
3849
+ const connectionRequestId = frame["connection_request_id"];
3850
+ const packageCborRaw = frame["package_cbor"];
3851
+ const packageCbor = packageCborRaw instanceof Uint8Array ? packageCborRaw
3852
+ : Buffer.isBuffer(packageCborRaw) ? new Uint8Array(packageCborRaw) : null;
3853
+ if (!connectionRequestId || !packageCbor)
3854
+ return;
3855
+ const pending = this.#pendingInboundRequests.get(connectionRequestId);
3856
+ if (!pending)
3857
+ return; // stale — already handled or timed out
3858
+ this.#pendingInboundRequests.delete(connectionRequestId);
3859
+ const fromPubkey = pending.from_pubkey;
3860
+ let verdict = "reject";
3861
+ let rejectReason;
3862
+ let unmetRequirements;
3863
+ if (!this.#connectionPolicy) {
3864
+ verdict = "accept";
3865
+ }
3866
+ else {
3867
+ let pkg;
3868
+ try {
3869
+ pkg = decodeConnectionPackage(packageCbor);
3870
+ }
3871
+ catch {
3872
+ verdict = "reject";
3873
+ rejectReason = "package_decode_failed";
3874
+ pkg = null;
3875
+ }
3876
+ if (pkg !== null) {
3877
+ const fromPubkeyBytes = Buffer.from(fromPubkey, "hex");
3878
+ const validatedPackage = validateConnectionPackage(pkg, fromPubkeyBytes, Date.now(), mlDsaVerify);
3879
+ if (!validatedPackage.valid) {
3880
+ verdict = "reject";
3881
+ rejectReason = validatedPackage.reason;
3882
+ }
3883
+ else {
3884
+ const context = {
3885
+ registered_at: 0,
3886
+ is_provisional: false,
3887
+ conversation_count: 0,
3888
+ clean_close_rate: 0,
3889
+ };
3890
+ if (this.#trackEvaluateCount) {
3891
+ this._evaluateCallCount++;
3892
+ }
3893
+ const { evaluateConnectionPackage } = await import("./connection-policy.js");
3894
+ const report = evaluateConnectionPackage(validatedPackage, this.#connectionPolicy, context, Date.now());
3895
+ if (report.verdict === "auto_accept") {
3896
+ verdict = "accept";
3897
+ }
3898
+ else if (report.verdict === "auto_reject") {
3899
+ verdict = "reject";
3900
+ rejectReason = report.reason;
3901
+ }
3902
+ else if (report.verdict === "auto_insufficient") {
3903
+ verdict = "insufficient";
3904
+ unmetRequirements = report.unmet_requirements;
3905
+ }
3906
+ else {
3907
+ // pending_agent_review in Round 2 (inference mode) — surface for agent review with is_round_2: true.
3908
+ // Build a Round 2 report by cloning the Round 1 report shape with is_round_2: true.
3909
+ const round2Report = { ...report, is_round_2: true };
3910
+ this.#pendingInboundRequests.set(connectionRequestId, {
3911
+ connection_request_id: connectionRequestId,
3912
+ from_pubkey: fromPubkey,
3913
+ package_cbor: packageCbor,
3914
+ round: 2,
3915
+ });
3916
+ const round2ReviewItem = {
3917
+ connection_request_id: connectionRequestId,
3918
+ from_pubkey: fromPubkey,
3919
+ report: round2Report,
3920
+ package_cbor: packageCbor,
3921
+ sender_registered_at: 0,
3922
+ sender_is_provisional: false,
3923
+ };
3924
+ const awaitResolver = this.#pendingAwaitConnectionRequestResolvers.shift();
3925
+ if (awaitResolver) {
3926
+ awaitResolver({
3927
+ connection_request_id: connectionRequestId,
3928
+ from_pubkey: fromPubkey,
3929
+ report: round2Report,
3930
+ });
3931
+ }
3932
+ else {
3933
+ this.#pendingReviewQueue.push(round2ReviewItem);
3934
+ }
3935
+ // No response yet — wait for agent to call accept/reject.
3936
+ return;
3937
+ }
3938
+ }
3939
+ }
3940
+ else {
3941
+ // pkg was null (decode failed) — verdict already set
3942
+ }
3943
+ }
3944
+ const responsePayload = {
3945
+ type: "connection_response",
3946
+ connection_request_id: connectionRequestId,
3947
+ verdict,
3948
+ };
3949
+ if (rejectReason !== undefined)
3950
+ responsePayload["reason"] = rejectReason;
3951
+ if (unmetRequirements !== undefined)
3952
+ responsePayload["unmet_requirements"] = unmetRequirements;
3953
+ const responseFrame = CBOR_ENC.encode(responsePayload);
3954
+ try {
3955
+ this.#persistentSignalingStream.send(lp.encode.single(responseFrame));
3956
+ }
3957
+ catch { /* stream closed */ }
3958
+ }
3959
+ // ─── ADAPTER-003: initiateSession ──────────────────────────────────────────
3960
+ /**
3961
+ * Send a `session_request` over the persistent directory signaling stream and await
3962
+ * the `session_assignment` or error response.
3963
+ *
3964
+ * PSEUDOCODE (Phase P — ADAPTER-003):
3965
+ *
3966
+ * initiateSession(targetPubkeyHex, opts):
3967
+ * 1. If no #directoryEndpoint configured → return { ok: false, reason: 'directory_unreachable' }
3968
+ * 2. Ensure #myPubkeyHex is set (read from keyProvider if not yet set)
3969
+ * 3. Open persistent signaling stream if not already open (DB-001: single retry on failure)
3970
+ * If stream still cannot be opened → return { ok: false, reason: 'directory_unreachable' }
3971
+ * 4. Encode session_request frame inline using CBOR (no @cello-protocol/directory import):
3972
+ * CBOR({ type: "session_request", target_pubkey: Buffer.from(targetPubkeyHex, 'hex') })
3973
+ * SI-001: only target_pubkey, no extra fields, no key material
3974
+ * 5. Create response Promise:
3975
+ * Create resolve fn, store in #pendingSessionRequestResolve
3976
+ * 6. Send frame on #persistentSignalingStream
3977
+ * 7. Race: response Promise vs timeout (opts.timeoutMs ?? DEFAULT_INITIATE_TIMEOUT_MS)
3978
+ * 8. On timeout:
3979
+ * Clear #pendingSessionRequestResolve
3980
+ * Return { ok: false, reason: 'timeout' }
3981
+ * 9. On session_request_error frame:
3982
+ * reason = frame['reason'] ('target_offline' | 'relay_unavailable')
3983
+ * Return { ok: false, reason }
3984
+ * 10. On session_assignment frame:
3985
+ * Decode assignment fields from frame['assignment']
3986
+ * Call receiveSessionAssignment(assignment, myPubkey)
3987
+ * If ok:true → return { ok: true, sessionId, genesisPrevRoot }
3988
+ * If ok:false → return { ok: false, reason: 'directory_unreachable' }
3989
+ *
3990
+ * SI-002: K_local private key never appears in frame, response, or log output.
3991
+ * keyProvider.getPublicKey() returns only the public key.
3992
+ */
3993
+ async initiateSession(targetPubkeyHex, opts) {
3994
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_INITIATE_TIMEOUT_MS;
3995
+ // SESSION-006: check local connections map before touching the signaling stream.
3996
+ // If we have a connection policy configured (M3 mode), require a connection.
3997
+ // If no connection policy (M2 mode), skip the gate.
3998
+ const connectionId = this.#connectionsByPeer.get(targetPubkeyHex);
3999
+ if (this.#connectionPolicy !== undefined && !connectionId) {
4000
+ return { ok: false, reason: "no_connection" };
4001
+ }
4002
+ if (!this.#directoryEndpoint && !opts?.directoryPeerId) {
4003
+ return { ok: false, reason: "directory_unreachable" };
4004
+ }
4005
+ // Ensure myPubkeyHex is set (needed for receiveSessionAssignment)
4006
+ if (!this.#myPubkeyHex) {
4007
+ const pubkey = await this.#keyProvider.getPublicKey();
4008
+ this.#myPubkeyHex = Buffer.from(pubkey).toString("hex");
4009
+ }
4010
+ const myPubkey = Buffer.from(this.#myPubkeyHex, "hex");
4011
+ // Open persistent signaling stream if not already open (DB-001)
4012
+ if (!this.#persistentSignalingStream) {
4013
+ const opened = await this.#openPersistentSignalingStream(opts?.directoryPeerId, opts?.directoryMultiaddr);
4014
+ if (!opened) {
4015
+ const retried = await this.#openPersistentSignalingStream(opts?.directoryPeerId, opts?.directoryMultiaddr);
4016
+ if (!retried) {
4017
+ return { ok: false, reason: "directory_unreachable" };
4018
+ }
4019
+ }
4020
+ }
4021
+ // SESSION-006: session_request frame includes connection_id if we have one
4022
+ // Encoded inline with raw CBOR — no import from @cello-protocol/directory
4023
+ const targetPubkeyBytes = Buffer.from(targetPubkeyHex, "hex");
4024
+ const sessionRequestPayload = {
4025
+ type: "session_request",
4026
+ target_pubkey: new Uint8Array(targetPubkeyBytes),
4027
+ };
4028
+ if (connectionId) {
4029
+ sessionRequestPayload["connection_id"] = connectionId;
4030
+ }
4031
+ const sessionRequestFrame = CBOR_ENC.encode(sessionRequestPayload);
4032
+ // Set up Promise that resolves when the directory responds
4033
+ let responseResolve;
4034
+ const responsePromise = new Promise((resolve) => {
4035
+ responseResolve = resolve;
4036
+ });
4037
+ this.#pendingSessionRequestResolve = responseResolve;
4038
+ try {
4039
+ this.#persistentSignalingStream.send(lp.encode.single(sessionRequestFrame));
4040
+ }
4041
+ catch {
4042
+ this.#pendingSessionRequestResolve = null;
4043
+ return { ok: false, reason: "directory_unreachable" };
4044
+ }
4045
+ // Race: directory response vs timeout
4046
+ let responseFrame = null;
4047
+ let timedOut = false;
4048
+ await Promise.race([
4049
+ responsePromise.then((f) => { responseFrame = f; }),
4050
+ new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, timeoutMs)),
4051
+ ]);
4052
+ if (timedOut) {
4053
+ this.#pendingSessionRequestResolve = null;
4054
+ return { ok: false, reason: "timeout" };
4055
+ }
4056
+ const frame = responseFrame;
4057
+ if (frame["type"] === "session_request_error") {
4058
+ const reason = frame["reason"];
4059
+ if (reason === "target_offline")
4060
+ return { ok: false, reason: "target_offline" };
4061
+ if (reason === "relay_unavailable")
4062
+ return { ok: false, reason: "relay_unavailable" };
4063
+ if (reason === "frost_signer_not_configured")
4064
+ return { ok: false, reason: "frost_signer_not_configured" };
4065
+ if (reason === "directory_below_threshold")
4066
+ return { ok: false, reason: "directory_below_threshold" };
4067
+ if (reason === "ceremony_conflict")
4068
+ return { ok: false, reason: "ceremony_conflict" };
4069
+ if (reason === "no_connection")
4070
+ return { ok: false, reason: "no_connection" };
4071
+ if (reason === "connection_id_required")
4072
+ return { ok: false, reason: "no_connection" };
4073
+ return { ok: false, reason: "directory_unreachable" };
4074
+ }
4075
+ if (frame["type"] === "session_assignment") {
4076
+ const rawAssignment = frame["assignment"];
4077
+ if (!rawAssignment)
4078
+ return { ok: false, reason: "directory_unreachable" };
4079
+ const assignment = parseSessionAssignment(rawAssignment);
4080
+ if (!assignment)
4081
+ return { ok: false, reason: "directory_unreachable" };
4082
+ const result = await this.receiveSessionAssignment(assignment, myPubkey);
4083
+ if (!result.ok) {
4084
+ return { ok: false, reason: "directory_unreachable" };
4085
+ }
4086
+ const sessionIdHex = Buffer.from(result.sessionId).toString("hex");
4087
+ const record = this.#sessions.get(sessionIdHex);
4088
+ if (!record)
4089
+ return { ok: false, reason: "directory_unreachable" };
4090
+ return {
4091
+ ok: true,
4092
+ sessionId: result.sessionId,
4093
+ genesisPrevRoot: record.genesis_prev_root,
4094
+ };
4095
+ }
4096
+ return { ok: false, reason: "directory_unreachable" };
4097
+ }
4098
+ /**
4099
+ * Open and authenticate the persistent directory signaling stream.
4100
+ * Returns true on success, false on failure.
4101
+ * Serializes concurrent calls: if an open is already in flight, awaits it instead of
4102
+ * starting another.
4103
+ *
4104
+ * Auth protocol: "CELLO-DIR-AUTH-v1" challenge-response (same as session-level streams).
4105
+ * Signature: Ed25519(SHA-256("CELLO-DIR-AUTH-v1" || nonce || pubkey), privkey)
4106
+ * per RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
4107
+ */
4108
+ #openPersistentSignalingStream(directoryPeerId, directoryMultiaddr) {
4109
+ // If stream is already open, nothing to do
4110
+ if (this.#persistentSignalingStream)
4111
+ return Promise.resolve(true);
4112
+ // If an open is already in flight, share its result
4113
+ if (this.#openingSignalingStream)
4114
+ return this.#openingSignalingStream;
4115
+ const p = this.#doOpenPersistentSignalingStream(directoryPeerId, directoryMultiaddr).finally(() => {
4116
+ if (this.#openingSignalingStream === p) {
4117
+ this.#openingSignalingStream = null;
4118
+ }
4119
+ });
4120
+ this.#openingSignalingStream = p;
4121
+ return p;
4122
+ }
4123
+ async #doOpenPersistentSignalingStream(directoryPeerId, directoryMultiaddr) {
4124
+ if (!this.#directoryEndpoint && !directoryPeerId)
4125
+ return false;
4126
+ if (this.#persistentSignalingStream)
4127
+ return true;
4128
+ const dirPeerId = directoryPeerId ?? this.#directoryEndpoint.peer_id;
4129
+ const dirMultiaddr = directoryMultiaddr ?? this.#directoryEndpoint?.multiaddrs[0];
4130
+ try {
4131
+ if (dirMultiaddr) {
4132
+ try {
4133
+ await this.#node.dial(dirMultiaddr);
4134
+ }
4135
+ catch { /* non-fatal */ }
4136
+ }
4137
+ let sigStream;
4138
+ try {
4139
+ sigStream = await this.#node.newStream(dirPeerId, SIGNALING_PROTOCOL_ID);
4140
+ }
4141
+ catch {
4142
+ return false;
4143
+ }
4144
+ const iter = lp.decode(sigStream)[Symbol.asyncIterator]();
4145
+ const { value: challengeRaw, done } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
4146
+ if (done || challengeRaw === undefined) {
4147
+ sigStream.abort(new Error("dir_auth_error"));
4148
+ return false;
4149
+ }
4150
+ let challengeFrame;
4151
+ try {
4152
+ challengeFrame = decode(toU8(challengeRaw));
4153
+ }
4154
+ catch {
4155
+ sigStream.abort(new Error("dir_auth_error"));
4156
+ return false;
4157
+ }
4158
+ if (challengeFrame["type"] !== "signaling_auth_challenge") {
4159
+ sigStream.abort(new Error("dir_auth_error"));
4160
+ return false;
4161
+ }
4162
+ const nonce = toU8(challengeFrame["nonce"]);
4163
+ if (nonce.length !== 32) {
4164
+ sigStream.abort(new Error("dir_auth_error"));
4165
+ return false;
4166
+ }
4167
+ if (!this.#myPubkeyHex) {
4168
+ const pubkey = await this.#keyProvider.getPublicKey();
4169
+ this.#myPubkeyHex = Buffer.from(pubkey).toString("hex");
4170
+ }
4171
+ const myPubkey = Buffer.from(this.#myPubkeyHex, "hex");
4172
+ const domain = Buffer.from(AUTH_DOMAIN_DIR, "utf8");
4173
+ const authMsg = new Uint8Array(Buffer.concat([domain, nonce, myPubkey]));
4174
+ const msgHash = new Uint8Array(createHash("sha256").update(authMsg).digest());
4175
+ const sig = await this.#keyProvider.sign(msgHash);
4176
+ const authResponseFrame = CBOR_ENC.encode({
4177
+ type: "signaling_auth_response",
4178
+ pubkey: myPubkey,
4179
+ signature: sig,
4180
+ });
4181
+ sigStream.send(lp.encode.single(authResponseFrame));
4182
+ const { value: ackRaw, done: ackDone } = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
4183
+ if (ackDone || ackRaw === undefined) {
4184
+ sigStream.abort(new Error("dir_auth_error"));
4185
+ return false;
4186
+ }
4187
+ let ackFrame;
4188
+ try {
4189
+ ackFrame = decode(toU8(ackRaw));
4190
+ }
4191
+ catch {
4192
+ sigStream.abort(new Error("dir_auth_error"));
4193
+ return false;
4194
+ }
4195
+ if (ackFrame["type"] !== "signaling_auth_ok") {
4196
+ sigStream.abort(new Error("dir_auth_error"));
4197
+ return false;
4198
+ }
4199
+ const peerInfoFrame = CBOR_ENC.encode({
4200
+ type: "peer_info_announce",
4201
+ peer_id: this.#node.getPeerId(),
4202
+ multiaddrs: this.#node.listenAddresses(),
4203
+ });
4204
+ sigStream.send(lp.encode.single(peerInfoFrame));
4205
+ this.#persistentSignalingStream = sigStream;
4206
+ this.#persistentSignalingIter = iter;
4207
+ void this.#runPersistentSignalingReader(sigStream, iter);
4208
+ return true;
4209
+ }
4210
+ catch {
4211
+ return false;
4212
+ }
4213
+ }
4214
+ /**
4215
+ * Persistent signaling stream reader loop.
4216
+ * Handles frames from the directory on the shared signaling stream:
4217
+ * - session_assignment → routes to #pendingSessionRequestResolve (outbound init)
4218
+ * or fires #onSessionAssignmentHandler (inbound assignment, participant B)
4219
+ * - session_request_error → routes to #pendingSessionRequestResolve
4220
+ * - session_sealed → routes to the matching session's seal handler
4221
+ * - session_seal_rejected → routes to the matching session's seal handler
4222
+ */
4223
+ async #runPersistentSignalingReader(stream, iter) {
4224
+ try {
4225
+ while (true) {
4226
+ let result;
4227
+ try {
4228
+ result = await iter.next();
4229
+ }
4230
+ catch {
4231
+ break;
4232
+ }
4233
+ if (result.done || result.value === undefined)
4234
+ break;
4235
+ let frame;
4236
+ try {
4237
+ frame = decode(toU8(result.value));
4238
+ }
4239
+ catch {
4240
+ continue;
4241
+ }
4242
+ if (frame["type"] === "session_assignment" || frame["type"] === "session_request_error") {
4243
+ // Route to pending session_request resolver (if one is waiting)
4244
+ const resolve = this.#pendingSessionRequestResolve;
4245
+ if (resolve) {
4246
+ this.#pendingSessionRequestResolve = null;
4247
+ resolve(frame);
4248
+ }
4249
+ else if (frame["type"] === "session_assignment") {
4250
+ // No pending outbound request — this is an inbound assignment (participant B role).
4251
+ // Call receiveSessionAssignment and fire the onSessionAssignment handler.
4252
+ const rawAssignment = frame["assignment"];
4253
+ if (rawAssignment) {
4254
+ const assignment = parseSessionAssignment(rawAssignment);
4255
+ if (assignment && this.#myPubkeyHex) {
4256
+ const myPubkey = Buffer.from(this.#myPubkeyHex, "hex");
4257
+ void this.receiveSessionAssignment(assignment, myPubkey);
4258
+ }
4259
+ }
4260
+ }
4261
+ }
4262
+ else if (frame["type"] === "session_sealed") {
4263
+ // Route to matching session's seal handler
4264
+ const sessionIdRaw = frame["session_id"];
4265
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4266
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4267
+ if (sessionId) {
4268
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4269
+ const session = this.#sessions.get(sessionIdHex);
4270
+ if (session) {
4271
+ this.#handleDirectorySessionSealed(sessionIdHex, frame, session.directory_pubkey);
4272
+ }
4273
+ }
4274
+ }
4275
+ else if (frame["type"] === "session_seal_rejected") {
4276
+ const sessionIdRaw = frame["session_id"];
4277
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4278
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4279
+ if (sessionId) {
4280
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4281
+ this.#handleDirectorySessionSealRejected(sessionIdHex, frame);
4282
+ }
4283
+ }
4284
+ else if (frame["type"] === "seal_rejected_tree_mismatch") {
4285
+ // PERSIST-014: tree mismatch — route to reconciliation handler
4286
+ const sessionIdRaw = frame["session_id"];
4287
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4288
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4289
+ if (sessionId) {
4290
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4291
+ this.#handleSealRejectedTreeMismatch(sessionIdHex, frame);
4292
+ }
4293
+ }
4294
+ else if (frame["type"] === "seal_unilateral_confirmed") {
4295
+ // PERSIST-015: unilateral seal confirmed — route to pending initiateUnilateralSeal caller and handler
4296
+ const sessionIdRaw = frame["session_id"];
4297
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4298
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4299
+ if (sessionId) {
4300
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4301
+ this.#handleSealUnilateralConfirmed(sessionIdHex, frame);
4302
+ }
4303
+ const unilateralResolve = this.#pendingUnilateralSealResolve;
4304
+ if (unilateralResolve) {
4305
+ this.#pendingUnilateralSealResolve = null;
4306
+ unilateralResolve(frame);
4307
+ }
4308
+ }
4309
+ else if (frame["type"] === "seal_unilateral_notification") {
4310
+ // PERSIST-015: absent party receives unilateral seal notification
4311
+ const sessionIdRaw = frame["session_id"];
4312
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4313
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4314
+ if (sessionId) {
4315
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4316
+ this.#handleSealUnilateralNotification(sessionIdHex, frame);
4317
+ }
4318
+ }
4319
+ else if (frame["type"] === "seal_unilateral_too_early") {
4320
+ // PERSIST-015: directory rejects unilateral seal — grace period not elapsed
4321
+ const sessionIdRaw = frame["session_id"];
4322
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4323
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4324
+ if (sessionId) {
4325
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4326
+ const remainingSeconds = typeof frame["remaining_seconds"] === "number" ? frame["remaining_seconds"] : 0;
4327
+ this.#logger.warn("session.unilateral.too.early", { sessionId: sessionIdHex, remainingSeconds });
4328
+ }
4329
+ const unilateralResolve = this.#pendingUnilateralSealResolve;
4330
+ if (unilateralResolve) {
4331
+ this.#pendingUnilateralSealResolve = null;
4332
+ unilateralResolve(frame);
4333
+ }
4334
+ }
4335
+ else if (frame["type"] === "seal_verified") {
4336
+ // SESSION-005: route seal_verified to the FROST ceremony handler.
4337
+ // This frame arrives when the directory has verified the Merkle tree and
4338
+ // the initiator must now participate in the FROST ceremony.
4339
+ const sessionIdRaw = frame["session_id"];
4340
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4341
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4342
+ if (sessionId) {
4343
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4344
+ void this.#handleSealVerified(sessionIdHex, frame);
4345
+ }
4346
+ }
4347
+ else if (frame["type"] === "session_frost_sealed") {
4348
+ // SESSION-005: deferred FROST seal completed — upgrade bilateral → frost.
4349
+ const sessionIdRaw = frame["session_id"];
4350
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
4351
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw) : null;
4352
+ if (sessionId) {
4353
+ const sessionIdHex = Buffer.from(sessionId).toString("hex");
4354
+ this.#handleSessionFrostSealed(sessionIdHex, frame);
4355
+ }
4356
+ }
4357
+ else if (frame["type"] === "ceremony_request") {
4358
+ // Directory asks the client to coordinate a FROST ceremony.
4359
+ // Client runs participateInCeremony and sends ceremony_result back.
4360
+ void this.#handleCeremonyRequest(stream, frame);
4361
+ }
4362
+ else if (frame["type"] === "dkg_ready") {
4363
+ // REG-001: directory is ready for DKG. Route to pending register() caller.
4364
+ const resolve = this.#pendingDkgReadyResolve;
4365
+ if (resolve) {
4366
+ this.#pendingDkgReadyResolve = null;
4367
+ resolve(frame);
4368
+ }
4369
+ }
4370
+ else if (frame["type"] === "register_success" || frame["type"] === "register_error") {
4371
+ // REG-001: route registration response to pending register() caller.
4372
+ // already_registered arrives before dkg_ready (directory skips DKG entirely).
4373
+ // Route to both resolvers so register() unblocks regardless of which stage it is at.
4374
+ const dkgResolve = this.#pendingDkgReadyResolve;
4375
+ if (dkgResolve) {
4376
+ this.#pendingDkgReadyResolve = null;
4377
+ dkgResolve(frame);
4378
+ }
4379
+ const resolve = this.#pendingRegisterResolve;
4380
+ if (resolve) {
4381
+ this.#pendingRegisterResolve = null;
4382
+ resolve(frame);
4383
+ }
4384
+ }
4385
+ else if (frame["type"] === "connection_established" ||
4386
+ frame["type"] === "connection_rejected" ||
4387
+ frame["type"] === "connection_insufficient" ||
4388
+ frame["type"] === "connection_request_error" ||
4389
+ frame["type"] === "disclosure_request_inbound") {
4390
+ // CONNREQ-002/CONNREQ-003: route connection outcome frames.
4391
+ //
4392
+ // CONNREQ-003: The single-slot #pendingConnectionRequestResolve is replaced by
4393
+ // #pendingConnectionRequestResolvers (Map keyed by target pubkey hex).
4394
+ // Routing uses counterparty_pubkey (for established) or target_pubkey (for errors)
4395
+ // to find the exact resolver for this target — ensuring cross-target isolation (SI-001).
4396
+ if (frame["type"] === "connection_established") {
4397
+ // Store connection record locally (applies to both sender A and target B)
4398
+ const connectionId = frame["connection_id"];
4399
+ const counterpartyPubkey = frame["counterparty_pubkey"];
4400
+ if (connectionId && counterpartyPubkey) {
4401
+ if (!this.#connections.has(connectionId)) {
4402
+ const record = {
4403
+ connection_id: connectionId,
4404
+ counterparty_pubkey: counterpartyPubkey,
4405
+ counterparty_primary_pubkey: "",
4406
+ counterparty_ml_dsa_pubkey: "",
4407
+ established_at: Date.now(),
4408
+ status: "active",
4409
+ };
4410
+ if (this.#profileUncheckedPeers.has(counterpartyPubkey)) {
4411
+ record.profile_unchecked = true;
4412
+ this.#profileUncheckedPeers.delete(counterpartyPubkey);
4413
+ }
4414
+ this.#connections.set(connectionId, record);
4415
+ this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
4416
+ }
4417
+ // Fire onConnectionEstablished handler (both A and B)
4418
+ const handler = this.#onConnectionEstablishedHandler;
4419
+ if (handler) {
4420
+ handler({ type: "connection_established", counterparty_pubkey: counterpartyPubkey, connection_id: connectionId });
4421
+ }
4422
+ }
4423
+ // CONNREQ-003: Route to the resolver for this specific target (keyed by counterparty pubkey).
4424
+ // SI-001: only the resolver waiting for counterpartyPubkey is unblocked; no cross-target routing.
4425
+ const resolve = this.#pendingConnectionRequestResolvers.get(counterpartyPubkey);
4426
+ if (resolve) {
4427
+ this.#pendingConnectionRequestResolvers.delete(counterpartyPubkey);
4428
+ resolve(frame);
4429
+ }
4430
+ else {
4431
+ // Round 2: route to disclosure resolver if pending (no in-flight connection_request)
4432
+ for (const [id, disclosureResolve] of this.#pendingDisclosureResolvers) {
4433
+ this.#pendingDisclosureResolvers.delete(id);
4434
+ disclosureResolve(frame);
4435
+ break;
4436
+ }
4437
+ }
4438
+ }
4439
+ else if (frame["type"] === "disclosure_request_inbound") {
4440
+ // CONNREQ-002 Round 2: target requests more disclosure → fire onDisclosureRequested
4441
+ const disclosureHandler = this.#onDisclosureRequestedHandler;
4442
+ if (disclosureHandler) {
4443
+ disclosureHandler({
4444
+ type: "disclosure_request_inbound",
4445
+ from_pubkey: frame["from_pubkey"],
4446
+ connection_request_id: frame["connection_request_id"],
4447
+ requested_items: frame["requested_items"] ?? [],
4448
+ });
4449
+ }
4450
+ // CONNREQ-003: disclosure_request_inbound carries from_pubkey (= target).
4451
+ // Route to the resolver for this target.
4452
+ const targetPubkeyForDisclosure = frame["from_pubkey"];
4453
+ const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForDisclosure);
4454
+ if (resolve) {
4455
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyForDisclosure);
4456
+ resolve(frame);
4457
+ }
4458
+ }
4459
+ else {
4460
+ // connection_rejected, connection_insufficient, connection_request_error
4461
+ // These frames carry target_pubkey so we can route to the correct resolver.
4462
+ const targetPubkeyForError = frame["target_pubkey"];
4463
+ if (targetPubkeyForError && this.#pendingConnectionRequestResolvers.has(targetPubkeyForError)) {
4464
+ const resolve = this.#pendingConnectionRequestResolvers.get(targetPubkeyForError);
4465
+ this.#pendingConnectionRequestResolvers.delete(targetPubkeyForError);
4466
+ resolve(frame);
4467
+ }
4468
+ else if (this.#pendingConnectionRequestResolvers.size === 1) {
4469
+ // Fallback: if exactly one request is in flight and frame has no target_pubkey,
4470
+ // route to that single resolver (backward-compatible with pre-CONNREQ-003 directory).
4471
+ const [singleKey, singleResolve] = this.#pendingConnectionRequestResolvers.entries().next().value;
4472
+ this.#pendingConnectionRequestResolvers.delete(singleKey);
4473
+ singleResolve(frame);
4474
+ }
4475
+ else {
4476
+ // Round 2: route to disclosure resolver if pending
4477
+ for (const [id, disclosureResolve] of this.#pendingDisclosureResolvers) {
4478
+ this.#pendingDisclosureResolvers.delete(id);
4479
+ disclosureResolve(frame);
4480
+ break;
4481
+ }
4482
+ }
4483
+ }
4484
+ }
4485
+ else if (frame["type"] === "connection_request_inbound") {
4486
+ // CONNREQ-002: inbound connection request for this client (B's side).
4487
+ // Evaluate the package using connectionPolicy and send connection_response.
4488
+ void this.#handleInboundConnectionRequest(frame);
4489
+ }
4490
+ else if (frame["type"] === "disclosure_response_inbound") {
4491
+ // CONNREQ-002 Round 2: A provided updated package → re-evaluate and send response.
4492
+ void this.#handleDisclosureResponse(frame);
4493
+ }
4494
+ }
4495
+ }
4496
+ catch { /* stream closed */ }
4497
+ // Stream closed — clear persistent stream ref
4498
+ if (this.#persistentSignalingStream === stream) {
4499
+ this.#persistentSignalingStream = null;
4500
+ this.#persistentSignalingIter = null;
4501
+ }
4502
+ // If a register() call is pending (dkg_ready phase), unblock it with a synthetic error
4503
+ const dkgReadyResolve = this.#pendingDkgReadyResolve;
4504
+ if (dkgReadyResolve) {
4505
+ this.#pendingDkgReadyResolve = null;
4506
+ dkgReadyResolve({ type: "register_error", reason: "stream_closed" });
4507
+ }
4508
+ // If a register() call is pending (register_success phase), unblock it with a synthetic error
4509
+ const regResolve = this.#pendingRegisterResolve;
4510
+ if (regResolve) {
4511
+ this.#pendingRegisterResolve = null;
4512
+ regResolve({ type: "register_error", reason: "stream_closed" });
4513
+ }
4514
+ // If a session_request is pending, unblock it with a synthetic error
4515
+ const resolve = this.#pendingSessionRequestResolve;
4516
+ if (resolve) {
4517
+ this.#pendingSessionRequestResolve = null;
4518
+ resolve({ type: "session_request_error", reason: "directory_unreachable" });
4519
+ }
4520
+ // CONNREQ-003: Unblock all pending connection request resolvers (AC-005)
4521
+ // When the signaling stream closes, all in-flight cello_request_connection calls
4522
+ // receive a synthetic connection_request_error so they can return directory_unreachable.
4523
+ for (const [targetPubkey, pendingResolve] of this.#pendingConnectionRequestResolvers) {
4524
+ this.#pendingConnectionRequestResolvers.delete(targetPubkey);
4525
+ pendingResolve({ type: "connection_request_error", reason: "directory_unreachable", target_pubkey: targetPubkey });
4526
+ }
4527
+ }
4528
+ }
4529
+ // ─── Stream iterator helpers ──────────────────────────────────────────────────
4530
+ // ─── ADAPTER-003: parseSessionAssignment ─────────────────────────────────────
4531
+ /**
4532
+ * Decode a raw CBOR-decoded object (from frame["assignment"]) into a typed SessionAssignment.
4533
+ * Returns null if any required field is missing or malformed.
4534
+ *
4535
+ * The object is already decoded by cbor-x from the outer frame — this function just
4536
+ * validates and casts the fields. No @cello-protocol/directory import needed.
4537
+ *
4538
+ * Wire shape (from encodeSessionAssignment in directory-frames.ts):
4539
+ * {
4540
+ * session_id: Uint8Array (16),
4541
+ * participant_a: { pubkey: Uint8Array (32), peer_id: string, multiaddrs: string[] },
4542
+ * participant_b: { pubkey: Uint8Array (32), peer_id: string, multiaddrs: string[] },
4543
+ * relay_endpoint: { peer_id: string, multiaddrs: string[] },
4544
+ * directory_endpoint: { peer_id: string, multiaddrs: string[] },
4545
+ * session_timestamp: number,
4546
+ * directory_pubkey: Uint8Array (32),
4547
+ * directory_signature: Uint8Array (64),
4548
+ * }
4549
+ */
4550
+ function parseSessionAssignment(raw) {
4551
+ const sessionId = toU8Safe(raw["session_id"]);
4552
+ if (!sessionId || sessionId.length !== 16)
4553
+ return null;
4554
+ const dirPubkey = toU8Safe(raw["directory_pubkey"]);
4555
+ if (!dirPubkey || dirPubkey.length !== 32)
4556
+ return null;
4557
+ const dirSig = toU8Safe(raw["directory_signature"]);
4558
+ if (!dirSig || dirSig.length !== 64)
4559
+ return null;
4560
+ const tsRaw = raw["session_timestamp"];
4561
+ const sessionTimestamp = typeof tsRaw === "number" ? tsRaw
4562
+ : typeof tsRaw === "bigint" ? Number(tsRaw) : null;
4563
+ if (sessionTimestamp === null)
4564
+ return null;
4565
+ const participantA = parseParticipantInfo(raw["participant_a"]);
4566
+ if (!participantA)
4567
+ return null;
4568
+ const participantB = parseParticipantInfo(raw["participant_b"]);
4569
+ if (!participantB)
4570
+ return null;
4571
+ const relayEndpoint = parseEndpointInfo(raw["relay_endpoint"]);
4572
+ if (!relayEndpoint)
4573
+ return null;
4574
+ const directoryEndpoint = parseEndpointInfo(raw["directory_endpoint"]);
4575
+ if (!directoryEndpoint)
4576
+ return null;
4577
+ const sigType = typeof raw["signature_type"] === "string" ? raw["signature_type"] : "single";
4578
+ if (sigType === "frost") {
4579
+ const signerPubkey = toU8Safe(raw["signer_pubkey"]);
4580
+ if (!signerPubkey || signerPubkey.length !== 32)
4581
+ return null;
4582
+ return {
4583
+ session_id: sessionId,
4584
+ participant_a: participantA,
4585
+ participant_b: participantB,
4586
+ relay_endpoint: relayEndpoint,
4587
+ directory_endpoint: directoryEndpoint,
4588
+ session_timestamp: sessionTimestamp,
4589
+ directory_pubkey: dirPubkey,
4590
+ directory_signature: dirSig,
4591
+ signature_type: "frost",
4592
+ signer_pubkey: signerPubkey,
4593
+ };
4594
+ }
4595
+ return {
4596
+ session_id: sessionId,
4597
+ participant_a: participantA,
4598
+ participant_b: participantB,
4599
+ relay_endpoint: relayEndpoint,
4600
+ directory_endpoint: directoryEndpoint,
4601
+ session_timestamp: sessionTimestamp,
4602
+ directory_pubkey: dirPubkey,
4603
+ directory_signature: dirSig,
4604
+ signature_type: "single",
4605
+ };
4606
+ }
4607
+ function parseParticipantInfo(raw) {
4608
+ if (typeof raw !== "object" || raw === null)
4609
+ return null;
4610
+ const r = raw;
4611
+ const pubkey = toU8Safe(r["pubkey"]);
4612
+ if (!pubkey || pubkey.length !== 32)
4613
+ return null;
4614
+ const peerId = typeof r["peer_id"] === "string" ? r["peer_id"] : null;
4615
+ if (!peerId)
4616
+ return null;
4617
+ const multiaddrs = parseStringArray(r["multiaddrs"]);
4618
+ if (!multiaddrs)
4619
+ return null;
4620
+ return { pubkey, peer_id: peerId, multiaddrs };
4621
+ }
4622
+ function parseEndpointInfo(raw) {
4623
+ if (typeof raw !== "object" || raw === null)
4624
+ return null;
4625
+ const r = raw;
4626
+ const peerId = typeof r["peer_id"] === "string" ? r["peer_id"] : null;
4627
+ if (!peerId)
4628
+ return null;
4629
+ const multiaddrs = parseStringArray(r["multiaddrs"]);
4630
+ if (!multiaddrs)
4631
+ return null;
4632
+ return { peer_id: peerId, multiaddrs };
4633
+ }
4634
+ function parseStringArray(v) {
4635
+ if (!Array.isArray(v))
4636
+ return null;
4637
+ if (!v.every((x) => typeof x === "string"))
4638
+ return null;
4639
+ return v;
4640
+ }
4641
+ // ─── Factory ─────────────────────────────────────────────────────────────────
4642
+ export function createClient(node, keyProvider, opts) {
4643
+ return new CelloClientImpl(node, keyProvider, opts?.onMessageQueued, opts?.contentGraceMs, opts?.reconnectTimeoutMs, opts?.thresholdSigner, opts?.sealFrostTimeoutMs, opts?.directoryEndpoint ?? null, opts?.mlDsaKeyFile, opts?.connectionPolicy, opts?.connectionTimeoutMs, opts?.round2TimeoutMs, opts?.trackEvaluateCount, opts?.whitelist, opts?.onConnectionPendingReview, opts?.crossCheckDirectoryOnInbound, opts?.logger);
4644
+ }
4645
+ // ─── Error helpers ────────────────────────────────────────────────────────────
4646
+ function isStructuredError(err, reason) {
4647
+ return (typeof err === "object" &&
4648
+ err !== null &&
4649
+ "reason" in err &&
4650
+ err.reason === reason);
4651
+ }
4652
+ function mapSendError(err) {
4653
+ if (isStructuredError(err, "node_stopped"))
4654
+ return "transport_not_started";
4655
+ if (isStructuredError(err, "connection_lost"))
4656
+ return "connection_lost";
4657
+ if (isStructuredError(err, "protocol_not_supported"))
4658
+ return "peer_unreachable";
4659
+ const msg = err instanceof Error ? err.message : String(err);
4660
+ if (msg.includes("reset") || msg.includes("aborted"))
4661
+ return "remote_rejected";
4662
+ return "connection_lost";
4663
+ }
4664
+ //# sourceMappingURL=client.js.map