@cello-protocol/client 0.0.3 → 0.0.5
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/db/migrations/V1__client_schema.sql +21 -0
- package/db/migrations/V2__client_schema_structured.sql +274 -0
- package/dist/agent-hash-queue.d.ts +19 -0
- package/dist/agent-hash-queue.d.ts.map +1 -1
- package/dist/agent-hash-queue.js +30 -0
- package/dist/agent-hash-queue.js.map +1 -1
- package/dist/client-state-persistence.d.ts +290 -0
- package/dist/client-state-persistence.d.ts.map +1 -0
- package/dist/client-state-persistence.js +402 -0
- package/dist/client-state-persistence.js.map +1 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +629 -8
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/network-directory-node.d.ts +7 -0
- package/dist/network-directory-node.d.ts.map +1 -1
- package/dist/network-directory-node.js +13 -1
- package/dist/network-directory-node.js.map +1 -1
- package/dist/sqlcipher-client-store.d.ts +28 -4
- package/dist/sqlcipher-client-store.d.ts.map +1 -1
- package/dist/sqlcipher-client-store.js +89 -52
- package/dist/sqlcipher-client-store.js.map +1 -1
- package/package.json +4 -3
package/dist/client.js
CHANGED
|
@@ -110,9 +110,11 @@ import { createHash } from "node:crypto";
|
|
|
110
110
|
import { Encoder, decode } from "cbor-x";
|
|
111
111
|
import * as lp from "it-length-prefixed";
|
|
112
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";
|
|
113
|
+
import { verify, buildMerkleTree, merkleRoot, verifyFrostSignature, CONTEXT_SESSION_ESTABLISHMENT, mlDsaKeygen, mlDsaKeygenWithBytes, mlDsaVerify, FileMlDsaKeyProvider, InMemoryMlDsaKeyProvider } from "@cello-protocol/crypto";
|
|
114
|
+
import { storeDkgResult } from "@cello-protocol/crypto/frost/frost-threshold-signer.js";
|
|
114
115
|
import { CELLO_PROTOCOL_ID, CELLO_CONTENT_PROTOCOL_ID } from "@cello-protocol/transport";
|
|
115
116
|
import { NetworkDirectoryNode, runNetworkDkg } from "./network-directory-node.js";
|
|
117
|
+
import { FrostThresholdSigner } from "@cello-protocol/crypto/frost/frost-threshold-signer.js";
|
|
116
118
|
const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
|
|
117
119
|
const AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
|
|
118
120
|
const SIGNALING_PROTOCOL_ID = "/cello/signaling/1.0.0";
|
|
@@ -362,7 +364,22 @@ class CelloClientImpl {
|
|
|
362
364
|
#decidedRequests = new Set();
|
|
363
365
|
// ─── PERSIST-014: Logger (injected, defaults to no-op) ───────────────────────
|
|
364
366
|
#logger;
|
|
365
|
-
|
|
367
|
+
// ─── PERSIST-024: Client state persistence (optional) ────────────────────────
|
|
368
|
+
#persistence = null;
|
|
369
|
+
// ─── PERSIST-024 AC-008: Hash queue for relay resubmission (optional) ────────
|
|
370
|
+
// Set by the composition root via setHashQueue() after loadPersistedState().
|
|
371
|
+
// Populated from pending_hashes DB table. Resubmission occurs on relay reconnect.
|
|
372
|
+
#hashQueue = null;
|
|
373
|
+
// ─── PERSIST-024: In-memory endorsement/attestation caches ───────────────────
|
|
374
|
+
#endorsements = [];
|
|
375
|
+
#attestations = [];
|
|
376
|
+
// ─── PERSIST-024 FINDING-4: pending hashes loaded on startup ────────────────
|
|
377
|
+
// These are populated by loadPersistedState() from the DB pending_hashes table.
|
|
378
|
+
// The caller (composition root) is responsible for passing them to AgentHashQueue
|
|
379
|
+
// so they are resubmitted to the relay on reconnect (AC-008).
|
|
380
|
+
// Access via getLoadedPendingHashes() after loadPersistedState() returns.
|
|
381
|
+
#loadedPendingHashes = [];
|
|
382
|
+
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, persistence) {
|
|
366
383
|
this.#node = node;
|
|
367
384
|
this.#keyProvider = keyProvider;
|
|
368
385
|
this.#onMessageQueued = onMessageQueued;
|
|
@@ -380,6 +397,7 @@ class CelloClientImpl {
|
|
|
380
397
|
this.#onConnectionPendingReview = onConnectionPendingReview;
|
|
381
398
|
this.#crossCheckDirectoryOnInbound = crossCheckDirectoryOnInbound;
|
|
382
399
|
this.#logger = logger ?? { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
|
|
400
|
+
this.#persistence = persistence ?? null;
|
|
383
401
|
}
|
|
384
402
|
/**
|
|
385
403
|
* SESSION-005: Set the FROST primary_pubkey for this client.
|
|
@@ -390,8 +408,386 @@ class CelloClientImpl {
|
|
|
390
408
|
setPrimaryPubkey(primaryPubkey) {
|
|
391
409
|
this.#myPrimaryPubkey = new Uint8Array(primaryPubkey);
|
|
392
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* PERSIST-024: Load all durable state from the SQLCipher DB and populate in-memory state.
|
|
413
|
+
*
|
|
414
|
+
* Pseudocode:
|
|
415
|
+
* 1. Call persistence.loadStartupState() — emits client.startup.state.loaded
|
|
416
|
+
* 2. Restore FROST key share → call storeDkgResult to populate module-level key store;
|
|
417
|
+
* reconstruct FrostThresholdSigner with same config used at registration
|
|
418
|
+
* 3. Restore ML-DSA keypair → reconstruct InMemoryMlDsaKeyProvider
|
|
419
|
+
* 4. Restore registration state → populate #registrationState
|
|
420
|
+
* 5. Restore connections → populate #connections and #connectionsByPeer
|
|
421
|
+
* 6. Restore connection policy → populate #connectionPolicy
|
|
422
|
+
* 7. Restore sessions → populate #sessions; load leaves per session
|
|
423
|
+
* 8. Restore peers → populate #peers
|
|
424
|
+
* 9. Restore decided requests → populate #decidedRequests
|
|
425
|
+
*
|
|
426
|
+
* Security invariants:
|
|
427
|
+
* SI-001: signing_share bytes never appear in any log event.
|
|
428
|
+
* SI-002: secret_key_blob bytes never appear in any log event.
|
|
429
|
+
*
|
|
430
|
+
* Crypto refs: RFC 9591 (FROST), NIST FIPS 204 (ML-DSA-44)
|
|
431
|
+
*/
|
|
432
|
+
async loadPersistedState() {
|
|
433
|
+
const p = this.#persistence;
|
|
434
|
+
if (!p)
|
|
435
|
+
return;
|
|
436
|
+
const state = await p.loadStartupState();
|
|
437
|
+
// ── 1. FROST key share ────────────────────────────────────────────────────
|
|
438
|
+
if (state.frostShare) {
|
|
439
|
+
const row = state.frostShare;
|
|
440
|
+
// Reconstruct FrostSecret: { identifier, signingShare }
|
|
441
|
+
const signingShareBytes = row.signing_share instanceof Buffer
|
|
442
|
+
? new Uint8Array(row.signing_share)
|
|
443
|
+
: new Uint8Array(row.signing_share);
|
|
444
|
+
const frostSecret = { identifier: row.identifier, signingShare: signingShareBytes };
|
|
445
|
+
// Reconstruct FrostPublic: { signers, commitments, verifyingShares }
|
|
446
|
+
// commitments_cbor is CBOR-encoded Uint8Array[]; verifying_shares_cbor is CBOR-encoded Record<string, Uint8Array>
|
|
447
|
+
let commitments = [];
|
|
448
|
+
let verifyingShares = {};
|
|
449
|
+
try {
|
|
450
|
+
const commitmentsCborBytes = row.commitments_cbor instanceof Buffer
|
|
451
|
+
? row.commitments_cbor
|
|
452
|
+
: Buffer.from(row.commitments_cbor);
|
|
453
|
+
const decodedCommitments = decode(commitmentsCborBytes);
|
|
454
|
+
if (Array.isArray(decodedCommitments)) {
|
|
455
|
+
commitments = decodedCommitments.map((c) => c instanceof Uint8Array ? c : Buffer.isBuffer(c) ? new Uint8Array(c) : new Uint8Array(0));
|
|
456
|
+
}
|
|
457
|
+
const verifyingSharesCborBytes = row.verifying_shares_cbor instanceof Buffer
|
|
458
|
+
? row.verifying_shares_cbor
|
|
459
|
+
: Buffer.from(row.verifying_shares_cbor);
|
|
460
|
+
const decodedVerifyingShares = decode(verifyingSharesCborBytes);
|
|
461
|
+
if (decodedVerifyingShares && typeof decodedVerifyingShares === "object" && !Array.isArray(decodedVerifyingShares)) {
|
|
462
|
+
for (const [k, v] of Object.entries(decodedVerifyingShares)) {
|
|
463
|
+
verifyingShares[k] = v instanceof Uint8Array ? v : Buffer.isBuffer(v) ? new Uint8Array(v) : new Uint8Array(0);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// HIGH-2: emit correct error event and return early — do not call storeDkgResult
|
|
469
|
+
// with broken/empty data, which would corrupt the module-level key store.
|
|
470
|
+
this.#logger.error("client.frost.share.load.failed", {
|
|
471
|
+
agentPubkey: row.agent_pubkey,
|
|
472
|
+
reason: "cbor_deserialize_failed",
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const frostPublic = {
|
|
477
|
+
signers: { min: row.threshold, max: row.participants },
|
|
478
|
+
commitments,
|
|
479
|
+
verifyingShares,
|
|
480
|
+
};
|
|
481
|
+
const myPubkeyHex = row.agent_pubkey;
|
|
482
|
+
try {
|
|
483
|
+
// SI-001: storeDkgResult does not log the secret
|
|
484
|
+
storeDkgResult(myPubkeyHex, frostSecret, frostPublic);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
this.#logger.error("client.frost.share.load.failed", {
|
|
488
|
+
agentPubkey: myPubkeyHex,
|
|
489
|
+
reason: "storeDkgResult_failed",
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// Reconstruct FrostThresholdSigner (config only — secret is in module-level store).
|
|
494
|
+
// HIGH-1: directoryEndpoint is NOT required for construction — the signer can verify
|
|
495
|
+
// signatures even without a live directory. Pass undefined for directoryNodes.
|
|
496
|
+
if (!this.#thresholdSigner) {
|
|
497
|
+
this.#thresholdSigner = new FrostThresholdSigner({
|
|
498
|
+
threshold: row.threshold,
|
|
499
|
+
participants: row.participants,
|
|
500
|
+
directoryNodes: undefined,
|
|
501
|
+
}, Buffer.from(myPubkeyHex, "hex"));
|
|
502
|
+
}
|
|
503
|
+
// Set primary_pubkey from stored commitments[0]
|
|
504
|
+
if (commitments.length > 0 && !this.#myPrimaryPubkey) {
|
|
505
|
+
this.#myPrimaryPubkey = new Uint8Array(commitments[0]);
|
|
506
|
+
}
|
|
507
|
+
// HIGH-1: emit success event after FROST share loaded
|
|
508
|
+
this.#logger.info("client.frost.share.loaded", {
|
|
509
|
+
agentPubkey: myPubkeyHex,
|
|
510
|
+
epochId: row.epoch_id,
|
|
511
|
+
threshold: row.threshold,
|
|
512
|
+
participants: row.participants,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
// HIGH-3: emit alarm when registration exists but no FROST share found
|
|
516
|
+
if (!state.frostShare && state.registrationState) {
|
|
517
|
+
this.#logger.error("client.frost.share.missing", {
|
|
518
|
+
agentPubkey: state.registrationState.agent_pubkey,
|
|
519
|
+
reason: "no_active_share_in_db",
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// ── 2. ML-DSA keypair ─────────────────────────────────────────────────────
|
|
523
|
+
if (state.mlDsaKeypair) {
|
|
524
|
+
const row = state.mlDsaKeypair;
|
|
525
|
+
try {
|
|
526
|
+
const secretKeyBlob = row.secret_key_blob instanceof Buffer
|
|
527
|
+
? new Uint8Array(row.secret_key_blob)
|
|
528
|
+
: new Uint8Array(row.secret_key_blob);
|
|
529
|
+
const mlDsaPubkeyBytes = Buffer.from(row.ml_dsa_pubkey, "hex");
|
|
530
|
+
// SI-002: InMemoryMlDsaKeyProvider does not log secret key
|
|
531
|
+
this.#mlDsaProvider = new InMemoryMlDsaKeyProvider(mlDsaPubkeyBytes, secretKeyBlob);
|
|
532
|
+
// HIGH-1: emit success event after ML-DSA keypair loaded
|
|
533
|
+
this.#logger.info("client.mldsa.keypair.loaded", {
|
|
534
|
+
agentPubkey: row.agent_pubkey,
|
|
535
|
+
mlDsaPubkey: row.ml_dsa_pubkey,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
// Story specifies level: error for this event
|
|
540
|
+
this.#logger.error("client.mldsa.load.failed", {
|
|
541
|
+
agentPubkey: row.agent_pubkey,
|
|
542
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// ── 3. Registration state ─────────────────────────────────────────────────
|
|
547
|
+
if (state.registrationState) {
|
|
548
|
+
const row = state.registrationState;
|
|
549
|
+
this.#registrationState = {
|
|
550
|
+
agent_id: row.agent_id,
|
|
551
|
+
primary_pubkey: row.primary_pubkey,
|
|
552
|
+
ml_dsa_pubkey: row.ml_dsa_pubkey,
|
|
553
|
+
registered_at: row.registered_at,
|
|
554
|
+
status: "active",
|
|
555
|
+
};
|
|
556
|
+
// HIGH-1: emit success event after registration state loaded
|
|
557
|
+
this.#logger.info("client.registration.loaded", {
|
|
558
|
+
agentPubkey: row.agent_pubkey,
|
|
559
|
+
agentId: row.agent_id,
|
|
560
|
+
status: row.status,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// ── 4. Connections ────────────────────────────────────────────────────────
|
|
564
|
+
for (const row of state.connections) {
|
|
565
|
+
if (!this.#connections.has(row.connection_id)) {
|
|
566
|
+
const record = {
|
|
567
|
+
connection_id: row.connection_id,
|
|
568
|
+
counterparty_primary_pubkey: row.counterparty_primary_pubkey ?? "",
|
|
569
|
+
counterparty_ml_dsa_pubkey: row.counterparty_ml_dsa_pubkey ?? "",
|
|
570
|
+
counterparty_pubkey: row.counterparty_pubkey,
|
|
571
|
+
established_at: row.established_at,
|
|
572
|
+
status: row.status,
|
|
573
|
+
};
|
|
574
|
+
this.#connections.set(row.connection_id, record);
|
|
575
|
+
this.#connectionsByPeer.set(row.counterparty_pubkey, row.connection_id);
|
|
576
|
+
if (row.profile_unchecked) {
|
|
577
|
+
this.#profileUncheckedPeers.add(row.counterparty_pubkey);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// ── 5. Connection policy ──────────────────────────────────────────────────
|
|
582
|
+
if (state.connectionPolicy) {
|
|
583
|
+
this.#connectionPolicy = state.connectionPolicy;
|
|
584
|
+
}
|
|
585
|
+
// ── 6. Sessions + leaves ──────────────────────────────────────────────────
|
|
586
|
+
for (const row of state.sessions) {
|
|
587
|
+
const sessionIdHex = row.session_id;
|
|
588
|
+
if (this.#sessions.has(sessionIdHex))
|
|
589
|
+
continue;
|
|
590
|
+
const leaves = await p.loadSessionTreeLeaves(sessionIdHex);
|
|
591
|
+
const localTreeLeaves = leaves.map((l) => ({
|
|
592
|
+
kind: l.leaf_kind,
|
|
593
|
+
s2_cbor: l.s2_cbor instanceof Buffer
|
|
594
|
+
? new Uint8Array(l.s2_cbor)
|
|
595
|
+
: new Uint8Array(l.s2_cbor),
|
|
596
|
+
}));
|
|
597
|
+
const counterpartyPubkey = row.counterparty_pubkey instanceof Buffer
|
|
598
|
+
? new Uint8Array(row.counterparty_pubkey)
|
|
599
|
+
: new Uint8Array(row.counterparty_pubkey);
|
|
600
|
+
const directoryPubkey = row.directory_pubkey instanceof Buffer
|
|
601
|
+
? new Uint8Array(row.directory_pubkey)
|
|
602
|
+
: new Uint8Array(row.directory_pubkey);
|
|
603
|
+
const genesisPrevRoot = row.genesis_prev_root instanceof Buffer
|
|
604
|
+
? new Uint8Array(row.genesis_prev_root)
|
|
605
|
+
: new Uint8Array(row.genesis_prev_root);
|
|
606
|
+
let counterpartyMultiaddrs = [];
|
|
607
|
+
let relayMultiaddrs = [];
|
|
608
|
+
let directoryMultiaddrs = [];
|
|
609
|
+
try {
|
|
610
|
+
counterpartyMultiaddrs = JSON.parse(row.counterparty_multiaddrs);
|
|
611
|
+
}
|
|
612
|
+
catch { /* ignore */ }
|
|
613
|
+
try {
|
|
614
|
+
relayMultiaddrs = JSON.parse(row.relay_multiaddrs);
|
|
615
|
+
}
|
|
616
|
+
catch { /* ignore */ }
|
|
617
|
+
try {
|
|
618
|
+
directoryMultiaddrs = JSON.parse(row.directory_multiaddrs);
|
|
619
|
+
}
|
|
620
|
+
catch { /* ignore */ }
|
|
621
|
+
const record = {
|
|
622
|
+
session_id: Buffer.from(sessionIdHex, "hex"),
|
|
623
|
+
counterparty_pubkey: counterpartyPubkey,
|
|
624
|
+
counterparty_peer_id: row.counterparty_peer_id,
|
|
625
|
+
counterparty_multiaddrs: counterpartyMultiaddrs,
|
|
626
|
+
relay_endpoint: { peer_id: row.relay_peer_id, multiaddrs: relayMultiaddrs },
|
|
627
|
+
directory_endpoint: { peer_id: row.directory_peer_id, multiaddrs: directoryMultiaddrs },
|
|
628
|
+
directory_pubkey: directoryPubkey,
|
|
629
|
+
genesis_prev_root: genesisPrevRoot,
|
|
630
|
+
last_seen_seq: row.last_seen_seq,
|
|
631
|
+
last_sent_seq: row.last_sent_seq,
|
|
632
|
+
next_expected_seq: row.next_expected_seq,
|
|
633
|
+
status: row.status,
|
|
634
|
+
desynchronized: row.desynchronized !== 0,
|
|
635
|
+
local_tree_leaves: localTreeLeaves,
|
|
636
|
+
sealed_root: row.sealed_root
|
|
637
|
+
? (row.sealed_root instanceof Buffer ? new Uint8Array(row.sealed_root) : new Uint8Array(row.sealed_root))
|
|
638
|
+
: undefined,
|
|
639
|
+
seal_type: row.seal_type ?? undefined,
|
|
640
|
+
close_timestamp: row.close_timestamp ?? undefined,
|
|
641
|
+
frost_signature: row.frost_signature
|
|
642
|
+
? (row.frost_signature instanceof Buffer ? new Uint8Array(row.frost_signature) : new Uint8Array(row.frost_signature))
|
|
643
|
+
: undefined,
|
|
644
|
+
signer_pubkey: row.signer_pubkey
|
|
645
|
+
? (row.signer_pubkey instanceof Buffer ? new Uint8Array(row.signer_pubkey) : new Uint8Array(row.signer_pubkey))
|
|
646
|
+
: undefined,
|
|
647
|
+
directory_signature: row.directory_signature
|
|
648
|
+
? (row.directory_signature instanceof Buffer ? new Uint8Array(row.directory_signature) : new Uint8Array(row.directory_signature))
|
|
649
|
+
: undefined,
|
|
650
|
+
};
|
|
651
|
+
this.#sessions.set(sessionIdHex, record);
|
|
652
|
+
this.#sessionMessageQueues.set(sessionIdHex, []);
|
|
653
|
+
// HIGH-4: emit alarm when loaded leaf count doesn't match sessions.leaf_count
|
|
654
|
+
if (leaves.length !== row.leaf_count) {
|
|
655
|
+
this.#logger.error("client.session.leaves.mismatch", {
|
|
656
|
+
agentPubkey: row.agent_pubkey,
|
|
657
|
+
sessionId: sessionIdHex,
|
|
658
|
+
expectedLeafCount: row.leaf_count,
|
|
659
|
+
actualLeafCount: leaves.length,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// ── 7. Peers ──────────────────────────────────────────────────────────────
|
|
664
|
+
for (const row of state.peers) {
|
|
665
|
+
if (!this.#peers.has(row.peer_pubkey_hex)) {
|
|
666
|
+
let multiaddrs = [];
|
|
667
|
+
try {
|
|
668
|
+
multiaddrs = JSON.parse(row.multiaddrs);
|
|
669
|
+
}
|
|
670
|
+
catch { /* ignore */ }
|
|
671
|
+
this.#peers.set(row.peer_pubkey_hex, { peerId: row.peer_id, multiaddrs, connected: false });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// ── 8. Decided requests ───────────────────────────────────────────────────
|
|
675
|
+
for (const row of state.decidedRequests) {
|
|
676
|
+
this.#decidedRequests.add(row.request_id);
|
|
677
|
+
}
|
|
678
|
+
// ── 9. Pending connection requests ────────────────────────────────────────
|
|
679
|
+
for (const row of state.pendingConnectionRequests) {
|
|
680
|
+
const packageCbor = row.package_cbor instanceof Buffer
|
|
681
|
+
? new Uint8Array(row.package_cbor)
|
|
682
|
+
: new Uint8Array(row.package_cbor);
|
|
683
|
+
// Populate #pendingInboundRequests so acceptConnection/rejectConnection work
|
|
684
|
+
this.#pendingInboundRequests.set(row.request_id, {
|
|
685
|
+
connection_request_id: row.request_id,
|
|
686
|
+
from_pubkey: row.from_pubkey,
|
|
687
|
+
package_cbor: packageCbor,
|
|
688
|
+
round: row.round,
|
|
689
|
+
});
|
|
690
|
+
// Populate #pendingReviewQueue so awaitConnectionRequest() returns it.
|
|
691
|
+
// Reconstruct a minimal pending_agent_review report — the full policy evaluation
|
|
692
|
+
// result is not persisted, only the package_cbor. The agent review UI only needs
|
|
693
|
+
// the connection_request_id, from_pubkey, and package_cbor to make a decision.
|
|
694
|
+
const restoredReport = {
|
|
695
|
+
verdict: "pending_agent_review",
|
|
696
|
+
policy_summary: {
|
|
697
|
+
mode: "unknown",
|
|
698
|
+
review_mode: "inference",
|
|
699
|
+
requirements_met: [],
|
|
700
|
+
requirements_unmet: [],
|
|
701
|
+
},
|
|
702
|
+
package_summary: {
|
|
703
|
+
pseudonym_label: "",
|
|
704
|
+
endorsement_count: 0,
|
|
705
|
+
attestation_types: [],
|
|
706
|
+
pseudonym_age_days: 0,
|
|
707
|
+
registration_age_days: 0,
|
|
708
|
+
is_provisional: false,
|
|
709
|
+
},
|
|
710
|
+
is_round_2: row.round > 1,
|
|
711
|
+
};
|
|
712
|
+
this.#pendingReviewQueue.push({
|
|
713
|
+
connection_request_id: row.request_id,
|
|
714
|
+
from_pubkey: row.from_pubkey,
|
|
715
|
+
report: restoredReport,
|
|
716
|
+
package_cbor: packageCbor,
|
|
717
|
+
sender_registered_at: 0,
|
|
718
|
+
sender_is_provisional: false,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
// ── 10. Endorsements and attestations (MED-4) ────────────────────────────────
|
|
722
|
+
if (state.endorsements.length > 0) {
|
|
723
|
+
this.#endorsements = state.endorsements;
|
|
724
|
+
}
|
|
725
|
+
if (state.attestations.length > 0) {
|
|
726
|
+
this.#attestations = state.attestations;
|
|
727
|
+
}
|
|
728
|
+
// ── HIGH-3: set #myPubkeyHex from registration state if sessions were loaded ──
|
|
729
|
+
// #myPubkeyHex is used in #sendMessageLocked with a non-null assertion.
|
|
730
|
+
// It is set during receiveSessionAssignment but never set during startup load.
|
|
731
|
+
// If the agent restarts with active sessions and calls sendMessage before any
|
|
732
|
+
// new session assignment, it would crash on the non-null assertion.
|
|
733
|
+
if (!this.#myPubkeyHex && state.registrationState) {
|
|
734
|
+
this.#myPubkeyHex = state.registrationState.agent_pubkey;
|
|
735
|
+
}
|
|
736
|
+
// ── PERSIST-024 FINDING-4: store loaded pending hashes for caller consumption ──
|
|
737
|
+
// The caller (composition root) must pass these to AgentHashQueue so they are
|
|
738
|
+
// resubmitted to the relay on reconnect (AC-008). CelloClientImpl does not hold
|
|
739
|
+
// an AgentHashQueue directly — that is a composition-root concern.
|
|
740
|
+
this.#loadedPendingHashes = state.pendingHashes.map((row) => ({
|
|
741
|
+
sessionId: row.session_id,
|
|
742
|
+
hashHex: row.hash_hex,
|
|
743
|
+
enqueuedAt: row.enqueued_at,
|
|
744
|
+
}));
|
|
745
|
+
// M-5: upsertAgent at the END of startup so last_seen_at only reflects a fully successful boot.
|
|
746
|
+
await p.upsertAgent();
|
|
747
|
+
// PERSIST-024 FINDING-3: emit client.startup.state.loaded AFTER all in-memory structures
|
|
748
|
+
// are populated. AC-013 requires this event to fire only after the full startup sequence.
|
|
749
|
+
// The event is not emitted by loadStartupState() (which only loads DB rows).
|
|
750
|
+
this.#logger.info("client.startup.state.loaded", {
|
|
751
|
+
agentPubkey: this.#myPubkeyHex ?? state.registrationState?.agent_pubkey ?? "unknown",
|
|
752
|
+
connectionCount: state.connectionCount,
|
|
753
|
+
sessionCount: state.sessionCount,
|
|
754
|
+
leafCount: state.leafCount,
|
|
755
|
+
pendingHashCount: state.pendingHashCount,
|
|
756
|
+
hasFrostShare: state.hasFrostShare,
|
|
757
|
+
hasMlDsaKeypair: state.hasMlDsaKeypair,
|
|
758
|
+
hasRegistration: state.hasRegistration,
|
|
759
|
+
hasPolicy: state.hasPolicy,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* PERSIST-024 FINDING-4: Return the pending hashes loaded during startup.
|
|
764
|
+
*
|
|
765
|
+
* Call after loadPersistedState() to retrieve hashes that were pending relay
|
|
766
|
+
* submission when the agent last shut down. The composition root must pass these
|
|
767
|
+
* to AgentHashQueue.enqueue() so they are resubmitted to the relay on reconnect.
|
|
768
|
+
*
|
|
769
|
+
* Returns an empty array if loadPersistedState() has not yet been called, if
|
|
770
|
+
* no persistence is configured, or if no hashes were pending.
|
|
771
|
+
*/
|
|
772
|
+
getLoadedPendingHashes() {
|
|
773
|
+
return this.#loadedPendingHashes;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* PERSIST-024 AC-008: Set the AgentHashQueue to use for relay resubmission.
|
|
777
|
+
*
|
|
778
|
+
* Called by the composition root after loadPersistedState() and after calling
|
|
779
|
+
* queue.loadPending(client.getLoadedPendingHashes()). On relay reconnect,
|
|
780
|
+
* #reconnectRelayStream will drain the queue for the reconnected session.
|
|
781
|
+
*/
|
|
782
|
+
setHashQueue(queue) {
|
|
783
|
+
this.#hashQueue = queue;
|
|
784
|
+
}
|
|
393
785
|
addPeer(peerPubkeyHex, peerId, multiaddrs) {
|
|
394
786
|
this.#peers.set(peerPubkeyHex, { peerId, multiaddrs, connected: true });
|
|
787
|
+
// PERSIST-024: persist peer to DB
|
|
788
|
+
if (this.#persistence) {
|
|
789
|
+
void this.#persistence.persistPeer({ peerPubkeyHex, peerId, multiaddrs });
|
|
790
|
+
}
|
|
395
791
|
}
|
|
396
792
|
async send(peerPubkeyHex, content) {
|
|
397
793
|
// Step 1: registry lookup
|
|
@@ -684,6 +1080,10 @@ class CelloClientImpl {
|
|
|
684
1080
|
desynchronized: false,
|
|
685
1081
|
};
|
|
686
1082
|
this.#sessions.set(sessionIdHex, record);
|
|
1083
|
+
// PERSIST-024: persist session to DB
|
|
1084
|
+
if (this.#persistence) {
|
|
1085
|
+
void this.#persistence.persistSession(sessionIdHex, record);
|
|
1086
|
+
}
|
|
687
1087
|
// Store the relay stream and start the persistent reader loop (MSG-004)
|
|
688
1088
|
this.#relayStreams.set(sessionIdHex, relayStream);
|
|
689
1089
|
this.#relayRecvSeq.set(sessionIdHex, 0);
|
|
@@ -780,6 +1180,13 @@ class CelloClientImpl {
|
|
|
780
1180
|
content_bytes: content,
|
|
781
1181
|
arrived_at: Date.now(),
|
|
782
1182
|
});
|
|
1183
|
+
// PERSIST-024 AC-008: Persist pending hash BEFORE relay submission (SI-001 of PERSIST-012).
|
|
1184
|
+
// The entry is removed after the ACK is confirmed. This ensures crash recovery can detect
|
|
1185
|
+
// un-ACKed hashes and resubmit them on relay reconnect.
|
|
1186
|
+
const enqueuedAt = Date.now();
|
|
1187
|
+
if (this.#persistence) {
|
|
1188
|
+
void this.#persistence.persistPendingHash({ sessionId: sessionIdHex, hashHex: contentHashHex, enqueuedAt });
|
|
1189
|
+
}
|
|
783
1190
|
// Set up ack resolver before sending to avoid race with fast relay.
|
|
784
1191
|
// The outbound queue guarantees at most one in-flight send per session.
|
|
785
1192
|
// Guard here so a queue bug causes an immediate throw rather than a silent orphan.
|
|
@@ -795,13 +1202,25 @@ class CelloClientImpl {
|
|
|
795
1202
|
catch {
|
|
796
1203
|
this.#pendingAckResolvers.delete(sessionIdHex);
|
|
797
1204
|
this.#ownPendingContent.get(sessionIdHex)?.delete(contentHashHex);
|
|
1205
|
+
// Remove the pending hash entry — the submission never reached the relay
|
|
1206
|
+
if (this.#persistence) {
|
|
1207
|
+
void this.#persistence.removePendingHash(sessionIdHex, contentHashHex);
|
|
1208
|
+
}
|
|
798
1209
|
return { ok: false, reason: "transport_unavailable" };
|
|
799
1210
|
}
|
|
800
1211
|
const ack = await ackPromise;
|
|
801
1212
|
if (!ack.ok) {
|
|
1213
|
+
// Relay rejected — remove the pending hash entry
|
|
1214
|
+
if (this.#persistence) {
|
|
1215
|
+
void this.#persistence.removePendingHash(sessionIdHex, contentHashHex);
|
|
1216
|
+
}
|
|
802
1217
|
return { ok: false, reason: "relay_rejected" };
|
|
803
1218
|
}
|
|
804
1219
|
const mySeq = ack.sequence_number;
|
|
1220
|
+
// PERSIST-024 AC-008: Relay ACKed — remove from pending_hashes (relay has stored the hash).
|
|
1221
|
+
if (this.#persistence) {
|
|
1222
|
+
void this.#persistence.removePendingHash(sessionIdHex, contentHashHex);
|
|
1223
|
+
}
|
|
805
1224
|
// Send content to counterparty on /cello/content/1.0.0
|
|
806
1225
|
// Best-effort: if content path fails, the receiver's 30s grace timer will desync
|
|
807
1226
|
const sess2 = this.#sessions.get(sessionIdHex);
|
|
@@ -1033,6 +1452,8 @@ class CelloClientImpl {
|
|
|
1033
1452
|
return { ok: false, reason: "session_not_active" };
|
|
1034
1453
|
session.status = "sealing";
|
|
1035
1454
|
this.#sealInitiatedSessions.add(sessionIdHex);
|
|
1455
|
+
// CRIT-1: persist sealing status
|
|
1456
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1036
1457
|
const result = await this.#submitSealLeaf(sessionIdHex, session, "initiator");
|
|
1037
1458
|
if (!result.ok)
|
|
1038
1459
|
return result;
|
|
@@ -1061,6 +1482,8 @@ class CelloClientImpl {
|
|
|
1061
1482
|
if (sealVerifiedEntry) {
|
|
1062
1483
|
sess.close_timestamp = sealVerifiedEntry.timestamp;
|
|
1063
1484
|
}
|
|
1485
|
+
// CRIT-1: persist seal_deferred status
|
|
1486
|
+
void this.#persistence?.persistSession(sessionIdHex, sess);
|
|
1064
1487
|
}
|
|
1065
1488
|
}
|
|
1066
1489
|
return { ok: true };
|
|
@@ -1387,6 +1810,8 @@ class CelloClientImpl {
|
|
|
1387
1810
|
session.seal_type = "frost";
|
|
1388
1811
|
session.close_timestamp = closeTimestamp;
|
|
1389
1812
|
this.#sealVerifiedData.delete(sessionIdHex);
|
|
1813
|
+
// CRIT-1: persist sealed state
|
|
1814
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1390
1815
|
// SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
|
|
1391
1816
|
this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
|
|
1392
1817
|
}
|
|
@@ -1411,6 +1836,8 @@ class CelloClientImpl {
|
|
|
1411
1836
|
session.sealed_root = sealedRoot;
|
|
1412
1837
|
session.directory_signature = dirSig;
|
|
1413
1838
|
session.close_timestamp = closeTimestamp;
|
|
1839
|
+
// CRIT-1: persist sealed state
|
|
1840
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1414
1841
|
// SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
|
|
1415
1842
|
this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
|
|
1416
1843
|
}
|
|
@@ -1480,6 +1907,8 @@ class CelloClientImpl {
|
|
|
1480
1907
|
session.seal_type = "frost";
|
|
1481
1908
|
session.close_timestamp = resolvedCloseTimestamp;
|
|
1482
1909
|
this.#sealVerifiedData.delete(sessionIdHex);
|
|
1910
|
+
// CRIT-1: persist sealed state
|
|
1911
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1483
1912
|
// SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
|
|
1484
1913
|
this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, resolvedCloseTimestamp);
|
|
1485
1914
|
// Resolve the seal-frost-timeout waiter so initiateSessionSeal returns promptly
|
|
@@ -1490,6 +1919,7 @@ class CelloClientImpl {
|
|
|
1490
1919
|
if (!session)
|
|
1491
1920
|
return;
|
|
1492
1921
|
session.status = "seal_rejected";
|
|
1922
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1493
1923
|
// Also resolve the seal-frost-timeout waiter so initiateSessionSeal doesn't wait for the timeout
|
|
1494
1924
|
this.#sealFrostResolvers.get(sessionIdHex)?.();
|
|
1495
1925
|
}
|
|
@@ -1667,6 +2097,8 @@ class CelloClientImpl {
|
|
|
1667
2097
|
session.sealed_root = sealedRoot;
|
|
1668
2098
|
session.seal_type = "unilateral";
|
|
1669
2099
|
session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
|
|
2100
|
+
// CRIT-1: persist sealed state
|
|
2101
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1670
2102
|
const correlationId = Buffer.from(session.session_id).toString("hex");
|
|
1671
2103
|
this.#logger.info("session.sealed", {
|
|
1672
2104
|
sessionId: sessionIdHex,
|
|
@@ -1731,6 +2163,8 @@ class CelloClientImpl {
|
|
|
1731
2163
|
session.sealed_root = sealedRoot;
|
|
1732
2164
|
session.seal_type = "unilateral";
|
|
1733
2165
|
session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
|
|
2166
|
+
// CRIT-1: persist sealed state
|
|
2167
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1734
2168
|
// SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
|
|
1735
2169
|
if (sealedRoot) {
|
|
1736
2170
|
this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, session.close_timestamp);
|
|
@@ -1932,6 +2366,8 @@ class CelloClientImpl {
|
|
|
1932
2366
|
session.frost_signature = frostSig;
|
|
1933
2367
|
session.signer_pubkey = signerPubkey;
|
|
1934
2368
|
session.seal_type = "frost";
|
|
2369
|
+
// CRIT-1: persist sealed state
|
|
2370
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
1935
2371
|
}
|
|
1936
2372
|
closeSession(sessionIdHex) {
|
|
1937
2373
|
this.#sessions.delete(sessionIdHex);
|
|
@@ -2067,6 +2503,7 @@ class CelloClientImpl {
|
|
|
2067
2503
|
return;
|
|
2068
2504
|
// Mark session transport_lost (SESSION-006 AC-001)
|
|
2069
2505
|
session.status = "transport_lost";
|
|
2506
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
2070
2507
|
// Unblock any waiting sendMessage calls with transport_unavailable
|
|
2071
2508
|
const ackResolve = this.#pendingAckResolvers.get(sessionIdHex);
|
|
2072
2509
|
if (ackResolve) {
|
|
@@ -2145,6 +2582,21 @@ class CelloClientImpl {
|
|
|
2145
2582
|
// Reconnect succeeded: install new stream and resume
|
|
2146
2583
|
this.#relayStreams.set(sessionIdHex, newStream);
|
|
2147
2584
|
sessionAfterAuth.status = "active";
|
|
2585
|
+
void this.#persistence?.persistSession(sessionIdHex, sessionAfterAuth);
|
|
2586
|
+
// PERSIST-024 AC-008: After relay reconnect, log any pending hashes that need
|
|
2587
|
+
// resubmission. These are hashes loaded from pending_hashes on startup that
|
|
2588
|
+
// were submitted to the relay before the last crash but not yet ACKed.
|
|
2589
|
+
// The relay is expected to replay hash_submit_ack on reconnect for these hashes.
|
|
2590
|
+
if (this.#hashQueue) {
|
|
2591
|
+
const pendingEntries = await this.#hashQueue.getPending(sessionIdHex);
|
|
2592
|
+
if (pendingEntries.length > 0) {
|
|
2593
|
+
this.#logger.info("client.hashqueue.resubmit.pending", {
|
|
2594
|
+
agentId: myPubkeyHex,
|
|
2595
|
+
sessionId: sessionIdHex,
|
|
2596
|
+
pendingCount: pendingEntries.length,
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2148
2600
|
// Start the new reader loop (authResult.iter is the continuation iterator)
|
|
2149
2601
|
void this.#runRelayStreamReader(sessionIdHex, newStream, myPubkeyHex, authResult.iter);
|
|
2150
2602
|
return;
|
|
@@ -2409,14 +2861,28 @@ class CelloClientImpl {
|
|
|
2409
2861
|
}
|
|
2410
2862
|
const kind = leaf_kind === 0x02 ? "ctrl" : "msg";
|
|
2411
2863
|
// Append to local tree
|
|
2864
|
+
const leafIndex = session.local_tree_leaves.length;
|
|
2412
2865
|
session.local_tree_leaves.push({ kind, s2_cbor });
|
|
2413
2866
|
session.next_expected_seq += 1;
|
|
2867
|
+
// PERSIST-024: persist leaf to DB
|
|
2868
|
+
if (this.#persistence) {
|
|
2869
|
+
void this.#persistence.persistSessionTreeLeaf({
|
|
2870
|
+
sessionIdHex,
|
|
2871
|
+
leafIndex,
|
|
2872
|
+
leafKind: kind,
|
|
2873
|
+
s2Cbor: s2_cbor,
|
|
2874
|
+
sequenceNumber: s2.sequence_number,
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2414
2877
|
// Compute leaf hash: SHA-256(leaf_kind_byte || s2_cbor) per MERKLE-001
|
|
2415
2878
|
const leafHash = new Uint8Array(createHash("sha256").update(new Uint8Array([leaf_kind])).update(s2_cbor).digest());
|
|
2416
2879
|
if (is_own_send) {
|
|
2417
2880
|
// Own-send echo: fire the send lock release and advance own-seq tracker.
|
|
2418
2881
|
// Do NOT enqueue into receiveMessage queues — callers don't "receive" their own sends.
|
|
2419
2882
|
session.last_sent_seq = s2.sequence_number;
|
|
2883
|
+
// PERSIST-024 FINDING-1: persist sequence counters after last_sent_seq mutation
|
|
2884
|
+
// so a crash between leaf accepts does not leave the reloaded session with stale counters.
|
|
2885
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
2420
2886
|
echo_resolve?.();
|
|
2421
2887
|
}
|
|
2422
2888
|
else {
|
|
@@ -2428,6 +2894,9 @@ class CelloClientImpl {
|
|
|
2428
2894
|
// SESSION-003: non-initiator auto-response to counterparty's SEAL leaf.
|
|
2429
2895
|
// Transition to sealing and submit our own SEAL leaf asynchronously.
|
|
2430
2896
|
session.status = "sealing";
|
|
2897
|
+
// PERSIST-024 FINDING-2: persist the responder's sealing status so a crash here
|
|
2898
|
+
// does not reload the session as "active". Must happen after status mutation.
|
|
2899
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
2431
2900
|
void this.#submitSealLeaf(sessionIdHex, session, "responder").then((result) => {
|
|
2432
2901
|
if (!result.ok) {
|
|
2433
2902
|
const s = this.#sessions.get(sessionIdHex);
|
|
@@ -2438,6 +2907,8 @@ class CelloClientImpl {
|
|
|
2438
2907
|
}
|
|
2439
2908
|
else {
|
|
2440
2909
|
// Counterparty message: enqueue for receiveMessage callers.
|
|
2910
|
+
// PERSIST-024 FINDING-1: persist sequence counters after last_seen_seq mutation.
|
|
2911
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
2441
2912
|
const msg = {
|
|
2442
2913
|
type: "message",
|
|
2443
2914
|
content: content_bytes,
|
|
@@ -2458,6 +2929,7 @@ class CelloClientImpl {
|
|
|
2458
2929
|
if (!session)
|
|
2459
2930
|
return;
|
|
2460
2931
|
session.desynchronized = true;
|
|
2932
|
+
void this.#persistence?.persistSession(sessionIdHex, session);
|
|
2461
2933
|
// Cancel pending S2 timers AND fire any migrated echo resolvers
|
|
2462
2934
|
const ps2 = this.#pendingS2.get(sessionIdHex);
|
|
2463
2935
|
if (ps2) {
|
|
@@ -2723,9 +3195,20 @@ class CelloClientImpl {
|
|
|
2723
3195
|
return { error: "already_registered" };
|
|
2724
3196
|
}
|
|
2725
3197
|
// Step 2: generate or load ML-DSA-44 keypair (NIST FIPS 204)
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
3198
|
+
// PERSIST-024: use mlDsaKeygenWithBytes when persistence is enabled to capture raw secret bytes
|
|
3199
|
+
let mlDsaProvider;
|
|
3200
|
+
let mlDsaSecretKeyBlob = null;
|
|
3201
|
+
if (this.#mlDsaKeyFile) {
|
|
3202
|
+
mlDsaProvider = await FileMlDsaKeyProvider.load(this.#mlDsaKeyFile);
|
|
3203
|
+
}
|
|
3204
|
+
else if (this.#persistence) {
|
|
3205
|
+
const { provider, secretKeyBlob } = await mlDsaKeygenWithBytes();
|
|
3206
|
+
mlDsaProvider = provider;
|
|
3207
|
+
mlDsaSecretKeyBlob = secretKeyBlob;
|
|
3208
|
+
}
|
|
3209
|
+
else {
|
|
3210
|
+
mlDsaProvider = await mlDsaKeygen();
|
|
3211
|
+
}
|
|
2729
3212
|
const mlDsaPubkey = await mlDsaProvider.getPublicKey();
|
|
2730
3213
|
const mlDsaPubkeyHex = Buffer.from(mlDsaPubkey).toString("hex");
|
|
2731
3214
|
// Step 3: open persistent signaling stream (handles auth including auth_ok wait)
|
|
@@ -2780,6 +3263,26 @@ class CelloClientImpl {
|
|
|
2780
3263
|
};
|
|
2781
3264
|
this.#registrationState = state;
|
|
2782
3265
|
this.#mlDsaProvider = mlDsaProvider;
|
|
3266
|
+
// HIGH-5: persist ML-DSA keypair and registration state on already_registered fast-return
|
|
3267
|
+
// Note: the guard is `this.#persistence && mlDsaSecretKeyBlob`. When #mlDsaKeyFile is set,
|
|
3268
|
+
// the agent uses FileMlDsaKeyProvider which manages its own file-based key persistence —
|
|
3269
|
+
// mlDsaSecretKeyBlob will be null in that path and the SQLCipher persistence skip is intentional.
|
|
3270
|
+
// Note on FROST share: DKG was skipped by the directory in this path, so no FROST share exists.
|
|
3271
|
+
// On the next restart, client.frost.share.missing will fire — that is correct behavior.
|
|
3272
|
+
// The operator should investigate why the directory considers this agent already registered
|
|
3273
|
+
// without a local FROST share.
|
|
3274
|
+
if (this.#persistence && mlDsaSecretKeyBlob) {
|
|
3275
|
+
void this.#persistence.persistMlDsaKeypair({
|
|
3276
|
+
mlDsaPubkey: mlDsaPubkeyHex,
|
|
3277
|
+
secretKeyBlob: mlDsaSecretKeyBlob,
|
|
3278
|
+
});
|
|
3279
|
+
void this.#persistence.persistRegistrationState({
|
|
3280
|
+
agentId: state.agent_id,
|
|
3281
|
+
primaryPubkey: state.primary_pubkey,
|
|
3282
|
+
mlDsaPubkey: state.ml_dsa_pubkey,
|
|
3283
|
+
registeredAt: state.registered_at,
|
|
3284
|
+
});
|
|
3285
|
+
}
|
|
2783
3286
|
return state;
|
|
2784
3287
|
}
|
|
2785
3288
|
return { error: reason };
|
|
@@ -2814,6 +3317,22 @@ class CelloClientImpl {
|
|
|
2814
3317
|
this.#thresholdSigner = dkgResult.signer;
|
|
2815
3318
|
// SESSION-005: update primary_pubkey so seal verification uses the DKG-derived key.
|
|
2816
3319
|
this.#myPrimaryPubkey = new Uint8Array(dkgResult.primaryPubkey);
|
|
3320
|
+
// PERSIST-024: persist FROST key share to DB (SI-001: signingShare is raw bytes, never logged)
|
|
3321
|
+
if (this.#persistence) {
|
|
3322
|
+
const commitmentsCbor = CBOR_ENC.encode(dkgResult.commitments);
|
|
3323
|
+
const verifyingSharesCbor = CBOR_ENC.encode(dkgResult.verifyingShares);
|
|
3324
|
+
void this.#persistence.persistFrostKeyShare({
|
|
3325
|
+
epochId,
|
|
3326
|
+
primaryPubkey: dkgPrimaryPubkeyHex,
|
|
3327
|
+
identifier: dkgResult.identifier,
|
|
3328
|
+
signingShare: dkgResult.signingShare,
|
|
3329
|
+
threshold: dkgResult.threshold,
|
|
3330
|
+
participants: dkgResult.participants,
|
|
3331
|
+
commitmentsCbor,
|
|
3332
|
+
verifyingSharesCbor,
|
|
3333
|
+
dkgMethod: "network_dkg",
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
2817
3336
|
}
|
|
2818
3337
|
catch {
|
|
2819
3338
|
return { error: "dkg_failed" };
|
|
@@ -2856,6 +3375,22 @@ class CelloClientImpl {
|
|
|
2856
3375
|
};
|
|
2857
3376
|
this.#registrationState = state;
|
|
2858
3377
|
this.#mlDsaProvider = mlDsaProvider;
|
|
3378
|
+
// HIGH-5: persist ML-DSA keypair and registration state on already_registered fast-return
|
|
3379
|
+
// Note: the guard is `this.#persistence && mlDsaSecretKeyBlob`. When #mlDsaKeyFile is set,
|
|
3380
|
+
// the agent uses FileMlDsaKeyProvider which manages its own file-based key persistence —
|
|
3381
|
+
// mlDsaSecretKeyBlob will be null in that path and the SQLCipher persistence skip is intentional.
|
|
3382
|
+
if (this.#persistence && mlDsaSecretKeyBlob) {
|
|
3383
|
+
void this.#persistence.persistMlDsaKeypair({
|
|
3384
|
+
mlDsaPubkey: mlDsaPubkeyHex,
|
|
3385
|
+
secretKeyBlob: mlDsaSecretKeyBlob,
|
|
3386
|
+
});
|
|
3387
|
+
void this.#persistence.persistRegistrationState({
|
|
3388
|
+
agentId: state.agent_id,
|
|
3389
|
+
primaryPubkey: state.primary_pubkey,
|
|
3390
|
+
mlDsaPubkey: state.ml_dsa_pubkey,
|
|
3391
|
+
registeredAt: state.registered_at,
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
2859
3394
|
return state;
|
|
2860
3395
|
}
|
|
2861
3396
|
return { error: reason };
|
|
@@ -2873,6 +3408,21 @@ class CelloClientImpl {
|
|
|
2873
3408
|
this.#registrationState = state;
|
|
2874
3409
|
// Store the ML-DSA key provider so MCP tools can build ConnectionPackages (CELLO-MCP-003).
|
|
2875
3410
|
this.#mlDsaProvider = mlDsaProvider;
|
|
3411
|
+
// PERSIST-024: persist ML-DSA keypair and registration state (SI-002: secretKeyBlob is raw bytes, never logged)
|
|
3412
|
+
if (this.#persistence && mlDsaSecretKeyBlob) {
|
|
3413
|
+
void this.#persistence.persistMlDsaKeypair({
|
|
3414
|
+
mlDsaPubkey: mlDsaPubkeyHex,
|
|
3415
|
+
secretKeyBlob: mlDsaSecretKeyBlob,
|
|
3416
|
+
});
|
|
3417
|
+
}
|
|
3418
|
+
if (this.#persistence) {
|
|
3419
|
+
void this.#persistence.persistRegistrationState({
|
|
3420
|
+
agentId: state.agent_id,
|
|
3421
|
+
primaryPubkey: state.primary_pubkey,
|
|
3422
|
+
mlDsaPubkey: state.ml_dsa_pubkey,
|
|
3423
|
+
registeredAt: state.registered_at,
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
2876
3426
|
return state;
|
|
2877
3427
|
}
|
|
2878
3428
|
async #handleInbound(stream) {
|
|
@@ -2991,6 +3541,10 @@ class CelloClientImpl {
|
|
|
2991
3541
|
*/
|
|
2992
3542
|
setPolicy(policy) {
|
|
2993
3543
|
this.#connectionPolicy = policy;
|
|
3544
|
+
// PERSIST-024: persist policy to DB
|
|
3545
|
+
if (this.#persistence) {
|
|
3546
|
+
void this.#persistence.persistConnectionPolicy(policy);
|
|
3547
|
+
}
|
|
2994
3548
|
}
|
|
2995
3549
|
/**
|
|
2996
3550
|
* Return the current connection policy. Returns default open/deterministic if none configured.
|
|
@@ -3031,6 +3585,10 @@ class CelloClientImpl {
|
|
|
3031
3585
|
// Mark as decided before sending to prevent races
|
|
3032
3586
|
this.#decidedRequests.add(connectionRequestId);
|
|
3033
3587
|
this.#pendingInboundRequests.delete(connectionRequestId);
|
|
3588
|
+
// CRIT-2: persist decision
|
|
3589
|
+
if (this.#persistence) {
|
|
3590
|
+
void this.#persistence.decidePendingConnectionRequest(connectionRequestId, "accepted");
|
|
3591
|
+
}
|
|
3034
3592
|
if (!this.#persistentSignalingStream) {
|
|
3035
3593
|
// Stream gone — still mark as decided
|
|
3036
3594
|
return { error: { reason: "no_pending_request" } };
|
|
@@ -3081,6 +3639,10 @@ class CelloClientImpl {
|
|
|
3081
3639
|
// Mark as decided
|
|
3082
3640
|
this.#decidedRequests.add(connectionRequestId);
|
|
3083
3641
|
this.#pendingInboundRequests.delete(connectionRequestId);
|
|
3642
|
+
// CRIT-2: persist decision
|
|
3643
|
+
if (this.#persistence) {
|
|
3644
|
+
void this.#persistence.decidePendingConnectionRequest(connectionRequestId, "rejected");
|
|
3645
|
+
}
|
|
3084
3646
|
if (!this.#persistentSignalingStream) {
|
|
3085
3647
|
return { rejected: true };
|
|
3086
3648
|
}
|
|
@@ -3119,6 +3681,10 @@ class CelloClientImpl {
|
|
|
3119
3681
|
}
|
|
3120
3682
|
// Advance to Round 2
|
|
3121
3683
|
pending.round = 2;
|
|
3684
|
+
// CRIT-2: persist disclosure decision
|
|
3685
|
+
if (this.#persistence) {
|
|
3686
|
+
void this.#persistence.decidePendingConnectionRequest(connectionRequestId, "more_disclosure");
|
|
3687
|
+
}
|
|
3122
3688
|
if (!this.#persistentSignalingStream) {
|
|
3123
3689
|
return { error: { reason: "no_pending_request" } };
|
|
3124
3690
|
}
|
|
@@ -3276,6 +3842,14 @@ class CelloClientImpl {
|
|
|
3276
3842
|
};
|
|
3277
3843
|
this.#connections.set(connectionId, record);
|
|
3278
3844
|
this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
|
|
3845
|
+
// PERSIST-024: persist connection to DB
|
|
3846
|
+
if (this.#persistence) {
|
|
3847
|
+
void this.#persistence.persistConnection({
|
|
3848
|
+
connectionId,
|
|
3849
|
+
counterpartyPubkey,
|
|
3850
|
+
establishedAt: record.established_at,
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3279
3853
|
this.#logger.info("connection.established", {
|
|
3280
3854
|
connectionId,
|
|
3281
3855
|
counterpartyPubkeyHex: counterpartyPubkey,
|
|
@@ -3305,6 +3879,14 @@ class CelloClientImpl {
|
|
|
3305
3879
|
};
|
|
3306
3880
|
this.#connections.set(connectionId, record);
|
|
3307
3881
|
this.#connectionsByPeer.set(targetPubkeyHex, connectionId);
|
|
3882
|
+
// PERSIST-024: persist connection to DB
|
|
3883
|
+
if (this.#persistence) {
|
|
3884
|
+
void this.#persistence.persistConnection({
|
|
3885
|
+
connectionId,
|
|
3886
|
+
counterpartyPubkey: targetPubkeyHex,
|
|
3887
|
+
establishedAt: record.established_at,
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3308
3890
|
}
|
|
3309
3891
|
this.#logger.info("connection.established", {
|
|
3310
3892
|
connectionId,
|
|
@@ -3365,16 +3947,24 @@ class CelloClientImpl {
|
|
|
3365
3947
|
if (type === "connection_established") {
|
|
3366
3948
|
const connectionId = frame["connection_id"];
|
|
3367
3949
|
const counterpartyPubkey = frame["counterparty_pubkey"];
|
|
3950
|
+
const establishedAt = Date.now();
|
|
3368
3951
|
const record = {
|
|
3369
3952
|
connection_id: connectionId,
|
|
3370
3953
|
counterparty_pubkey: counterpartyPubkey,
|
|
3371
3954
|
counterparty_primary_pubkey: "",
|
|
3372
3955
|
counterparty_ml_dsa_pubkey: "",
|
|
3373
|
-
established_at:
|
|
3956
|
+
established_at: establishedAt,
|
|
3374
3957
|
status: "active",
|
|
3375
3958
|
};
|
|
3376
3959
|
this.#connections.set(connectionId, record);
|
|
3377
3960
|
this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
|
|
3961
|
+
if (this.#persistence) {
|
|
3962
|
+
void this.#persistence.persistConnection({
|
|
3963
|
+
connectionId,
|
|
3964
|
+
counterpartyPubkey,
|
|
3965
|
+
establishedAt,
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3378
3968
|
return { result: "established", connection_id: connectionId };
|
|
3379
3969
|
}
|
|
3380
3970
|
if (type === "connection_rejected") {
|
|
@@ -3556,7 +4146,12 @@ class CelloClientImpl {
|
|
|
3556
4146
|
}
|
|
3557
4147
|
sigStream.close().catch(() => { });
|
|
3558
4148
|
if (respFrame["type"] === "relay_pubkey_response") {
|
|
3559
|
-
|
|
4149
|
+
const pubkeyHex = respFrame["public_key_hex"];
|
|
4150
|
+
// PERSIST-024: cache in known_relays so lookupRelayPubkey can serve from DB
|
|
4151
|
+
if (pubkeyHex && this.#persistence) {
|
|
4152
|
+
void this.#persistence.persistKnownRelay(relayId, pubkeyHex, "directory");
|
|
4153
|
+
}
|
|
4154
|
+
return pubkeyHex;
|
|
3560
4155
|
}
|
|
3561
4156
|
// relay_pubkey_error (not_found) or unexpected type
|
|
3562
4157
|
const errorReason = respFrame["reason"] ?? "not_found";
|
|
@@ -3780,6 +4375,15 @@ class CelloClientImpl {
|
|
|
3780
4375
|
// No caller waiting — enqueue for future awaitConnectionRequest() poll
|
|
3781
4376
|
this.#pendingReviewQueue.push(reviewItem);
|
|
3782
4377
|
}
|
|
4378
|
+
// CRIT-2: persist pending connection request
|
|
4379
|
+
if (this.#persistence) {
|
|
4380
|
+
void this.#persistence.persistPendingConnectionRequest({
|
|
4381
|
+
requestId: connectionRequestId,
|
|
4382
|
+
fromPubkey,
|
|
4383
|
+
packageCbor,
|
|
4384
|
+
round: 1,
|
|
4385
|
+
});
|
|
4386
|
+
}
|
|
3783
4387
|
if (this.#onConnectionPendingReview) {
|
|
3784
4388
|
this.#onConnectionPendingReview({
|
|
3785
4389
|
type: "connection_request_inbound",
|
|
@@ -3913,6 +4517,14 @@ class CelloClientImpl {
|
|
|
3913
4517
|
package_cbor: packageCbor,
|
|
3914
4518
|
round: 2,
|
|
3915
4519
|
});
|
|
4520
|
+
if (this.#persistence) {
|
|
4521
|
+
void this.#persistence.persistPendingConnectionRequest({
|
|
4522
|
+
requestId: connectionRequestId,
|
|
4523
|
+
fromPubkey,
|
|
4524
|
+
packageCbor,
|
|
4525
|
+
round: 2,
|
|
4526
|
+
});
|
|
4527
|
+
}
|
|
3916
4528
|
const round2ReviewItem = {
|
|
3917
4529
|
connection_request_id: connectionRequestId,
|
|
3918
4530
|
from_pubkey: fromPubkey,
|
|
@@ -4413,6 +5025,15 @@ class CelloClientImpl {
|
|
|
4413
5025
|
}
|
|
4414
5026
|
this.#connections.set(connectionId, record);
|
|
4415
5027
|
this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
|
|
5028
|
+
// PERSIST-024: persist connection to DB
|
|
5029
|
+
if (this.#persistence) {
|
|
5030
|
+
void this.#persistence.persistConnection({
|
|
5031
|
+
connectionId,
|
|
5032
|
+
counterpartyPubkey,
|
|
5033
|
+
establishedAt: record.established_at,
|
|
5034
|
+
profileUnchecked: record.profile_unchecked,
|
|
5035
|
+
});
|
|
5036
|
+
}
|
|
4416
5037
|
}
|
|
4417
5038
|
// Fire onConnectionEstablished handler (both A and B)
|
|
4418
5039
|
const handler = this.#onConnectionEstablishedHandler;
|
|
@@ -4640,7 +5261,7 @@ function parseStringArray(v) {
|
|
|
4640
5261
|
}
|
|
4641
5262
|
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
4642
5263
|
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);
|
|
5264
|
+
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, opts?.persistence);
|
|
4644
5265
|
}
|
|
4645
5266
|
// ─── Error helpers ────────────────────────────────────────────────────────────
|
|
4646
5267
|
function isStructuredError(err, reason) {
|