@cello-protocol/daemon 0.0.48 → 0.0.50

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.
@@ -81,11 +81,14 @@ import { SessionTree } from "./session-tree.js";
81
81
  import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/transport";
82
82
  import { verify } from "@cello-protocol/crypto";
83
83
  import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
84
+ import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex } from "./park-envelope.js";
84
85
  import { AgentRelayClient, LEAF_KIND_CTRL } from "./session-relay-client.js";
85
86
  import { RelayReceiptStore } from "./relay-receipt-store.js";
86
87
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
87
88
  import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
88
89
  const CBOR_ENC = new Encoder({ tagUint8Array: false });
90
+ /** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
91
+ const MAX_REFUSED_PARKED_ENTRIES = 512;
89
92
  // M8C-ABUSE-1 + DOD-TIER-2: persistence bounds. Formerly a binary "known contacts exempt entirely,
90
93
  // everyone else 3 sessions / 25 MB"; now TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-
91
94
  // migration). The two consts below are kept for back-compat but DERIVE from the grid's UNKNOWN row —
@@ -231,6 +234,12 @@ export class SessionNodeManager {
231
234
  * (direct-fail or TTF expiry). The daemon's hook seals (sealToRecipient) + deposits via
232
235
  * ContentParkClient. Best-effort.
233
236
  */
237
+ /**
238
+ * SEC-1 / review M4: parked entries already refused by the authentication gate, keyed
239
+ * `${agent}:${session}:${contentHash}` → the refusal reason. Bounded (see
240
+ * #rememberRefusedParkedEntry) because its keys come from a REMOTE mailbox.
241
+ */
242
+ #refusedParkedEntries = new Map();
234
243
  #contentParkHook = null;
235
244
  constructor(opts) {
236
245
  this.#factory = opts.factory;
@@ -286,6 +295,8 @@ export class SessionNodeManager {
286
295
  return false;
287
296
  try {
288
297
  const result = await hook({
298
+ // SEC-1: the hook must sign as the SENDING agent — it needs to know who that is.
299
+ agentName,
289
300
  sessionId,
290
301
  recipientPubkeyHex: entry.counterpartyPubkey,
291
302
  relayPeerId: entry.relayPeerId,
@@ -3464,37 +3475,101 @@ export class SessionNodeManager {
3464
3475
  * structure2_cbor = [seq, sender_pubkey, content_hash, sender_signature, scan_result, prev_root].
3465
3476
  */
3466
3477
  /**
3467
- * DOD-MSG-4 (2b): encode the pre-seal park envelope `[1, content, structure1_cbor|null,
3468
- * structure2_cbor|null]`. The daemon seals THIS (not the bare content) so a parked entry carries
3469
- * its own signed ordering record recover then orders it the same way the direct frame does. The
3470
- * relay still only ever holds the sealed ciphertext (INV-3 preserved).
3478
+ * DOD-MSG-4 (2b) / SEC-1: decode a park envelope. Legacy/unsigned shapes still DECODE so that
3479
+ * `recoverParkedEntry` can refuse them BY NAME (`unsigned_envelope`) decoding is not accepting.
3480
+ * Encoding lives in park-envelope.ts and REQUIRES a sender signature (see SEC-1); it is not
3481
+ * exposed here, so no caller can seal an unsigned envelope through this class.
3471
3482
  */
3472
- encodeParkEnvelope(content, structure1Cbor, structure2Cbor) {
3473
- return CBOR_ENC.encode([1, content, structure1Cbor ?? null, structure2Cbor ?? null]);
3483
+ decodeParkEnvelope(plaintext) {
3484
+ return decodeParkEnvelope(plaintext);
3474
3485
  }
3475
3486
  /**
3476
- * DOD-MSG-4 (2b): decode a park envelope produced by encodeParkEnvelope. Falls back to treating the
3477
- * whole plaintext as raw content (no ordering record) for entries sealed the old way (e.g. test
3478
- * fixtures that seal bare content) so recover stays backward-compatible.
3487
+ * SEC-1 THE ONLY WAY PARKED CONTENT ENTERS THE TRANSCRIPT.
3488
+ *
3489
+ * Authentication and ingest are fused here on purpose. Before SEC-1 the recover loop in daemon.ts
3490
+ * did `decode → (maybe verify ordering) → ingest`, where the verify step was OPTIONAL (skipped
3491
+ * entirely when the attacker simply omitted the ordering record) and could not reject anything
3492
+ * even when it ran — it gated ORDERING, not INGEST. Splitting the check from the write is what
3493
+ * made the downgrade possible, so they no longer live apart: a caller cannot ingest parked content
3494
+ * without passing the signature gate, because there is no other entry point.
3495
+ *
3496
+ * FAILS CLOSED. No signature / bad signature / signer is not this session's counterparty / no
3497
+ * session at all → REFUSED, nothing is appended, nothing is written, and the caller MUST NOT
3498
+ * confirm-delete the entry from the relay (a forgery must not be able to evict itself, and a
3499
+ * genuine bug must not silently eat mail).
3479
3500
  */
3480
- decodeParkEnvelope(plaintext) {
3481
- try {
3482
- const arr = decode(plaintext);
3483
- // Discriminator (review #2): a 4-element array tagged with version 1 + a byte-string content.
3484
- // The content-hash cross-check in ingestReceivedContent is the real safety net, but narrowing
3485
- // to length 4 makes a bare-content false-positive astronomically less likely still.
3486
- if (Array.isArray(arr) && arr.length === 4 && arr[0] === 1 && arr[1] instanceof Uint8Array) {
3487
- return {
3488
- content: arr[1],
3489
- structure1Cbor: arr[2] instanceof Uint8Array ? arr[2] : undefined,
3490
- structure2Cbor: arr[3] instanceof Uint8Array ? arr[3] : undefined,
3491
- };
3492
- }
3501
+ async recoverParkedEntry(agentName, sessionId, recipientPubkey, unsealed, contentHash, correlationId) {
3502
+ const contentHashHex = Buffer.from(contentHash).toString("hex");
3503
+ // Review M4: a refused entry is deliberately NOT confirm-deleted (a forgery must not be able to
3504
+ // evict itself), which means an adversary could otherwise make us unseal + Ed25519-verify the
3505
+ // same forged entries on EVERY reconnect, forever — turning the mailbox into an amplification
3506
+ // vector against our own recover path. Remember what we already refused and skip the crypto on
3507
+ // re-pull. Still never confirmed, so nothing is destroyed. In-memory and BOUNDED: the cost of
3508
+ // forgetting across a restart is one more verify, which is exactly the pre-existing behavior.
3509
+ const refusalKey = `${this.#k(agentName, sessionId)}:${contentHashHex}`;
3510
+ const remembered = this.#refusedParkedEntries.get(refusalKey);
3511
+ if (remembered) {
3512
+ this.#logger.warn("content.recover.unauthenticated", {
3513
+ agentName,
3514
+ sessionId,
3515
+ contentHash: contentHashHex,
3516
+ reason: remembered,
3517
+ repeat: true,
3518
+ correlationId,
3519
+ });
3520
+ return { ok: false, reason: remembered };
3521
+ }
3522
+ const env = decodeParkEnvelope(unsealed);
3523
+ const verdict = authenticateParkedEntry({
3524
+ env,
3525
+ sessionIdHex: sessionId,
3526
+ recipientPubkey,
3527
+ contentHash,
3528
+ counterpartyPubkeyHex: this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey,
3529
+ });
3530
+ if (!verdict.ok) {
3531
+ // SEC-1: loud, and specific about WHICH gate refused — a silent drop here would look
3532
+ // identical to "no mail", which is how an injection attempt would go unnoticed.
3533
+ this.#rememberRefusedParkedEntry(refusalKey, verdict.reason);
3534
+ this.#logger.warn("content.recover.unauthenticated", {
3535
+ agentName,
3536
+ sessionId,
3537
+ contentHash: contentHashHex,
3538
+ reason: verdict.reason,
3539
+ envelopeVersion: env.version,
3540
+ correlationId,
3541
+ });
3542
+ return { ok: false, reason: verdict.reason };
3493
3543
  }
3494
- catch {
3495
- /* not an envelope fall through to raw */
3544
+ // Authenticated. The ordering record (when present) is still verified independently by
3545
+ // #recordFrameOrdering it answers a different question (WHERE this message sits in the
3546
+ // canonical sequence, per the relay) and is still best-effort: a bad record must not block a
3547
+ // message whose AUTHORSHIP we have now proven.
3548
+ if (env.structure1Cbor && env.structure2Cbor) {
3549
+ this.recordOrderingRecord(agentName, sessionId, env.structure1Cbor, env.structure2Cbor, contentHash, correlationId);
3550
+ }
3551
+ this.#logger.info("content.recover.verified", {
3552
+ agentName,
3553
+ sessionId,
3554
+ contentHash: contentHashHex,
3555
+ correlationId,
3556
+ });
3557
+ return await this.ingestReceivedContent(agentName, sessionId, env.content, contentHash, correlationId);
3558
+ }
3559
+ /**
3560
+ * Review M4: bounded memo of parked entries we have already refused, so a mailbox stuffed with
3561
+ * forgeries cannot force an unbounded unseal+verify on every reconnect. FIFO-capped — the cap
3562
+ * matters more than the retention: forgetting an entry only costs one extra verification, whereas
3563
+ * an unbounded map would be a memory leak fed by a remote party (the exact class the DOD-MSG-4
3564
+ * review already caught once in the offered-moniker map).
3565
+ */
3566
+ #rememberRefusedParkedEntry(key, reason) {
3567
+ if (this.#refusedParkedEntries.size >= MAX_REFUSED_PARKED_ENTRIES) {
3568
+ const oldest = this.#refusedParkedEntries.keys().next();
3569
+ if (!oldest.done)
3570
+ this.#refusedParkedEntries.delete(oldest.value);
3496
3571
  }
3497
- return { content: plaintext };
3572
+ this.#refusedParkedEntries.set(key, reason);
3498
3573
  }
3499
3574
  /**
3500
3575
  * DOD-MSG-4 (2b): public entry for the recover path to verify + record a parked entry's ordering
@@ -3531,8 +3606,11 @@ export class SessionNodeManager {
3531
3606
  // key. FAIL CLOSED (review L) — if the counterparty pubkey is unknown we cannot prove the signer,
3532
3607
  // so we do NOT trust the framed ordering record (fall back to the witness stream / arrival). The
3533
3608
  // "B does not trust the counterparty for ordering" invariant is non-negotiable; never fail open.
3609
+ // Review M1: compare BYTES, not hex strings — `counterparty_pubkey` is stored verbatim from the
3610
+ // IPC param and is never case-normalized, so a string compare would fail for a mixed-case
3611
+ // pubkey and silently strip the canonical ordering from every message in that session.
3534
3612
  const counterparty = this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
3535
- if (!counterparty || Buffer.from(s1Pubkey).toString("hex") !== counterparty) {
3613
+ if (!pubkeyMatchesHex(s1Pubkey, counterparty)) {
3536
3614
  this.#logger.warn("session.content.ordering.wrong_signer", {
3537
3615
  sessionId,
3538
3616
  reason: counterparty ? "signer_not_counterparty" : "counterparty_unknown",