@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.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +81 -24
- package/dist/daemon.js.map +1 -1
- package/dist/park-envelope.d.ts +93 -0
- package/dist/park-envelope.d.ts.map +1 -0
- package/dist/park-envelope.js +172 -0
- package/dist/park-envelope.js.map +1 -0
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +13 -1
- package/dist/retry-queue.js.map +1 -1
- package/dist/session-node-manager.d.ts +31 -13
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +104 -26
- package/dist/session-node-manager.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -2
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +10 -8
- package/dist/vocabulary.js.map +1 -1
- package/package.json +4 -4
|
@@ -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):
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
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
|
-
|
|
3473
|
-
return
|
|
3483
|
+
decodeParkEnvelope(plaintext) {
|
|
3484
|
+
return decodeParkEnvelope(plaintext);
|
|
3474
3485
|
}
|
|
3475
3486
|
/**
|
|
3476
|
-
*
|
|
3477
|
-
*
|
|
3478
|
-
*
|
|
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
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
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
|
-
|
|
3495
|
-
|
|
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
|
-
|
|
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 (!
|
|
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",
|