@cello-protocol/daemon 0.0.190 → 0.0.191

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.
@@ -1887,7 +1887,7 @@ export class SessionNodeManager {
1887
1887
  * when its value is `undefined` — forces each of the three callers to state what this message was
1888
1888
  * hashed under, so a new fourth caller cannot omit it by accident.
1889
1889
  */
1890
- async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) {
1890
+ async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg, structure1Signature, parkLeafKind) {
1891
1891
  // Fault injection FIRST, so it reproduces the real shape: the refusal happens at the same point
1892
1892
  // the live hook refuses (before any deposit), with the same event and the same `cause`.
1893
1893
  if (this.#parkFaultRemaining > 0) {
@@ -1923,6 +1923,11 @@ export class SessionNodeManager {
1923
1923
  // on recover too (sealed INTO the ciphertext envelope — INV-3: the relay still sees only ciphertext).
1924
1924
  structure1Cbor,
1925
1925
  structure2Cbor,
1926
+ // 034-CARRYLEAF: the author's signature over `structure1Cbor`, so the RECIPIENT can witness
1927
+ // this leaf if its author never does. Without it the mailbox route stays truncatable.
1928
+ structure1Signature,
1929
+ // 034-CARRYLEAF: the leaf DOMAIN, so a recovered leaf is never witnessed under a guess.
1930
+ leafKind: parkLeafKind,
1926
1931
  // B2b: the park route must name the same algorithm the direct frame did, or the recipient
1927
1932
  // verifies the same message two different ways depending on which route it took.
1928
1933
  contentHashAlg,
@@ -7935,6 +7940,16 @@ export class SessionNodeManager {
7935
7940
  // `finally` retires — same defect, other end, other cap (64 outbound per protocol per
7936
7941
  // connection). See the note on #handleContentStream's finally.
7937
7942
  let sendStream;
7943
+ /**
7944
+ * 034-CARRYLEAF — HOISTED OUT OF THE TRY so the PARK path in the catch can carry them.
7945
+ *
7946
+ * The park copy is built when the direct send fails, which is inside the catch below; declaring
7947
+ * these in the try left the parked envelope with only the relay-WITNESSED claim, so a message
7948
+ * its author deliberately did not witness was parked with no ordering claim at all and could
7949
+ * never be witnessed by its recipient.
7950
+ */
7951
+ let frameS1;
7952
+ let frameSig;
7938
7953
  try {
7939
7954
  /**
7940
7955
  * ─── EVERY FRAME CARRIES ITS OWN PROOF — `DOD-M15-AUTHORSHIP-ABSENT-1` ────────────────────
@@ -7958,8 +7973,8 @@ export class SessionNodeManager {
7958
7973
  * widened that window enough to break the live two-node round trip. Measured, not reasoned
7959
7974
  * about: seam-3 went red and both daemons logged `session.key.agreed` before the refusal.
7960
7975
  */
7961
- let frameS1 = orderingS1;
7962
- let frameSig = orderingSig;
7976
+ frameS1 = orderingS1;
7977
+ frameSig = orderingSig;
7963
7978
  let frameS2 = orderingS2;
7964
7979
  if (frameS1 === undefined || frameSig === undefined) {
7965
7980
  const own = await this.#signOwnContentClaim(agentName, sessionId, entry, contentHash);
@@ -8097,6 +8112,21 @@ export class SessionNodeManager {
8097
8112
  // unknown CBOR key, so emitting it is safe for every build in existence; a newer one reads
8098
8113
  // it and verifies under the named algorithm instead of assuming.
8099
8114
  content_hash_alg: contentHashAlg,
8115
+ /**
8116
+ * 034-CARRYLEAF review F5 — WHICH LEAF DOMAIN this content belongs to.
8117
+ *
8118
+ * Documents and rejection envelopes ride this same frame, and the receiver had no way to
8119
+ * recover their kind: it appended them locally as "doc" from its own inspection of the
8120
+ * body, while anything it witnessed on the sender's behalf went to the relay as a MESSAGE
8121
+ * leaf. The certified root is over content hashes, so no root diverges — but the relay's
8122
+ * canonical log and the carried `leaf_kind` would describe the leaf as something it is not,
8123
+ * and a leaf kind selects a HASH DOMAIN everywhere else in this protocol.
8124
+ *
8125
+ * Same argument as `content_hash_alg` beside it: an older peer ignores an unknown CBOR key,
8126
+ * so emitting it is safe for every build in existence, and a receiver that does not see it
8127
+ * declines to witness rather than guessing (see `#witnessReceivedLeaf`).
8128
+ */
8129
+ leaf_kind: leafKind,
8100
8130
  });
8101
8131
  // Injected dial failure — thrown from inside the try so it lands in exactly the catch the
8102
8132
  // real connection_lost lands in, and the whole downstream path (untrack → park → durable
@@ -8175,7 +8205,21 @@ export class SessionNodeManager {
8175
8205
  ...this.#streamCensus(entry.node, entry.counterpartySessionPeerId),
8176
8206
  correlationId,
8177
8207
  });
8178
- const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2, contentHashAlg);
8208
+ /**
8209
+ * ⚠️ **THE PARKED COPY CARRIES THE SIGNED CLAIM, NOT ONLY THE WITNESSED ONE — 034-CARRYLEAF
8210
+ * review F1, and this is what closes the withholding attack on the MAILBOX route.**
8211
+ *
8212
+ * It passed `orderingS1`, which exists only when the relay witnessed this leaf — so a message
8213
+ * the sender deliberately did not witness was parked with no ordering claim at all, and the
8214
+ * recipient recovered it holding nothing the relay would accept as proof of authorship. They
8215
+ * could read it and could never put it in a receipt.
8216
+ *
8217
+ * `frameS1`/`frameSig` are the pair the DIRECT frame carries for this same message: the
8218
+ * relay's witnessed claim when there is one, and otherwise a claim this agent signed itself.
8219
+ * Either way it is the author's signature over the author's own ordering claim, which is the
8220
+ * only form the relay accepts when the recipient witnesses on their behalf.
8221
+ */
8222
+ const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, frameS1, orderingS2, contentHashAlg, frameSig, leafKind);
8179
8223
  if (attempt.outcome === "parked") {
8180
8224
  this.#noteImpairmentRetention(agentName, sessionId, "parked");
8181
8225
  return { ok: true, delivered: false, parked: true, ...(assignedSeq === undefined ? {} : { sequenceNumber: assignedSeq }), ...(sentAuthorship === undefined ? {} : { authorship: sentAuthorship }), ...(relayRefusal === undefined ? {} : { relayRefusal }) };
@@ -12235,6 +12279,32 @@ export class SessionNodeManager {
12235
12279
  * wired, this line is the difference between an all-clear and a permanent silent discard — and
12236
12280
  * finding that then costs more than the clause costs now.
12237
12281
  */
12282
+ /**
12283
+ * ─── WITNESS A RECOVERED MESSAGE ITS SENDER NEVER WITNESSED — 034-CARRYLEAF review F1 ────────
12284
+ *
12285
+ * The mailbox route's half of the withholding fix. `recoveredSeq` absent means no relay ordering
12286
+ * record came with it, which is the same shape the direct path treats as "their submit never
12287
+ * happened" — and it is reached the same two ways: their relay was briefly unreachable, or they
12288
+ * are withholding on purpose.
12289
+ *
12290
+ * ⚠️ THE SIGNATURE COMES FROM THE ENVELOPE, AND ONLY A v4 ENVELOPE HAS ONE. `parkSig`
12291
+ * authenticates the DEPOSIT — it signs `(session_id, recipient_pubkey, content_hash)` — and the
12292
+ * relay will not accept it, because a counter-submit is admissible only against the author's own
12293
+ * signature over their own ordering claim. A v2 or v3 envelope therefore cannot be witnessed on
12294
+ * its author's behalf, and is left alone rather than guessed at.
12295
+ *
12296
+ * **So this route is closed against a peer running a stock client, and open to one that
12297
+ * deliberately emits an older envelope.** Requiring v4 is the step that closes it completely,
12298
+ * and it waits on nothing in the field emitting v2 or v3 — the same tolerate-then-enforce
12299
+ * sequence every bilateral wire change in this milestone follows.
12300
+ */
12301
+ if (result.ok && result.held !== true && result.screenedOut !== true &&
12302
+ recoveredSeq === null && env.structure1Cbor && env.structure1Signature) {
12303
+ const kind = env.leafKind;
12304
+ if (typeof kind === "number") {
12305
+ this.#witnessReceivedLeaf(agentName, sessionId, contentHash, env.structure1Cbor, env.structure1Signature, kind, correlationId);
12306
+ }
12307
+ }
12238
12308
  if (priorDeclaredAlg !== undefined && result.ok && result.held !== true && result.screenedOut !== true) {
12239
12309
  // Cleared ONLY on a real reconciliation. Clearing on the lookup (as this did) forgets the
12240
12310
  // refusal even when the recovery fails, so the next genuine reconciliation says nothing.
@@ -12937,6 +13007,81 @@ export class SessionNodeManager {
12937
13007
  this.#sessionContentKeys.set(this.#k(agentName, sessionId), Uint8Array.from(key));
12938
13008
  this.#contentEncryptionReasons.delete(this.#k(agentName, sessionId));
12939
13009
  }
13010
+ /**
13011
+ * Hand the relay a leaf this agent RECEIVED whose author never submitted it — 034-CARRYLEAF.
13012
+ *
13013
+ * **The attack this closes:** somebody sends you something, declines to have it witnessed, and
13014
+ * seals one message short. The relay's account really does end before their last message, so your
13015
+ * receipt does too — every leaf validly signed, nothing false, the last thing said simply absent.
13016
+ *
13017
+ * **Why this is admissible and not a forgery:** the bytes are theirs, the signature over them is
13018
+ * theirs, and `#verifyAuthorshipClaim` verified it against this session's counterparty before a
13019
+ * word of it was ingested. The relay verifies it again against the directory-signed assignment.
13020
+ * Nothing here is asserted by us except that we received it.
13021
+ *
13022
+ * ⚠️ BEST-EFFORT, AND ITS FAILURE IS NOT SILENT. If the relay cannot be reached, the message is
13023
+ * still delivered and read — refusing it would make the relay a precondition for reading mail,
13024
+ * which is the thing every unit on this path has been careful not to do. What is lost is only the
13025
+ * guarantee that it can enter a receipt, and that surfaces where the operator can act on it: the
13026
+ * seal's own pre-flight refuses a gapped chain by name (`seal_carry_noncontiguous`) with guidance,
13027
+ * so the consequence reaches them at the moment it matters rather than as a log line here.
13028
+ */
13029
+ #witnessReceivedLeaf(agentName, sessionId, contentHash, structure1Cbor, senderSignature,
13030
+ /** The domain the AUTHOR assigned this leaf, read off their frame — never guessed (review F5). */
13031
+ leafKind, correlationId) {
13032
+ const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
13033
+ if (!entry?.relayClient || !entry.relaySessionIdBytes) {
13034
+ this.#logger.warn("session.content.witness_received.unavailable", {
13035
+ agentName, sessionId, correlationId,
13036
+ impact: "a message arrived that its sender never had witnessed, and this side has no relay client " +
13037
+ "for the session, so it could not be witnessed here either. It is delivered and readable; " +
13038
+ "it cannot enter a notarized receipt until some party witnesses it.",
13039
+ });
13040
+ return;
13041
+ }
13042
+ void entry.relayClient
13043
+ .witnessReceivedLeaf(entry.node, entry.relaySessionIdBytes, contentHash, leafKind, {
13044
+ structure1Cbor,
13045
+ senderSignature,
13046
+ })
13047
+ .then((res) => {
13048
+ if (res.ok) {
13049
+ this.#logger.info("session.content.witness_received", {
13050
+ agentName, sessionId, correlationId, relaySequence: res.sequence_number,
13051
+ impact: "this side witnessed a message its SENDER did not. The leaf now holds a canonical " +
13052
+ "position, so it can appear in a receipt whatever the sender does next.",
13053
+ });
13054
+ // It has a position now, so it can be acknowledged like any other received message.
13055
+ this.#noteAcknowledgeable(agentName, sessionId, res.sequence_number - 1, contentHash);
13056
+ return;
13057
+ }
13058
+ /**
13059
+ * `counter_submit_duplicate` is NOT a failure and must not be logged as one: it means this
13060
+ * relay already holds the leaf, which is the outcome we wanted. It fires on the ordinary
13061
+ * race where the sender's own submit lands while ours is in flight.
13062
+ */
13063
+ if (res.reason === "counter_submit_duplicate") {
13064
+ this.#logger.info("session.content.witness_received.already_held", {
13065
+ agentName, sessionId, correlationId,
13066
+ impact: "the relay already held this leaf — its sender witnessed it after all, or in parallel with us.",
13067
+ });
13068
+ return;
13069
+ }
13070
+ this.#logger.error("session.content.witness_received.failed", {
13071
+ agentName, sessionId, correlationId, reason: res.reason,
13072
+ ...(res.detail === undefined ? {} : { detail: res.detail }),
13073
+ impact: "a message arrived that its sender never had witnessed, and this side could not witness " +
13074
+ "it either. It is delivered and readable. What is at risk is the RECEIPT: if this " +
13075
+ "message is still unwitnessed when the conversation is sealed, the seal will refuse a " +
13076
+ "gapped chain by name rather than quietly leaving it out.",
13077
+ });
13078
+ })
13079
+ .catch((err) => {
13080
+ this.#logger.error("session.content.witness_received.threw", {
13081
+ agentName, sessionId, correlationId, error: extractErrorMessage(err),
13082
+ });
13083
+ });
13084
+ }
12940
13085
  /**
12941
13086
  * Record that a message ARRIVED and was accepted at a known canonical position — 033-ACKEMIT
12942
13087
  * review F1.
@@ -15645,8 +15790,46 @@ export class SessionNodeManager {
15645
15790
  * whatever we hold of the content. That limit is structural to a (position, content) pair
15646
15791
  * and it is what the carried-leaf follow-on closes.
15647
15792
  */
15648
- if (framedSeq !== null)
15793
+ if (framedSeq !== null) {
15649
15794
  this.#noteAcknowledgeable(agentName, sessionId, framedSeq, contentHash);
15795
+ }
15796
+ else {
15797
+ /**
15798
+ * ─── WITNESS WHAT THEY DID NOT — 034-CARRYLEAF, and this is the line that closes
15799
+ * `DOD-M15-WITHHOLD-SEAL-1` ────────────────────────────────────────────────────────────
15800
+ *
15801
+ * No ordering record means the sender never asked the relay to witness this message. Two
15802
+ * things look identical from here: their relay was briefly unreachable, or they are
15803
+ * withholding it on purpose so it cannot appear in the receipt. **We do not need to tell
15804
+ * those apart, and that is the point** — the same action repairs both, and it costs the
15805
+ * honest case nothing.
15806
+ *
15807
+ * We hold their signature over their own bytes. So we hand it to the relay ourselves.
15808
+ */
15809
+ /**
15810
+ * ⚠️ **THE KIND COMES OFF THE FRAME, AND WITHOUT IT WE DECLINE TO WITNESS — review F5.**
15811
+ *
15812
+ * A leaf kind selects a HASH DOMAIN, and documents and rejection envelopes ride this same
15813
+ * frame. Hardcoding `msg` meant a document witnessed on its author's behalf entered the
15814
+ * relay's canonical log described as something it is not. A peer too old to send the field
15815
+ * is left alone rather than guessed at: witnessing their leaf under the wrong domain would
15816
+ * be a worse outcome than not witnessing it, because it puts a wrong statement in the
15817
+ * record instead of leaving a gap the seal can name.
15818
+ */
15819
+ const framedKind = frame["leaf_kind"];
15820
+ if (typeof framedKind === "number") {
15821
+ void this.#witnessReceivedLeaf(agentName, sessionId, contentHash, s1Cbor, senderSig, framedKind, correlationId);
15822
+ }
15823
+ else {
15824
+ this.#logger.info("session.content.witness_received.kind_unknown", {
15825
+ agentName, sessionId, correlationId,
15826
+ impact: "a message arrived that its sender never had witnessed, and the frame did not say " +
15827
+ "which leaf domain it belongs to — their build predates the field. It was NOT " +
15828
+ "witnessed on their behalf, because witnessing it under a guessed domain would put " +
15829
+ "a wrong statement in the record rather than leave a gap the seal can name.",
15830
+ });
15831
+ }
15832
+ }
15650
15833
  void this.#sendDeliveryAck(agentName, sessionId, contentHash, correlationId);
15651
15834
  }
15652
15835
  }