Skip to content

The converger evicts on raw unverified holding claims — a signed lie makes honest nodes delete real copies; HoldingClaimVerification exists and is never called #582

Description

@emooreatx

The converger's eviction decision counts raw, unverified holding claims. A signed lie inflates the holder count and makes honest nodes delete real copies. The substrate primitive that prevents this exists, is tested, and is never called by the runtime.

The gap

swarm/runtime.rs:1200 feeds eviction from:

let observed_count = snapshot.distinct_holders(&content_id);

and distinct_holders is map.len():

fn distinct_holders(&self, content_id: &str) -> u32 {
    self.inner.get(content_id).map_or(0, |m| u32::try_from(m.len()).unwrap_or(u32::MAX))
}

That count goes straight into should_eject_with_diversity(observed_count, …), which evicts once it exceeds target_holders + 15% (30 + 4 = 34 at defaults).

Meanwhile holonomic/swarm_rarity.rs already defines the gate:

pub enum HoldingClaimVerification {
    PossessionVerified,  // sig verified AND a possession challenge answered
    SignatureOnly,       // sig valid, possession unchallenged — HALF weight,
                         // "to bound the lying-holder force-evict surface"
    Unverified,          // does NOT count
}

with claim_weight(), and compute_consent_aware_rarity(content_id, symbol_id, claims_with_verification, consent) taking &[(FountainHoldingClaim, HoldingClaimVerification)]. The runtime calls the unweighted compute_rarity_score(&content_id, *sym, &all_claims) instead, and never constructs the paired form at all.

So the discount designed to bound this exact attack is inert.

The attack

Signing a FountainHoldingClaim is cheap and requires no bytes. A peer (or a handful of Sybils under one operator) claims to hold content_id X. Honest nodes' observed_count crosses target_holders + grace, their convergers fire EjectionVerdict::Eject, and they delete real copies of content the liar never had.

Cost to attacker: one signature per claim. Cost to federation: real data, and the loss compounds — each eviction lowers the true holder count while the inflated count stays high.

Note the direction. This is not a storage-flooding attack (that's the store gate, #581). It is an availability attack via forced eviction, and it runs through the one code path that deletes.

The rule this makes explicit — fail toward retention

The two error directions are not symmetric:

  • Over-count → data loss. Unrecoverable if the last honest holders evict.
  • Under-count → over-replication. Wasted disk, self-correcting, recoverable.

So: a claim that RAISES the holder count must clear a higher bar than one that lowers it. Eviction is the privileged operation, not admission.

Fix

  1. Feed the weighted count into eviction. Track HoldingClaimVerification alongside each observed claim and pass the paired form to compute_consent_aware_rarity. Unverified contributes 0; SignatureOnly contributes half. That alone doubles the number of Sybil identities needed.
  2. Spot-check before deleting. When the weighted count crosses the eviction threshold, challenge a sample of claimants for a fresh symbol hash before dropping a local copy. Failed or unanswered challenges demote the claim to Unverified. This is the PossessionVerified arm the enum already anticipates and nothing yet produces.
  3. Never evict the last locally-verified copy on unverified evidence. The runtime already avoids evicting the last local copy of a rare symbol via local_symbol_rarity; extend that so unverified remote claims cannot be the sole justification for a delete.

register_observed_claim is the natural place for (1) — it currently accepts a bare FountainHoldingClaim and should record the verification state it was admitted under.

Why this and #581 are different issues

#581 governs what we accept into the store. This governs what we delete from it. They share the fountain runtime and nothing else: the store gate refuses on provenance/scope/consent before bytes land; this one hardens the count that authorises deletion. Fixing either leaves the other open.

Related: the sibling stub

The same converger hardcodes let consent = ConsentState::Active; (runtime.rs:1212), so ConsentState::Revoked — which should_eject_above_target honours above all rarity and count logic — is never signalled in production either. Both are the same shape: a well-built, well-tested substrate primitive with no production caller. Worth fixing together, since both feed the same decision.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions