@cello-protocol/client 0.0.21 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/client-send-helpers.d.ts +25 -0
  2. package/dist/client-send-helpers.d.ts.map +1 -0
  3. package/dist/client-send-helpers.js +118 -0
  4. package/dist/client-send-helpers.js.map +1 -0
  5. package/dist/client-startup.d.ts +74 -0
  6. package/dist/client-startup.d.ts.map +1 -0
  7. package/dist/client-startup.js +337 -0
  8. package/dist/client-startup.js.map +1 -0
  9. package/dist/client-wiring.d.ts +120 -0
  10. package/dist/client-wiring.d.ts.map +1 -0
  11. package/dist/client-wiring.js +289 -0
  12. package/dist/client-wiring.js.map +1 -0
  13. package/dist/client.d.ts +29 -169
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +222 -5396
  16. package/dist/client.js.map +1 -1
  17. package/dist/connection-inbound-handler.d.ts +47 -0
  18. package/dist/connection-inbound-handler.d.ts.map +1 -0
  19. package/dist/connection-inbound-handler.js +325 -0
  20. package/dist/connection-inbound-handler.js.map +1 -0
  21. package/dist/connection-manager.d.ts +191 -0
  22. package/dist/connection-manager.d.ts.map +1 -0
  23. package/dist/connection-manager.js +692 -0
  24. package/dist/connection-manager.js.map +1 -0
  25. package/dist/frame-dispatch.d.ts +28 -0
  26. package/dist/frame-dispatch.d.ts.map +1 -0
  27. package/dist/frame-dispatch.js +118 -0
  28. package/dist/frame-dispatch.js.map +1 -0
  29. package/dist/registration-manager.d.ts +54 -0
  30. package/dist/registration-manager.d.ts.map +1 -0
  31. package/dist/registration-manager.js +248 -0
  32. package/dist/registration-manager.js.map +1 -0
  33. package/dist/relay-stream-manager.d.ts +136 -0
  34. package/dist/relay-stream-manager.d.ts.map +1 -0
  35. package/dist/relay-stream-manager.js +834 -0
  36. package/dist/relay-stream-manager.js.map +1 -0
  37. package/dist/seal-manager.d.ts +133 -0
  38. package/dist/seal-manager.d.ts.map +1 -0
  39. package/dist/seal-manager.js +803 -0
  40. package/dist/seal-manager.js.map +1 -0
  41. package/dist/session-assignment-parser.d.ts +33 -0
  42. package/dist/session-assignment-parser.d.ts.map +1 -0
  43. package/dist/session-assignment-parser.js +149 -0
  44. package/dist/session-assignment-parser.js.map +1 -0
  45. package/dist/session-manager.d.ts +132 -0
  46. package/dist/session-manager.d.ts.map +1 -0
  47. package/dist/session-manager.js +605 -0
  48. package/dist/session-manager.js.map +1 -0
  49. package/dist/signaling-manager.d.ts +85 -0
  50. package/dist/signaling-manager.d.ts.map +1 -0
  51. package/dist/signaling-manager.js +597 -0
  52. package/dist/signaling-manager.js.map +1 -0
  53. package/package.json +3 -3
@@ -0,0 +1,803 @@
1
+ /**
2
+ * SealManager — SESSION-003, SESSION-005, PERSIST-015
3
+ *
4
+ * Extracted from CelloClientImpl. Handles the complete seal ceremony lifecycle:
5
+ * - Bilateral seal initiation (initiateSessionSeal)
6
+ * - Unilateral seal initiation (initiateUnilateralSeal)
7
+ * - FROST seal verification (handleSealVerified, handleFrostSealed)
8
+ * - Directory seal notifications (handleDirectorySessionSealed, handleSessionFrostSealed)
9
+ * - Seal rejection and tree-mismatch reconciliation
10
+ * - Ceremony request participation (handleCeremonyRequest)
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { Encoder } from "cbor-x";
14
+ import * as lp from "it-length-prefixed";
15
+ import { encodeSealPayload, buildSealTbs } from "@cello-protocol/protocol-types";
16
+ import { verify, buildMerkleTree, merkleRoot, verifyFrostSignature } from "@cello-protocol/crypto";
17
+ const CBOR_ENC = new Encoder({ tagUint8Array: false });
18
+ export class SealManager {
19
+ #ctx;
20
+ #sealFrostTimeoutMs;
21
+ // ─── Owned state ─────────────────────────────────────────────────────────────
22
+ // SESSION-005: session_id_hex → resolve fn for seal-frost-timeout Promise
23
+ #sealFrostResolvers = new Map();
24
+ // SESSION-005: session_id_hex → { leafCount, timestamp } from seal_verified frame.
25
+ #sealVerifiedData = new Map();
26
+ // Session IDs where THIS client is the FROST ceremony participant
27
+ #frostCeremonyParticipant = new Set();
28
+ // Session IDs where seal was initiated by this client
29
+ #sealInitiatedSessions = new Set();
30
+ // Pending resolver for seal_unilateral_confirmed / seal_unilateral_too_early (PERSIST-015)
31
+ #pendingUnilateralSealResolve = null;
32
+ // SESSION-005: track the primary_pubkey for this client (set after bootstrapKeyShares)
33
+ #myPrimaryPubkey = null;
34
+ constructor(ctx, sealFrostTimeoutMs) {
35
+ this.#ctx = ctx;
36
+ this.#sealFrostTimeoutMs = sealFrostTimeoutMs;
37
+ }
38
+ // ─── State accessors (called by facade.closeSession, loadPersistedState) ──────
39
+ setMyPrimaryPubkey(pubkey) {
40
+ this.#myPrimaryPubkey = pubkey;
41
+ }
42
+ getMyPrimaryPubkey() {
43
+ return this.#myPrimaryPubkey;
44
+ }
45
+ /** Mark a session as seal-initiated by this client (called from injectTestSession). */
46
+ markSealInitiated(sessionIdHex) {
47
+ this.#sealInitiatedSessions.add(sessionIdHex);
48
+ }
49
+ /** Mark a session as having a FROST ceremony participant (called from injectTestSession). */
50
+ markFrostCeremonyParticipant(sessionIdHex) {
51
+ this.#frostCeremonyParticipant.add(sessionIdHex);
52
+ }
53
+ /**
54
+ * Resolve the pending unilateral seal waiter (if any) with the given frame.
55
+ * Called from facade.#dispatchSignalingFrame for seal_unilateral_confirmed and
56
+ * seal_unilateral_too_early frames. Clears the resolver after calling it.
57
+ */
58
+ resolvePendingUnilateralSeal(frame) {
59
+ if (this.#pendingUnilateralSealResolve) {
60
+ const resolve = this.#pendingUnilateralSealResolve;
61
+ this.#pendingUnilateralSealResolve = null;
62
+ resolve(frame);
63
+ }
64
+ }
65
+ closeSession(sessionIdHex) {
66
+ // Resolve seal-frost-timeout waiter so initiateSessionSeal doesn't hang
67
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
68
+ this.#sealFrostResolvers.delete(sessionIdHex);
69
+ this.#sealVerifiedData.delete(sessionIdHex);
70
+ this.#sealInitiatedSessions.delete(sessionIdHex);
71
+ this.#frostCeremonyParticipant.delete(sessionIdHex);
72
+ }
73
+ async initiateSessionSeal(sessionIdHex) {
74
+ const session = this.#ctx.getSession(sessionIdHex);
75
+ if (!session)
76
+ return { ok: false, reason: "session_not_found" };
77
+ if (session.status !== "active")
78
+ return { ok: false, reason: "session_not_active" };
79
+ // Fix 1: ensure the signaling stream is alive before mutating session state.
80
+ // The directory replies (seal_verified / session_frost_sealed) on this stream.
81
+ // If the stream dropped silently (libp2p TCP idle disconnect), the reply is lost,
82
+ // the 15-second FROST timeout fires, and the session permanently ends as seal_deferred.
83
+ // Reconnecting first guarantees the directory's response lands on a live reader loop.
84
+ // Status mutation is deferred until after reconnect succeeds to avoid a sealing-stuck
85
+ // crash window (if the process restarts between the persist and the rollback, the session
86
+ // would be permanently unresealable).
87
+ const sigStream = this.#ctx.getPersistentSignalingStream();
88
+ if (!sigStream || sigStream.status !== "open") {
89
+ const correlationId = Buffer.from(session.session_id).toString("hex");
90
+ this.#ctx.logger.info("seal.reconnect.attempted", { sessionId: sessionIdHex, correlationId });
91
+ this.#ctx.setPersistentSignalingStream(null);
92
+ this.#ctx.setPersistentSignalingIter(null);
93
+ const opened = await this.#ctx.openPersistentSignalingStream();
94
+ if (!opened || !this.#ctx.getPersistentSignalingStream()) {
95
+ return { ok: false, reason: "directory_unreachable" };
96
+ }
97
+ // Re-validate after the async reconnect: a concurrent caller may have already
98
+ // mutated session status while this call was awaiting the stream open.
99
+ // TypeScript narrows session.status to "active" at this point (line 1732 guard), but
100
+ // async suspension means the actual value may have changed — re-read via a typed cast.
101
+ // Mirror the guard at #sendMessageLocked: sealing/sealed/seal_deferred/seal_rejected → "session_sealed".
102
+ const statusNow = session.status;
103
+ if (statusNow === "sealing" || statusNow === "sealed" || statusNow === "seal_deferred" || statusNow === "seal_rejected") {
104
+ return { ok: false, reason: "session_sealed" };
105
+ }
106
+ if (statusNow !== "active")
107
+ return { ok: false, reason: "session_not_active" };
108
+ }
109
+ session.status = "sealing";
110
+ this.#sealInitiatedSessions.add(sessionIdHex);
111
+ // CRIT-1: persist sealing status
112
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
113
+ const result = await this.#submitSealLeaf(sessionIdHex, session, "initiator");
114
+ if (!result.ok)
115
+ return result;
116
+ // SESSION-005: if a threshold signer is configured, wait for the FROST seal ceremony.
117
+ // The directory runs verification and FROST ceremony; if it doesn't reply within
118
+ // sealFrostTimeoutMs, this is a bilateral seal (directory unreachable).
119
+ // Without a threshold signer (M1 compatibility), return immediately —
120
+ // the M1 single-key seal notification will arrive asynchronously.
121
+ if (this.#ctx.getThresholdSigner()) {
122
+ const sealReceived = new Promise((resolve) => {
123
+ this.#sealFrostResolvers.set(sessionIdHex, resolve);
124
+ });
125
+ const timeout = new Promise((resolve) => setTimeout(resolve, this.#sealFrostTimeoutMs));
126
+ await Promise.race([sealReceived, timeout]);
127
+ // Clean up resolver
128
+ this.#sealFrostResolvers.delete(sessionIdHex);
129
+ // Check if session_sealed arrived (status would be 'sealed' by now)
130
+ const sess = this.#ctx.getSession(sessionIdHex);
131
+ if (sess && sess.status === "sealing") {
132
+ // Timeout elapsed without session_sealed — bilateral fallback (DB-001)
133
+ sess.status = "seal_deferred";
134
+ sess.seal_type = "bilateral";
135
+ // M-003: store the verified timestamp so handleSessionFrostSealed can reconstruct
136
+ // the exact TBS if the directory later completes the deferred FROST ceremony.
137
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
138
+ if (sealVerifiedEntry) {
139
+ sess.close_timestamp = sealVerifiedEntry.timestamp;
140
+ }
141
+ // CRIT-1: persist seal_deferred status
142
+ void this.#ctx.persistence?.persistSession(sessionIdHex, sess);
143
+ }
144
+ }
145
+ return { ok: true };
146
+ }
147
+ // PERSIST-015: send seal_unilateral to the directory after delivery_grace_seconds elapses.
148
+ async initiateUnilateralSeal(sessionIdHex) {
149
+ const session = this.#ctx.getSession(sessionIdHex);
150
+ if (!session)
151
+ return { ok: false, reason: "session_not_found" };
152
+ if (session.status !== "active" && session.status !== "sealing") {
153
+ return { ok: false, reason: "session_not_active" };
154
+ }
155
+ const sigStream = this.#ctx.getPersistentSignalingStream();
156
+ if (!sigStream || sigStream.status !== "open") {
157
+ this.#ctx.setPersistentSignalingStream(null);
158
+ this.#ctx.setPersistentSignalingIter(null);
159
+ const opened = await this.#ctx.openPersistentSignalingStream();
160
+ if (!opened || !this.#ctx.getPersistentSignalingStream()) {
161
+ return { ok: false, reason: "directory_unreachable" };
162
+ }
163
+ }
164
+ const localRoot = this.#computeLocalRoot(session) ?? session.genesis_prev_root;
165
+ const reportedSeq = session.next_expected_seq - 1;
166
+ const frame = CBOR_ENC.encode({
167
+ type: "seal_unilateral",
168
+ session_id: session.session_id,
169
+ reported_root: localRoot,
170
+ reported_seq: reportedSeq,
171
+ });
172
+ this.#ctx.getPersistentSignalingStream().send(lp.encode.single(frame));
173
+ const UNILATERAL_TIMEOUT_MS = 15_000;
174
+ let timeoutHandle;
175
+ const responseFrame = await Promise.race([
176
+ new Promise((resolve) => {
177
+ this.#pendingUnilateralSealResolve = resolve;
178
+ }),
179
+ new Promise((resolve) => {
180
+ timeoutHandle = setTimeout(() => {
181
+ this.#pendingUnilateralSealResolve = null;
182
+ resolve({ type: "seal_unilateral_error", reason: "timeout" });
183
+ }, UNILATERAL_TIMEOUT_MS);
184
+ }),
185
+ ]);
186
+ clearTimeout(timeoutHandle);
187
+ if (responseFrame["type"] === "seal_unilateral_confirmed") {
188
+ const sealedRootRaw = responseFrame["sealed_root"];
189
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
190
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw)
191
+ : new Uint8Array(32);
192
+ const sealedAt = typeof responseFrame["sealed_at"] === "number" ? responseFrame["sealed_at"] : Date.now();
193
+ return { ok: true, sealed_root: sealedRoot, sealed_at: sealedAt };
194
+ }
195
+ if (responseFrame["type"] === "seal_unilateral_too_early") {
196
+ const remainingSeconds = typeof responseFrame["remaining_seconds"] === "number"
197
+ ? responseFrame["remaining_seconds"] : 0;
198
+ return { ok: false, reason: "too_early", remaining_seconds: remainingSeconds };
199
+ }
200
+ return { ok: false, reason: responseFrame["reason"] ?? "unknown" };
201
+ }
202
+ async #submitSealLeaf(sessionIdHex, session, _role) {
203
+ const relayStream = this.#ctx.getRelayStream(sessionIdHex);
204
+ if (!relayStream || relayStream.status !== "open") {
205
+ return { ok: false, reason: "transport_unavailable" };
206
+ }
207
+ // Compute current local tree root (R_tail for initiator, root-after-initiator-SEAL for responder)
208
+ const finalRoot = session.local_tree_leaves.length === 0
209
+ ? session.genesis_prev_root
210
+ : (() => {
211
+ const inputs = session.local_tree_leaves.map(l => ({
212
+ kind: l.kind,
213
+ data: l.s2_cbor,
214
+ }));
215
+ return merkleRoot(buildMerkleTree(inputs));
216
+ })();
217
+ const close_timestamp = Date.now();
218
+ const sealPayload = encodeSealPayload({
219
+ session_id: session.session_id,
220
+ final_root: finalRoot,
221
+ close_timestamp,
222
+ attestation: "PENDING",
223
+ });
224
+ // content_hash = SHA-256(0x02 || seal_payload) — ctrl leaf kind byte is 0x02
225
+ const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([0x02])).update(sealPayload).digest());
226
+ const myPubkeyHex = this.#ctx.getMyPubkeyHex();
227
+ const myPubkeyBytes = Buffer.from(myPubkeyHex, "hex");
228
+ const tbs = CBOR_ENC.encode([
229
+ 1,
230
+ contentHash,
231
+ myPubkeyBytes,
232
+ session.session_id,
233
+ session.last_seen_seq,
234
+ close_timestamp,
235
+ ]);
236
+ const signature = await this.#ctx.keyProvider.sign(tbs);
237
+ const hashSubmitFrame = CBOR_ENC.encode({
238
+ type: "hash_submit",
239
+ session_id: session.session_id,
240
+ leaf_kind: 0x02,
241
+ structure1_cbor: tbs,
242
+ sender_signature: signature,
243
+ });
244
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
245
+ this.#ctx.getOwnPendingContent(sessionIdHex)?.set(contentHashHex, {
246
+ content_bytes: sealPayload,
247
+ arrived_at: Date.now(),
248
+ });
249
+ if (this.#ctx.getPendingAckResolver(sessionIdHex)) {
250
+ return { ok: false, reason: "ack_resolver_conflict" };
251
+ }
252
+ let ackResolve;
253
+ const ackPromise = new Promise((r) => { ackResolve = r; });
254
+ this.#ctx.setPendingAckResolver(sessionIdHex, ackResolve);
255
+ try {
256
+ relayStream.send(lp.encode.single(hashSubmitFrame));
257
+ }
258
+ catch {
259
+ this.#ctx.deletePendingAckResolver(sessionIdHex);
260
+ this.#ctx.getOwnPendingContent(sessionIdHex)?.delete(contentHashHex);
261
+ return { ok: false, reason: "transport_unavailable" };
262
+ }
263
+ const ack = await ackPromise;
264
+ if (!ack.ok)
265
+ return { ok: false, reason: "relay_rejected" };
266
+ const mySeq = ack.sequence_number;
267
+ // Send SEAL payload as content_frame to counterparty so they can cross-check
268
+ const sess2 = this.#ctx.getSession(sessionIdHex);
269
+ if (sess2 && !sess2.desynchronized) {
270
+ void this.#ctx.sendContentFrame(sess2, sealPayload, contentHash);
271
+ }
272
+ // Wait for own echo
273
+ await this.#ctx.waitForOwnEcho(sessionIdHex, mySeq);
274
+ const sess3 = this.#ctx.getSession(sessionIdHex);
275
+ if (!sess3 || sess3.desynchronized)
276
+ return { ok: false, reason: "session_desynchronized" };
277
+ return { ok: true };
278
+ }
279
+ // ─── Directory signaling stream (SESSION-003) ────────────────────────────────
280
+ handleDirectorySessionSealed(sessionIdHex, frame, directoryPubkey) {
281
+ const session = this.#ctx.getSession(sessionIdHex);
282
+ if (!session)
283
+ return;
284
+ const signatureType = frame["signature_type"];
285
+ // If this client has a threshold signer (M2 mode), enforce FROST-only.
286
+ if (this.#ctx.getThresholdSigner()) {
287
+ // SI-003: reject M1-era single-key seal notarizations in M2 mode
288
+ if (signatureType === "single") {
289
+ this.#ctx.logger.warn("seal.signature.type.unsupported", { sessionId: sessionIdHex, signatureType: "single" });
290
+ return;
291
+ }
292
+ if (signatureType !== "frost") {
293
+ // Unknown signature_type — ignore
294
+ return;
295
+ }
296
+ this.#handleFrostSealed(sessionIdHex, frame, session);
297
+ }
298
+ else {
299
+ // M1 compatibility mode: no threshold signer — verify directory_signature
300
+ this.#handleSingleSealed(sessionIdHex, frame, directoryPubkey, session);
301
+ }
302
+ }
303
+ /** No-threshold-signer path: handles both 'single' (Ed25519 dir sig) and 'frost' seal frames. */
304
+ #handleSingleSealed(sessionIdHex, frame, directoryPubkey, session) {
305
+ const signatureType = frame["signature_type"];
306
+ const sealedRootRaw = frame["sealed_root"];
307
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
308
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
309
+ const ctRaw = frame["close_timestamp"];
310
+ const closeTimestamp = typeof ctRaw === "number" ? ctRaw : typeof ctRaw === "bigint" ? Number(ctRaw) : null;
311
+ const sidRaw = frame["session_id"];
312
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
313
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
314
+ if (!sealedRoot || sealedRoot.length !== 32)
315
+ return;
316
+ if (closeTimestamp === null)
317
+ return;
318
+ if (!sessionId)
319
+ return;
320
+ if (signatureType === "frost") {
321
+ // FROST seal received by an M1 client (no threshold signer).
322
+ // Verify using the signer_pubkey embedded in the frame (initiator's primary_pubkey).
323
+ const frostSigRaw = frame["frost_signature"];
324
+ const frostSig = frostSigRaw instanceof Uint8Array ? frostSigRaw
325
+ : Buffer.isBuffer(frostSigRaw) ? new Uint8Array(frostSigRaw) : null;
326
+ const signerPubkeyRaw = frame["signer_pubkey"];
327
+ const signerPubkey = signerPubkeyRaw instanceof Uint8Array ? signerPubkeyRaw
328
+ : Buffer.isBuffer(signerPubkeyRaw) ? new Uint8Array(signerPubkeyRaw) : null;
329
+ if (!frostSig || frostSig.length !== 64)
330
+ return;
331
+ if (!signerPubkey || signerPubkey.length !== 32)
332
+ return;
333
+ // Use sealVerifiedData if available (responder also gets seal_verified when M2 initiator),
334
+ // else fall back to local_tree_leaves.length.
335
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
336
+ const leafCount = sealVerifiedEntry?.leafCount ?? session.local_tree_leaves.length;
337
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, closeTimestamp);
338
+ if (!verifyFrostSignature(frostSig, tbs, "cello-frost-seal-v1", signerPubkey)) {
339
+ this.#ctx.logger.warn("seal.frost.signature.invalid", { sessionId: sessionIdHex, path: "m1_compat_frost" });
340
+ return;
341
+ }
342
+ session.status = "sealed";
343
+ session.sealed_root = sealedRoot;
344
+ session.frost_signature = frostSig;
345
+ session.signer_pubkey = signerPubkey;
346
+ session.seal_type = "frost";
347
+ session.close_timestamp = closeTimestamp;
348
+ this.#sealVerifiedData.delete(sessionIdHex);
349
+ // CRIT-1: persist sealed state
350
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
351
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
352
+ this.#ctx.enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
353
+ }
354
+ else {
355
+ // M1 single-key: verify directory_signature against pinned directory pubkey
356
+ const dirSigRaw = frame["directory_signature"];
357
+ const dirSig = dirSigRaw instanceof Uint8Array ? dirSigRaw
358
+ : Buffer.isBuffer(dirSigRaw) ? new Uint8Array(dirSigRaw) : null;
359
+ if (!dirSig || dirSig.length !== 64)
360
+ return;
361
+ // SI-005 (M1): verify directory signature against pinned directory pubkey
362
+ const tbs = CBOR_ENC.encode([
363
+ sessionId,
364
+ sealedRoot,
365
+ closeTimestamp > 0xffffffff ? BigInt(closeTimestamp) : closeTimestamp,
366
+ ]);
367
+ if (!verify(directoryPubkey, tbs, dirSig)) {
368
+ this.#ctx.logger.warn("seal.directory.signature.invalid", { sessionId: sessionIdHex });
369
+ return;
370
+ }
371
+ session.status = "sealed";
372
+ session.sealed_root = sealedRoot;
373
+ session.directory_signature = dirSig;
374
+ session.close_timestamp = closeTimestamp;
375
+ // CRIT-1: persist sealed state
376
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
377
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
378
+ this.#ctx.enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
379
+ }
380
+ // Resolve the seal-frost-timeout waiter
381
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
382
+ }
383
+ /** M2 FROST seal verification. */
384
+ #handleFrostSealed(sessionIdHex, frame, session) {
385
+ const sealedRootRaw = frame["sealed_root"];
386
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
387
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
388
+ const frostSigRaw = frame["frost_signature"];
389
+ const frostSig = frostSigRaw instanceof Uint8Array ? frostSigRaw
390
+ : Buffer.isBuffer(frostSigRaw) ? new Uint8Array(frostSigRaw) : null;
391
+ const signerPubkeyRaw = frame["signer_pubkey"];
392
+ const signerPubkey = signerPubkeyRaw instanceof Uint8Array ? signerPubkeyRaw
393
+ : Buffer.isBuffer(signerPubkeyRaw) ? new Uint8Array(signerPubkeyRaw) : null;
394
+ const ctRaw = frame["close_timestamp"];
395
+ const closeTimestamp = typeof ctRaw === "number" ? ctRaw : typeof ctRaw === "bigint" ? Number(ctRaw) : null;
396
+ const sidRaw = frame["session_id"];
397
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
398
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
399
+ const leafCountRaw = frame["leaf_count"];
400
+ // Prefer stored sealVerifiedData so we use the same leafCount that was used during
401
+ // the FROST ceremony, even if local_tree_leaves is incomplete due to a desync race.
402
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
403
+ const leafCount = typeof leafCountRaw === "number" ? leafCountRaw
404
+ : (sealVerifiedEntry?.leafCount ?? session.local_tree_leaves.length);
405
+ const resolvedCloseTimestamp = closeTimestamp ?? sealVerifiedEntry?.timestamp ?? null;
406
+ if (!sealedRoot || sealedRoot.length !== 32)
407
+ return;
408
+ if (!frostSig || frostSig.length !== 64)
409
+ return;
410
+ if (!signerPubkey || signerPubkey.length !== 32)
411
+ return;
412
+ if (resolvedCloseTimestamp === null)
413
+ return;
414
+ if (!sessionId)
415
+ return;
416
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, resolvedCloseTimestamp);
417
+ // Determine verification key.
418
+ // Use #myPrimaryPubkey only if this client ran the FROST ceremony (received seal_verified).
419
+ // #frostCeremonyParticipant is set by handleSealVerified before the ceremony runs —
420
+ // a concurrent-close counterparty (in sealInitiatedSessions but NOT frostCeremonyParticipant)
421
+ // must use signerPubkey from the frame (the actual initiator's key).
422
+ const isFrostInitiator = this.#frostCeremonyParticipant.has(sessionIdHex);
423
+ let verifyKey;
424
+ if (isFrostInitiator) {
425
+ const myPrimaryPubkey = this.#myPrimaryPubkey;
426
+ if (!myPrimaryPubkey) {
427
+ this.#ctx.logger.warn("seal.frost.initiator.no.primary.pubkey", { sessionId: sessionIdHex });
428
+ return;
429
+ }
430
+ verifyKey = myPrimaryPubkey;
431
+ }
432
+ else {
433
+ verifyKey = signerPubkey;
434
+ }
435
+ // SI-001: verify FROST signature before transitioning to sealed.
436
+ if (!this.#ctx.getThresholdSigner().verifySignature(frostSig, tbs, "cello-frost-seal-v1", verifyKey)) {
437
+ this.#ctx.logger.warn("seal.frost.signature.invalid", { sessionId: sessionIdHex, path: "m2_frost" });
438
+ return;
439
+ }
440
+ session.status = "sealed";
441
+ session.sealed_root = sealedRoot;
442
+ session.frost_signature = frostSig;
443
+ session.signer_pubkey = signerPubkey;
444
+ session.seal_type = "frost";
445
+ session.close_timestamp = resolvedCloseTimestamp;
446
+ this.#sealVerifiedData.delete(sessionIdHex);
447
+ // CRIT-1: persist sealed state
448
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
449
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
450
+ this.#ctx.enqueueSessionSealedEvent(sessionIdHex, sealedRoot, resolvedCloseTimestamp);
451
+ // Resolve the seal-frost-timeout waiter so initiateSessionSeal returns promptly
452
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
453
+ }
454
+ handleDirectorySessionSealRejected(sessionIdHex, _frame) {
455
+ const session = this.#ctx.getSession(sessionIdHex);
456
+ if (!session)
457
+ return;
458
+ session.status = "seal_rejected";
459
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
460
+ // Also resolve the seal-frost-timeout waiter so initiateSessionSeal doesn't wait for the timeout
461
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
462
+ }
463
+ /**
464
+ * PERSIST-014: Handle seal_rejected_tree_mismatch from the directory.
465
+ * Determines if this client is the behind party and initiates gap-fill reconciliation.
466
+ */
467
+ handleSealRejectedTreeMismatch(sessionIdHex, frame) {
468
+ const session = this.#ctx.getSession(sessionIdHex);
469
+ if (!session)
470
+ return;
471
+ const partyASequence = typeof frame["party_a_sequence"] === "number" ? frame["party_a_sequence"] : 0;
472
+ const partyBSequence = typeof frame["party_b_sequence"] === "number" ? frame["party_b_sequence"] : 0;
473
+ // Determine this client's local sequence (highest seq in its Merkle tree).
474
+ // next_expected_seq is 1-indexed: the next seq the relay will assign, so local highest = next - 1.
475
+ const mySequence = session.next_expected_seq - 1;
476
+ const aheadSequence = Math.max(partyASequence, partyBSequence);
477
+ if (mySequence >= aheadSequence) {
478
+ // We are NOT the behind party — wait for the behind party to reconcile and retry
479
+ return;
480
+ }
481
+ // We are the behind party — initiate gap-fill reconciliation
482
+ const gapSize = aheadSequence - mySequence;
483
+ const correlationId = Buffer.from(session.session_id).toString("hex") + "-" + Date.now().toString(36);
484
+ this.#ctx.logger.info("session.reconciliation.started", {
485
+ sessionId: sessionIdHex,
486
+ gapSize,
487
+ fromSequence: mySequence,
488
+ toSequence: aheadSequence,
489
+ correlationId,
490
+ });
491
+ void this.#ctx.performGapFillReconciliation(sessionIdHex, mySequence, aheadSequence, correlationId);
492
+ }
493
+ /**
494
+ * PERSIST-015: Handle seal_unilateral_confirmed from the directory.
495
+ * The submitting party receives this when the unilateral seal succeeds.
496
+ */
497
+ handleSealUnilateralConfirmed(sessionIdHex, frame) {
498
+ const session = this.#ctx.getSession(sessionIdHex);
499
+ if (!session)
500
+ return;
501
+ const sealedRootRaw = frame["sealed_root"];
502
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
503
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
504
+ session.status = "sealed";
505
+ if (sealedRoot)
506
+ session.sealed_root = sealedRoot;
507
+ session.seal_type = "unilateral";
508
+ session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
509
+ // CRIT-1: persist sealed state
510
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
511
+ const correlationId = Buffer.from(session.session_id).toString("hex");
512
+ this.#ctx.logger.info("session.sealed", {
513
+ sessionId: sessionIdHex,
514
+ sealType: "UNILATERAL",
515
+ rootHash: sealedRoot ? Buffer.from(sealedRoot).toString("hex") : "unknown",
516
+ correlationId,
517
+ });
518
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
519
+ if (sealedRoot) {
520
+ this.#ctx.enqueueSessionSealedEvent(sessionIdHex, sealedRoot, session.close_timestamp ?? Date.now());
521
+ }
522
+ // Resolve the FROST ceremony waiter only if a bilateral seal was in-flight for this session.
523
+ // The unilateral and FROST paths are mutually exclusive once the session seals — resolving an
524
+ // absent FROST waiter is harmless (map miss returns undefined), but resolving a present one
525
+ // spuriously would confuse the bilateral seal flow. Guard on whether the session was actually
526
+ // in sealing state via the FROST path before the unilateral confirmation arrived.
527
+ if (this.#sealInitiatedSessions.has(sessionIdHex)) {
528
+ this.#sealFrostResolvers.get(sessionIdHex)?.();
529
+ }
530
+ }
531
+ /**
532
+ * PERSIST-015: Handle seal_unilateral_notification from the directory.
533
+ * The absent party receives this on reconnect — verifies sealed root against local state.
534
+ */
535
+ handleSealUnilateralNotification(sessionIdHex, frame) {
536
+ let session = this.#ctx.getSession(sessionIdHex);
537
+ if (!session) {
538
+ // Absent party reconnecting after session was sealed without them — create a minimal
539
+ // sealed session record so the notification is observable via listSessions().
540
+ const sessionIdRaw = frame["session_id"];
541
+ const sessionId = sessionIdRaw instanceof Uint8Array ? sessionIdRaw
542
+ : Buffer.isBuffer(sessionIdRaw) ? new Uint8Array(sessionIdRaw)
543
+ : Buffer.from(sessionIdHex, "hex");
544
+ // Build the stub as sealed from the start — no transient "active" state visible to readers.
545
+ // Fields like counterparty_pubkey and directory_pubkey are zeroed because the absent party
546
+ // does not have session state; computeLocalRoot handles the empty-leaves case explicitly.
547
+ const stub = {
548
+ session_id: sessionId,
549
+ counterparty_pubkey: new Uint8Array(32),
550
+ counterparty_peer_id: "",
551
+ counterparty_multiaddrs: [],
552
+ relay_endpoint: { peer_id: "", multiaddrs: [] },
553
+ directory_endpoint: { peer_id: "", multiaddrs: [] },
554
+ directory_pubkey: new Uint8Array(32),
555
+ genesis_prev_root: new Uint8Array(32),
556
+ last_seen_seq: 0,
557
+ last_sent_seq: 0,
558
+ status: "sealed",
559
+ seal_type: "unilateral",
560
+ local_tree_leaves: [],
561
+ next_expected_seq: 1,
562
+ desynchronized: false,
563
+ };
564
+ this.#ctx.getSessions().set(sessionIdHex, stub);
565
+ session = stub;
566
+ }
567
+ const sealedRootRaw = frame["sealed_root"];
568
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
569
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
570
+ session.status = "sealed";
571
+ if (sealedRoot)
572
+ session.sealed_root = sealedRoot;
573
+ session.seal_type = "unilateral";
574
+ session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
575
+ // CRIT-1: persist sealed state
576
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
577
+ // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
578
+ if (sealedRoot) {
579
+ this.#ctx.enqueueSessionSealedEvent(sessionIdHex, sealedRoot, session.close_timestamp);
580
+ }
581
+ // AC-004: Verify sealed root against local Merkle state
582
+ const localRoot = this.#computeLocalRoot(session);
583
+ if (localRoot == null) {
584
+ // Cannot verify — no local leaves received yet; log distinctly rather than as mismatch
585
+ this.#ctx.logger.info("session.unilateral.no.local.state", {
586
+ sessionId: sessionIdHex,
587
+ correlationId: sessionIdHex,
588
+ });
589
+ return;
590
+ }
591
+ const match = sealedRoot != null && Buffer.from(localRoot).equals(Buffer.from(sealedRoot));
592
+ if (match) {
593
+ this.#ctx.logger.info("session.unilateral.verified", {
594
+ sessionId: sessionIdHex,
595
+ match: true,
596
+ correlationId: sessionIdHex,
597
+ });
598
+ }
599
+ else {
600
+ this.#ctx.logger.warn("session.unilateral.mismatch", {
601
+ sessionId: sessionIdHex,
602
+ localRoot: Buffer.from(localRoot).toString("hex"),
603
+ sealedRoot: sealedRoot ? Buffer.from(sealedRoot).toString("hex") : "null",
604
+ correlationId: sessionIdHex,
605
+ });
606
+ }
607
+ }
608
+ /**
609
+ * PERSIST-015: Compute the local Merkle root from the session's accepted leaves.
610
+ */
611
+ computeLocalRoot(session) {
612
+ return this.#computeLocalRoot(session);
613
+ }
614
+ #computeLocalRoot(session) {
615
+ if (!session.local_tree_leaves || session.local_tree_leaves.length === 0)
616
+ return null;
617
+ const leafInputs = session.local_tree_leaves.map((l) => ({
618
+ kind: l.kind,
619
+ data: l.s2_cbor,
620
+ }));
621
+ const tree = buildMerkleTree(leafInputs);
622
+ return merkleRoot(tree);
623
+ }
624
+ /**
625
+ * SESSION-005: Handle seal_verified event from the directory.
626
+ * The directory has verified the Merkle tree; the initiator must now coordinate
627
+ * the FROST ceremony and return the combined signature.
628
+ */
629
+ async handleSealVerified(sessionIdHex, frame) {
630
+ const session = this.#ctx.getSession(sessionIdHex);
631
+ if (!session)
632
+ return;
633
+ const thresholdSigner = this.#ctx.getThresholdSigner();
634
+ if (!thresholdSigner)
635
+ return; // no FROST signer — bilateral only
636
+ const sealedRootRaw = frame["sealed_root"];
637
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
638
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
639
+ const sidRaw = frame["session_id"];
640
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
641
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
642
+ const leafCountRaw = frame["leaf_count"];
643
+ const leafCount = typeof leafCountRaw === "number" ? leafCountRaw : null;
644
+ const tsRaw = frame["timestamp"];
645
+ const timestamp = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "bigint" ? Number(tsRaw) : null;
646
+ if (!sealedRoot || !sessionId || leafCount === null || timestamp === null)
647
+ return;
648
+ // Store for handleSessionFrostSealed so it can use the authoritative leafCount/timestamp
649
+ // even if local_tree_leaves is incomplete due to a desync race.
650
+ this.#sealVerifiedData.set(sessionIdHex, { leafCount, timestamp });
651
+ // Mark this client as the FROST ceremony participant so handleFrostSealed uses
652
+ // myPrimaryPubkey for verification (anti-substitution guard).
653
+ this.#frostCeremonyParticipant.add(sessionIdHex);
654
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, timestamp);
655
+ // Participate in the FROST seal ceremony as coordinator
656
+ const ceremonyId = `seal:${sessionIdHex}`;
657
+ let result;
658
+ try {
659
+ result = await thresholdSigner.participateInCeremony(ceremonyId, tbs, "cello-frost-seal-v1");
660
+ }
661
+ catch {
662
+ // Ceremony failed — bilateral fallback; do not send seal_frost_signature
663
+ return;
664
+ }
665
+ if (!result.ok) {
666
+ // DB-002: ceremony failed (threshold not met) — bilateral fallback
667
+ return;
668
+ }
669
+ // Send seal_frost_signature to directory.
670
+ // Prefer the per-session directory stream; fall back to the persistent signaling stream
671
+ // (which is used when receiveSessionAssignment detects a persistent stream is already open).
672
+ const dirStream = this.#ctx.getDirectoryStream(sessionIdHex);
673
+ const sendStream = (dirStream && dirStream.status === "open") ? dirStream : this.#ctx.getPersistentSignalingStream();
674
+ if (!sendStream)
675
+ return;
676
+ const sealFrostSigFrame = CBOR_ENC.encode({
677
+ type: "seal_frost_signature",
678
+ session_id: sessionId,
679
+ frost_signature: result.signature,
680
+ });
681
+ try {
682
+ sendStream.send(lp.encode.single(sealFrostSigFrame));
683
+ }
684
+ catch {
685
+ // Stream closed — bilateral fallback
686
+ }
687
+ }
688
+ /**
689
+ * Handle ceremony_request from the directory.
690
+ * The directory sends this when a session_request requires a FROST ceremony but
691
+ * the directory is not the coordinator. The client runs participateInCeremony
692
+ * and sends back a ceremony_result with the combined signature.
693
+ */
694
+ async handleCeremonyRequest(stream, frame) {
695
+ const thresholdSigner = this.#ctx.getThresholdSigner();
696
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: thresholdSigner=${thresholdSigner ? "SET" : "NULL"}` });
697
+ if (!thresholdSigner) {
698
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: ABORT thresholdSigner is null — sending null ceremony_result` });
699
+ const ceremonyId = frame["ceremony_id"];
700
+ if (ceremonyId) {
701
+ stream.send(lp.encode.single(CBOR_ENC.encode({ type: "ceremony_result", ceremony_id: ceremonyId, signature: null })));
702
+ }
703
+ return;
704
+ }
705
+ const ceremonyId = frame["ceremony_id"];
706
+ const tbsRaw = frame["tbs"];
707
+ const tbs = tbsRaw instanceof Uint8Array ? tbsRaw
708
+ : Buffer.isBuffer(tbsRaw) ? new Uint8Array(tbsRaw) : null;
709
+ const context = frame["context"];
710
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: ceremonyId=${ceremonyId?.slice(0, 16)} tbs=${tbs ? `Uint8Array(${tbs.length})` : "NULL"} context=${context}` });
711
+ if (!ceremonyId || !tbs || !context) {
712
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: ABORT missing fields ceremonyId=${!!ceremonyId} tbs=${!!tbs} context=${!!context}` });
713
+ return;
714
+ }
715
+ try {
716
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: calling participateInCeremony` });
717
+ const result = await thresholdSigner.participateInCeremony(ceremonyId, tbs, context);
718
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: participateInCeremony returned ok=${result.ok} reason=${!result.ok ? result.error?.reason : "N/A"}` });
719
+ const sig = result.ok ? result.signature : null;
720
+ stream.send(lp.encode.single(CBOR_ENC.encode({
721
+ type: "ceremony_result",
722
+ ceremony_id: ceremonyId,
723
+ signature: sig ? new Uint8Array(sig) : null,
724
+ })));
725
+ }
726
+ catch (err) {
727
+ const msg = err instanceof Error ? err.message : String(err);
728
+ this.#ctx.logger.debug("frost.ceremony.debug", { message: `handleCeremonyRequest: CAUGHT ERROR: ${msg}` });
729
+ stream.send(lp.encode.single(CBOR_ENC.encode({
730
+ type: "ceremony_result",
731
+ ceremony_id: ceremonyId,
732
+ signature: null,
733
+ })));
734
+ }
735
+ }
736
+ /**
737
+ * SESSION-005: Handle session_frost_sealed event — deferred FROST seal completed.
738
+ * Sent by the directory when a previously deferred seal ceremony completes.
739
+ * Updates the session from seal_deferred/bilateral to sealed/frost.
740
+ */
741
+ handleSessionFrostSealed(sessionIdHex, frame) {
742
+ const session = this.#ctx.getSession(sessionIdHex);
743
+ if (!session)
744
+ return;
745
+ const sealedRootRaw = frame["sealed_root"];
746
+ const sealedRoot = sealedRootRaw instanceof Uint8Array ? sealedRootRaw
747
+ : Buffer.isBuffer(sealedRootRaw) ? new Uint8Array(sealedRootRaw) : null;
748
+ const frostSigRaw = frame["frost_signature"];
749
+ const frostSig = frostSigRaw instanceof Uint8Array ? frostSigRaw
750
+ : Buffer.isBuffer(frostSigRaw) ? new Uint8Array(frostSigRaw) : null;
751
+ const signerPubkeyRaw = frame["signer_pubkey"];
752
+ const signerPubkey = signerPubkeyRaw instanceof Uint8Array ? signerPubkeyRaw
753
+ : Buffer.isBuffer(signerPubkeyRaw) ? new Uint8Array(signerPubkeyRaw) : null;
754
+ const sidRaw = frame["session_id"];
755
+ const sessionId = sidRaw instanceof Uint8Array ? sidRaw
756
+ : Buffer.isBuffer(sidRaw) ? new Uint8Array(sidRaw) : null;
757
+ if (!sealedRoot || frostSig === null || !signerPubkey || !sessionId)
758
+ return;
759
+ if (!frostSig || frostSig.length !== 64)
760
+ return;
761
+ if (!signerPubkey || signerPubkey.length !== 32)
762
+ return;
763
+ const thresholdSigner = this.#ctx.getThresholdSigner();
764
+ if (!thresholdSigner)
765
+ return;
766
+ // Prefer stored sealVerifiedData leafCount (same as handleFrostSealed) so verification
767
+ // uses the count from the FROST ceremony even if local_tree_leaves is incomplete.
768
+ const sealVerifiedEntry = this.#sealVerifiedData.get(sessionIdHex);
769
+ const leafCount = sealVerifiedEntry?.leafCount ?? session.local_tree_leaves.length;
770
+ // M-003: close_timestamp must be set (stored during bilateral fallback from seal_verified).
771
+ // Without it we cannot reconstruct the exact TBS and verification would be unsound.
772
+ const closeTimestamp = session.close_timestamp ?? sealVerifiedEntry?.timestamp;
773
+ if (closeTimestamp === undefined) {
774
+ this.#ctx.logger.warn("seal.frost.sealed.missing.close.timestamp", { sessionId: sessionIdHex });
775
+ return;
776
+ }
777
+ const tbs = buildSealTbs(sessionId, sealedRoot, leafCount, closeTimestamp);
778
+ const isFrostInitiator = this.#frostCeremonyParticipant.has(sessionIdHex);
779
+ let verifyKey;
780
+ if (isFrostInitiator) {
781
+ const myPrimaryPubkey = this.#myPrimaryPubkey;
782
+ if (!myPrimaryPubkey)
783
+ return;
784
+ verifyKey = myPrimaryPubkey;
785
+ }
786
+ else {
787
+ verifyKey = signerPubkey;
788
+ }
789
+ if (!thresholdSigner.verifySignature(frostSig, tbs, "cello-frost-seal-v1", verifyKey)) {
790
+ this.#ctx.logger.warn("seal.frost.sealed.signature.invalid", { sessionId: sessionIdHex });
791
+ return;
792
+ }
793
+ // AC-004: update session from bilateral to frost
794
+ session.status = "sealed";
795
+ session.sealed_root = sealedRoot;
796
+ session.frost_signature = frostSig;
797
+ session.signer_pubkey = signerPubkey;
798
+ session.seal_type = "frost";
799
+ // CRIT-1: persist sealed state
800
+ void this.#ctx.persistence?.persistSession(sessionIdHex, session);
801
+ }
802
+ }
803
+ //# sourceMappingURL=seal-manager.js.map