commonware_consensus/simplex/scheme/
reporter.rs

1//! Wrapper for scheme-dependent activity filtering and verification.
2//!
3//! # Overview
4//!
5//! The [`AttributableReporter`] provides a composable wrapper around consensus reporters
6//! that automatically filters and verifies activities based on scheme attributability.
7//! This ensures that:
8//!
9//! 1. **Peer activities are cryptographically verified** before being reported
10//! 2. **Non-attributable schemes** suppress per-validator activities from peers to prevent
11//!    signature forgery attacks
12//! 3. **Certificates** are always reported as they contain valid quorum proofs
13//!
14//! # Security Rationale
15//!
16//! With [`super::bls12381_threshold`], any `t` valid partial signatures
17//! can be used to forge a partial signature for any participant. If per-validator activities
18//! were exposed for such schemes, adversaries could fabricate evidence of either liveness or of committing a fault.
19//! This wrapper prevents that attack by suppressing peer activities for non-attributable schemes.
20
21use crate::{
22    simplex::{scheme::Scheme, types::Activity},
23    Reporter,
24};
25use commonware_cryptography::{certificate, Digest};
26use commonware_parallel::Strategy;
27use rand_core::CryptoRngCore;
28
29/// Reporter wrapper that filters and verifies activities based on scheme attributability.
30///
31/// This wrapper provides scheme-aware activity filtering with automatic verification of peer
32/// activities. It prevents signature forgery attacks on non-attributable schemes while ensuring
33/// all activities are cryptographically valid before reporting.
34#[derive(Clone)]
35pub struct AttributableReporter<
36    E: Clone + CryptoRngCore + Send + 'static,
37    S: certificate::Scheme,
38    D: Digest,
39    T: Strategy,
40    R: Reporter<Activity = Activity<S, D>>,
41> {
42    /// RNG for certificate verification
43    rng: E,
44    /// Signing scheme for verification
45    scheme: S,
46    /// Inner reporter that receives filtered activities
47    reporter: R,
48    /// Strategy for parallel operations.
49    strategy: T,
50    /// Whether to always verify peer activities
51    verify: bool,
52}
53
54impl<
55        E: Clone + CryptoRngCore + Send + 'static,
56        S: certificate::Scheme,
57        D: Digest,
58        T: Strategy,
59        R: Reporter<Activity = Activity<S, D>>,
60    > AttributableReporter<E, S, D, T, R>
61{
62    /// Creates a new `AttributableReporter` that wraps an inner reporter.
63    pub const fn new(rng: E, scheme: S, reporter: R, strategy: T, verify: bool) -> Self {
64        Self {
65            rng,
66            scheme,
67            reporter,
68            strategy,
69            verify,
70        }
71    }
72}
73
74impl<
75        E: Clone + CryptoRngCore + Send + 'static,
76        S: Scheme<D>,
77        D: Digest,
78        T: Strategy,
79        R: Reporter<Activity = Activity<S, D>>,
80    > Reporter for AttributableReporter<E, S, D, T, R>
81{
82    type Activity = Activity<S, D>;
83
84    async fn report(&mut self, activity: Self::Activity) {
85        // Verify peer activities if verification is enabled
86        if self.verify
87            && !activity.verified()
88            && !activity.verify(&mut self.rng, &self.scheme, &self.strategy)
89        {
90            // Drop unverified peer activity
91            return;
92        }
93
94        // Filter based on scheme attributability
95        if !S::is_attributable() {
96            match activity {
97                Activity::Notarize(_)
98                | Activity::Nullify(_)
99                | Activity::Finalize(_)
100                | Activity::ConflictingNotarize(_)
101                | Activity::ConflictingFinalize(_)
102                | Activity::NullifyFinalize(_) => {
103                    // Drop per-validator peer activity for non-attributable scheme
104                    return;
105                }
106                Activity::Notarization(_)
107                | Activity::Certification(_)
108                | Activity::Nullification(_)
109                | Activity::Finalization(_) => {
110                    // Always report certificates
111                }
112            }
113        }
114
115        self.reporter.report(activity).await;
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::{
123        simplex::{
124            scheme::{bls12381_threshold, ed25519},
125            types::{Notarization, Notarize, Proposal, Subject},
126        },
127        types::{Epoch, Round, View},
128    };
129    use commonware_cryptography::{
130        bls12381::primitives::variant::MinPk,
131        certificate::{self, mocks::Fixture, Scheme as _},
132        ed25519::PublicKey as Ed25519PublicKey,
133        sha256::Digest as Sha256Digest,
134        Hasher, Sha256,
135    };
136    use commonware_parallel::Sequential;
137    use commonware_utils::{test_rng, N3f1};
138    use futures::executor::block_on;
139    use std::sync::{Arc, Mutex};
140
141    const NAMESPACE: &[u8] = b"test-reporter";
142
143    #[derive(Clone)]
144    struct MockReporter<S: certificate::Scheme, D: Digest> {
145        activities: Arc<Mutex<Vec<Activity<S, D>>>>,
146    }
147
148    impl<S: certificate::Scheme, D: Digest> MockReporter<S, D> {
149        fn new() -> Self {
150            Self {
151                activities: Arc::new(Mutex::new(Vec::new())),
152            }
153        }
154
155        fn reported(&self) -> Vec<Activity<S, D>> {
156            self.activities.lock().unwrap().clone()
157        }
158
159        fn count(&self) -> usize {
160            self.activities.lock().unwrap().len()
161        }
162    }
163
164    impl<S: certificate::Scheme, D: Digest> Reporter for MockReporter<S, D> {
165        type Activity = Activity<S, D>;
166
167        async fn report(&mut self, activity: Self::Activity) {
168            self.activities.lock().unwrap().push(activity);
169        }
170    }
171
172    fn create_proposal(epoch: u64, view: u64) -> Proposal<Sha256Digest> {
173        let data = format!("proposal-{epoch}-{view}");
174        let hash = Sha256::hash(data.as_bytes());
175        let epoch = Epoch::new(epoch);
176        let view = View::new(view);
177        Proposal::new(Round::new(epoch, view), view, hash)
178    }
179
180    #[test]
181    fn test_invalid_peer_activity_dropped() {
182        // Invalid peer activities should be dropped when verification is enabled
183        let mut rng = test_rng();
184        let Fixture { verifier, .. } = ed25519::fixture(&mut rng, NAMESPACE, 4);
185
186        // Create a scheme with wrong namespace to generate invalid signatures
187        let Fixture {
188            schemes: wrong_schemes,
189            ..
190        } = ed25519::fixture(&mut rng, b"wrong-namespace", 4);
191
192        assert!(
193            ed25519::Scheme::is_attributable(),
194            "Ed25519 must be attributable"
195        );
196
197        let mock = MockReporter::new();
198        let mut reporter = AttributableReporter::new(rng, verifier, mock.clone(), Sequential, true);
199
200        // Create an invalid activity (signed with wrong namespace scheme)
201        let proposal = create_proposal(0, 1);
202        let attestation = wrong_schemes[1]
203            .sign::<Sha256Digest>(Subject::Notarize {
204                proposal: &proposal,
205            })
206            .expect("signing failed");
207        let notarize = Notarize {
208            proposal,
209            attestation,
210        };
211
212        // Report it
213        block_on(reporter.report(Activity::Notarize(notarize)));
214
215        // Should be dropped
216        assert_eq!(mock.count(), 0);
217    }
218
219    #[test]
220    fn test_skip_verification() {
221        // When verification is disabled, invalid activities pass through
222        let mut rng = test_rng();
223        let Fixture { verifier, .. } = ed25519::fixture(&mut rng, NAMESPACE, 4);
224
225        // Create a scheme with wrong namespace to generate invalid signatures
226        let Fixture {
227            schemes: wrong_schemes,
228            ..
229        } = ed25519::fixture(&mut rng, b"wrong-namespace", 4);
230
231        assert!(
232            ed25519::Scheme::is_attributable(),
233            "Ed25519 must be attributable"
234        );
235
236        let mock = MockReporter::new();
237        let mut reporter = AttributableReporter::new(
238            rng,
239            verifier,
240            mock.clone(),
241            Sequential,
242            false, // Disable verification
243        );
244
245        // Create an invalid activity (signed with wrong namespace scheme)
246        let proposal = create_proposal(0, 1);
247        let attestation = wrong_schemes[1]
248            .sign::<Sha256Digest>(Subject::Notarize {
249                proposal: &proposal,
250            })
251            .expect("signing failed");
252        let notarize = Notarize {
253            proposal,
254            attestation,
255        };
256
257        // Report it
258        block_on(reporter.report(Activity::Notarize(notarize)));
259
260        // Should be reported even though it's invalid
261        assert_eq!(mock.count(), 1);
262        let reported = mock.reported();
263        assert!(matches!(reported[0], Activity::Notarize(_)));
264    }
265
266    #[test]
267    fn test_certificates_always_reported() {
268        // Certificates should always be reported, even for non-attributable schemes
269        let mut rng = test_rng();
270        let Fixture {
271            schemes, verifier, ..
272        } = bls12381_threshold::fixture::<MinPk, _>(&mut rng, NAMESPACE, 4);
273
274        assert!(
275            !bls12381_threshold::Scheme::<Ed25519PublicKey, MinPk>::is_attributable(),
276            "BLS threshold must be non-attributable"
277        );
278
279        let mock = MockReporter::new();
280        let mut reporter = AttributableReporter::new(rng, verifier, mock.clone(), Sequential, true);
281
282        // Create a certificate from multiple validators
283        let proposal = create_proposal(0, 1);
284        let votes: Vec<_> = schemes
285            .iter()
286            .map(|scheme| {
287                scheme
288                    .sign::<Sha256Digest>(Subject::Notarize {
289                        proposal: &proposal,
290                    })
291                    .expect("signing failed")
292            })
293            .collect();
294
295        let certificate = schemes[0]
296            .assemble::<_, N3f1>(votes, &Sequential)
297            .expect("failed to assemble certificate");
298
299        let notarization = Notarization {
300            proposal,
301            certificate,
302        };
303
304        // Report it
305        block_on(reporter.report(Activity::Notarization(notarization)));
306
307        // Should be reported even though scheme is non-attributable (certificates are quorum proofs)
308        assert_eq!(mock.count(), 1);
309        let reported = mock.reported();
310        assert!(matches!(reported[0], Activity::Notarization(_)));
311    }
312
313    #[test]
314    fn test_non_attributable_filters_peer_activities() {
315        // Non-attributable schemes (like BLS threshold) must filter peer per-validator activities
316        let mut rng = test_rng();
317        let Fixture {
318            schemes, verifier, ..
319        } = bls12381_threshold::fixture::<MinPk, _>(&mut rng, NAMESPACE, 4);
320
321        assert!(
322            !bls12381_threshold::Scheme::<Ed25519PublicKey, MinPk>::is_attributable(),
323            "BLS threshold must be non-attributable"
324        );
325
326        let mock = MockReporter::new();
327        let mut reporter = AttributableReporter::new(rng, verifier, mock.clone(), Sequential, true);
328
329        // Create peer activity (from validator 1)
330        let proposal = create_proposal(0, 1);
331        let attestation = schemes[1]
332            .sign::<Sha256Digest>(Subject::Notarize {
333                proposal: &proposal,
334            })
335            .expect("signing failed");
336
337        let notarize = Notarize {
338            proposal,
339            attestation,
340        };
341
342        // Report peer per-validator activity
343        block_on(reporter.report(Activity::Notarize(notarize)));
344
345        // Must be filtered
346        assert_eq!(mock.count(), 0);
347    }
348
349    #[test]
350    fn test_attributable_scheme_reports_peer_activities() {
351        // Ed25519 (attributable) should report peer per-validator activities
352        let mut rng = test_rng();
353        let Fixture {
354            schemes, verifier, ..
355        } = ed25519::fixture(&mut rng, NAMESPACE, 4);
356
357        assert!(
358            ed25519::Scheme::is_attributable(),
359            "Ed25519 must be attributable"
360        );
361
362        let mock = MockReporter::new();
363        let mut reporter = AttributableReporter::new(rng, verifier, mock.clone(), Sequential, true);
364
365        // Create a peer activity (from validator 1)
366        let proposal = create_proposal(0, 1);
367        let attestation = schemes[1]
368            .sign::<Sha256Digest>(Subject::Notarize {
369                proposal: &proposal,
370            })
371            .expect("signing failed");
372
373        let notarize = Notarize {
374            proposal,
375            attestation,
376        };
377
378        // Report the peer per-validator activity
379        block_on(reporter.report(Activity::Notarize(notarize)));
380
381        // Should be reported since scheme is attributable
382        assert_eq!(mock.count(), 1);
383        let reported = mock.reported();
384        assert!(matches!(reported[0], Activity::Notarize(_)));
385    }
386}