@cello-protocol/daemon 0.0.189 → 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.
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +8 -0
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/daemon.js +5 -1
- package/dist/daemon.js.map +1 -1
- package/dist/park-envelope.d.ts +58 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +61 -1
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +21 -0
- package/dist/refusal-reasons.d.ts.map +1 -1
- package/dist/refusal-reasons.js +27 -0
- package/dist/refusal-reasons.js.map +1 -1
- package/dist/session-connection-gater.d.ts +19 -3
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +40 -11
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-node-manager.d.ts +69 -5
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +1221 -135
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-relay-client.d.ts +93 -3
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +352 -40
- package/dist/session-relay-client.js.map +1 -1
- package/package.json +5 -5
|
@@ -31,7 +31,7 @@ var _a;
|
|
|
31
31
|
import { createHash } from "node:crypto";
|
|
32
32
|
import * as lp from "it-length-prefixed";
|
|
33
33
|
import { decode } from "cbor-x";
|
|
34
|
-
import { encodeCbor, decodeSealPayload, encodeStructure1, decodeStructure1 } from "@cello-protocol/protocol-types";
|
|
34
|
+
import { encodeCbor, decodeSealPayload, encodeStructure1, decodeStructure1, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
|
|
35
35
|
import { verify } from "@cello-protocol/crypto";
|
|
36
36
|
import { extractErrorMessage } from "./error-message.js";
|
|
37
37
|
import { evaluateRelayAck } from "./relay-receipt-store.js";
|
|
@@ -197,6 +197,22 @@ function buildWitnessAlertTbs(sessionId, reason, observedAt, submitterIsCounterp
|
|
|
197
197
|
const body = encodeCbor([RELAY_WITNESS_DOMAIN, sessionId, reason, observedAt, submitterIsCounterparty]);
|
|
198
198
|
return new Uint8Array(createHash("sha256").update(body).digest());
|
|
199
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The genesis prev_root derivable from a relay assignment — 033-ACKEMIT.
|
|
202
|
+
*
|
|
203
|
+
* The carry holds both participant keys and the session timestamp, which with the session id are
|
|
204
|
+
* exactly `computeGenesisPrevRoot`'s inputs. Returns `undefined` when there is no assignment, and
|
|
205
|
+
* the caller then has no seed: absence is reported at the submit, never papered over.
|
|
206
|
+
*
|
|
207
|
+
* ⚠️ NOT 32 ZERO BYTES, and not any other constant. A value identical across every session is one
|
|
208
|
+
* an attacker can present for any session, which would make the first message's acknowledgement
|
|
209
|
+
* unfalsifiable exactly where it is most exposed.
|
|
210
|
+
*/
|
|
211
|
+
function genesisFromAssignment(sessionIdHex, assignment) {
|
|
212
|
+
if (!assignment)
|
|
213
|
+
return undefined;
|
|
214
|
+
return computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionIdHex, "hex")), assignment.sessionTimestamp);
|
|
215
|
+
}
|
|
200
216
|
function toU8(v) {
|
|
201
217
|
if (v instanceof Uint8Array)
|
|
202
218
|
return v;
|
|
@@ -266,10 +282,25 @@ export class AgentRelayClient {
|
|
|
266
282
|
#connecting = null;
|
|
267
283
|
#closed = false;
|
|
268
284
|
/**
|
|
269
|
-
* PER-SESSION
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
285
|
+
* PER-SESSION acknowledgement state (session_id hex → the position AND the content at it).
|
|
286
|
+
*
|
|
287
|
+
* `seq` is the highest relay-assigned sequence. The relay's `seq_counter` is per session, and it
|
|
288
|
+
* rejects `last_seen_seq > seq_counter`, so each session's submit MUST carry that session's own
|
|
289
|
+
* high-water mark — NOT an agent-global one (which would make a newer session's first submit look
|
|
290
|
+
* ahead and get rejected).
|
|
291
|
+
*
|
|
292
|
+
* ⚠️ `hash` IS THE SAME FACT AS `seq`, WHICH IS WHY THEY LIVE IN ONE ENTRY — 033-ACKEMIT.
|
|
293
|
+
*
|
|
294
|
+
* `last_seen_seq` is a NUMBER: "I saw position 7" attests to a POSITION and never to CONTENT, so
|
|
295
|
+
* a signed acknowledgement was an unbacked number that only the relay's separate receipt gave any
|
|
296
|
+
* meaning to. `hash` is the content hash of the message AT that position, and the two are written
|
|
297
|
+
* together in `#bumpLastSeen` from ONE decode of ONE leaf. They cannot be assigned apart, so they
|
|
298
|
+
* cannot come to mean different messages — which is the defect this unit exists to remove, not a
|
|
299
|
+
* disagreement to reconcile later.
|
|
300
|
+
*
|
|
301
|
+
* Seeded at `registerSession` with `{ seq: 0, hash: genesisPrevRoot }`: the first message of a
|
|
302
|
+
* session has seen nothing, and that case is a DEFINED 32-byte value — the agreed starting point
|
|
303
|
+
* of this two-party chain — never a missing field and never a fallback to v1.
|
|
273
304
|
*/
|
|
274
305
|
#lastSeen = new Map();
|
|
275
306
|
/** The one outstanding submit's resolver (global FIFO — ack carries no session_id). */
|
|
@@ -320,17 +351,43 @@ export class AgentRelayClient {
|
|
|
320
351
|
* (idempotent). Storing the node per session lets a pure-receiver session re-establish
|
|
321
352
|
* the shared stream if the node that originally dialed is torn down.
|
|
322
353
|
*/
|
|
323
|
-
registerSession(sessionIdHex, node, onLeafDeliver, assignment
|
|
354
|
+
registerSession(sessionIdHex, node, onLeafDeliver, assignment,
|
|
355
|
+
/**
|
|
356
|
+
* 033-ACKEMIT — the session's genesis prev_root: what the FIRST message of this session
|
|
357
|
+
* acknowledges, before anything has been received.
|
|
358
|
+
*
|
|
359
|
+
* Supplied by the caller because `session-node-manager` is where the session record lives. When
|
|
360
|
+
* it is absent an ASSIGNMENT can still produce it (both participant keys and the session
|
|
361
|
+
* timestamp are on the carry), and that covers re-registration of a session whose row predates
|
|
362
|
+
* the column. When NEITHER is available the session has no seed, and its first submit claims
|
|
363
|
+
* position 0 with no hash — which asserts nothing about content and is therefore honest, rather
|
|
364
|
+
* than a claim about a position it cannot back.
|
|
365
|
+
*/
|
|
366
|
+
genesisPrevRoot) {
|
|
324
367
|
const existing = this.#sessions.get(sessionIdHex);
|
|
368
|
+
const carriedAssignment = assignment ?? existing?.assignment;
|
|
325
369
|
this.#sessions.set(sessionIdHex, {
|
|
326
370
|
node,
|
|
327
371
|
onLeafDeliver: onLeafDeliver ?? (() => { }),
|
|
328
372
|
// Carry the assignment forward across re-registration; never lose a recorded flag on re-register.
|
|
329
|
-
assignment:
|
|
373
|
+
assignment: carriedAssignment,
|
|
330
374
|
recorded: existing?.recorded ?? false,
|
|
331
375
|
recordRejected: existing?.recordRejected ?? false,
|
|
332
376
|
recordTimedOut: existing?.recordTimedOut ?? false,
|
|
333
377
|
});
|
|
378
|
+
/**
|
|
379
|
+
* SEED THE ACKNOWLEDGEMENT, and only when there is nothing to lose.
|
|
380
|
+
*
|
|
381
|
+
* A session that has already received a leaf holds a REAL `{ seq, hash }`; overwriting it with
|
|
382
|
+
* the genesis on a re-registration would walk the acknowledgement backwards to "I have seen
|
|
383
|
+
* nothing" for a conversation that is well underway — and the relay would then answer the next
|
|
384
|
+
* submit from a position we had already passed.
|
|
385
|
+
*/
|
|
386
|
+
if (!this.#lastSeen.has(sessionIdHex)) {
|
|
387
|
+
const seed = genesisPrevRoot ?? genesisFromAssignment(sessionIdHex, carriedAssignment);
|
|
388
|
+
if (seed)
|
|
389
|
+
this.#lastSeen.set(sessionIdHex, { seq: 0, hash: seed });
|
|
390
|
+
}
|
|
334
391
|
// Eagerly present the assignment so the relay records the session (binds peer IDs, creates the
|
|
335
392
|
// session entry) BEFORE the first hash_submit or the counterparty's leaves arrive — the relay
|
|
336
393
|
// rejects frames for a session it has not recorded. Best-effort + serialized on the submit chain
|
|
@@ -495,12 +552,25 @@ export class AgentRelayClient {
|
|
|
495
552
|
catch { /* best-effort */ }
|
|
496
553
|
}
|
|
497
554
|
}
|
|
498
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Advance this session's acknowledgement to a counterparty leaf: the POSITION and the CONTENT
|
|
557
|
+
* AT IT, written together (033-ACKEMIT).
|
|
558
|
+
*
|
|
559
|
+
* `contentHash` comes from the same `decodeStructure1` of the same leaf that produced `seq`, so
|
|
560
|
+
* the pair describes one message by construction. There is no path that advances one without the
|
|
561
|
+
* other, and that is deliberate: a `last_seen_seq` and a `last_seen_hash` that could drift apart
|
|
562
|
+
* would let this daemon sign an acknowledgement of a message it never saw.
|
|
563
|
+
*
|
|
564
|
+
* Monotonic on `seq` — a re-delivery of an earlier leaf must not walk the acknowledgement
|
|
565
|
+
* backwards, and it must not swap the hash under an unchanged position either.
|
|
566
|
+
*/
|
|
567
|
+
#bumpLastSeen(sessionIdHex, seq, contentHash) {
|
|
499
568
|
if (seq < 0)
|
|
500
569
|
return;
|
|
501
|
-
const prev = this.#lastSeen.get(sessionIdHex)
|
|
502
|
-
if (seq
|
|
503
|
-
|
|
570
|
+
const prev = this.#lastSeen.get(sessionIdHex);
|
|
571
|
+
if (prev && seq <= prev.seq)
|
|
572
|
+
return;
|
|
573
|
+
this.#lastSeen.set(sessionIdHex, { seq, hash: contentHash });
|
|
504
574
|
}
|
|
505
575
|
/** True if this Structure-1 leaf was authored by US (sender_pubkey === our K_local). */
|
|
506
576
|
#isOwnLeaf(structure1Cbor) {
|
|
@@ -587,15 +657,44 @@ export class AgentRelayClient {
|
|
|
587
657
|
// submit's paired in-flight values. Best-effort + separate from the receipt write.
|
|
588
658
|
const structure2Cbor = frame["structure2_cbor"] instanceof Uint8Array ? frame["structure2_cbor"] : undefined;
|
|
589
659
|
if (this.#sealLeafStore && structure2Cbor && structure1Cbor && this.#pendingLeafKind !== null) {
|
|
660
|
+
/**
|
|
661
|
+
* ⚠️ **THE AUTHOR COMES FROM THE SIGNED BYTES, AND THE RECEIPT ONLY ATTACHES TO OUR OWN
|
|
662
|
+
* LEAF — 034-CARRYLEAF review F3.**
|
|
663
|
+
*
|
|
664
|
+
* This wrote `senderPubkeyHex: this.senderPubkeyHex` unconditionally, which was true for
|
|
665
|
+
* as long as the only thing a client could submit was its own leaf. `witnessReceivedLeaf`
|
|
666
|
+
* ended that: on a counter-submit `structure1Cbor` holds the COUNTERPARTY's bytes, so
|
|
667
|
+
* this row labelled their leaf as ours — and attached a relay receipt to it, which is the
|
|
668
|
+
* one thing that must never happen to a leaf we did not author, because a receipt is what
|
|
669
|
+
* pins OUR leaves to a sequence we could otherwise renumber.
|
|
670
|
+
*
|
|
671
|
+
* It was inert on the wire by luck: `sender_pubkey_hex` is not transmitted and the
|
|
672
|
+
* directory re-derives the author from `structure2_cbor`. It was never inert locally —
|
|
673
|
+
* the store is `INSERT OR IGNORE` on `(agent, session, sequence)`, and the ack arrives
|
|
674
|
+
* BEFORE the `leaf_deliver` echo, so this row won and the correct one was silently
|
|
675
|
+
* dropped.
|
|
676
|
+
*
|
|
677
|
+
* Same rule as everywhere else on this path: the identity comes from inside the bytes
|
|
678
|
+
* the author signed, never from an ambient value that happens to be right today.
|
|
679
|
+
*/
|
|
680
|
+
const authored = decodeStructure1(structure1Cbor);
|
|
681
|
+
const authorHex = authored.ok
|
|
682
|
+
? Buffer.from(authored.fields.senderPubkey).toString("hex")
|
|
683
|
+
: this.senderPubkeyHex;
|
|
684
|
+
const ourOwnLeaf = authorHex === this.senderPubkeyHex;
|
|
590
685
|
this.#sealLeafStore.store(this.senderPubkeyHex, ev.receipt.sessionIdHex, {
|
|
591
686
|
sequenceNumber: seq,
|
|
592
687
|
leafKind: this.#pendingLeafKind,
|
|
593
|
-
senderPubkeyHex:
|
|
688
|
+
senderPubkeyHex: authorHex,
|
|
594
689
|
structure2Cbor,
|
|
595
690
|
structure1Cbor,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
691
|
+
...(ourOwnLeaf
|
|
692
|
+
? {
|
|
693
|
+
relayId: ev.receipt.relayId,
|
|
694
|
+
relayTimestamp: ev.receipt.timestamp,
|
|
695
|
+
relaySignatureHex: ev.receipt.signatureHex,
|
|
696
|
+
}
|
|
697
|
+
: {}),
|
|
599
698
|
}, Date.now());
|
|
600
699
|
}
|
|
601
700
|
}
|
|
@@ -687,15 +786,100 @@ export class AgentRelayClient {
|
|
|
687
786
|
const seq = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : -1;
|
|
688
787
|
const sidHex = Buffer.from(toU8(frame["session_id"])).toString("hex");
|
|
689
788
|
const s1 = toU8(frame["structure1_cbor"]);
|
|
690
|
-
|
|
789
|
+
/**
|
|
790
|
+
* ONE DECODE, TWO CONSUMERS — 033-ACKEMIT. The acknowledgement bump and the seal-leaf capture
|
|
791
|
+
* below both need the sender's own signed fields, and they must agree about which leaf they
|
|
792
|
+
* are looking at. Decoding twice would let a future edit change one read and not the other.
|
|
793
|
+
*/
|
|
794
|
+
const deliveredS1 = decodeStructure1(s1);
|
|
795
|
+
// Advance last_seen ONLY for a COUNTERPARTY leaf. The relay also echoes our OWN
|
|
691
796
|
// leaf back as a leaf_deliver — that must NOT advance it (same reason as the ack above).
|
|
692
797
|
const authoredByUs = this.#isOwnLeaf(s1);
|
|
693
|
-
|
|
694
|
-
|
|
798
|
+
/**
|
|
799
|
+
* The POSITION and the CONTENT AT IT, from the counterparty's own signed bytes.
|
|
800
|
+
*
|
|
801
|
+
* ⚠️ THE HASH COMES FROM INSIDE `structure1_cbor`, NEVER FROM AN ENVELOPE FIELD. The frame
|
|
802
|
+
* also carries `structure2_cbor`, which the RELAY built — taking the hash from there would key
|
|
803
|
+
* our acknowledgement on a value the witness supplies, and a tampering relay could then make
|
|
804
|
+
* us sign an acknowledgement of content the counterparty never sent. Index 1 of Structure 1 is
|
|
805
|
+
* inside the bytes the counterparty signed, so it is the one copy neither we nor the relay can
|
|
806
|
+
* move.
|
|
807
|
+
*
|
|
808
|
+
* A leaf whose layout this build cannot name advances NOTHING — position included. The
|
|
809
|
+
* previous code advanced `seq` from the envelope regardless, so an unreadable leaf could move
|
|
810
|
+
* the acknowledgement forward while leaving the hash behind it; refusing to advance keeps the
|
|
811
|
+
* pair describing one real message, and the relay's own decoder already gates what reaches
|
|
812
|
+
* here.
|
|
813
|
+
*/
|
|
814
|
+
if (seq >= 0 && !authoredByUs) {
|
|
815
|
+
if (deliveredS1.ok) {
|
|
816
|
+
this.#bumpLastSeen(sidHex, seq, deliveredS1.fields.contentHash);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
this.#logger.warn("relay.leaf_deliver.unreadable", {
|
|
820
|
+
seq,
|
|
821
|
+
session: sidHex,
|
|
822
|
+
structure1Reason: deliveredS1.reason,
|
|
823
|
+
impact: "this delivered leaf could not be read, so this session's acknowledgement was NOT " +
|
|
824
|
+
"advanced to it. The next message this agent sends will acknowledge the last leaf it " +
|
|
825
|
+
"could read, which is honest — it never claims to have seen something it could not.",
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
695
829
|
// Record the COUNTERPARTY's delivered leaf in the seal-leaf log (no relay
|
|
696
830
|
// receipt — the relay does not ack-sign a delivery to the recipient). It is pinned at seal by the
|
|
697
831
|
// absent party's sender_signature (unforgeable) + sequence contiguity against our receipt-pinned own
|
|
698
832
|
// leaves. Our OWN echoed leaf is skipped here (it is recorded WITH its receipt on the ack path).
|
|
833
|
+
/**
|
|
834
|
+
* ─── OUR OWN LEAF, WITNESSED BY SOMEBODY ELSE — 034-CARRYLEAF review F2 ──────────────────
|
|
835
|
+
*
|
|
836
|
+
* ⚠️ **WITHOUT THIS, MAKING THE COUNTERPARTY ABLE TO WITNESS OUR LEAF COST US THE RECEIPT.**
|
|
837
|
+
*
|
|
838
|
+
* When the counterparty counter-submits a leaf WE authored, the relay assigns it a position
|
|
839
|
+
* and delivers it to us — and `authoredByUs` is true, so the capture below skipped it. We
|
|
840
|
+
* never submitted it ourselves, so no ack ever arrived and the ack path never wrote a row
|
|
841
|
+
* either. The result was a permanent hole at that position in our own carry: the unilateral
|
|
842
|
+
* seal refused it as `seal_carry_noncontiguous`, and the bilateral one refused to co-sign a
|
|
843
|
+
* root it could not judge. **An honest sender whose relay hiccuped once lost the receipt for
|
|
844
|
+
* the entire conversation** — for a message sitting in their own transcript.
|
|
845
|
+
*
|
|
846
|
+
* So the leaf is stored, **with NO relay receipt**, because we hold none: nobody acked it to
|
|
847
|
+
* us. That asymmetry is exactly the one the seal design already relies on. A bilateral seal
|
|
848
|
+
* needs a contiguous chain and gets one. A UNILATERAL seal additionally requires every one of
|
|
849
|
+
* our OWN leaves to carry a receipt — so a party who never witnesses their own messages still
|
|
850
|
+
* cannot seal alone on them (`unilateral_own_leaf_unwitnessed`), which is precisely what
|
|
851
|
+
* `DOD-M15-WITHHOLD-SEAL-1` intends.
|
|
852
|
+
*
|
|
853
|
+
* `INSERT OR IGNORE` keeps whichever row lands first, and a real receipt-bearing row for the
|
|
854
|
+
* same position can only come from our own ack — which cannot exist here, or we would have
|
|
855
|
+
* submitted it ourselves.
|
|
856
|
+
*/
|
|
857
|
+
if (this.#sealLeafStore && seq >= 0 && authoredByUs && deliveredS1.ok) {
|
|
858
|
+
const s2Own = frame["structure2_cbor"];
|
|
859
|
+
if (s2Own instanceof Uint8Array && s1.length > 0) {
|
|
860
|
+
try {
|
|
861
|
+
const wrote = this.#sealLeafStore.store(this.senderPubkeyHex, sidHex, {
|
|
862
|
+
sequenceNumber: seq,
|
|
863
|
+
leafKind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
|
|
864
|
+
senderPubkeyHex: this.senderPubkeyHex,
|
|
865
|
+
structure2Cbor: s2Own,
|
|
866
|
+
structure1Cbor: s1,
|
|
867
|
+
}, Date.now());
|
|
868
|
+
if (wrote) {
|
|
869
|
+
this.#logger.info("relay.seal_leaf.own.witnessed_by_counterparty", {
|
|
870
|
+
seq,
|
|
871
|
+
session: sidHex,
|
|
872
|
+
impact: "a message THIS agent wrote was witnessed by the counterparty rather than by us — " +
|
|
873
|
+
"our own submit did not land. The leaf is kept so this conversation can still be " +
|
|
874
|
+
"sealed together; sealing it alone would still need a receipt we do not hold.",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
this.#logger.error("relay.seal_leaf.own.store_failed", { seq, session: sidHex, error: extractErrorMessage(err) });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
699
883
|
if (this.#sealLeafStore && seq >= 0 && !authoredByUs) {
|
|
700
884
|
const s2 = frame["structure2_cbor"];
|
|
701
885
|
const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
|
|
@@ -705,9 +889,8 @@ export class AgentRelayClient {
|
|
|
705
889
|
// #isOwnLeaf and #captureReceipt and was simply missed by the order's reader list, which is
|
|
706
890
|
// exactly the "next layout change has to find them again" problem the shared decoder exists
|
|
707
891
|
// to end. Behaviour for every relay-accepted leaf is unchanged; the fail-open closes.
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
? Buffer.from(s1Decoded.fields.senderPubkey).toString("hex")
|
|
892
|
+
const senderHex = deliveredS1.ok
|
|
893
|
+
? Buffer.from(deliveredS1.fields.senderPubkey).toString("hex")
|
|
711
894
|
: undefined;
|
|
712
895
|
if (structure2Cbor && s1.length > 0 && senderHex) {
|
|
713
896
|
try {
|
|
@@ -1220,6 +1403,28 @@ export class AgentRelayClient {
|
|
|
1220
1403
|
*/
|
|
1221
1404
|
return this.submitLeaf(node, sessionId, contentHash, leafKind, null);
|
|
1222
1405
|
}
|
|
1406
|
+
/**
|
|
1407
|
+
* ─── WITNESS A LEAF THIS AGENT RECEIVED BUT DID NOT AUTHOR — 034-CARRYLEAF ────────────────────
|
|
1408
|
+
*
|
|
1409
|
+
* **This is what closes `DOD-M15-WITHHOLD-SEAL-1`.** Until it existed, `submitMessageHash` had
|
|
1410
|
+
* one production caller on the SEND path, so nothing ever witnessed a message that was RECEIVED.
|
|
1411
|
+
* A counterparty who delivered a message directly and never submitted its hash left the relay's
|
|
1412
|
+
* account of the conversation one message short — permanently — and a unilateral seal then agreed
|
|
1413
|
+
* with the witness. Every leaf validly signed, nothing false, the last thing said simply absent.
|
|
1414
|
+
*
|
|
1415
|
+
* **The teeth are the author's own signature.** It arrived on the content frame beside the bytes
|
|
1416
|
+
* it signs, this daemon verified it before ingesting anything, and it cannot be forged here. The
|
|
1417
|
+
* relay verifies it again against the session's assignment before sequencing — so what this hands
|
|
1418
|
+
* over is a claim the author made and cannot disown.
|
|
1419
|
+
*
|
|
1420
|
+
* ⚠️ **THE BYTES ARE PASSED THROUGH, NEVER REBUILT.** A signature is over the encoded bytes, and
|
|
1421
|
+
* the one measured cost of forgetting that on this exact structure was a daemon-local encoder
|
|
1422
|
+
* emitting a timestamp as float64 where the published one promotes to uint64 — same value,
|
|
1423
|
+
* different signed bytes, refused by everyone.
|
|
1424
|
+
*/
|
|
1425
|
+
async witnessReceivedLeaf(node, sessionId, contentHash, leafKind, carried) {
|
|
1426
|
+
return this.submitLeaf(node, sessionId, contentHash, leafKind, null, carried);
|
|
1427
|
+
}
|
|
1223
1428
|
/**
|
|
1224
1429
|
* Submit a leaf hash of a given kind (0x00 message / 0x02 control) to the relay. The SEAL
|
|
1225
1430
|
* ctrl leaf rides this path: two distinct-sender ctrl leaves in the relay's
|
|
@@ -1263,7 +1468,15 @@ export class AgentRelayClient {
|
|
|
1263
1468
|
* before any test runs. Every caller must now say what this leaf carries, and `submitMessageHash`
|
|
1264
1469
|
* says `null` in one visible place instead of by saying nothing at all.
|
|
1265
1470
|
*/
|
|
1266
|
-
async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes
|
|
1471
|
+
async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes,
|
|
1472
|
+
/**
|
|
1473
|
+
* 034-CARRYLEAF — a leaf THIS AGENT DID NOT AUTHOR, carried on its author's behalf.
|
|
1474
|
+
*
|
|
1475
|
+
* Absent for every ordinary send, where this client builds and signs its own claim. Present
|
|
1476
|
+
* only when witnessing something received whose author never submitted it — see
|
|
1477
|
+
* `witnessReceivedLeaf`.
|
|
1478
|
+
*/
|
|
1479
|
+
carried) {
|
|
1267
1480
|
if (contentBytes !== null && leafKind !== LEAF_KIND_CTRL) {
|
|
1268
1481
|
// Logged at ERROR and returned: a caller reaching this line is trying to hand the relay
|
|
1269
1482
|
// operator content, and the log must carry it even if the caller swallows the result.
|
|
@@ -1331,7 +1544,7 @@ export class AgentRelayClient {
|
|
|
1331
1544
|
}
|
|
1332
1545
|
// Chain on the prior submit so only one is outstanding at a time (FIFO). The ack
|
|
1333
1546
|
// carries no session_id, so concurrent submits on one stream would be ambiguous.
|
|
1334
|
-
const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes));
|
|
1547
|
+
const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried));
|
|
1335
1548
|
// Keep the chain alive regardless of this submit's outcome.
|
|
1336
1549
|
this.#submitChain = run.then(() => undefined, () => undefined);
|
|
1337
1550
|
return run;
|
|
@@ -1379,7 +1592,7 @@ export class AgentRelayClient {
|
|
|
1379
1592
|
* answering `retry_after_ms: 3600000`. Past this the send fails and says so.
|
|
1380
1593
|
*/
|
|
1381
1594
|
static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
|
|
1382
|
-
async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
|
|
1595
|
+
async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried) {
|
|
1383
1596
|
const sessionIdHex = Buffer.from(sessionId).toString("hex");
|
|
1384
1597
|
// Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
|
|
1385
1598
|
//
|
|
@@ -1397,7 +1610,7 @@ export class AgentRelayClient {
|
|
|
1397
1610
|
// reported `session_not_found`, indistinguishable at the wire from the race — which is why
|
|
1398
1611
|
// this must be discriminated on OUR state, not on the relay's reason string.
|
|
1399
1612
|
const recordedBefore = this.#sessions.get(sessionIdHex)?.recorded === true;
|
|
1400
|
-
let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
1613
|
+
let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
|
|
1401
1614
|
for (let attempt = 1; attempt < _a.#SESSION_NOT_FOUND_ATTEMPTS
|
|
1402
1615
|
&& !recordedBefore
|
|
1403
1616
|
&& !result.ok
|
|
@@ -1416,7 +1629,7 @@ export class AgentRelayClient {
|
|
|
1416
1629
|
attempt,
|
|
1417
1630
|
reason: result.reason,
|
|
1418
1631
|
});
|
|
1419
|
-
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
1632
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
|
|
1420
1633
|
}
|
|
1421
1634
|
/**
|
|
1422
1635
|
* DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
|
|
@@ -1451,7 +1664,7 @@ export class AgentRelayClient {
|
|
|
1451
1664
|
await new Promise((r) => setTimeout(r, waitMs));
|
|
1452
1665
|
if (this.#closed)
|
|
1453
1666
|
break;
|
|
1454
|
-
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
1667
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
|
|
1455
1668
|
}
|
|
1456
1669
|
if (!result.ok && result.reason === "rate_limited") {
|
|
1457
1670
|
// Option 2, the fallback: it did not clear within our budget, so the caller must hear it
|
|
@@ -1476,7 +1689,7 @@ export class AgentRelayClient {
|
|
|
1476
1689
|
}
|
|
1477
1690
|
return result;
|
|
1478
1691
|
}
|
|
1479
|
-
async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes) {
|
|
1692
|
+
async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried) {
|
|
1480
1693
|
if (this.#closed)
|
|
1481
1694
|
return { ok: false, reason: "relay_client_closed" };
|
|
1482
1695
|
if (!(await this.#ensureConnected(node)))
|
|
@@ -1512,22 +1725,85 @@ export class AgentRelayClient {
|
|
|
1512
1725
|
const stream = this.#stream;
|
|
1513
1726
|
if (!stream)
|
|
1514
1727
|
return { ok: false, reason: "relay_unavailable" };
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1728
|
+
/**
|
|
1729
|
+
* ─── WHAT THIS SEND ACKNOWLEDGES — 033-ACKEMIT ───────────────────────────────────────────────
|
|
1730
|
+
*
|
|
1731
|
+
* This session's OWN high-water mark (NOT an agent-global one) — the relay's seq_counter is per
|
|
1732
|
+
* session and rejects `last_seen_seq > seq_counter` — AND the content hash at that position,
|
|
1733
|
+
* read from the one entry that holds both.
|
|
1734
|
+
*/
|
|
1735
|
+
const lastSeen = this.#lastSeen.get(sessionIdHex);
|
|
1736
|
+
/**
|
|
1737
|
+
* ⚠️ **THIS COMMENT USED TO SAY "REFUSED, NOT DOWNGRADED", AND THE CODE UNDER IT DID REFUSE.**
|
|
1738
|
+
* It is rewritten rather than deleted because a comment asserting a refusal that no longer
|
|
1739
|
+
* happens is how the next reader comes to believe a guard exists where there is a fallback.
|
|
1740
|
+
*
|
|
1741
|
+
* There is no seed only when this session was registered with neither a genesis nor an
|
|
1742
|
+
* assignment to derive one from, AND nothing has been received on it. The claim that goes out
|
|
1743
|
+
* then is `last_seen_seq: 0` with no hash — "I have seen nothing of yours" — which is true and
|
|
1744
|
+
* asserts nothing about content, so it is not the unbacked number this unit exists to stop
|
|
1745
|
+
* signing. A claim that NAMES a position with no hash is the defect, and the receiving daemon
|
|
1746
|
+
* refuses exactly that.
|
|
1747
|
+
*/
|
|
1748
|
+
/**
|
|
1749
|
+
* ⚠️ NOT FOR A CARRIED LEAF — 034-CARRYLEAF review F8. This branch describes THIS agent having
|
|
1750
|
+
* nothing to acknowledge, and on a counter-submit the acknowledgement inside the bytes is the
|
|
1751
|
+
* AUTHOR's, already made. Logging "this submit acknowledges nothing" about it would be false,
|
|
1752
|
+
* and this daemon's own seed is irrelevant to a claim it did not write.
|
|
1753
|
+
*/
|
|
1754
|
+
if (!lastSeen && !carried) {
|
|
1755
|
+
/**
|
|
1756
|
+
* ⚠️ **v1, AND ONLY BECAUSE THERE IS NOTHING TO ACKNOWLEDGE.** Same rule as the content
|
|
1757
|
+
* claim's, and the receiving half is `#verifyAcknowledgedContent` — a v1 claim is refused the
|
|
1758
|
+
* moment it names position 1 or beyond, and accepted when it names none.
|
|
1759
|
+
*
|
|
1760
|
+
* Reaching here means the session was registered with no genesis and no assignment to derive
|
|
1761
|
+
* one from, and no counterparty leaf has been delivered. `last_seen_seq: 0` with no hash says
|
|
1762
|
+
* "I have seen nothing of yours", which is true, and asserts nothing about content — so it is
|
|
1763
|
+
* not the unbacked number this unit exists to stop signing.
|
|
1764
|
+
*
|
|
1765
|
+
* It does not refuse the submit, and an earlier version did. Sessions brokered without a
|
|
1766
|
+
* relay assignment are real, and refusing there left them unable to be witnessed at all.
|
|
1767
|
+
*/
|
|
1768
|
+
this.#logger.info("session.relay.submit.unacknowledged", {
|
|
1769
|
+
relayPeerId: this.#relayPeerId,
|
|
1770
|
+
session: sessionIdHex,
|
|
1771
|
+
impact: "this submit acknowledges nothing: the session has no recorded starting point and no " +
|
|
1772
|
+
"counterparty leaf has arrived on it. The leaf is witnessed as normal; the claim simply " +
|
|
1773
|
+
"makes no assertion about what this agent has received.",
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1518
1776
|
// The published encoder from protocol-types — the ONE definition of the field order, pinned by
|
|
1519
|
-
// `structure1-canonical.json
|
|
1520
|
-
// the drift was invisible because both copies
|
|
1521
|
-
//
|
|
1522
|
-
// it to a uint64. Same value, different
|
|
1523
|
-
|
|
1777
|
+
// `structure1-canonical.json` (v1) and `structure1-v2-canonical.json` (v2). A second local copy
|
|
1778
|
+
// lived here until 020-ACKHASH; it drifted, and the drift was invisible because both copies
|
|
1779
|
+
// "worked": it encoded a timestamp above 2^32-1 as a CBOR float64 while the published encoder
|
|
1780
|
+
// (and every other TBS builder in this package) promotes it to a uint64. Same value, different
|
|
1781
|
+
// signed bytes, and only the vector said which was canonical.
|
|
1782
|
+
//
|
|
1783
|
+
// `lastSeenHash` is passed on EVERY send, so every claim this daemon signs is v2 and binds to
|
|
1784
|
+
// content. Nothing here ever passes `undefined` — see the refusal above.
|
|
1785
|
+
/**
|
|
1786
|
+
* ⚠️ **A CARRIED LEAF IS SENT VERBATIM AND SIGNED BY NOBODY HERE — 034-CARRYLEAF.**
|
|
1787
|
+
*
|
|
1788
|
+
* When this agent is witnessing something it RECEIVED, the claim already exists: its author
|
|
1789
|
+
* built it, signed it, and put it on the content frame. Re-encoding it would change the signed
|
|
1790
|
+
* bytes and the relay would refuse a leaf that is perfectly valid. Signing it ourselves would be
|
|
1791
|
+
* worse — it would turn their statement into ours, which is the one thing that must never happen
|
|
1792
|
+
* to a record whose whole value is that each party's words are their own.
|
|
1793
|
+
*
|
|
1794
|
+
* So this branch takes the bytes as they arrived, and this agent's own acknowledgement state is
|
|
1795
|
+
* deliberately NOT consulted: `last_seen_seq` and `last_seen_hash` inside those bytes are the
|
|
1796
|
+
* AUTHOR's account of what THEY had seen, and they are not ours to restate.
|
|
1797
|
+
*/
|
|
1798
|
+
const structure1 = carried ? carried.structure1Cbor : encodeStructure1({
|
|
1524
1799
|
contentHash,
|
|
1525
1800
|
senderPubkey: this.#senderPubkey,
|
|
1526
1801
|
sessionId,
|
|
1527
|
-
lastSeenSeq:
|
|
1802
|
+
lastSeenSeq: lastSeen?.seq ?? 0,
|
|
1528
1803
|
timestamp: Date.now(),
|
|
1804
|
+
...(lastSeen ? { lastSeenHash: lastSeen.hash } : {}),
|
|
1529
1805
|
});
|
|
1530
|
-
const signature = await this.#keyProvider.sign(structure1);
|
|
1806
|
+
const signature = carried ? carried.senderSignature : await this.#keyProvider.sign(structure1);
|
|
1531
1807
|
const frame = encodeCbor({
|
|
1532
1808
|
type: "hash_submit",
|
|
1533
1809
|
session_id: sessionId,
|
|
@@ -1609,8 +1885,44 @@ export class AgentRelayClient {
|
|
|
1609
1885
|
}
|
|
1610
1886
|
}
|
|
1611
1887
|
/** The highest relay-assigned sequence observed for a given session (ack or deliver). */
|
|
1612
|
-
|
|
1613
|
-
|
|
1888
|
+
/**
|
|
1889
|
+
* Advance this session's acknowledgement from a message that ARRIVED — 033-ACKEMIT review F1.
|
|
1890
|
+
*
|
|
1891
|
+
* ⚠️ **`#bumpLastSeen` used to have exactly one caller, inside the `leaf_deliver` handler, so the
|
|
1892
|
+
* acknowledgement tracked what the RELAY DELIVERED rather than what was RECEIVED.** On a direct
|
|
1893
|
+
* session that is a real difference: the content arrives peer-to-peer and the relay's copy of the
|
|
1894
|
+
* leaf follows separately, so until it did, this daemon signed an acknowledgement one message
|
|
1895
|
+
* behind what it had actually read — and on a session where delivery never came back at all, the
|
|
1896
|
+
* acknowledgement never moved.
|
|
1897
|
+
*
|
|
1898
|
+
* The order's own words are "the content hash of the last message this sender ACTUALLY RECEIVED".
|
|
1899
|
+
* This is the caller that makes that true: the receive path calls it as soon as a message has been
|
|
1900
|
+
* verified and ingested at a known canonical position.
|
|
1901
|
+
*
|
|
1902
|
+
* **THE POSITION IS STILL REQUIRED, and that is a real limit rather than an oversight.** The pair
|
|
1903
|
+
* is (position, content-at-position), and the relay refuses a `last_seen_seq` that runs ahead of
|
|
1904
|
+
* its counter — so a message that arrived with NO ordering record cannot be acknowledged by
|
|
1905
|
+
* position at all, whatever we hold of it. That case is the withheld-submit attack itself, and it
|
|
1906
|
+
* is closed by carrying the sender's signed leaf into the seal, not from here.
|
|
1907
|
+
*/
|
|
1908
|
+
noteReceivedLeaf(sessionIdHex, relaySeq, contentHash) {
|
|
1909
|
+
this.#bumpLastSeen(sessionIdHex, relaySeq, contentHash);
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* The POSITION and the CONTENT AT IT together — 033-ACKEMIT.
|
|
1913
|
+
*
|
|
1914
|
+
* ⚠️ **IT REPLACED `lastSeenSeq()`, WHICH IS DELETED RATHER THAN LEFT WIRED.** That accessor
|
|
1915
|
+
* returned the position alone, and `session-node-manager`'s unwitnessed content claim was its
|
|
1916
|
+
* only caller. Leaving it in place after this one took over would leave a second way to read half
|
|
1917
|
+
* of a pair that must be read whole: a `last_seen_seq` paired with a `last_seen_hash` for a
|
|
1918
|
+
* different message is worse than no acknowledgement at all, because it looks checkable and
|
|
1919
|
+
* fails.
|
|
1920
|
+
*
|
|
1921
|
+
* `undefined` means this session has no acknowledgement to make, which the caller must handle
|
|
1922
|
+
* rather than fill in.
|
|
1923
|
+
*/
|
|
1924
|
+
lastSeenAck(sessionIdHex) {
|
|
1925
|
+
return this.#lastSeen.get(sessionIdHex);
|
|
1614
1926
|
}
|
|
1615
1927
|
close() {
|
|
1616
1928
|
this.#closed = true;
|