@cello-protocol/client 0.0.20 → 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.
- package/dist/client-send-helpers.d.ts +25 -0
- package/dist/client-send-helpers.d.ts.map +1 -0
- package/dist/client-send-helpers.js +118 -0
- package/dist/client-send-helpers.js.map +1 -0
- package/dist/client-startup.d.ts +74 -0
- package/dist/client-startup.d.ts.map +1 -0
- package/dist/client-startup.js +337 -0
- package/dist/client-startup.js.map +1 -0
- package/dist/client-wiring.d.ts +120 -0
- package/dist/client-wiring.d.ts.map +1 -0
- package/dist/client-wiring.js +289 -0
- package/dist/client-wiring.js.map +1 -0
- package/dist/client.d.ts +29 -169
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +222 -5372
- package/dist/client.js.map +1 -1
- package/dist/connection-inbound-handler.d.ts +47 -0
- package/dist/connection-inbound-handler.d.ts.map +1 -0
- package/dist/connection-inbound-handler.js +325 -0
- package/dist/connection-inbound-handler.js.map +1 -0
- package/dist/connection-manager.d.ts +191 -0
- package/dist/connection-manager.d.ts.map +1 -0
- package/dist/connection-manager.js +692 -0
- package/dist/connection-manager.js.map +1 -0
- package/dist/frame-dispatch.d.ts +28 -0
- package/dist/frame-dispatch.d.ts.map +1 -0
- package/dist/frame-dispatch.js +118 -0
- package/dist/frame-dispatch.js.map +1 -0
- package/dist/registration-manager.d.ts +54 -0
- package/dist/registration-manager.d.ts.map +1 -0
- package/dist/registration-manager.js +248 -0
- package/dist/registration-manager.js.map +1 -0
- package/dist/relay-stream-manager.d.ts +136 -0
- package/dist/relay-stream-manager.d.ts.map +1 -0
- package/dist/relay-stream-manager.js +834 -0
- package/dist/relay-stream-manager.js.map +1 -0
- package/dist/seal-manager.d.ts +133 -0
- package/dist/seal-manager.d.ts.map +1 -0
- package/dist/seal-manager.js +803 -0
- package/dist/seal-manager.js.map +1 -0
- package/dist/session-assignment-parser.d.ts +33 -0
- package/dist/session-assignment-parser.d.ts.map +1 -0
- package/dist/session-assignment-parser.js +149 -0
- package/dist/session-assignment-parser.js.map +1 -0
- package/dist/session-manager.d.ts +132 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +605 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/signaling-manager.d.ts +85 -0
- package/dist/signaling-manager.d.ts.map +1 -0
- package/dist/signaling-manager.js +597 -0
- package/dist/signaling-manager.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|