Просмотр исходного кода

event_graph: Tombstone slashed RLN identities

x 1 месяц назад
Родитель
Сommit
702bf72973
3 измененных файлов с 228 добавлено и 46 удалено
  1. 83 20
      src/event_graph/mod.rs
  2. 49 12
      src/event_graph/rln.rs
  3. 96 14
      src/event_graph/tests_rln.rs

+ 83 - 20
src/event_graph/mod.rs

@@ -815,19 +815,29 @@ impl EventGraph {
         });
 
         let mut expected_commitments = BTreeSet::new();
+        let mut expected_slashed = BTreeSet::new();
         for (_, node) in &events {
             match node {
                 rln::RLNNode::Registration(commitment) => {
-                    expected_commitments.insert(commitment.to_repr());
+                    let repr = commitment.to_repr();
+                    if !expected_slashed.contains(&repr) {
+                        expected_commitments.insert(repr);
+                    }
                 }
                 rln::RLNNode::Slashing(commitment) => {
-                    expected_commitments.remove(&commitment.to_repr());
+                    let repr = commitment.to_repr();
+                    expected_slashed.insert(repr);
+                    expected_commitments.remove(&repr);
                 }
             }
         }
 
         let expected_leaves = expected_commitments.len();
-        let actual_commitments = self.identity_state.read().await.commitment_reprs();
+        let expected_slashed_count = expected_slashed.len();
+        let (actual_commitments, actual_slashed) = {
+            let state = self.identity_state.read().await;
+            (state.commitment_reprs(), state.slashed_commitment_reprs())
+        };
         let (actual_leaves, leaves_consistent) = match actual_commitments {
             Ok(commitments) => {
                 let len = commitments.len();
@@ -842,20 +852,35 @@ impl EventGraph {
             }
         };
 
+        let (actual_slashed_count, slashed_consistent) = match actual_slashed {
+            Ok(commitments) => {
+                let len = commitments.len();
+                (len, commitments == expected_slashed)
+            }
+            Err(e) => {
+                warn!(
+                    target: "event_graph::new",
+                    "[EVENTGRAPH] RLN slashed identity audit failed: {e}; rebuilding",
+                );
+                (0, false)
+            }
+        };
+
         let static_count = events.len();
         let historical_roots_consistent = self.historical_roots_index_consistent(static_count)?;
         let recorded_count = self.rln_historical_roots_ordered.len();
         let by_value_count = self.rln_historical_roots_by_value.len();
-        let consistent = historical_roots_consistent && leaves_consistent;
+        let consistent = historical_roots_consistent && leaves_consistent && slashed_consistent;
 
         info!(
             target: "event_graph::new",
             concat!(
                 "[EVENTGRAPH] RLN state audit: static_count={} recorded_count={} ",
-                "by_value_count={} actual_leaves={} expected_leaves={} consistent={}",
+                "by_value_count={} actual_leaves={} expected_leaves={} actual_slashed={} ",
+                "expected_slashed={} consistent={}",
             ),
             static_count, recorded_count, by_value_count, actual_leaves, expected_leaves,
-            consistent,
+            actual_slashed_count, expected_slashed_count, consistent,
         );
 
         if consistent {
@@ -866,9 +891,10 @@ impl EventGraph {
             target: "event_graph::new",
             concat!(
                 "[EVENTGRAPH] Rebuilding RLN state: {} static events, {} recorded roots, ",
-                "{} by-value roots, {} leaves (expected {})",
+                "{} by-value roots, {} leaves (expected {}), {} slashed (expected {})",
             ),
             static_count, recorded_count, by_value_count, actual_leaves, expected_leaves,
+            actual_slashed_count, expected_slashed_count,
         );
 
         self.rln_historical_roots_ordered.clear()?;
@@ -2058,9 +2084,9 @@ impl EventGraph {
         Ok(())
     }
 
-    async fn static_persist(&self, ev: &Event) -> Result<()> {
+    fn static_persist_serialized(&self, ev_id: &blake3::Hash, ev_bytes: &[u8]) -> Result<()> {
         let mut ov = SledTreeOverlay::new(&self.static_dag);
-        ov.insert(ev.id().as_bytes(), &serialize_async(ev).await)?;
+        ov.insert(ev_id.as_bytes(), ev_bytes)?;
 
         if let Some(b) = ov.aggregate() {
             self.static_dag.apply_batch(b)?;
@@ -2071,7 +2097,8 @@ impl EventGraph {
 
     #[cfg(test)]
     pub(crate) async fn static_insert(&self, ev: &Event) -> Result<()> {
-        self.static_persist(ev).await?;
+        let ev_bytes = serialize_async(ev).await;
+        self.static_persist_serialized(&ev.id(), &ev_bytes)?;
         self.static_pub.notify(ev.clone()).await;
         Ok(())
     }
@@ -2094,9 +2121,16 @@ impl EventGraph {
             return Err(Error::Custom("static RLN event blob must not be empty".into()))
         }
 
-        self.static_blob_store(&ev.id(), blob)?;
-        self.static_persist(ev).await?;
-        let root = self.apply_rln_static_event(ev, rln_node).await?;
+        let ev_id = ev.id();
+        let ev_bytes = serialize_async(ev).await;
+        let mut state = self.identity_state.write().await;
+        Self::ensure_rln_static_event_transition(&state, rln_node)?;
+
+        self.static_blob_store(&ev_id, blob)?;
+        self.static_persist_serialized(&ev_id, &ev_bytes)?;
+        let root = self.apply_rln_static_event_locked(ev, rln_node, &mut state)?;
+        drop(state);
+
         self.static_pub.notify(ev.clone()).await;
         Ok(root)
     }
@@ -2261,21 +2295,46 @@ impl EventGraph {
         node: &rln::RLNNode,
     ) -> Result<pallas::Base> {
         let mut state = self.identity_state.write().await;
+        self.apply_rln_static_event_locked(ev, node, &mut state)
+    }
 
+    fn ensure_rln_static_event_transition(
+        state: &rln::IdentityState,
+        node: &rln::RLNNode,
+    ) -> Result<()> {
         match node {
             rln::RLNNode::Registration(commitment) => {
-                // Soft-fail on duplicate (race with another peer).
+                if state.contains(commitment) || state.is_slashed(commitment) {
+                    return Err(Error::Custom(
+                        "static RLN registration is duplicate or slashed".into(),
+                    ))
+                }
+            }
+            rln::RLNNode::Slashing(_) => {}
+        }
+
+        Ok(())
+    }
+
+    fn apply_rln_static_event_locked(
+        &self,
+        ev: &Event,
+        node: &rln::RLNNode,
+        state: &mut rln::IdentityState,
+    ) -> Result<pallas::Base> {
+        match node {
+            rln::RLNNode::Registration(commitment) => {
+                // Soft-fail on duplicate during internal replay/rebuild.
                 let _ = state.register(*commitment);
             }
             rln::RLNNode::Slashing(commitment) => {
-                // Idempotent - slashing a non-present identity is
-                // a no-op.
+                // Slashes are durable evidence. Replayed slashes keep the
+                // tombstone and record another static root entry.
                 let _ = state.slash(*commitment);
             }
         }
 
         let new_root = state.root();
-        drop(state);
 
         // Record the root in both side-tables. We do this even if
         // the SMT mutation was a no-op (duplicate register, slash of
@@ -2740,7 +2799,8 @@ impl EventGraph {
                 if blob == rln::GENESIS_BLOB_GUARD {
                     let repr = commitment.to_repr();
                     if self.pregenerated_identity_commitment_reprs.contains(&repr) {
-                        if self.identity_state.read().await.contains(commitment) {
+                        let state = self.identity_state.read().await;
+                        if state.contains(commitment) || state.is_slashed(commitment) {
                             return StaticEventCheck::Rejected
                         }
                         return StaticEventCheck::AcceptedRegistration(*commitment)
@@ -2851,8 +2911,11 @@ impl EventGraph {
         }
 
         for commitment in self.pregenerated_identity_commitments.iter() {
-            if self.identity_state.read().await.contains(commitment) {
-                continue
+            {
+                let state = self.identity_state.read().await;
+                if state.contains(commitment) || state.is_slashed(commitment) {
+                    continue
+                }
             }
 
             let rln_node = rln::RLNNode::Registration(*commitment);

+ 49 - 12
src/event_graph/rln.rs

@@ -337,6 +337,7 @@ pub enum StaticEventCheck {
 pub struct IdentityState {
     smt: SmtMemoryFp,
     leaves: sled::Tree,
+    slashed: sled::Tree,
     recent_roots: VecDeque<pallas::Base>,
 }
 
@@ -347,6 +348,7 @@ impl IdentityState {
         let mut smt = SmtMemoryFp::new(store, hasher, &EMPTY_NODES_FP);
 
         let leaves = sled_db.open_tree("rln-identity-leaves")?;
+        let slashed = sled_db.open_tree("rln-slashed-identity-leaves")?;
 
         let mut batch = vec![];
         for item in leaves.iter() {
@@ -356,6 +358,9 @@ impl IdentityState {
             }
             let mut repr = [0u8; 32];
             repr.copy_from_slice(&val);
+            if slashed.contains_key(repr)? {
+                continue
+            }
             if let Some(c) = pallas::Base::from_repr(repr).into() {
                 batch.push((c, c));
             }
@@ -372,7 +377,7 @@ impl IdentityState {
         let mut recent_roots = VecDeque::with_capacity(ROOT_HISTORY_SIZE);
         recent_roots.push_back(smt.root());
 
-        Ok(Self { smt, leaves, recent_roots })
+        Ok(Self { smt, leaves, slashed, recent_roots })
     }
 
     /// Returns true if the commitment is already a leaf in the tree.
@@ -380,14 +385,21 @@ impl IdentityState {
         self.leaves.contains_key(commitment.to_repr()).unwrap_or(false)
     }
 
+    /// Returns true if the commitment has been permanently slashed.
+    pub fn is_slashed(&self, commitment: &pallas::Base) -> bool {
+        self.slashed.contains_key(commitment.to_repr()).unwrap_or(false)
+    }
+
     /// Register a new identity.
     ///
-    /// Returns `Err(Error::DuplicateIdentity)` if the commitment is
-    /// already in the tree. Callers should treat this as a soft
-    /// failure (drop the message), not as protocol-level malice.
-    /// Concurrent honest registrations of the same commitment can
-    /// race during P2P propagation.
+    /// Returns an error if the commitment is already active or has been
+    /// slashed before. Callers should treat this as a soft failure (drop the
+    /// message), not as protocol-level malice. Concurrent honest registrations
+    /// of the same commitment can race during P2P propagation.
     pub fn register(&mut self, commitment: pallas::Base) -> Result<()> {
+        if self.is_slashed(&commitment) {
+            return Err(Error::Custom("RLN: slashed identity commitment".into()))
+        }
         if self.contains(&commitment) {
             return Err(Error::Custom("RLN: duplicate identity commitment".into()))
         }
@@ -397,15 +409,17 @@ impl IdentityState {
         Ok(())
     }
 
-    /// Slash (remove) an identity. Idempotent: removing a
-    /// non-present commitment is a no-op rather than an error,
-    /// because the same slash proof may legitimately arrive twice
-    /// via different propagation paths.
+    /// Slash (remove) an identity and permanently tombstone its commitment.
+    ///
+    /// Removing a non-present commitment remains idempotent: the slash is
+    /// still recorded as a tombstone, but the SMT root is unchanged.
     pub fn slash(&mut self, commitment: pallas::Base) -> Result<()> {
+        let repr = commitment.to_repr();
+        self.slashed.insert(repr, repr.as_ref())?;
         if !self.contains(&commitment) {
             return Ok(())
         }
-        self.leaves.remove(commitment.to_repr())?;
+        self.leaves.remove(repr)?;
         self.smt.remove_leaves(vec![(commitment, commitment)])?;
         self.push_root();
         Ok(())
@@ -442,6 +456,28 @@ impl IdentityState {
         Ok(commitments)
     }
 
+    /// Return the commitments persisted in the slashed-identity tombstone tree.
+    pub(crate) fn slashed_commitment_reprs(&self) -> Result<BTreeSet<[u8; 32]>> {
+        let mut commitments = BTreeSet::new();
+        for item in self.slashed.iter() {
+            let (key, val) = item?;
+            if key.len() != 32 || val.len() != 32 {
+                return Err(Error::Custom(format!(
+                    "RLN slashed identity key/value must be 32 bytes, got {}/{}",
+                    key.len(),
+                    val.len()
+                )))
+            }
+            if key.as_ref() != val.as_ref() {
+                return Err(Error::Custom("RLN slashed identity key/value mismatch".into()))
+            }
+            let mut repr = [0u8; 32];
+            repr.copy_from_slice(&val);
+            commitments.insert(repr);
+        }
+        Ok(commitments)
+    }
+
     /// Check whether `root` matches the current SMT root.
     pub fn is_current_root(&self, root: &pallas::Base) -> bool {
         self.smt.root() == *root
@@ -472,8 +508,9 @@ impl IdentityState {
     /// empty state, in preparation for replaying the canonical
     /// static-DAG history.
     pub fn clear_for_rebuild(&mut self) -> Result<()> {
-        // Drop every leaf from sled.
+        // Drop every derived identity state from sled.
         self.leaves.clear()?;
+        self.slashed.clear()?;
 
         // Replace the in-memory SMT with a fresh empty one.
         let hasher = PoseidonFp::new();

+ 96 - 14
src/event_graph/tests_rln.rs

@@ -170,6 +170,8 @@ fn rln_identity_state_register_then_slash() {
 
     s.slash(c).unwrap();
     assert!(!s.contains(&c));
+    assert!(s.is_slashed(&c));
+    assert!(s.register(c).is_err());
 }
 
 #[test]
@@ -187,24 +189,31 @@ fn rln_identity_state_register_rejects_duplicate() {
 fn rln_identity_state_slash_idempotent_for_unknown() {
     let db = sled::Config::new().temporary(true).open().unwrap();
     let mut s = IdentityState::new(&db).unwrap();
-    // Slashing something that was never registered is a no-op,
-    // not an error. This matters for P2P propagation: a slash
-    // event may legitimately arrive twice via different paths.
-    s.slash(pallas::Base::from(7u64)).unwrap();
+    // Slashing something that was never registered is not an error. This
+    // matters for P2P propagation: a slash event may legitimately arrive twice
+    // via different paths. The commitment is still tombstoned permanently.
+    let c = pallas::Base::from(7u64);
+    s.slash(c).unwrap();
+    assert!(s.is_slashed(&c));
+    assert!(s.register(c).is_err());
 }
 
 #[test]
 fn rln_identity_state_persists_across_reopen() {
     let db = sled::Config::new().temporary(true).open().unwrap();
     let c = pallas::Base::from(0xfeedu64);
+    let slashed = pallas::Base::from(0xdead_u64);
 
     {
         let mut s = IdentityState::new(&db).unwrap();
         s.register(c).unwrap();
-    } // drop closes the in-memory SMT but the leaves are in sled
+        s.slash(slashed).unwrap();
+    } // drop closes the in-memory SMT but the derived state is in sled
 
-    let s2 = IdentityState::new(&db).unwrap();
+    let mut s2 = IdentityState::new(&db).unwrap();
     assert!(s2.contains(&c), "leaf should survive close-and-reopen");
+    assert!(s2.is_slashed(&slashed), "tombstone should survive close-and-reopen");
+    assert!(s2.register(slashed).is_err());
 }
 
 #[test]
@@ -712,7 +721,7 @@ fn rln_static_event_slash_invalid_blobs_rejected() {
 }
 
 #[test]
-fn rln_identity_state_re_register_after_slash_works() {
+fn rln_identity_state_re_register_after_slash_requires_new_commitment() {
     // A slashed identity can re-register with new credentials
     // (different commitment). The ban is on the commitment, not
     // on the underlying network identity.
@@ -730,13 +739,10 @@ fn rln_identity_state_re_register_after_slash_works() {
     s.register(c2).unwrap();
     assert!(s.contains(&c2));
 
-    // The slashed commitment can ALSO be re-registered (which would
-    // never happen in practice - same identity_secret_hash means
-    // the same identity is back, but if the network policy says
-    // "ok", we should support it). This test documents that
-    // behaviour rather than asserting it should be otherwise.
-    s.register(c1).unwrap();
-    assert!(s.contains(&c1));
+    // The slashed commitment itself is permanently tombstoned.
+    assert!(s.register(c1).is_err());
+    assert!(!s.contains(&c1));
+    assert!(s.is_slashed(&c1));
 }
 
 #[test]
@@ -1039,6 +1045,82 @@ async fn concurrent_slashes(ex: Arc<Executor<'static>>) {
     shutdown_network(&nodes).await;
 }
 
+#[test]
+fn rln_static_slashes_persist_and_tombstone_commitment() {
+    smol::block_on(async {
+        let id = TestIdentity::new();
+        let commitment = id.commitment();
+        let config = EventGraphConfig {
+            pregenerated_identity_commitments: vec![commitment.to_repr()],
+            ..test_config()
+        };
+        let eg = make_eg_with_config(config).await;
+
+        let reg_node = RLNNode::Registration(commitment);
+        let reg_event = synth_static_event(1, 499_000, &reg_node).await;
+        let _ = eg.apply_rln_static_event(&reg_event, &reg_node).await.unwrap();
+        eg.static_insert(&reg_event).await.unwrap();
+        assert!(eg.rln_contains(&commitment).await);
+
+        let slash_pk = eg.zk_keys.load_slash_pk().unwrap();
+        let (proof, root) = crate::event_graph::rln::create_slash_proof(
+            id.identity_secret_hash(),
+            &mut *eg.identity_state.write().await,
+            &slash_pk,
+        )
+        .unwrap();
+        let slash_blob =
+            SlashBlob { proof, identity_secret_hash: id.identity_secret_hash(), merkle_root: root };
+        let blob = serialize_async(&slash_blob).await;
+        let slash_node = RLNNode::Slashing(commitment);
+
+        let first_slash = synth_static_event(2, 500_000, &slash_node).await;
+        let outcome =
+            eg.rln_verify_static_event(&slash_node, &blob, first_slash.header.timestamp).await;
+        assert!(matches!(outcome, StaticEventCheck::AcceptedSlash(c) if c == commitment));
+        eg.commit_verified_static_event(&first_slash, &blob, &slash_node).await.unwrap();
+        assert!(!eg.rln_contains(&commitment).await);
+        assert!(eg.identity_state.read().await.is_slashed(&commitment));
+        assert!(eg.static_fetch(&first_slash.id()).await.unwrap().is_some());
+
+        let replayed_slash = synth_static_event(3, 500_001, &slash_node).await;
+        assert_ne!(first_slash.id(), replayed_slash.id());
+        let outcome =
+            eg.rln_verify_static_event(&slash_node, &blob, replayed_slash.header.timestamp).await;
+        assert!(matches!(outcome, StaticEventCheck::AcceptedSlash(c) if c == commitment));
+        eg.commit_verified_static_event(&replayed_slash, &blob, &slash_node).await.unwrap();
+        assert!(eg.static_fetch(&replayed_slash.id()).await.unwrap().is_some());
+        assert_eq!(eg.static_blob_fetch(&replayed_slash.id()).unwrap().unwrap(), blob);
+        assert_eq!(eg.rln_historical_roots_ordered.len(), 3);
+        assert_eq!(eg.rln_historical_roots_by_value.len(), 3);
+
+        let re_registration = synth_static_event(4, 500_002, &reg_node).await;
+        let outcome = eg
+            .rln_verify_static_event(
+                &reg_node,
+                GENESIS_BLOB_GUARD,
+                re_registration.header.timestamp,
+            )
+            .await;
+        assert!(matches!(outcome, StaticEventCheck::Rejected));
+        assert!(eg
+            .commit_verified_static_event(&re_registration, GENESIS_BLOB_GUARD, &reg_node)
+            .await
+            .is_err());
+        assert!(eg.static_fetch(&re_registration.id()).await.unwrap().is_none());
+        assert!(eg.static_blob_fetch(&re_registration.id()).unwrap().is_none());
+        assert_eq!(eg.rln_historical_roots_ordered.len(), 3);
+
+        eg.rln_historical_roots_ordered.clear().unwrap();
+        eg.rln_historical_roots_by_value.clear().unwrap();
+        eg.identity_state.write().await.clear_for_rebuild().unwrap();
+        eg.rebuild_historical_roots_if_needed().await.unwrap();
+        assert!(!eg.rln_contains(&commitment).await);
+        assert!(eg.identity_state.read().await.is_slashed(&commitment));
+        assert_eq!(eg.rln_historical_roots_ordered.len(), 3);
+    })
+}
+
 #[test]
 fn rln_static_blob_audit_repairs_pregenerated_guard() {
     smol::block_on(async {