Metadata-private channels: anonymous pool + X3DH/Double-Ratchet engine - #412
Open
MudDev wants to merge 50 commits into
Open
Metadata-private channels: anonymous pool + X3DH/Double-Ratchet engine#412MudDev wants to merge 50 commits into
MudDev wants to merge 50 commits into
Conversation
…engine) Replace the metadata-public Epix Mail model with an anonymous sealed-envelope pool plus node-side trial-decryption into a private index. No observer or node operator can learn who messages whom, when, or that a user sent anything. New crates: - epix-content/pool.rs: the `epix-pool-1` anonymous record class (PoW-gated, size-padded, day-bucketed, fresh throwaway author) + verify/merge/shard math. - epix-envelope: generic Engine trait + EnvelopeStore + trial-decrypt indexer (app-neutral, reusable by a future encrypted forum) + FakeEngine test double. - epix-pairwise-engine: real X3DH + header-encrypted Double Ratchet, forward- secure detection-tag chains, Elligator2 first-contact tags, HKDF-SHA256 / HMAC-SHA256 KDFs, ChaCha20-Poly1305 AEAD. - epix-channel: private `channels.db` index (impl EnvelopeStore) + legacy import. Node integration: - epix-ui: pool lifecycle, generic capability registry, LocalFeedSource, EDX serve/apply-inbound seams; epix-db PRAGMA busy_timeout. - epix-plugins/channel: Channel plugin + `channel*` WS API. PoW runs off the async runtime; the send path is serialized per identity. Crypto review (docs/channel-crypto-spec.md + frozen vectors + interop tests): - fix first-contact sender spoofing — bind the claimed sender_xid to the transcript identity key (ik_a) via the sender's published bundle before commit; - fix an AEAD nonce-reuse race under concurrent sends; - zeroize identity/session secrets on drop.
…oral) Follow-up coverage from the adversarial crypto review: - C1: byte-exact record-level KAT via a test-only injectable RNG through begin/seal, pinning the full on-wire record (Elligator tag, FC/EST header layout, ad=tag, nonce labels, padding, bucket choice) and the X3DH/tck contexts a symmetric seal/open change would otherwise hide. - C6: established-tag replay against an advanced session fails closed; open_first is re-derivable (FC replay handled at the pool layer). - C7: two senders to one recipient are isolated (distinct tags/convs, no cross). - C9: oversize body -> TooBig; record sizes fall in declared buckets. - C10/C8: corrupt-but-addressed body opens as candidate but not decryptable; garbage/short ct rejected without panic.
Directly pin header_key_for: a gap of exactly MAX_SKIP opens, one more is refused (None) so open() fails closed. Not reachable via the public API (gated by the 32-tag publish window). The distinct 'stalled' outcome + window widening (review F3) remains a follow-up observability improvement.
…itives SonarCloud (rust:S3776 cognitive complexity): - extract open_established / open_first_contact helpers from process_record - extract load_published_bundles from index_batch CodeQL (rust/hard-coded-cryptographic-value): exclude this query in the CodeQL config. It fires on the engine's domain-separation constants (HKDF info contexts, HMAC chain/tag labels, AEAD nonce labels, the public 0xFF^32 X3DH salt) — all fixed-and-public by construction, not secrets, so 100% false-positive here. The construction is review-gated in docs/channel-crypto-spec.md with frozen vectors; hard-coded secrets are covered by the required gitleaks check.
The receive window (LOOKAHEAD=32) was far narrower than the ratchet's skip tolerance (MAX_SKIP=512), so a head-of-chain gap of 33..512 records was recoverable by the ratchet but its tag was never registered — the record silently missed forever, indistinguishable from NoMatch. - Align LOOKAHEAD == MAX_SKIP (both 64): any registered tag is now openable and vice-versa, so realistic reordering/loss self-heals. 64 (not 512) keeps the per-received-message tag-registration cost modest for large backfills. - ratchet_decrypt_key now returns None explicitly on a refused skip instead of deriving a wrong-index key that only the AEAD would (silently) reject (C4). - Test: a 60-message head gap (>old 32-window) now self-heals. - Spec §4/§5.1/§8 updated; a UI 'session stalled' signal for gaps > MAX_SKIP is noted as a node-level follow-up.
New channel_encrypt_at_rest config: when on, message bodies/subjects, their thread previews, and the live ratchet session blobs in channels.db are sealed with XChaCha20-Poly1305 (random 24-byte nonce per value) under a key derived from the node master seed (crate epix-channel::enc). A stolen channels.db then yields neither past content nor the keys to continue a session. - ChannelDb gains open_encrypted/memory_encrypted + centralized seal/dec helpers; the reserved enc discriminator columns (session, msg, +new thread.enc) mark sealed rows so a db can hold a mix during an enable/disable transition. - FTS indexes ciphertext when sealed, so search() falls back to a decrypt-then- scan; plaintext mode keeps FTS. - Plugin open_db derives the key via derive_consumer_seed and opens accordingly. - Tests: content + ratchet sealed at rest (raw columns hold ciphertext), reads decrypt, search still works.
Ordered, partly-destructive migration from the metadata-public Epix Mail model to the anonymous channel pool: re-sign with the fixed JS (needs owner key), per-user non-destructive channelMigrateLegacy import, per-user bundle-only data.json republish, then the single destructive owner publish that drops messages.json, plus verification greps and rollback.
The engine now reports the outstanding skipped-message count after an open (records known to exist but not yet received) as Opened.pending, threaded through ProcessOutcome::Indexed into the channelEvent as 'pending'. >0 means earlier messages in the conversation are still arriving (received out of order), which the site can show as a 'N messages still arriving' hint. Test: receiving a far message reports the earlier ones as pending.
New crate implementing the forward-secure key-management core of a Sender-Keys group protocol for the anonymous pool — so an N-member group costs ONE pool record per message instead of N-way fan-out. - SenderChain: per-group/per-sender forward-secure message-key chain (mk=MAC(ck,0x01), ck'=MAC(ck,0x02)) + detection-tag chain (tag=MAC(dck,"gtag"), dck'=MAC(dck,"gchain")); LOOKAHEAD==MAX_SKIP==256 so realistic reorder/loss self-heals; out-of-order recv stores skipped keys + reports the pending gap. - GroupSession: own send chain + per-member receive chains, seal/open, and my_bootstrap/add_member for pairwise-delivered key distribution. - HKDF-SHA256/HMAC-SHA256/ChaCha20-Poly1305 (matches the pairwise engine); secrets wiped on drop. Tests: group roundtrip (metadata-free), out-of-order self-heal + pending, stranger-can't-open. docs/channel-group-engine.md: design + ordered next phases (REQUIRED per-sender signatures for member-vs-member authenticity, membership rekey / MLS swap for group PCS, node wiring, review gate). Not for real group confidentiality yet.
An identity revoked on chain (lost/stolen/compromised key) could still send AND receive metadata-private channel mail indefinitely, because the channel trust path only read the local data/users/<xid>/data.json bundle and never consulted chain state. Three coordinated fixes: - epix-chain xid_signers::resolve now filters to ACTIVE, non-revoked linked identities, so a revoked key is no longer a valid content signer (it can't re-publish/replace a bundle on sync). - New Merkle-verified epix_chain::xid_identity::name_has_active_identity (three- valued: active / all-revoked / indeterminate) + AppState::xid_name_active. - The channel bundle path gates on it: load_published_bundles (→ the M1 anti-spoof), channelSend (refuse a revoked recipient), and channelKeyLookup (reports has_bundle=false + revoked=true) all drop a bundle whose xID has no active linked identity — failing OPEN when the chain is unreachable so a chain outage doesn't block mail. Test: name_has_active_identity maps the cached active flag to keep/cut-off/fail- open without network. Spec §2 updated. Residual (documented): the channel IK is node-seed-derived, so this retires the revoked identity's bundle/attribution, not the IK per-linked-key — true per-key IK retirement needs IK bound to the chain key.
…ntity A channel name (mud.epix) can have more than one linked identity/device, and a message to that name must reach ALL of them so the recipient can read/reply on whichever they use. Before this a name had exactly one channel identity, a second device clobbered the first's data.json, and a send resolved only that one bundle (the "adding a linked identity is cosmetic" half of the xID audit). Node: - channelSend now FANS OUT one sealed envelope per recipient device (reusing the per-leg fan-out already used across recipients), recording the sender's own copy exactly once; result reports `envelopes` (≥ recipients). - load_published_bundles groups every data.json / data-<auth>.json by xID name into a Vec of device bundles (bundle_path_parts), and refine_device_bundles applies revocation (name-level via xid_name_active + per-device via the new AppState xid_active_addrs → xid_signers::resolve, both fail-open) and dedups by IK keeping the freshest spk_idx. - The sealed-sender anti-spoof (open_first_contact) now accepts a first-contact whose transcript ik_a matches ANY of the sender's published device bundles, and DEFERS (NoMatch, unprocessed) instead of dropping-for-good when none match — so a genuine message from a not-yet-synced device indexes once its bundle arrives, while a forgery (ik_a never matches) is still never trusted. resolve_bundle closure generalized Option<Value> → Vec<Value>. - channelKeyBundlePublish stamps the device's linked address as `auth` on the bundle and returns primary_path / device_path; channelKeyLookup reports the active device count. Site (deployed separately, owner re-sign — data dir, not this repo): - Channel.js publishKeyBundle picks the primary data.json slot when free/its own, else its per-device data-<auth>.json — cutover-safe (single-device users stay on data.json, readable by old nodes; two devices never clobber). - data/users/content.json + content-default.json permission_rules widen files_allowed to `data\.json|data-[0-9a-z]+\.json`. Tests (all green): multi_device_sender_any_linked_key_accepted (any-IK accept + defer-then-index once the device bundle syncs); refine_device_bundles keep-all / per-device-drop / legacy-no-auth-kept / dedup-freshest; path-helper contract; existing resolve closures updated to Vec. Docs: docs/channel-multi-device.md; crypto-spec §2 bundle-authenticity note updated. Residual (documented): channel IK is node-seed-derived, not bound to the linked chain key — this retires a revoked device's bundle/attribution, not the IK per key; and two active devices share one user directory whose per-user content.json is single-signer (rare first-publish race, self-heals; robust upgrade is a signed-CRDT bundle merge file, deferred).
…vice count The per-device fan-out posted one pool record per destination, so a peer counting a send burst could recover the recipient's device count — and since linked-identity counts are public on-chain, a unique count deanonymizes the recipient. This packs every send into ONE fixed-width record so the observable record count is independent of how many devices/recipients it reaches. New `epix-envelope::multislot`: - A send's `ct` carries a FIXED SLOTS=8 (detection-tag, keyslot) pairs — real destinations plus uniform-random dummies — followed by ONE shared AEAD body under a fresh single-use K_msg. A 1-device DM and an 8-destination group are byte-equal. - The frozen `epix-pool-1` primitive is UNCHANGED: the public record `tag` is a random routing value; real detection tags live inside the opaque `ct`. - The ratchet engine is UNCHANGED: each real keyslot is a normal per-device pairwise seal — of the tiny `K_msg ‖ H(body)` payload — so per-device Double Ratchet, forward secrecy and the sealed-sender anti-spoof are preserved. Only new primitive is the shared-body ChaCha20-Poly1305, bound to each keyslot by the hash so no substituted body is accepted. - send_multi(dests) assembles it; send_message is now a 1-dest wrapper over it. Receive (indexer): process_record unpacks the ct, scans the SLOTS in-ct detection tags (Tier-1), opens the matching keyslot → K_msg → decrypts the shared body; Tier-2 first-contact probes each slot. sender_xid moved into the encrypted body and cross-checked against the keyslot's transcript ik_a (anti-spoof intact). Plugin: channelSend flattens all recipients' active device bundles and sends them as SLOTS-sized chunks (one record each); >SLOTS destinations span the minimum number of fixed-width records (leaks only ">SLOTS"). Pool descriptor pad_buckets widened to [8192,32768,131072], max_record_bytes 200000 (fixed slot overhead ~4.4KB) — owner-signed content.json, takes effect on site re-sign. Tests (all green, incl. 61 untouched pool-primitive tests): 1-dest vs 3-dest records are byte-identical size with SLOTS slots; one record reaches two recipients (each opens its own slot); shared-body hash-binding rejects a substituted body / wrong key; ct pack/unpack round-trip + over-large rejection; all existing e2e/first-contact/ reply/group/spoof/multi-device tests pass over the new transport. Docs: docs/channel-count-privacy.md; multi-device + crypto-spec cross-referenced. Chosen SLOTS=8 per the no-on-chain-cap finding in x/xid (LinkIdentity is uncapped).
…otgun, shuffle) Adversarial crypto review of the multi-slot transport found no confidentiality or spoofing break (all four properties hold against the real PairwiseEngine), but flagged correctness/hardening items: - FIX (real): the first-contact anti-spoof deferral returned Ok(Some(NoMatch)), which the multi-slot Tier-2 scan treated as a hit and returned — so one slot whose sender bundle wasn't synced masked every LATER openable slot in the record. Now returns Ok(None) so the scan falls through (matching the body-binding path). - HARDENING: rand32() (K_msg source) silently fell back to a zero vector on RNG failure — a zero K_msg under the fixed zero nonce would be catastrophic keystream reuse. Now panics (unreachable in practice; new_seed already panics on OS-RNG failure) — fail closed, no footgun. - DEFENSE-IN-DEPTH: shuffle real + dummy slots (Fisher-Yates) so real slots aren't a fixed 0..n prefix; if content indistinguishability ever regressed, the count still couldn't be read off slot positions. Receiver scans all slots, so order is irrelevant. Documented (deferred, needs schema change; zero exposure in the one-identity-per- node deployment): a node hosting TWO channel identities both addressed in one record indexes only the first (idempotency is keyed record-wide on sign_h). Fix is per-(sign_h, identity_id) idempotency + scan every identity×slot. Also documented the >SLOTS burst timing+size correlation (bucketed count) more precisely. All channel/envelope/plugin tests still green.
…ddressed identity Completes the review's HIGH-severity Defect A: a single count-hiding record carries a slot for several recipients, so a node hosting more than one channel identity must deliver it to EACH addressed local identity. Previously process_record indexed only the first slot and marked the whole record processed by its record-wide `sign`, silently dropping the rest (and a same-record `msg.sign_h UNIQUE` physically blocked a second row). - process_record now returns Vec<ProcessOutcome> and scans every (identity × slot), emitting one outcome per delivered slot. process_record_one is a convenience wrapper for the single-identity-per-node norm (and tests). - Idempotency + the `processed` set are keyed on (sign_h, identity_id): a slot still deferred for one identity (sender bundle not synced) is re-checked independently of another identity's delivered slot; a rescan makes no duplicates. - Schema v2: msg `sign_h UNIQUE` → `UNIQUE(sign_h, identity_id)`; processed PK → (sign_h, identity_id). A pre-existing db is migrated IN PLACE (user_version guard) PRESERVING all messages + FTS + ratchet sessions — only the two constraint-changed tables are rebuilt; runs once. - Store trait: is_processed/mark_processed take identity_id. index_batch fires a channelEvent per delivered outcome. Tests: one_record_delivers_to_two_local_identities (both personas get the message from ONE record; rescan idempotent, none lost); migrates_v1_db_preserving_data (a seeded v1 db keeps its message + FTS through migration and gains per-identity uniqueness). All existing channel/envelope/plugin + 61 pool-primitive tests green. Docs updated: multi-identity is now supported, not a limitation.
… complexity Closes the two documented count-privacy residuals and both SonarCloud S3776 (cognitive-complexity) findings on PR #412. Burst jitter (>SLOTS sends) — channel.rs A send to more than SLOTS destinations spans ceil(N/SLOTS) records. Appended back-to-back they were a simultaneous same-size burst a directly-connected peer could count to recover a *bucketed* destination count. append_records_jittered now posts the first record immediately (byte-identical to a normal single-record send) and dribbles the rest from a detached task with a random per-record gap (channel_burst_jitter_max_secs, default 60s, 0=off). The records are pre-sealed with the ratchet already advanced+persisted, so a deferred append is crash-safe. Sub-SLOTS sends are one record and never delayed. First-contact re-wrap dedup — indexer.rs A first-contact-openable record for a conversation leg that ALREADY has a session is a re-opener (a send retry, or a second opener that raced the first) — never a distinct message (genuine follow-ups are Tier-1, tag-matched). open_first_contact now checks session_id_for_leg before create_session and drops the duplicate idempotently (marks it processed, returns AlreadyProcessed) instead of forking a second ratchet and double-indexing the opener. New test first_contact_rewrap_does_not_duplicate_session. SonarCloud S3776 - process_record (was 19): the two tier-scan loops are extracted to scan_established / scan_first_contact; the function is now a linear dispatch. - index_batch (was 17): the blocking body is extracted to process_batch_blocking + indexed_event. Also fix a latent flaky test: channel_pipeline's metadata-leak check grepped the base64 text of the opaque random-padded ct, so short markers ("bob", "ZXQ1") collided by chance (~4%/run). It now checks the two real vectors precisely — the decoded ct never contains the whole plaintext body/subject, and the ct/tag/sign/author-redacted record carries no name/content marker. Deterministic over 12+ runs. Full mail stack green (content/envelope/channel/pairwise-engine/plugins); clippy clean on the touched crates; full node builds.
Makes channel_send_jitter_max_secs real (it was a config stub the send path
never read) and corrects doc drift the audit flagged.
Send-origin jitter — channel.rs
When channel_send_jitter_max_secs > 0, the WHOLE pool injection is delayed by a
random 0..=max seconds and fully detached from the send handler, so a
directly-connected clearnet peer can't bind "user pressed send" to the node's
pool write. The handler returns immediately; the sender's own copy is already in
the private index, so the UI is unaffected. Default 0 (off) — Tor-Always remains
the primary send-origin mitigation; send jitter is recommended on non-Tor
deployments. append_records_jittered now composes send jitter (whole-send delay)
with the existing burst jitter (spacing >SLOTS records); post_records factors the
shared spacing loop.
Config
Register channel_burst_jitter_max_secs in the config schema (it was read with a
default but not settable via the UI). channel_send_jitter_max_secs was already
registered; it is now actually consumed.
Docs
- channels.md: stale pad_buckets [512,2048,8192] -> live [8192,32768,131072] +
max_record_bytes 200000; the "BLAKE3 KDFs" deviation is stale (switched to
HKDF-SHA256; blake3 survives only as the multi-slot body-binding hash);
residuals note the on-chain-public directory and the now-real send jitter;
describe both jitter knobs in Config.
- channel-count-privacy.md: the send-origin bullet reflects the implemented
optional send jitter instead of describing it as pre-existing.
Finality note: investigation of x/xid (EpixChain) confirmed the digest attestation
is auto-generated in BeginBlock with Signature="auto:consensus" (no per-validator
signature), so the client's digest->finalized step is an RPC-asserted boolean.
name->digest IS client-Merkle-verified. Trustless finality needs a CometBFT light
client + validator-set anchor — surfaced separately for a scope decision; not in
this commit.
Affected crates build/test green; clippy clean on touched files.
…ight client)
First unit of the combined xID-finality push. Adds the pure, fully-tested
verification core that lets the client prove a state digest was signed by >2/3 of
a PINNED validator set — replacing the RPC-asserted `finalized` boolean — without a
CometBFT light client (no ics23/tendermint-rs), so it stays cheap on mobile.
crates/epix-chain/src/finality.rs (new)
- attest_sign_bytes(): canonical, domain-separated (EPIX-XID-ATTEST1),
fixed-width + length-prefixed bytes over (chain_id, height, block_time, digest)
— must match the chain signer byte-for-byte; a chain_id boundary-shift pair is
proven non-colliding.
- verify_finality(): enforces every rule the adversarial review required —
verify against the PINNED pubkey (never the RPC-supplied one), dedup by valcons,
STRICT supermajority sum*3 > total*2 AND a >=80% power safety-buffer, freshness
|now-block_time|<=skew, monotonic height, weak-subjectivity pin-expiry
(fail-closed), and height >= pin height.
- 16 unit vectors incl. the negatives: rpc-pubkey!=pinned not credited, duplicate
valcons counts once, exactly-2/3 rejected, buffer rejects a strict-2/3 pass,
tampered sig, wrong digest, stale/future time, height rollback, expired pin.
- ed25519-dalek 3 (already in the workspace via epix-tor; zero net new crates).
Decisions (this push): no equivocation slashing — honest claim is "signed by >2/3
of a pinned set", bounded by WS pin-expiry + the power buffer; combined push on
this branch, landed as one unit.
Remaining (tracked in docs/xid-lightclient-finality.md): the leaf-binding forgery
fix + record-trust path, client config wiring, and the chain-side x/xid
vote-extension attestation. Design + review: docs/xid-lightclient-finality.md,
workflow w7fh379aw.
Closes the first of the two live forgery holes the review found: today's resolver verifies the Merkle path against the RPC-supplied leaf_hash but never checks the returned `domain` data IS that leaf, so a hostile RPC serves a genuine inclusion proof next to arbitrary data and it passes. crates/epix-chain/src/leaf.rs (new) verify_and_parse_leaf(preimage, leaf_hash, name, tld): (1) sha256(preimage) == leaf_hash (binds data -> leaf); (2) parse the snapshot FROM the preimage bytes (order-agnostic JSON — we hash the received bytes, never re-serialize, so Go's exact encoding/json output doesn't matter); (3) require entry name/tld == queried. Chains onto the caller's inclusion-proof + finalized-digest checks: validators -> digest -> leaf -> the data actually used. The chain will expose the exact `leaf_preimage` (Go json.Marshal(domainDigestEntry) from x/xid computeLeafHash) in resolve_with_proof; the client hashes+parses it. 5 vectors incl. the swapped-data-behind-a-real-proof attack and wrong-name reject. ChainError::LeafBindingFailed added. Wiring into resolver.rs (behind the finality gate) lands with the config plumbing next.
…ding)
Completes the CLIENT half of the finality upgrade. Behind `xid_verify_finality`
(default OFF = legacy RPC-boolean, unchanged), the resolver now:
- LEAF-BINDING: uses the chain's canonical `leaf_preimage` (hex) — hashes it to
the proof leaf, binds the name, and parses the snapshot from the proven bytes;
required when verification is on (an RPC can't downgrade by omitting it).
- FINALITY: verify_finality_gated() fetches the attestation bundle, parses it
(parse_bundle, Cosmos string-encoded uint64s + hex sigs), and verifies signed
PINNED voting power over the digest — no RPC boolean. Memoized per digest;
fails closed if no pin is installed. On success advances the monotonic floor.
lib.rs: process-global config the node sets at boot — PINNED_VALIDATORS,
xid_verify_finality gate, XID_MAX_HEIGHT (persist across restarts), skew /
ws_period / min_power_bps policy, finality_params()/now_unix() helpers.
finality.rs: parse_bundle() + num_u64/num_i64 (proto-JSON encodes uint64 as
strings). ChainError::{LeafBindingFailed, FinalityUnverified}.
28 unit vectors green; downstream (epix-ui) builds. Node wiring (call the setters
from config) + the chain side (x/xid signed attestations + leaf_preimage) land
next, then a devnet proves the two halves agree end-to-end.
…bundle field name - attest_sign_bytes_kat: asserts the exact same vector as EpixChain's x/xid/types/attestation_signbytes_test.go — locks the cross-repo sign-bytes contract the finality verifier depends on. - parse_bundle reads `validator_cons_addr` (matches the proto field the chain emits).
…fied end-to-end Two frozen vectors captured from a live single-validator EpixChain devnet (vote extensions enabled, attest key registered), proving the Go chain and the Rust client agree end-to-end: - devnet_finality_kat: the client's verify_finality ACCEPTS a real bundle whose ed25519 signature was produced by the devnet validator's ExtendVote and persisted by PreBlocker (>2/3 power, finalized) — and REJECTS a wrong pinned pubkey. - devnet_leaf_kat: verify_and_parse_leaf hashes the exact Go leaf_preimage (json.Marshal(domainDigestEntry) for alice.epix) to the proven leaf, parses the snapshot, and binds the name — rejecting a wrong name and tampered bytes. Full epix-chain suite: 29 unit vectors + these 2 devnet KATs, all green.
Implementation status: all client + chain + KAT + devnet items done. Rollout note: xid_verify_finality ships OFF until VoteExtensionsEnableHeight is set, validators register attest keys, and a pinned set ships to clients.
…st key)
Reworks the finality verifier to match the chain's consensus-key attestation:
validators sign nothing extra — CometBFT signs each vote-extension payload with the
consensus key — so the client pins the CONSENSUS validator set (already on-chain)
and verifies CometBFT's ExtensionSignature.
finality.rs: verify_finality reconstructs
MarshalDelimited(CanonicalVoteExtension{extension, height, round, chain_id})
byte-for-byte (hand-rolled protobuf; proto3 so round==0 is omitted — the subtle bit,
KAT'd) and verifies it against the pinned consensus pubkey; parses (height,
block_time, digest) from the raw extension and binds digest==proof_root; keeps the
dedup / strict >2/3 + 80% buffer / freshness / monotonic-height / WS-expiry rules.
parse_bundle reads valcons + signature(hex) + vote_extension(base64) + round; drops
auto:consensus rows. AttestationEntry/FinalityBundle carry the extension + round.
Adds base64 (proto bytes via the gateway). Removed attest_sign_bytes.
16 unit vectors (incl. round-0 omission, wrong-round, wrong-pinned-key) + 2 REAL
devnet KATs — the client verifies the live validator's consensus-key signature and
the exact leaf preimage. Full epix-chain suite green.
…pgrade) The shipped finality design uses CometBFT's own vote-extension signature (consensus key) — no attest key, no registration, slashable — superseding the separate-key approach. Client pins the consensus validator set and reproduces CanonicalVoteExtension.
Exposes refine_device_bundles and adds crates/epix-plugins/tests/devnet_channel_revocation.rs. The test drives the exact decisions the channel send/reply path makes to pick recipient devices: the Merkle+finality-verified active linked-identity set (XidResolver::resolve plus the `active && revoked_at == 0` filter) and refine_device_bundles (the per-device fan-out filter, re-run fresh on every send incl. replies). Production resolves against DEFAULT_RPC_URL; the test points its own resolver instance at a devnet via EPIX_XID_RPC_URL (a test input the library never reads). Verified against a live devnet: with two devices linked a message fans out to both; after one is unlinked on-chain, a reply fans out to the survivor only — the revoked device is dropped.
The xID resolver targets a single chain REST base (DEFAULT_RPC_URL) and appends the /xid/v1/... paths itself — there is no separate xID endpoint. Allow that one base to be overridden at launch via EPIX_XID_RPC_URL, falling back to DEFAULT_RPC_URL when unset, so a node can run its xID stack against a devnet or alternate chain (EPIX_XID_RPC_URL=http://127.0.0.1:1317) without editing the constant and rebuilding. Env-only; no config wiring or setter.
Adds the crypto foundation for RLN-based anonymous rate limiting, the reputation-slash spam defense from the ECX design: a pool record proves its sender is a member in good standing and within its per-epoch allowance without revealing WHICH member, and a double-signal within an epoch reveals the offender's secret so the network can evict the identity. - vendor/rln: the audited zerokit rln 3.0.0, minimally patched to gate its sled-backed tree (and C FFI) behind an off-by-default feature. Upstream rln links native zstd via sled, which conflicts with arti's async-compression zstd (only one crate may set links = "zstd"). We use only the stateless verifier + our own xID-anchored membership tree, so sled is dead weight. The circuit/protocol/nullifier code is untouched; see vendor/rln/EPIXNET_PATCH.md. Pulled in via [patch.crates-io], mirroring the saturating-time fork pattern. - crates/epix-rln: the EpixNet-shaped seam over zerokit -- rate_commitment / external_nullifier / message_signal helpers and re-exports. - tests/round_trip.rs: a real end-to-end proof -- register identity, prove membership + rate, verify against the root, double-signal, and recover the offender's secret. Confirms the patched (sled-gated) build works in the Tor-coexisting workspace. WIP (follow-up commits): the xID-anchored membership tree + reputation/ban, per-epoch nullifier tracking, and the verify_pool_record admission hook.
Builds the RLN engine on top of the zerokit foundation -- the piece the pool admission hook will call: - RlnIdentity: a member identity derived deterministically from a seed, so it can be anchored to a (paid, permanent) xID and always reconstructed. - Membership: the xID-anchored tree (sparse OptimalMerkleTree). Leaves are rate commitments; insert enrolls a member, remove is a ban (which changes the root so the banned member's proofs stop verifying). - Rln: the stateless prover/verifier. prove() returns the serialized proof blob a record carries; verify() checks it against the accepted membership roots and binds it to the epoch (external nullifier), the record (signal), and a real membership root, then returns the nullifier + Shamir share. - NullifierLog: per-epoch nullifier tracking. A repeated nullifier with the same share is a replay; with a different share it is a double-signal, and compute_id_secret recovers the offender's identity secret. - commitment_of_secret: maps a recovered secret back to its commitment so the node knows which leaf to evict. tests/engine.rs drives the whole lifecycle and passes: admit, replay, wrong-epoch / wrong-root / signal-tamper rejection, the double-signal reveal (recovering and identifying the offender), and ban (evicted member no longer verifies against the new root). Deterministic-identity and honest-across-epochs cases included. Next: the verify_pool_record admission hook + send-path wiring in the node/pool.
Wires RLN into the pool: records can now carry a proof, and the node has an
admission gate that verifies it and enforces the rate limit.
epix-content (the ECX record format, kept light — no arkworks here):
- PoolRule gains rln_required (parsed from the pool descriptor; default false =
PoW-only).
- The pool record may carry an optional `rln` field, permitted ONLY where the
rule requires it (otherwise it is a rejected covert channel). Because
record_signed_data strips only `sign`, the rln field is covered by PoW and the
record signature like every other field.
- verify_pool_record checks the field's presence and shape (base64, non-empty,
<= 1024 bytes) when rln_required. The zk proof itself is NOT verified here —
epix-content stays free of the proving stack; the node verifies it where the
membership root and epix-rln are available. New PoolError variants
MissingRlnProof / BadRlnProof. Six structural tests.
epix-rln (the node-callable gate):
- PoolGate owns the engine, the membership tree, and the nullifier log for one
pool. enroll() adds members; prove() is the send side (attach a proof to a
record); admit() is the ingest side and returns Admit / Duplicate /
Reject(err) / Evicted{offender_commitment}. On a double-signal it recovers the
secret, traces it to the leaf, and removes it (a ban that changes the root).
- Shared fr_key helper (nullifier + commitment map keys).
- tests/pool_gate.rs: non-member rejected, honest record admitted, re-broadcast
deduped, second message in one epoch evicts the sender.
Remaining (Phase 3): call PoolGate from the node's pool ingest + push paths,
thread the membership root window, and drive enroll/ban from the xID-anchored
membership set.
Adds send_multi_with_rln: for a pool whose rule sets rln_required, the caller supplies an rln_prover(ct, epoch) closure (holding the member identity + membership, i.e. epix-rln's PoolGate::prove) and the proof is attached to the record before PoW/sign, so both cover it. send_multi keeps its exact signature (delegates with no prover), so all existing callers are unchanged; the RLN path is opt-in and only engages where the pool rule requires it.
Adds the seam where the node verifies a pool record's RLN proof, without pulling the arkworks proving stack into epix-ui: - PoolAdmission trait (epix-ui): a node-installed hook that verifies one record's proof against the membership root and tracks the nullifier. The concrete impl lives in a crate that depends on epix-rln and is installed on AppState via set_pool_admission. - apply_inbound_pool_update: for an rln_required pool, inbound records are run through filter_rln_admitted BEFORE merge — any whose proof does not verify are dropped, so an unverified record is never merged into a shard we store/serve. Fail-closed: if no hook is installed for an rln_required pool, nothing is admitted. The whole RLN skeleton is now wired end to end (record format, send-path proof attachment, ingest verification). Remaining: the concrete epix-rln-backed PoolAdmission impl, which needs the membership-source design (how xID-anchored members reach the gate) + ban propagation.
Prepares the engine for the owner-signed membership model (the chosen v1):
- Membership::from_commitments builds the tree from an ordered roster of member
identity commitments (the owner-signed list), so every node with the same
signed roster derives the same root.
- commitment_to_hex / commitment_from_hex encode a commitment for the roster.
- PoolGate is now detection-only: on a double-signal it reports RateExceeded
{ offender_commitment } and drops the record (the rate limit is enforced by
the nullifier log), but does NOT locally remove the leaf. Under a static
owner-signed root, local removal would fork a node's root from the owner's;
structural removal is the owner regenerating its roster. evict_member() is
kept for owner-driven (or future self-validating) removal.
tests: detection-only + explicit eviction, and that an owner roster's root
matches enrolling the same members one by one (with a hex round-trip).
…side)
The node now gates inbound records of an rln_required pool against the owner's
signed member roster:
- PoolGate::from_roster builds a gate from a list of member identity commitments
(no secrets needed — the owner vouches for the list), the form a node reads
from content.
- epix-plugins/rln.rs: RlnAdmission implements epix-ui's PoolAdmission. refresh()
reads pool.<name>.{rln_required, rln_limit, rln_roster} from a xite's
content.json, builds a per-pool gate (external-nullifier domain derived from
the pool address), and admit_record() gates each inbound record — admit only
if the proof verifies against the roster root and the sender is within its
per-epoch allowance; over-limit and invalid records are dropped. Fail-closed
if no roster is loaded.
- ChannelPlugin::start installs it (set_pool_admission) and loads the roster.
Inert unless a pool sets rln_required, so it is always safe to wire.
This pulls the arkworks proving stack into the node binary (inherent — the node
must verify proofs). Remaining: the send side (attach a proof to outbound
records) + a two-node runtime test.
Completes the node hookup: for an rln_required pool, the channel send path now
proves membership on every record it sends.
- PoolGate::prove_as looks up a member's own index in the roster (the send path
knows the member, not its index) and proves.
- RlnAdmission::prove_for exposes that over the shared, capability-stored gate.
- ChannelPlugin::start stashes the admission as a capability (RLN_CAP) so the
send path reaches the same gates the ingest path verifies against.
- channelSend: when rule.rln_required, derive this node's RLN identity from a
stable seed (derive_consumer_seed("rln", auth)) and call
send_multi_with_rln with a prover closure; a non-member or missing roster
correctly fails the send. PoW-only pools are unchanged.
- Re-export send_multi_with_rln from epix-envelope.
Full node binary builds. The RLN stack is now wired end to end (record format,
send proof, ingest verification, owner-signed roster). A member registers by
giving the pool owner its commitment out of band; the owner adds it to the
signed roster. Remaining before any pool flips rln_required: content.json roster
tooling, a two-node runtime test, and the crypto-composition review.
Exercises the node wiring end to end, the layer unit tests could not reach: a record is built through the real send seam (send_multi_with_rln) with an RLN proof, appended to a pool shard, and carried to a SECOND node whose RlnAdmission loaded the owner-signed roster from served content and gates it via apply_inbound_pool_update -> filter_rln_admitted -> PoolGate. Proves the three network properties: a valid member's record is admitted, a non-member's is rejected, and a double-signal (second message in one epoch) is dropped.
…der rail Turns RLN from "N messages per epoch" into "N units per epoch," where a record's cost is its size bucket, and adds the honest-client safety rail plus the order-independent double-signal detection discussed in the design. Engine (epix-rln), now on the multi-message-id circuit (max_out_4): - prove(first_unit, weight): a record spends `weight` distinct allowance units (one per size-bucket unit) in a single proof, as `weight` active slots. - verify(expected_units): the VERIFIER, from the record's ct alone, requires the proof to spend exactly that many DISTINCT units. A prover cannot under-declare a big record's cost (WrongUnits) and cannot repeat a unit within a proof. - bucket_weight(ct_len, smallest_bucket): the deterministic cost function both sides compute, so sender and every verifier agree. - The per-epoch cap is enforced by the circuit itself (message_id < limit), so a unit index at/over the limit is simply unprovable. Convergent slashing (NullifierLog): a proof carries one nullifier per unit; detection is a deterministic function of the two colliding shares, so nodes that reconcile partitioned shards in any order reach the SAME offender. Reusing a spent unit across records double-signals and reveals the secret. Sender rail (epix-plugins RlnAdmission): a persistent per-(pool,epoch) usage cursor spends a fresh unit range each send and REFUSES once the allowance is exhausted. An honest client therefore never reuses a unit and can never slash itself; only a modified client that bypasses the rail can, and the admission side catches it. Admission computes a record's cost from its size bucket and verifies against it. Tests: engine (weighting, reuse-reveals, under-pay rejected, cap unprovable), pool_gate (weighted admit/reject/reveal), and two-node runtime (weighted cross-node admission + the rail refusing past the allowance).
Adds optional, per-xite pool retention so each xite owner picks a policy that fits its function (ephemeral chat, longer-lived mail, archival forum), set in content.json. - PoolRule gains retention_weeks (descriptor pool.<name>.retention_weeks); absent or <=0 keeps everything forever (the prior behaviour). retention_keep_from computes the oldest week to keep from the current week. - AppState::prune_expired_pool_shards deletes shards for weeks past the window; it runs on the periodic pool sweep. Received messages live in each recipient's private index, so pruning the SHARED pool never loses delivered mail — it only reclaims disk. This is the total-disk backstop that pairs with RLN's per-user rate: disk ~= users x units-per-epoch x retention. Tests: descriptor parse + keep-from math (epix-content), and runtime pruning (expired shard deleted, recent kept, retention-off keeps an ancient shard).
The node exposes the data the site renders as a progress bar + reset countdown: whether the pool requires RLN, the per-epoch unit allowance, units spent this epoch (from the send rail's usage ledger), seconds until the allowance resets, whether this node is an enrolled member, and the pool's retention window. A PoW-only pool reports just retention. - PoolGate::is_member, RlnAdmission::usage/is_member, UsageLedger::spent (read-only accessors over the existing rail state). - channelRlnStatus WS command wraps them with the epoch/reset math. This is the humane layer over the rail: the client shows how much allowance is left and when it resets, so an honest user sees the limit coming and is never surprised by the send path's refusal (and never at risk of a slash).
Makes 'require xID finality' a real, safe switch rather than a bare flag. - install_finality_pin / parse_finality_pin (epix-chain): load a pinned validator set from JSON and turn ON client-side finality verification, after which xID resolution requires a >2/3 validator-signed digest and fails closed. - node boot reads xid_pin.json from the data root: present -> pin installed and finality required; absent -> legacy RPC-trusted resolution with a warning. So requiring finality == shipping the pin. - docs/xid-finality-pin.md: why the pin can only be captured AFTER the v0.7.2 mainnet upgrade (there are no attestations to pin before then), the capture command, and the completeness check (pin the full bonded power). The pin itself is a post-upgrade artifact, which is exactly why this branch waits for the chain upgrade before merging. Tests cover the parser.
- Bump h2 0.4.15 -> 0.4.17 (RUSTSEC-2026-0258, unbounded empty DATA frames). - Ignore two arkworks-pinned build-time advisories introduced by the vendored rln stack, unfixable without patching arkworks 0.5: RUSTSEC-2025-0055 (tracing-subscriber 0.2 ANSI log poisoning, CVSS 2.3, ark-relations diagnostics only) and RUSTSEC-2024-0388 (derivative unmaintained, build-time proc-macro). Added to cargo-audit, deny.toml and osv-scanner.toml with rationale. - Harden the "Install Linux build deps" apt step in ci.yml/codeql.yml with a per-command timeout + retry, and add job-level timeout-minutes, so a stalled Azure apt mirror fails in minutes instead of hanging the full 6h job (observed on this branch).
- verify_pool_record: extract the field-set, RLN-proof and epoch-shard checks into helpers (cognitive complexity 22 -> under 15). - create_session: group its parameters into a NewSession struct (S107). - Move three test modules after all other items (S9045). - Drop 10 redundant `as i64` casts, 8 closure->method-ref rewrites and a redundant 'static (S4325 / S1612 / S8863). - Add the retention_weeks/rln_required fields to the epix-channel indexer test's PoolRule literal - a build break the hung CI never reached. Left untouched: the three pre-existing S107 EDX trait methods (push_update, apply_inbound_update, finish_inbound_update) - refactoring their signatures ripples across the trait and the replication hot path, out of scope here. The two S1488 "return the expression directly" hints are false positives (the intermediate binding drops a lock/read guard before its owner drops), so they stay as explicit bindings with a comment.
- Group the parameters of the three flagged EDX methods into structs so each
call stays under the argument-count limit (S107):
* EdxFetcher::push_update -> PushJob (address/inner_path/signed/modified/
diffs/sender_peers), keeping peer and progressed separate. Updated the
trait, the runtime impl, all mock impls and both call sites; impl bodies
destructure PushJob so their logic is unchanged.
* AppState::apply_inbound_update -> InboundSource (sender/diffs/sender_peers).
* AppState::finish_inbound_update -> FinishInbound (all fields).
- Rework the two S1488 "return the expression directly" sites to bind the
lock/read guard instead of the result. This satisfies the rule (the tail is
now an expression, not a bare temporary) while keeping the guard's drop
ordered before its owner - returning the expression directly does not
borrow-check.
Grouping finish_inbound_update's parameters into a struct (previous commit) marked the function as changed, surfacing its pre-existing cognitive complexity of 45 (S3776). Extract its steps - diff application, pushed-file fetch, child ingest, per-file patch, and peer screening - into helpers so the orchestrator and every helper stay well under the 15 limit. Behavior is unchanged; the full epix-ui and epix-runtime suites pass.
…LN, finality) Addresses the critical, high, and medium findings from the code review of the metadata-private channels work. Critical - xID first-contact spoofing: key published key-bundles by the cert-gated user directory they live in, not the self-declared "xid" field, so an attacker can no longer file a bundle carrying their own ik under a victim's name and defeat the M1 anti-spoof check. Drop a bundle whose declared xid disagrees. High - Per-device sessions: key the pairwise session by the peer DEVICE identity key (new session.peer_ik column + v3 migration), so a recipient's multiple devices get distinct ratchets instead of colliding on one shared session. Fixes the NULL peer_xid duplicate-session case too. - Pool sub-index binding: merge_pool now requires shard_sub(tag,fanout)==sub, so valid records from other subs can't be piled into one shard to evict genuine mail. Inbound records are sub-filtered before any nullifier-mutating admission. - RLN roster convergence: admit against the current root plus a time-bounded grace window of recently-superseded roots, so roster churn no longer drops valid proofs permanently while a removed member is still cut off promptly. - Finality parser hardening: checked/bounded uvarint skip (no infinite loop or overflow on a hostile RPC) and saturating staleness check (no i64::MIN bypass). - RLN ingest: run the Groth16 verify on spawn_blocking (off the reactor) and only after full verify_pool_record, closing an ingest DoS + nullifier-poison vector. - Concurrency: per-shard locks serialize the pool read-merge-write, and the indexer shares the send lock so inbound ratchet advances aren't lost. Medium - Anti-spoof fails closed when ik_a is absent; established-message sender is the verified session peer, never the body sender_xid. - RLN allowance rolled back on failed prove; usage ledger and nullifier log pruned (nullifier prune clamped to the local clock); mutex poison recovered. - dec_blob no longer returns ciphertext as plaintext note: covered by peer_ik path; finality staleness overflow; per-attestation round; digest memo tagged by trust level so the legacy RPC path can't satisfy the cryptographic gate. - Resolver returns NotFound (not Malformed) for unregistered names; a corrupt xid_pin.json now fails startup closed instead of silently downgrading. - max_shard_bytes clamped to the 8 MiB serve cap; canonical shard-path parsing and a bounded, self-cleaning shard-lock map close a remote memory DoS. - refresh_pool_rules wired to content.json changes; duplicate/inert config knobs removed; per-identity legacy-import dedup; group-engine attributes to the authenticated chain owner, not the payload's self-declared sender.
Replaces nine identical `.unwrap_or_else(|e| e.into_inner())` closures in the RLN admission gate with the equivalent method reference `PoisonError::into_inner`, resolving SonarCloud rust:S1612.
…ing read Two low-severity follow-ups from the review of 46f917f: - xid_identity cache: negative answers ("address not linked" / "not in the verified domain") were stored with no finality binding, so under verified mode xid_cache_binding_current(None, true) returned false and the entry was stored but never served - re-issuing the reverse_identity RPC on every call instead of deduping for NEGATIVE_TTL. Gate only POSITIVE answers on the checkpoint binding; serve negatives on their TTL alone (a stale negative can never forge a positive, and NEGATIVE_TTL already bounds link latency). - node_resolve: the 16 KiB response cap was checked only after bytes() had already buffered the whole body, so a stale/hostile process on the loopback UI port could stream an unbounded body into memory. Reject an oversized Content-Length up front and accumulate chunk-by-chunk, bailing the instant the running total exceeds the cap.
…nnels Resolution policy: main's staged-manifest publish pipeline wins structurally (ManifestTransaction/InboundFinish/RootFinalize, UpdatePayload/PublishOptions/PublishResult, 7-arg EdxFetcher::push_update with EdxPushProgress, AbortOnDropJoin push cleanup, verified-authority serving). The channels branch's cosmetic regroupings of the old pipeline (PushJob, InboundSource, FinishInbound, progressed AtomicBool) were dropped in favor of main's rewrite. Every channels feature was re-seated in main's flow: - pool-rule transactions + admission-gate refresh moved into commit_root_update_owned (acquired before the staged promotion, refreshed after alias adoption, before register_new_manifest_objects) - pool shard serving/inbound routing re-inserted into the EDX SignedProvider get_signed/apply_update ahead of the verified-signed and manifest-apply gates (with the propagation hint preserved) - publish engine split into publish_body_to shared by main's publish_to and the pool outbox's publish_bytes_to, keeping the all-reachable-peers-refused error and PublishRun.refusals - xid_signers finality binding + active/revoked filtering threaded through main's resolve_checked_with (fetch now returns signers plus the finality binding) - verify.rs keeps main's merge-exclusivity-then-case-collision ordering with the pool-directory exclusion check between them - channel tests adapted to main's fixtures (signed roots, manifest transactions, Result-returning list_files)
The merge re-seated the channels branch's pool-rule/RLN-roster refresh inside ingest_file's event tail, but main's ingest pipeline gates on db-authority path sets that only know DATA files - so a root content.json change never reached the refresh (or bump_modified / file_done), and a failed db rebuild swallowed the tail entirely. Governing files (content.json manifests, dbschema.json) now pass the path filters, and the event tail runs even when rebuild_xite_db fails: the refresh reads the already-adopted root, independent of the db. Restores root_content_change_refreshes_rln_roster_without_restart; epix-ui, epix-runtime and epix-plugins suites all green (28 suites).
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What
Makes Epix messaging metadata-private: no observer, node operator, or future pool-holder can learn who messages whom, when, or that a user sent anything. Replaces the metadata-public Epix Mail model (which exposed sender, full recipient list, conv ids, timestamps, and lengths in the clear) with an anonymous sealed-envelope pool + node-side trial-decryption into a private local index. The machinery is generic so a future encrypted forum can reuse it.
How
New crates
epix-content/pool.rs— theepix-pool-1anonymous record class: PoW-gated, size-padded to fixed buckets, day-bucketed epochs, a fresh throwaway ephemeral author per record; union-merge CRDT + shard math.epix-envelope— genericEnginetrait +EnvelopeStoretrait + trial-decrypt indexer (app-neutral). Ships a deterministic FakeEngine for pipeline tests.epix-pairwise-engine— the real crypto: X3DH (no OPK) + header-encrypted Double Ratchet, forward-secure detection-tag chains, Elligator2 first-contact tags, HKDF-SHA256 / HMAC-SHA256 KDFs, ChaCha20-Poly1305 AEAD.epix-channel— the privatechannels.dbindex (implementsEnvelopeStore) + non-destructive legacy import.Node integration
epix-ui— pool lifecycle (append_pool_record/apply_inbound_pool_update), a generic capability registry,LocalFeedSource, EDX serve/apply-inbound seams;epix-dbgainsPRAGMA busy_timeout.epix-plugins/channel— the Channel plugin +channel*WebSocket API. PoW runs off the async runtime; the send path is serialized per identity.Crypto review (this is the important part)
The engine ships with a written spec (
docs/channel-crypto-spec.md), frozen known-answer vectors, and two-state-machine interop/tamper tests. An adversarial review pass (four lenses, each finding independently verified against the code) surfaced and this PR fixes:sender_xidverbatim, so anyone could post a message that displayed as "from Alice." NowOpened.ik_ais surfaced andprocess_recordrequiressender_ik(published_bundle) == ik_abefore committing a first contact (mismatch → dropped; unverifiable → deferred). Regression test included.(key, nonce)reuse. Fixed by serializing the seal→persist critical section.gen-rekey exists; SPK rotation gives no FS vs seed compromise; first-contact messages have no first-message FS; skipped-key stores areMAX_SKIP-capped).The core design was confirmed sound (DH separation, HKDF salt, tag-chain FS + replay resistance, honest-path nonce uniqueness).
Testing
New + existing tests pass across
epix-content,epix-envelope,epix-pairwise-engine(unit + frozen vectors + interop/tamper),epix-channel(incl. an anti-spoof regression), andepix-plugins(incl. a live node-glue integration test). Full node builds clean. Verified live end-to-end over the real WebSocket API against a runningepix-server.Not in scope / follow-ups
MAX_SKIPboundary + a distinct "stalled" outcome, replay/isolation edge tests).channels.db(schema reserves theenccolumn); MLS-backed group engine.