commonware_consensus/simplex/signing_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::{signing_scheme::Scheme, types::Activity},
23    Reporter,
24};
25use commonware_cryptography::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: 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: 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 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,
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::Nullification(_)
104                | Activity::Finalization(_) => {
105                    // Always report certificates
106                }
107            }
108        }
109
110        self.reporter.report(activity).await;
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::{
118        simplex::{
119            mocks::fixtures::{bls12381_threshold, ed25519, Fixture},
120            signing_scheme::Scheme,
121            types::{Notarization, Notarize, Proposal, VoteContext},
122        },
123        types::Round,
124    };
125    use commonware_cryptography::{
126        bls12381::primitives::variant::MinPk, sha256::Digest as Sha256Digest, Hasher, Sha256,
127    };
128    use futures::executor::block_on;
129    use rand::{rngs::StdRng, SeedableRng};
130    use std::sync::{Arc, Mutex};
131
132    const NAMESPACE: &[u8] = b"test-reporter";
133
134    #[derive(Clone)]
135    struct MockReporter<S: Scheme, D: Digest> {
136        activities: Arc<Mutex<Vec<Activity<S, D>>>>,
137    }
138
139    impl<S: Scheme, D: Digest> MockReporter<S, D> {
140        fn new() -> Self {
141            Self {
142                activities: Arc::new(Mutex::new(Vec::new())),
143            }
144        }
145
146        fn reported(&self) -> Vec<Activity<S, D>> {
147            self.activities.lock().unwrap().clone()
148        }
149
150        fn count(&self) -> usize {
151            self.activities.lock().unwrap().len()
152        }
153    }
154
155    impl<S: Scheme, D: Digest> Reporter for MockReporter<S, D> {
156        type Activity = Activity<S, D>;
157
158        async fn report(&mut self, activity: Self::Activity) {
159            self.activities.lock().unwrap().push(activity);
160        }
161    }
162
163    fn create_proposal(epoch: u64, view: u64) -> Proposal<Sha256Digest> {
164        let data = format!("proposal-{epoch}-{view}");
165        let hash = Sha256::hash(data.as_bytes());
166        Proposal::new(Round::new(epoch, view), view, hash)
167    }
168
169    #[test]
170    fn test_invalid_peer_activity_dropped() {
171        // Invalid peer activities should be dropped when verification is enabled
172        let mut rng = StdRng::seed_from_u64(42);
173        let Fixture {
174            schemes, verifier, ..
175        } = ed25519(&mut rng, 4);
176
177        assert!(verifier.is_attributable(), "Ed25519 must be attributable");
178
179        let mock = MockReporter::new();
180        let mut reporter =
181            AttributableReporter::new(rng, verifier, NAMESPACE.to_vec(), mock.clone(), true);
182
183        // Create an invalid activity (wrong namespace)
184        let proposal = create_proposal(0, 1);
185        let vote = schemes[1]
186            .sign_vote::<Sha256Digest>(
187                &[], // Invalid namespace
188                VoteContext::Notarize {
189                    proposal: &proposal,
190                },
191            )
192            .expect("signing failed");
193        let notarize = Notarize { proposal, vote };
194
195        // Report it
196        block_on(reporter.report(Activity::Notarize(notarize)));
197
198        // Should be dropped
199        assert_eq!(mock.count(), 0);
200    }
201
202    #[test]
203    fn test_skip_verification() {
204        // When verification is disabled, invalid activities pass through
205        let mut rng = StdRng::seed_from_u64(42);
206        let Fixture {
207            schemes, verifier, ..
208        } = ed25519(&mut rng, 4);
209
210        assert!(verifier.is_attributable(), "Ed25519 must be attributable");
211
212        let mock = MockReporter::new();
213        let mut reporter = AttributableReporter::new(
214            rng,
215            verifier,
216            NAMESPACE.to_vec(),
217            mock.clone(),
218            false, // Disable verification
219        );
220
221        // Create an invalid activity (wrong namespace)
222        let proposal = create_proposal(0, 1);
223        let vote = schemes[1]
224            .sign_vote::<Sha256Digest>(
225                &[], // Invalid namespace
226                VoteContext::Notarize {
227                    proposal: &proposal,
228                },
229            )
230            .expect("signing failed");
231        let notarize = Notarize { proposal, vote };
232
233        // Report it
234        block_on(reporter.report(Activity::Notarize(notarize)));
235
236        // Should be reported even though it's invalid
237        assert_eq!(mock.count(), 1);
238        let reported = mock.reported();
239        assert!(matches!(reported[0], Activity::Notarize(_)));
240    }
241
242    #[test]
243    fn test_certificates_always_reported() {
244        // Certificates should always be reported, even for non-attributable schemes
245        let mut rng = StdRng::seed_from_u64(42);
246        let Fixture {
247            schemes, verifier, ..
248        } = bls12381_threshold::<MinPk, _>(&mut rng, 4);
249
250        assert!(
251            !verifier.is_attributable(),
252            "BLS threshold must be non-attributable"
253        );
254
255        let mock = MockReporter::new();
256        let mut reporter =
257            AttributableReporter::new(rng, verifier, NAMESPACE.to_vec(), mock.clone(), true);
258
259        // Create a certificate from multiple validators
260        let proposal = create_proposal(0, 1);
261        let votes: Vec<_> = schemes
262            .iter()
263            .map(|scheme| {
264                scheme
265                    .sign_vote::<Sha256Digest>(
266                        NAMESPACE,
267                        VoteContext::Notarize {
268                            proposal: &proposal,
269                        },
270                    )
271                    .expect("signing failed")
272            })
273            .collect();
274
275        let certificate = schemes[0]
276            .assemble_certificate(votes)
277            .expect("failed to assemble certificate");
278
279        let notarization = Notarization {
280            proposal,
281            certificate,
282        };
283
284        // Report it
285        block_on(reporter.report(Activity::Notarization(notarization)));
286
287        // Should be reported even though scheme is non-attributable (certificates are quorum proofs)
288        assert_eq!(mock.count(), 1);
289        let reported = mock.reported();
290        assert!(matches!(reported[0], Activity::Notarization(_)));
291    }
292
293    #[test]
294    fn test_non_attributable_filters_peer_activities() {
295        // Non-attributable schemes (like BLS threshold) must filter peer per-validator activities
296        let mut rng = StdRng::seed_from_u64(42);
297        let Fixture {
298            schemes, verifier, ..
299        } = bls12381_threshold::<MinPk, _>(&mut rng, 4);
300
301        assert!(
302            !verifier.is_attributable(),
303            "BLS threshold must be non-attributable"
304        );
305
306        let mock = MockReporter::new();
307        let mut reporter =
308            AttributableReporter::new(rng, verifier, NAMESPACE.to_vec(), mock.clone(), true);
309
310        // Create peer activity (from validator 1)
311        let proposal = create_proposal(0, 1);
312        let vote = schemes[1]
313            .sign_vote::<Sha256Digest>(
314                NAMESPACE,
315                VoteContext::Notarize {
316                    proposal: &proposal,
317                },
318            )
319            .expect("signing failed");
320
321        let notarize = Notarize { proposal, vote };
322
323        // Report peer per-validator activity
324        block_on(reporter.report(Activity::Notarize(notarize)));
325
326        // Must be filtered
327        assert_eq!(mock.count(), 0);
328    }
329
330    #[test]
331    fn test_attributable_scheme_reports_peer_activities() {
332        // Ed25519 (attributable) should report peer per-validator activities
333        let mut rng = StdRng::seed_from_u64(42);
334        let Fixture {
335            schemes, verifier, ..
336        } = ed25519(&mut rng, 4);
337
338        assert!(verifier.is_attributable(), "Ed25519 must be attributable");
339
340        let mock = MockReporter::new();
341        let mut reporter =
342            AttributableReporter::new(rng, verifier, NAMESPACE.to_vec(), mock.clone(), true);
343
344        // Create a peer activity (from validator 1)
345        let proposal = create_proposal(0, 1);
346        let vote = schemes[1]
347            .sign_vote::<Sha256Digest>(
348                NAMESPACE,
349                VoteContext::Notarize {
350                    proposal: &proposal,
351                },
352            )
353            .expect("signing failed");
354
355        let notarize = Notarize { proposal, vote };
356
357        // Report the peer per-validator activity
358        block_on(reporter.report(Activity::Notarize(notarize)));
359
360        // Should be reported since scheme is attributable
361        assert_eq!(mock.count(), 1);
362        let reported = mock.reported();
363        assert!(matches!(reported[0], Activity::Notarize(_)));
364    }
365}