Skip to content

Metadata-private channels: anonymous pool + X3DH/Double-Ratchet engine - #412

Open
MudDev wants to merge 50 commits into
mainfrom
feat/metadata-private-channels
Open

Metadata-private channels: anonymous pool + X3DH/Double-Ratchet engine#412
MudDev wants to merge 50 commits into
mainfrom
feat/metadata-private-channels

Conversation

@MudDev

@MudDev MudDev commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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 — the epix-pool-1 anonymous 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-envelopegeneric Engine trait + EnvelopeStore trait + 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 private channels.db index (implements EnvelopeStore) + 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-db gains PRAGMA 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:

  • CRITICAL — first-contact sender spoofing. The engine discarded the transcript-bound identity key and the node trusted the attacker-controlled sender_xid verbatim, so anyone could post a message that displayed as "from Alice." Now Opened.ik_a is surfaced and process_record requires sender_ik(published_bundle) == ik_a before committing a first contact (mismatch → dropped; unverifiable → deferred). Regression test included.
  • HIGH — AEAD nonce-reuse race. Two concurrent sends on one leg could seal from identical ratchet state → ChaCha20-Poly1305 (key, nonce) reuse. Fixed by serializing the seal→persist critical section.
  • Spec accuracy — corrected false forward-secrecy / GC claims (no gen-rekey exists; SPK rotation gives no FS vs seed compromise; first-contact messages have no first-message FS; skipped-key stores are MAX_SKIP-capped).
  • Zeroization — identity/session secrets are now wiped on drop.

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), and epix-plugins (incl. a live node-glue integration test). Full node builds clean. Verified live end-to-end over the real WebSocket API against a running epix-server.

Not in scope / follow-ups

  • External cryptographic review is still required before relying on confidentiality — this is hand-rolled X3DH + Double Ratchet. The spec's §10 checklist enumerates the open questions and accepted deviations to hand a reviewer.
  • Coverage backlog (byte-exact record-level KAT, MAX_SKIP boundary + a distinct "stalled" outcome, replay/isolation edge tests).
  • At-rest encryption of channels.db (schema reserves the enc column); MLS-backed group engine.
  • The coordinated hard cutover (per-user legacy import + republish) is a separate operational step.

MudDev added 3 commits August 13, 2026 17:38
…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.
Comment thread crates/epix-envelope/src/indexer.rs Fixed
Comment thread crates/epix-pairwise-engine/src/crypto.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/crypto.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/crypto.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/crypto.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/ratchet.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/ratchet.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/ratchet.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/ratchet.rs Dismissed
Comment thread crates/epix-pairwise-engine/src/ratchet.rs Dismissed
MudDev added 22 commits August 13, 2026 18:07
…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.
MudDev added 3 commits August 16, 2026 11:52
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.
MudDev added 18 commits August 17, 2026 14:58
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.
Comment thread crates/epix-group-engine/src/lib.rs Dismissed
MudDev added 4 commits August 20, 2026 18:55
…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).
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants