@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.
- package/dist/agent-hash-queue.d.ts +206 -0
- package/dist/agent-hash-queue.d.ts.map +1 -0
- package/dist/agent-hash-queue.js +380 -0
- package/dist/agent-hash-queue.js.map +1 -0
- package/dist/backup-key-derivation.d.ts +37 -0
- package/dist/backup-key-derivation.d.ts.map +1 -0
- package/dist/backup-key-derivation.js +48 -0
- package/dist/backup-key-derivation.js.map +1 -0
- package/dist/client-backup.d.ts +144 -0
- package/dist/client-backup.d.ts.map +1 -0
- package/dist/client-backup.js +273 -0
- package/dist/client-backup.js.map +1 -0
- package/dist/client.d.ts +249 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +4664 -0
- package/dist/client.js.map +1 -0
- package/dist/connection-policy.d.ts +163 -0
- package/dist/connection-policy.d.ts.map +1 -0
- package/dist/connection-policy.js +248 -0
- package/dist/connection-policy.js.map +1 -0
- package/dist/db-key-derivation.d.ts +26 -0
- package/dist/db-key-derivation.d.ts.map +1 -0
- package/dist/db-key-derivation.js +37 -0
- package/dist/db-key-derivation.js.map +1 -0
- package/dist/encrypted-file-signing-key-provider.d.ts +92 -0
- package/dist/encrypted-file-signing-key-provider.d.ts.map +1 -0
- package/dist/encrypted-file-signing-key-provider.js +251 -0
- package/dist/encrypted-file-signing-key-provider.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.d.ts +270 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +1155 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/network-directory-node.d.ts +85 -0
- package/dist/network-directory-node.d.ts.map +1 -0
- package/dist/network-directory-node.js +584 -0
- package/dist/network-directory-node.js.map +1 -0
- package/dist/s3-cloud-storage-provider.d.ts +54 -0
- package/dist/s3-cloud-storage-provider.d.ts.map +1 -0
- package/dist/s3-cloud-storage-provider.js +78 -0
- package/dist/s3-cloud-storage-provider.js.map +1 -0
- package/dist/sqlcipher-client-store.d.ts +68 -0
- package/dist/sqlcipher-client-store.d.ts.map +1 -0
- package/dist/sqlcipher-client-store.js +382 -0
- package/dist/sqlcipher-client-store.js.map +1 -0
- package/dist/types.d.ts +408 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- 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
|