@cello-protocol/client 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent-hash-queue.d.ts +206 -0
  2. package/dist/agent-hash-queue.d.ts.map +1 -0
  3. package/dist/agent-hash-queue.js +380 -0
  4. package/dist/agent-hash-queue.js.map +1 -0
  5. package/dist/backup-key-derivation.d.ts +37 -0
  6. package/dist/backup-key-derivation.d.ts.map +1 -0
  7. package/dist/backup-key-derivation.js +48 -0
  8. package/dist/backup-key-derivation.js.map +1 -0
  9. package/dist/client-backup.d.ts +144 -0
  10. package/dist/client-backup.d.ts.map +1 -0
  11. package/dist/client-backup.js +273 -0
  12. package/dist/client-backup.js.map +1 -0
  13. package/dist/client.d.ts +249 -0
  14. package/dist/client.d.ts.map +1 -0
  15. package/dist/client.js +4664 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/connection-policy.d.ts +163 -0
  18. package/dist/connection-policy.d.ts.map +1 -0
  19. package/dist/connection-policy.js +248 -0
  20. package/dist/connection-policy.js.map +1 -0
  21. package/dist/db-key-derivation.d.ts +26 -0
  22. package/dist/db-key-derivation.d.ts.map +1 -0
  23. package/dist/db-key-derivation.js +37 -0
  24. package/dist/db-key-derivation.js.map +1 -0
  25. package/dist/encrypted-file-signing-key-provider.d.ts +92 -0
  26. package/dist/encrypted-file-signing-key-provider.d.ts.map +1 -0
  27. package/dist/encrypted-file-signing-key-provider.js +251 -0
  28. package/dist/encrypted-file-signing-key-provider.js.map +1 -0
  29. package/dist/index.d.ts +13 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +8 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/mcp-server.d.ts +270 -0
  34. package/dist/mcp-server.d.ts.map +1 -0
  35. package/dist/mcp-server.js +1155 -0
  36. package/dist/mcp-server.js.map +1 -0
  37. package/dist/network-directory-node.d.ts +85 -0
  38. package/dist/network-directory-node.d.ts.map +1 -0
  39. package/dist/network-directory-node.js +584 -0
  40. package/dist/network-directory-node.js.map +1 -0
  41. package/dist/s3-cloud-storage-provider.d.ts +54 -0
  42. package/dist/s3-cloud-storage-provider.d.ts.map +1 -0
  43. package/dist/s3-cloud-storage-provider.js +78 -0
  44. package/dist/s3-cloud-storage-provider.js.map +1 -0
  45. package/dist/sqlcipher-client-store.d.ts +68 -0
  46. package/dist/sqlcipher-client-store.d.ts.map +1 -0
  47. package/dist/sqlcipher-client-store.js +382 -0
  48. package/dist/sqlcipher-client-store.js.map +1 -0
  49. package/dist/types.d.ts +408 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +7 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +48 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * CELLO-PERSIST-012 — Agent Hash Queue + Signed Relay ACKs
3
+ *
4
+ * ── Pseudocode (Phase P) ──────────────────────────────────────────────────────
5
+ *
6
+ * The agent hash queue is a first-class protocol primitive. When relay connectivity
7
+ * is interrupted, the P2P conversation continues and hashes accumulate in the queue.
8
+ * On relay recovery or reassignment, queued hashes are submitted in FIFO order.
9
+ *
10
+ * buildSignedAckTbs(hashHex, sequenceNumber, timestamp):
11
+ * // RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
12
+ * // Deterministic TBS for signing and verification. Fixed-width encodings prevent
13
+ * // ambiguity: a 4-byte and an 8-byte integer concatenated to 32-byte hash bytes.
14
+ * hashBytes = Buffer.from(hashHex, 'hex') // 32 bytes
15
+ * seqBuf = 4-byte big-endian uint32 // 4 bytes
16
+ * tsBuf = 8-byte big-endian uint64 // 8 bytes
17
+ * preimage = concat(hashBytes, seqBuf, tsBuf) // 44 bytes
18
+ * return SHA-256(preimage) // 32 bytes
19
+ *
20
+ * verifyRelayAck(hashHex, ack, relayPubkey):
21
+ * // Reconstructs TBS and verifies Ed25519 signature
22
+ * tbs = buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp)
23
+ * if ack.signature.length !== 64: return false
24
+ * return Ed25519.verify(relayPubkey, tbs, ack.signature)
25
+ *
26
+ * AgentHashQueue.enqueue(sessionId, hashHex, correlationId):
27
+ * // Must happen BEFORE relay submission (AC-001)
28
+ * pending = await #loadPending(sessionId)
29
+ * entry = { hashHex, sessionId, enqueuedAt: Date.now() }
30
+ * pending.push(entry)
31
+ * await #savePending(sessionId, pending)
32
+ * logger.info("client.hashqueue.enqueued", { agentId, sessionId, hashHex,
33
+ * queueDepth: pending.length, correlationId })
34
+ *
35
+ * AgentHashQueue.processAck(sessionId, hashHex, ack, lookupRelayPubkey, correlationId):
36
+ * // 1. Verify ACK signature (SI-001: never remove without valid ACK)
37
+ * relayPubkey = await lookupRelayPubkey(ack.relayId)
38
+ * if relayPubkey === null || !verifyRelayAck(hashHex, ack, relayPubkey):
39
+ * logger.warn("client.relay.ack.invalid", { agentId, sessionId, hashHex, reason })
40
+ * return // hash stays in queue
41
+ * // 2. Store ACK as cryptographic receipt (SI-003: immutable, never overwrite)
42
+ * existing = await #loadAck(hashHex)
43
+ * if existing === undefined:
44
+ * await #saveAck(hashHex, ack)
45
+ * // 3. Remove from pending queue (only AFTER ACK is stored)
46
+ * pending = await #loadPending(sessionId)
47
+ * pending = pending.filter(e => e.hashHex !== hashHex)
48
+ * await #savePending(sessionId, pending)
49
+ * logger.info("client.hashqueue.acked", { agentId, sessionId, hashHex,
50
+ * sequenceNumber: ack.sequenceNumber, correlationId })
51
+ *
52
+ * AgentHashQueue.pollDepth():
53
+ * // Called on 30-second interval (externally scheduled)
54
+ * // Logs client.hashqueue.depth when depth > 0 (AC-006)
55
+ * // Also checks for stale entries (DB-001)
56
+ * allPending = await #loadAllPending()
57
+ * depth = total pending count
58
+ * if depth === 0: return
59
+ * logger.info("client.hashqueue.depth", { agentId, depth })
60
+ * // Check staleness
61
+ * oldestEntry = entry with minimum enqueuedAt
62
+ * oldestAge = Date.now() - oldestEntry.enqueuedAt
63
+ * if oldestAge > hashQueueMaxAgeMs:
64
+ * logger.warn("client.hashqueue.stale", { agentId, depth, oldestHashAge: oldestAge })
65
+ *
66
+ * Storage keys:
67
+ * pending queue: "hq:pending:{sessionId}" → JSON PendingHashEntry[]
68
+ * ACK receipt: "hq:ack:{hashHex}" → JSON StoredRelayAck
69
+ * All-sessions index: "hq:sessions" → JSON string[] of sessionIds with queues
70
+ */
71
+ import type { ClientStore, Logger } from "@cello-protocol/interfaces";
72
+ /** A single entry in the local pending hash queue. */
73
+ export interface PendingHashEntry {
74
+ /** Session this hash belongs to. */
75
+ sessionId: string;
76
+ /** SHA-256 content hash as lowercase hex (32 bytes → 64 chars). */
77
+ hashHex: string;
78
+ /** Unix ms when the entry was enqueued. Used for staleness checks. */
79
+ enqueuedAt: number;
80
+ }
81
+ /** A relay's signed ACK for a submitted hash. Stored as an immutable receipt. */
82
+ export interface RelayAck {
83
+ /** Stable identifier for the signing relay (used for pubkey lookup). */
84
+ relayId: string;
85
+ /** 32-byte Ed25519 public key of the relay that signed this ACK. */
86
+ relayPubkey: Uint8Array;
87
+ /** Relay-assigned sequence number for this hash. */
88
+ sequenceNumber: number;
89
+ /** Unix ms timestamp embedded in the ACK TBS. */
90
+ timestamp: number;
91
+ /** 64-byte Ed25519 signature over SHA-256(hashHex_bytes || seq_BE4 || ts_BE8). */
92
+ signature: Uint8Array;
93
+ }
94
+ /** Sentinel returned by handleResubmitRejection when predecessor relay is unknown. */
95
+ export declare const RELAY_PREDECESSOR_UNKNOWN: "RELAY_PREDECESSOR_UNKNOWN";
96
+ export type RelayPredecessorUnknown = typeof RELAY_PREDECESSOR_UNKNOWN;
97
+ /** Options for constructing an AgentHashQueue. */
98
+ export interface AgentHashQueueOptions {
99
+ /** ClientStore for persisting the queue and ACK receipts. */
100
+ store: ClientStore;
101
+ /** Stable agent identifier — included in all log events. */
102
+ agentId: string;
103
+ /** Structured logger injected at the composition root. */
104
+ logger: Logger;
105
+ /**
106
+ * Maximum age (ms) for a pending hash before DB-001 stale warning fires.
107
+ * Default: 24 * 60 * 60 * 1000 (24 hours).
108
+ */
109
+ hashQueueMaxAgeMs?: number;
110
+ }
111
+ /**
112
+ * Build the to-be-signed bytes for a relay ACK.
113
+ *
114
+ * Hex-decodes hashHex and delegates to buildRelayAckTbs (the canonical
115
+ * implementation in @cello-protocol/crypto shared with the relay signer).
116
+ *
117
+ * TBS = SHA-256(hash_bytes || seq_BE4 || ts_BE8). RFC 8032, FIPS 180-4.
118
+ */
119
+ export declare function buildSignedAckTbs(hashHex: string, sequenceNumber: number, timestamp: number): Uint8Array;
120
+ /**
121
+ * Verify a relay ACK signature.
122
+ *
123
+ * Returns true if:
124
+ * - ack.signature is exactly 64 bytes
125
+ * - Ed25519.verify(relayPubkey, buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp), ack.signature) is true
126
+ *
127
+ * RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
128
+ */
129
+ export declare function verifyRelayAck(hashHex: string, ack: RelayAck, relayPubkey: Uint8Array): boolean;
130
+ /**
131
+ * Manages the local queue of Structure 1 hashes pending relay submission.
132
+ *
133
+ * Security invariants:
134
+ * SI-001: A hash is never removed from the pending queue without a valid stored ACK.
135
+ * The remove-from-queue step only executes after ACK verification passes.
136
+ * SI-002: getPending() returns items in strict insertion order. The caller is
137
+ * responsible for FIFO submission — the queue does not reorder.
138
+ * SI-003: Once an ACK is stored for a hash, subsequent ACKs for the same hash
139
+ * do not overwrite the existing receipt.
140
+ *
141
+ * Persistence: all state is stored in the injected ClientStore using JSON encoding.
142
+ * Keys: "hq:pending:{sessionId}" and "hq:ack:{hashHex}".
143
+ */
144
+ export declare class AgentHashQueue {
145
+ #private;
146
+ constructor(opts: AgentHashQueueOptions);
147
+ /**
148
+ * Enqueue a hash in the local hash queue before relay submission.
149
+ *
150
+ * Must be called BEFORE attempting relay submission (AC-001).
151
+ * The hash is present in the queue until a valid signed ACK is processed.
152
+ *
153
+ * Logs: client.hashqueue.enqueued with { agentId, sessionId, hashHex, queueDepth, correlationId }
154
+ */
155
+ enqueue(sessionId: string, hashHex: string, correlationId: string): Promise<void>;
156
+ /**
157
+ * Process a relay ACK for a previously enqueued hash.
158
+ *
159
+ * Steps (SI-001 enforced):
160
+ * 1. Look up the relay's public key via lookupRelayPubkey(ack.relayId).
161
+ * 2. Verify the ACK signature with verifyRelayAck().
162
+ * 3. If verification fails: log client.relay.ack.invalid; return WITHOUT touching the queue.
163
+ * 4. Store the ACK as immutable receipt (SI-003: skip if already stored).
164
+ * 5. Remove the hash from the pending queue ONLY after ACK is stored.
165
+ *
166
+ * @param sessionId - session the hash belongs to
167
+ * @param hashHex - the hash being ACKed
168
+ * @param ack - the relay's ACK frame
169
+ * @param lookupRelayPubkey - async function that looks up relay pubkey by relayId
170
+ * @param correlationId - correlation ID for log tracing
171
+ */
172
+ processAck(sessionId: string, hashHex: string, ack: RelayAck, lookupRelayPubkey: (relayId: string) => Promise<Uint8Array | null>, correlationId: string): Promise<void>;
173
+ /**
174
+ * Return all pending (un-ACKed) hashes for a session in insertion order (SI-002).
175
+ */
176
+ getPending(sessionId: string): Promise<PendingHashEntry[]>;
177
+ /**
178
+ * Return the stored relay ACK for a hash, or undefined if not yet ACKed.
179
+ */
180
+ getAck(hashHex: string): Promise<RelayAck | undefined>;
181
+ /**
182
+ * Return the total number of pending hashes for a session.
183
+ */
184
+ depth(sessionId: string): Promise<number>;
185
+ /**
186
+ * Poll the total queue depth across all sessions.
187
+ *
188
+ * If depth > 0: logs client.hashqueue.depth at INFO with { agentId, depth }.
189
+ * If depth > 0 and oldest entry is older than hashQueueMaxAgeMs: additionally logs
190
+ * client.hashqueue.stale at WARN with { agentId, depth, oldestHashAge } (DB-001).
191
+ *
192
+ * Called externally on a 30-second interval while the agent is running.
193
+ * Test-friendly: call directly without waiting.
194
+ */
195
+ pollDepth(): Promise<void>;
196
+ /**
197
+ * Handle a re-submission rejection from the new relay.
198
+ *
199
+ * Logs client.relay.resubmit.rejected at ERROR with { agentId, sessionId, hashHex, reason }.
200
+ * The hash remains in the pending queue (DB-002: operator must resolve manually).
201
+ *
202
+ * @returns the reason string (for caller convenience)
203
+ */
204
+ handleResubmitRejection(sessionId: string, hashHex: string, reason: string): Promise<string>;
205
+ }
206
+ //# sourceMappingURL=agent-hash-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-hash-queue.d.ts","sourceRoot":"","sources":["../src/agent-hash-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAC;AAItE,sDAAsD;AACtD,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,WAAW,EAAE,UAAU,CAAC;IACxB,oDAAoD;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,SAAS,EAAE,UAAU,CAAC;CACvB;AAWD,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,2BAAoC,CAAC;AAC9E,MAAM,MAAM,uBAAuB,GAAG,OAAO,yBAAyB,CAAC;AAEvE,kDAAkD;AAClD,MAAM,WAAW,qBAAqB;IACpC,6DAA6D;IAC7D,KAAK,EAAE,WAAW,CAAC;IACnB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAgBD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,GAChB,UAAU,CAEZ;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,QAAQ,EACb,WAAW,EAAE,UAAU,GACtB,OAAO,CAIT;AAID;;;;;;;;;;;;;GAaG;AACH,qBAAa,cAAc;;gBAMb,IAAI,EAAE,qBAAqB;IASvC;;;;;;;OAOG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwBvF;;;;;;;;;;;;;;;OAeG;IACG,UAAU,CACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,QAAQ,EACb,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,EAClE,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,IAAI,CAAC;IAgDhB;;OAEG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IAIhE;;OAEG;IACG,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC;IAI5D;;OAEG;IACG,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAO/C;;;;;;;;;OASG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAgChC;;;;;;;OAOG;IACG,uBAAuB,CAC3B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,CAAC;CA2FnB"}
@@ -0,0 +1,380 @@
1
+ /**
2
+ * CELLO-PERSIST-012 — Agent Hash Queue + Signed Relay ACKs
3
+ *
4
+ * ── Pseudocode (Phase P) ──────────────────────────────────────────────────────
5
+ *
6
+ * The agent hash queue is a first-class protocol primitive. When relay connectivity
7
+ * is interrupted, the P2P conversation continues and hashes accumulate in the queue.
8
+ * On relay recovery or reassignment, queued hashes are submitted in FIFO order.
9
+ *
10
+ * buildSignedAckTbs(hashHex, sequenceNumber, timestamp):
11
+ * // RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
12
+ * // Deterministic TBS for signing and verification. Fixed-width encodings prevent
13
+ * // ambiguity: a 4-byte and an 8-byte integer concatenated to 32-byte hash bytes.
14
+ * hashBytes = Buffer.from(hashHex, 'hex') // 32 bytes
15
+ * seqBuf = 4-byte big-endian uint32 // 4 bytes
16
+ * tsBuf = 8-byte big-endian uint64 // 8 bytes
17
+ * preimage = concat(hashBytes, seqBuf, tsBuf) // 44 bytes
18
+ * return SHA-256(preimage) // 32 bytes
19
+ *
20
+ * verifyRelayAck(hashHex, ack, relayPubkey):
21
+ * // Reconstructs TBS and verifies Ed25519 signature
22
+ * tbs = buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp)
23
+ * if ack.signature.length !== 64: return false
24
+ * return Ed25519.verify(relayPubkey, tbs, ack.signature)
25
+ *
26
+ * AgentHashQueue.enqueue(sessionId, hashHex, correlationId):
27
+ * // Must happen BEFORE relay submission (AC-001)
28
+ * pending = await #loadPending(sessionId)
29
+ * entry = { hashHex, sessionId, enqueuedAt: Date.now() }
30
+ * pending.push(entry)
31
+ * await #savePending(sessionId, pending)
32
+ * logger.info("client.hashqueue.enqueued", { agentId, sessionId, hashHex,
33
+ * queueDepth: pending.length, correlationId })
34
+ *
35
+ * AgentHashQueue.processAck(sessionId, hashHex, ack, lookupRelayPubkey, correlationId):
36
+ * // 1. Verify ACK signature (SI-001: never remove without valid ACK)
37
+ * relayPubkey = await lookupRelayPubkey(ack.relayId)
38
+ * if relayPubkey === null || !verifyRelayAck(hashHex, ack, relayPubkey):
39
+ * logger.warn("client.relay.ack.invalid", { agentId, sessionId, hashHex, reason })
40
+ * return // hash stays in queue
41
+ * // 2. Store ACK as cryptographic receipt (SI-003: immutable, never overwrite)
42
+ * existing = await #loadAck(hashHex)
43
+ * if existing === undefined:
44
+ * await #saveAck(hashHex, ack)
45
+ * // 3. Remove from pending queue (only AFTER ACK is stored)
46
+ * pending = await #loadPending(sessionId)
47
+ * pending = pending.filter(e => e.hashHex !== hashHex)
48
+ * await #savePending(sessionId, pending)
49
+ * logger.info("client.hashqueue.acked", { agentId, sessionId, hashHex,
50
+ * sequenceNumber: ack.sequenceNumber, correlationId })
51
+ *
52
+ * AgentHashQueue.pollDepth():
53
+ * // Called on 30-second interval (externally scheduled)
54
+ * // Logs client.hashqueue.depth when depth > 0 (AC-006)
55
+ * // Also checks for stale entries (DB-001)
56
+ * allPending = await #loadAllPending()
57
+ * depth = total pending count
58
+ * if depth === 0: return
59
+ * logger.info("client.hashqueue.depth", { agentId, depth })
60
+ * // Check staleness
61
+ * oldestEntry = entry with minimum enqueuedAt
62
+ * oldestAge = Date.now() - oldestEntry.enqueuedAt
63
+ * if oldestAge > hashQueueMaxAgeMs:
64
+ * logger.warn("client.hashqueue.stale", { agentId, depth, oldestHashAge: oldestAge })
65
+ *
66
+ * Storage keys:
67
+ * pending queue: "hq:pending:{sessionId}" → JSON PendingHashEntry[]
68
+ * ACK receipt: "hq:ack:{hashHex}" → JSON StoredRelayAck
69
+ * All-sessions index: "hq:sessions" → JSON string[] of sessionIds with queues
70
+ */
71
+ import { verify, buildRelayAckTbs } from "@cello-protocol/crypto";
72
+ /** Sentinel returned by handleResubmitRejection when predecessor relay is unknown. */
73
+ export const RELAY_PREDECESSOR_UNKNOWN = "RELAY_PREDECESSOR_UNKNOWN";
74
+ // ─── Storage key helpers ──────────────────────────────────────────────────────
75
+ function pendingKey(sessionId) {
76
+ return `hq:pending:${sessionId}`;
77
+ }
78
+ function ackKey(hashHex) {
79
+ return `hq:ack:${hashHex}`;
80
+ }
81
+ const SESSIONS_INDEX_KEY = "hq:sessions";
82
+ // ─── Pure crypto helpers ──────────────────────────────────────────────────────
83
+ /**
84
+ * Build the to-be-signed bytes for a relay ACK.
85
+ *
86
+ * Hex-decodes hashHex and delegates to buildRelayAckTbs (the canonical
87
+ * implementation in @cello-protocol/crypto shared with the relay signer).
88
+ *
89
+ * TBS = SHA-256(hash_bytes || seq_BE4 || ts_BE8). RFC 8032, FIPS 180-4.
90
+ */
91
+ export function buildSignedAckTbs(hashHex, sequenceNumber, timestamp) {
92
+ return buildRelayAckTbs(Buffer.from(hashHex, "hex"), sequenceNumber, timestamp);
93
+ }
94
+ /**
95
+ * Verify a relay ACK signature.
96
+ *
97
+ * Returns true if:
98
+ * - ack.signature is exactly 64 bytes
99
+ * - Ed25519.verify(relayPubkey, buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp), ack.signature) is true
100
+ *
101
+ * RFC 8032 (Ed25519), FIPS 180-4 (SHA-256)
102
+ */
103
+ export function verifyRelayAck(hashHex, ack, relayPubkey) {
104
+ if (ack.signature.length !== 64)
105
+ return false;
106
+ const tbs = buildSignedAckTbs(hashHex, ack.sequenceNumber, ack.timestamp);
107
+ return verify(relayPubkey, tbs, ack.signature);
108
+ }
109
+ // ─── AgentHashQueue ───────────────────────────────────────────────────────────
110
+ /**
111
+ * Manages the local queue of Structure 1 hashes pending relay submission.
112
+ *
113
+ * Security invariants:
114
+ * SI-001: A hash is never removed from the pending queue without a valid stored ACK.
115
+ * The remove-from-queue step only executes after ACK verification passes.
116
+ * SI-002: getPending() returns items in strict insertion order. The caller is
117
+ * responsible for FIFO submission — the queue does not reorder.
118
+ * SI-003: Once an ACK is stored for a hash, subsequent ACKs for the same hash
119
+ * do not overwrite the existing receipt.
120
+ *
121
+ * Persistence: all state is stored in the injected ClientStore using JSON encoding.
122
+ * Keys: "hq:pending:{sessionId}" and "hq:ack:{hashHex}".
123
+ */
124
+ export class AgentHashQueue {
125
+ #store;
126
+ #agentId;
127
+ #logger;
128
+ #hashQueueMaxAgeMs;
129
+ constructor(opts) {
130
+ this.#store = opts.store;
131
+ this.#agentId = opts.agentId;
132
+ this.#logger = opts.logger;
133
+ this.#hashQueueMaxAgeMs = opts.hashQueueMaxAgeMs ?? 24 * 60 * 60 * 1000;
134
+ }
135
+ // ─── Enqueue ───────────────────────────────────────────────────────────────
136
+ /**
137
+ * Enqueue a hash in the local hash queue before relay submission.
138
+ *
139
+ * Must be called BEFORE attempting relay submission (AC-001).
140
+ * The hash is present in the queue until a valid signed ACK is processed.
141
+ *
142
+ * Logs: client.hashqueue.enqueued with { agentId, sessionId, hashHex, queueDepth, correlationId }
143
+ */
144
+ async enqueue(sessionId, hashHex, correlationId) {
145
+ // Track the session in the sessions index for pollDepth()
146
+ await this.#addToSessionsIndex(sessionId);
147
+ const pending = await this.#loadPending(sessionId);
148
+ const entry = {
149
+ sessionId,
150
+ hashHex,
151
+ enqueuedAt: Date.now(),
152
+ };
153
+ pending.push(entry);
154
+ await this.#savePending(sessionId, pending);
155
+ this.#logger.info("client.hashqueue.enqueued", {
156
+ agentId: this.#agentId,
157
+ sessionId,
158
+ hashHex,
159
+ queueDepth: pending.length,
160
+ correlationId,
161
+ });
162
+ }
163
+ // ─── Process ACK ──────────────────────────────────────────────────────────
164
+ /**
165
+ * Process a relay ACK for a previously enqueued hash.
166
+ *
167
+ * Steps (SI-001 enforced):
168
+ * 1. Look up the relay's public key via lookupRelayPubkey(ack.relayId).
169
+ * 2. Verify the ACK signature with verifyRelayAck().
170
+ * 3. If verification fails: log client.relay.ack.invalid; return WITHOUT touching the queue.
171
+ * 4. Store the ACK as immutable receipt (SI-003: skip if already stored).
172
+ * 5. Remove the hash from the pending queue ONLY after ACK is stored.
173
+ *
174
+ * @param sessionId - session the hash belongs to
175
+ * @param hashHex - the hash being ACKed
176
+ * @param ack - the relay's ACK frame
177
+ * @param lookupRelayPubkey - async function that looks up relay pubkey by relayId
178
+ * @param correlationId - correlation ID for log tracing
179
+ */
180
+ async processAck(sessionId, hashHex, ack, lookupRelayPubkey, correlationId) {
181
+ // Step 1: Look up the relay's public key
182
+ const relayPubkey = await lookupRelayPubkey(ack.relayId);
183
+ // Step 2: Verify ACK signature
184
+ let validationError = null;
185
+ if (relayPubkey === null) {
186
+ validationError = "relay_pubkey_not_found";
187
+ }
188
+ else if (ack.signature.length !== 64) {
189
+ validationError = "signature_malformed";
190
+ }
191
+ else if (!verifyRelayAck(hashHex, ack, relayPubkey)) {
192
+ validationError = "signature_invalid";
193
+ }
194
+ if (validationError !== null) {
195
+ // Step 3: Invalid — log warning and leave hash in queue (SI-001)
196
+ this.#logger.warn("client.relay.ack.invalid", {
197
+ agentId: this.#agentId,
198
+ sessionId,
199
+ hashHex,
200
+ reason: validationError,
201
+ });
202
+ return;
203
+ }
204
+ // Step 4: Store ACK as immutable receipt (SI-003: never overwrite existing ACK)
205
+ const existing = await this.#loadAck(hashHex);
206
+ if (existing === undefined) {
207
+ await this.#saveAck(hashHex, ack);
208
+ }
209
+ // If already stored: SI-003 guarantees the first ACK is preserved.
210
+ // Step 5: Remove from pending queue ONLY after ACK is stored
211
+ const pending = await this.#loadPending(sessionId);
212
+ const updated = pending.filter((e) => e.hashHex !== hashHex);
213
+ await this.#savePending(sessionId, updated);
214
+ this.#logger.info("client.hashqueue.acked", {
215
+ agentId: this.#agentId,
216
+ sessionId,
217
+ hashHex,
218
+ sequenceNumber: ack.sequenceNumber,
219
+ correlationId,
220
+ });
221
+ }
222
+ // ─── Queue inspection ──────────────────────────────────────────────────────
223
+ /**
224
+ * Return all pending (un-ACKed) hashes for a session in insertion order (SI-002).
225
+ */
226
+ async getPending(sessionId) {
227
+ return this.#loadPending(sessionId);
228
+ }
229
+ /**
230
+ * Return the stored relay ACK for a hash, or undefined if not yet ACKed.
231
+ */
232
+ async getAck(hashHex) {
233
+ return this.#loadAck(hashHex);
234
+ }
235
+ /**
236
+ * Return the total number of pending hashes for a session.
237
+ */
238
+ async depth(sessionId) {
239
+ const pending = await this.#loadPending(sessionId);
240
+ return pending.length;
241
+ }
242
+ // ─── Depth polling (AC-006, DB-001) ──────────────────────────────────────
243
+ /**
244
+ * Poll the total queue depth across all sessions.
245
+ *
246
+ * If depth > 0: logs client.hashqueue.depth at INFO with { agentId, depth }.
247
+ * If depth > 0 and oldest entry is older than hashQueueMaxAgeMs: additionally logs
248
+ * client.hashqueue.stale at WARN with { agentId, depth, oldestHashAge } (DB-001).
249
+ *
250
+ * Called externally on a 30-second interval while the agent is running.
251
+ * Test-friendly: call directly without waiting.
252
+ */
253
+ async pollDepth() {
254
+ const allPending = await this.#loadAllPending();
255
+ const depth = allPending.length;
256
+ if (depth === 0)
257
+ return;
258
+ // DB-001: compute oldest age before logging so it appears in the depth event
259
+ const now = Date.now();
260
+ let oldest = now;
261
+ for (const entry of allPending) {
262
+ if (entry.enqueuedAt < oldest) {
263
+ oldest = entry.enqueuedAt;
264
+ }
265
+ }
266
+ const oldestHashAge = now - oldest;
267
+ this.#logger.info("client.hashqueue.depth", {
268
+ agentId: this.#agentId,
269
+ depth,
270
+ oldestHashAge,
271
+ });
272
+ if (oldestHashAge > this.#hashQueueMaxAgeMs) {
273
+ this.#logger.warn("client.hashqueue.stale", {
274
+ agentId: this.#agentId,
275
+ depth,
276
+ oldestHashAge,
277
+ });
278
+ }
279
+ }
280
+ // ─── Error handlers ────────────────────────────────────────────────────────
281
+ /**
282
+ * Handle a re-submission rejection from the new relay.
283
+ *
284
+ * Logs client.relay.resubmit.rejected at ERROR with { agentId, sessionId, hashHex, reason }.
285
+ * The hash remains in the pending queue (DB-002: operator must resolve manually).
286
+ *
287
+ * @returns the reason string (for caller convenience)
288
+ */
289
+ async handleResubmitRejection(sessionId, hashHex, reason) {
290
+ this.#logger.error("client.relay.resubmit.rejected", {
291
+ agentId: this.#agentId,
292
+ sessionId,
293
+ hashHex,
294
+ reason,
295
+ });
296
+ // Hash stays in queue — no state mutation
297
+ return reason;
298
+ }
299
+ // ─── Private storage helpers ───────────────────────────────────────────────
300
+ async #loadPending(sessionId) {
301
+ const raw = await this.#store.get(pendingKey(sessionId));
302
+ if (raw === undefined)
303
+ return [];
304
+ try {
305
+ return JSON.parse(Buffer.from(raw).toString("utf8"));
306
+ }
307
+ catch {
308
+ return [];
309
+ }
310
+ }
311
+ async #savePending(sessionId, entries) {
312
+ const raw = Buffer.from(JSON.stringify(entries), "utf8");
313
+ await this.#store.set(pendingKey(sessionId), new Uint8Array(raw));
314
+ }
315
+ async #loadAck(hashHex) {
316
+ const raw = await this.#store.get(ackKey(hashHex));
317
+ if (raw === undefined)
318
+ return undefined;
319
+ try {
320
+ const stored = JSON.parse(Buffer.from(raw).toString("utf8"));
321
+ return {
322
+ relayId: stored.relayId,
323
+ relayPubkey: new Uint8Array(Buffer.from(stored.relayPubkeyHex, "hex")),
324
+ sequenceNumber: stored.sequenceNumber,
325
+ timestamp: stored.timestamp,
326
+ signature: new Uint8Array(Buffer.from(stored.signatureHex, "hex")),
327
+ };
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ }
333
+ async #saveAck(hashHex, ack) {
334
+ const stored = {
335
+ relayId: ack.relayId,
336
+ relayPubkeyHex: Buffer.from(ack.relayPubkey).toString("hex"),
337
+ sequenceNumber: ack.sequenceNumber,
338
+ timestamp: ack.timestamp,
339
+ signatureHex: Buffer.from(ack.signature).toString("hex"),
340
+ };
341
+ const raw = Buffer.from(JSON.stringify(stored), "utf8");
342
+ await this.#store.set(ackKey(hashHex), new Uint8Array(raw));
343
+ }
344
+ async #addToSessionsIndex(sessionId) {
345
+ const raw = await this.#store.get(SESSIONS_INDEX_KEY);
346
+ let sessions = [];
347
+ if (raw !== undefined) {
348
+ try {
349
+ sessions = JSON.parse(Buffer.from(raw).toString("utf8"));
350
+ }
351
+ catch {
352
+ sessions = [];
353
+ }
354
+ }
355
+ if (!sessions.includes(sessionId)) {
356
+ sessions.push(sessionId);
357
+ const newRaw = Buffer.from(JSON.stringify(sessions), "utf8");
358
+ await this.#store.set(SESSIONS_INDEX_KEY, new Uint8Array(newRaw));
359
+ }
360
+ }
361
+ async #loadAllPending() {
362
+ const raw = await this.#store.get(SESSIONS_INDEX_KEY);
363
+ if (raw === undefined)
364
+ return [];
365
+ let sessions = [];
366
+ try {
367
+ sessions = JSON.parse(Buffer.from(raw).toString("utf8"));
368
+ }
369
+ catch {
370
+ return [];
371
+ }
372
+ const all = [];
373
+ for (const sessionId of sessions) {
374
+ const pending = await this.#loadPending(sessionId);
375
+ all.push(...pending);
376
+ }
377
+ return all;
378
+ }
379
+ }
380
+ //# sourceMappingURL=agent-hash-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-hash-queue.js","sourceRoot":"","sources":["../src/agent-hash-queue.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * backup-key-derivation.ts — HKDF derivation of the cloud backup encryption key.
3
+ *
4
+ * PERSIST-011: backup_key = HKDF-SHA256(ikm=identity_key, salt=none,
5
+ * info='backup-key' || agent_id, length=32)
6
+ *
7
+ * Security invariants (PERSIST-011 SI-001):
8
+ * - identity_key is passed in by the caller; it is NEVER stored here.
9
+ * - The returned backup_key is NEVER logged or persisted by this function.
10
+ * - This function has no side effects — pure transformation only.
11
+ *
12
+ * Key independence (AC-001):
13
+ * - backup_key and db_key use distinct info strings:
14
+ * db_key: info = 'local-db-key\x00' + agentId
15
+ * backup_key: info = 'backup-key\x00' + agentId
16
+ * - For the same identity_key and agentId, backup_key != db_key.
17
+ * - Losing one key does not compromise the other.
18
+ *
19
+ * RFC reference: RFC 5869 (HKDF). Node.js implementation: crypto.hkdfSync.
20
+ */
21
+ /**
22
+ * Derive a 32-byte cloud backup encryption key from the agent's identity_key.
23
+ *
24
+ * @param identityKey - The agent's long-term root key (32 bytes). Never stored or logged.
25
+ * @param agentId - The stable agent identifier. Binds the key to a specific agent.
26
+ * @returns - A 32-byte Uint8Array suitable for use as an AES-256-GCM key.
27
+ *
28
+ * Security: The backup_key is deterministic — same inputs always produce the same output.
29
+ * This is intentional: the client must re-derive the key on every backup/restore without
30
+ * storing it, using only the identity_key (which is stored separately, protected
31
+ * by the OS keychain or equivalent).
32
+ *
33
+ * The null-byte separator between the literal and agentId prevents prefix-collision
34
+ * attacks where two different (literal, agentId) pairs produce the same concatenation.
35
+ */
36
+ export declare function deriveBackupKey(identityKey: Uint8Array, agentId: string): Uint8Array;
37
+ //# sourceMappingURL=backup-key-derivation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-key-derivation.d.ts","sourceRoot":"","sources":["../src/backup-key-derivation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,UAAU,CAYpF"}