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