Skip to main content

optirs_core/privacy/federated/
secure_aggregation.rs

1// Secure Aggregation Module
2//
3// Bonawitz-style secure aggregation for federated learning: clients mask their
4// updates with pairwise secrets agreed by X25519 key exchange, upload the
5// masked values, and the server -- which holds no key material at all -- adds
6// the uploads together. The masks cancel exactly, so the server learns the
7// cohort's sum and nothing about any individual update.
8//
9// What was wrong before
10// ---------------------
11// The previous revision of this file was not a weakened protocol, it was the
12// inverse of one:
13//
14//   * `prepare_round` generated *every client's mask on the server*
15//     (`Random::default()` inside the server loop) and stored them in a
16//     server-side map. A server that knows all the masks can subtract any one
17//     of them from the matching upload, so confidentiality was exactly zero.
18//   * The masks were drawn from `-1.0..1.0` and simply *added* to each update
19//     during aggregation, then never removed. They therefore did not cancel:
20//     the "aggregate" was the mean of the updates plus the mean of a pile of
21//     random noise, i.e. neither private nor correct.
22//   * `prepare_round` panicked on a poisoned lock (`.expect("lock poisoned")`)
23//     over a mutex guarding nothing but a monotone counter.
24//
25// The current implementation moves all key material and all mask generation to
26// the clients (see [`super::pairwise_masking`]), leaves the server with
27// modular addition, and replaces the mutex-guarded counter with a per-round
28// salt drawn from operating-system entropy -- so the poisoning failure mode no
29// longer exists rather than being handled.
30//
31// Threat model, stated honestly
32// -----------------------------
33// What this provides: an honest-but-curious server that does not collude with
34// any client learns only the sum of the received updates. Confidentiality of
35// an individual update holds as long as at least two clients in the round keep
36// their secret keys, since the mask of a client is the sum of its pairwise
37// masks with all peers.
38//
39// What it does not provide:
40//   * No self-mask. Bonawitz's full protocol adds a per-client mask `b_i`
41//     shared out via Shamir secret sharing, which defends against a server
42//     that declares a client dropped, collects the pairwise-mask disclosures,
43//     and *then* processes that client's delayed upload. This implementation
44//     instead closes the same hole procedurally: once a client is marked
45//     dropped its upload is refused (see [`SecureAggregator::receive`]), and a
46//     client that has submitted cannot be marked dropped.
47//   * No malicious-server verification. A server that lies about the
48//     public-key directory (a man-in-the-middle on the key distribution step)
49//     can learn individual updates. Deployments must authenticate the
50//     directory out of band.
51//   * No differential privacy. Secure aggregation hides individual updates
52//     from the server; it says nothing about what the *sum* reveals. Compose
53//     it with the differential privacy machinery in [`crate::privacy`] for
54//     that; `SecureAggregationConfig::aggregate_dp` is rejected here rather
55//     than silently pretending to supply it.
56//
57// Reference
58// ---------
59//   * Bonawitz, K. et al. "Practical Secure Aggregation for Privacy-Preserving
60//     Machine Learning." CCS 2017.
61
62use super::super::secure_aggregation::{dequantize_gradient, quantize_gradient};
63use super::pairwise_masking::{
64    compute_client_mask, fresh_round_seed, signed_pairwise_mask, ClientKeyPair, ClientPublicKey,
65};
66use crate::error::{OptimError, Result};
67use scirs2_core::ndarray::Array1;
68use scirs2_core::numeric::Float;
69use serde::{Deserialize, Serialize};
70use std::collections::{BTreeMap, HashSet};
71use std::fmt::Debug;
72
73/// Default width, in bits, of the additive group used for masking.
74pub const DEFAULT_MODULUS_BITS: u8 = 31;
75
76/// Narrowest and widest usable group widths. The lower bound keeps the group
77/// large enough for any useful quantisation scale; the upper bound keeps
78/// `modulus` and every intermediate sum inside `i64`.
79pub const MIN_MODULUS_BITS: u8 = 8;
80/// See [`MIN_MODULUS_BITS`].
81pub const MAX_MODULUS_BITS: u8 = 62;
82
83/// Secure aggregation configuration
84#[derive(Debug, Clone)]
85pub struct SecureAggregationConfig {
86    /// Whether the protocol is active. Protocol operations error when this is
87    /// false rather than quietly aggregating in the clear.
88    pub enabled: bool,
89
90    /// Minimum number of *received* submissions required to aggregate.
91    pub min_clients: usize,
92
93    /// Maximum number of dropouts a round may tolerate. A round with more
94    /// dropouts than this is refused.
95    pub max_dropouts: usize,
96
97    /// Dimension of the update vectors being aggregated.
98    pub masking_dimension: usize,
99
100    /// How pairwise seeds are established.
101    pub seed_sharing: SeedSharingMethod,
102
103    /// Width, in bits, of the additive group `Z_(2^bits)` used for masking.
104    /// `None` selects [`DEFAULT_MODULUS_BITS`].
105    pub quantization_bits: Option<u8>,
106
107    /// Fixed-point scale: an update coordinate `g` is encoded as
108    /// `round(g * quantization_scale)`.
109    pub quantization_scale: f64,
110
111    /// Declared per-coordinate bound on client updates. Enforced when a client
112    /// masks its update, and used to prove that the cohort's summed
113    /// fixed-point value cannot wrap the group.
114    pub max_update_magnitude: f64,
115
116    /// Add differential privacy noise to the aggregate. Not implemented here;
117    /// setting it is an error (see the module documentation).
118    pub aggregate_dp: bool,
119}
120
121/// How the pairwise seeds behind the masks are established.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum SeedSharingMethod {
124    /// Per-round X25519 elliptic-curve Diffie-Hellman between every pair of
125    /// participants. The only method implemented here.
126    EphemeralDiffieHellman,
127
128    /// Shamir secret sharing of per-client seeds, as in the full Bonawitz
129    /// protocol. Not implemented.
130    ShamirSecretSharing,
131
132    /// Threshold encryption of per-client seeds. Not implemented.
133    ThresholdEncryption,
134
135    /// Distributed key generation. Not implemented.
136    DistributedKeyGeneration,
137}
138
139/// A client's masked upload.
140///
141/// `values[k] = (round(update[k] * scale) + mask[k]) mod modulus`, always in
142/// `[0, modulus)`.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct MaskedClientUpdate {
145    /// Submitting client.
146    pub client_id: String,
147    /// Masked, quantised coordinates.
148    pub values: Vec<i64>,
149}
150
151/// A surviving client's disclosure of the pairwise mask it holds with a client
152/// that dropped out.
153///
154/// Revealing this leaks nothing about the dropped client's update -- it never
155/// uploaded one -- but it does let the server cancel the residue the dropout
156/// left behind. The corresponding upload from the dropped client is refused
157/// afterwards, which is what keeps the disclosure safe.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct DropoutDisclosure {
160    /// Surviving client making the disclosure.
161    pub from_client: String,
162    /// Client that dropped out.
163    pub dropped_client: String,
164    /// The signed pairwise mask `from_client` applied on behalf of
165    /// `dropped_client`, in `[0, modulus)`.
166    pub signed_mask: Vec<i64>,
167}
168
169/// The public parameters of one aggregation round, published by the server.
170///
171/// Everything in here is public. The masks are protected by the clients'
172/// X25519 secret keys, which never appear in a plan.
173#[derive(Debug, Clone)]
174pub struct SecureAggregationPlan {
175    /// Public per-round salt mixed into every pairwise seed.
176    pub round_seed: u64,
177
178    /// Participating client identifiers, sorted.
179    pub participating_clients: Vec<String>,
180
181    /// Public-key directory for the round.
182    pub public_keys: BTreeMap<String, ClientPublicKey>,
183
184    /// Minimum number of submissions the server will aggregate.
185    pub min_threshold: usize,
186
187    /// Always true: a plan is only issued when masking is active.
188    pub masking_enabled: bool,
189
190    /// Update dimension.
191    pub masking_dimension: usize,
192
193    /// Modulus of the additive group.
194    pub modulus: i64,
195
196    /// Fixed-point scale.
197    pub quantization_scale: f64,
198
199    /// Per-coordinate bound each client must respect.
200    pub max_update_magnitude: f64,
201}
202
203/// Server-side secure aggregation state.
204///
205/// Holds no secret key material -- only the published public keys, the
206/// received masked uploads, the set of clients known to have dropped, and the
207/// disclosures that cancel their residue. There is deliberately no field from
208/// which an individual client's update could be recovered.
209pub struct SecureAggregator<T: Float + Debug + Send + Sync + 'static> {
210    config: SecureAggregationConfig,
211    modulus: i64,
212    registered_keys: BTreeMap<String, ClientPublicKey>,
213    current_plan: Option<SecureAggregationPlan>,
214    received: BTreeMap<String, MaskedClientUpdate>,
215    dropped: HashSet<String>,
216    disclosures: Vec<DropoutDisclosure>,
217    rounds_prepared: u64,
218    _marker: std::marker::PhantomData<T>,
219}
220
221impl<T: Float + Debug + Send + Sync + 'static> Debug for SecureAggregator<T> {
222    /// Summarises the round rather than dumping every masked coordinate.
223    ///
224    /// Every field of this type is server-side and public by construction --
225    /// there is no key material to redact, which is the whole point.
226    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        formatter
228            .debug_struct("SecureAggregator")
229            .field("modulus", &self.modulus)
230            .field("registered_clients", &self.registered_keys.len())
231            .field("round_open", &self.current_plan.is_some())
232            .field("received", &self.received.len())
233            .field("dropped", &self.dropped.len())
234            .field("disclosures", &self.disclosures.len())
235            .field("rounds_prepared", &self.rounds_prepared)
236            .finish()
237    }
238}
239
240/// Mask a client's update for upload.
241///
242/// Performed entirely on the client, from its own key pair and the round's
243/// public plan. Enforces the plan's `max_update_magnitude` so that the
244/// server's no-wraparound argument actually holds instead of being assumed.
245pub fn mask_client_update<T: Float + Debug + Send + Sync + 'static>(
246    client_id: &str,
247    keys: &ClientKeyPair,
248    update: &Array1<T>,
249    plan: &SecureAggregationPlan,
250) -> Result<MaskedClientUpdate> {
251    if update.len() != plan.masking_dimension {
252        return Err(OptimError::DimensionMismatch(format!(
253            "client {client_id} supplied {} coordinates but the round dimension is {}",
254            update.len(),
255            plan.masking_dimension
256        )));
257    }
258
259    let mut as_f64 = Vec::with_capacity(update.len());
260    for value in update.iter() {
261        let converted = value.to_f64().ok_or_else(|| {
262            OptimError::ComputationError(format!(
263                "client {client_id} supplied a value that is not representable as f64: {value:?}"
264            ))
265        })?;
266        if !converted.is_finite() {
267            return Err(OptimError::InvalidParameter(format!(
268                "client {client_id} supplied a non-finite update coordinate ({converted})"
269            )));
270        }
271        if converted.abs() > plan.max_update_magnitude {
272            return Err(OptimError::InvalidParameter(format!(
273                "client {client_id} supplied a coordinate of magnitude {} which exceeds the \
274                 round's declared bound {}; clip the update before masking, otherwise the \
275                 cohort's fixed-point sum could wrap the modulus and the aggregate would be \
276                 silently wrong",
277                converted.abs(),
278                plan.max_update_magnitude
279            )));
280        }
281        as_f64.push(converted);
282    }
283
284    let quantised =
285        quantize_gradient(&Array1::from(as_f64), plan.quantization_scale, plan.modulus)?;
286    let mask = compute_client_mask(
287        client_id,
288        keys,
289        &plan.public_keys,
290        plan.round_seed,
291        plan.masking_dimension,
292        plan.modulus,
293    )?;
294
295    if quantised.len() != mask.len() {
296        return Err(OptimError::DimensionMismatch(format!(
297            "quantised update has {} coordinates but the derived mask has {}; zipping them \
298             would silently truncate the upload",
299            quantised.len(),
300            mask.len()
301        )));
302    }
303    let values = quantised
304        .iter()
305        .zip(mask.iter())
306        .map(|(quantum, mask_value)| (*quantum + *mask_value).rem_euclid(plan.modulus))
307        .collect();
308    Ok(MaskedClientUpdate {
309        client_id: client_id.to_string(),
310        values,
311    })
312}
313
314/// Produce the disclosures a surviving client owes for a set of dropped
315/// clients.
316///
317/// The client reveals only the signed pairwise masks it holds with the dropped
318/// peers -- never its secret key, and never anything about its own update.
319pub fn disclose_dropout_masks(
320    client_id: &str,
321    keys: &ClientKeyPair,
322    dropped_clients: &[String],
323    plan: &SecureAggregationPlan,
324) -> Result<Vec<DropoutDisclosure>> {
325    let mut disclosures = Vec::with_capacity(dropped_clients.len());
326    for dropped in dropped_clients.iter() {
327        if dropped == client_id {
328            return Err(OptimError::InvalidParameter(format!(
329                "client {client_id} cannot disclose a pairwise mask with itself"
330            )));
331        }
332        let dropped_key = plan.public_keys.get(dropped).ok_or_else(|| {
333            OptimError::InvalidParameter(format!(
334                "dropped client {dropped} is not in the round's public-key directory"
335            ))
336        })?;
337        let signed_mask = signed_pairwise_mask(
338            client_id,
339            keys,
340            dropped,
341            dropped_key,
342            plan.round_seed,
343            plan.masking_dimension,
344            plan.modulus,
345        )?;
346        disclosures.push(DropoutDisclosure {
347            from_client: client_id.to_string(),
348            dropped_client: dropped.clone(),
349            signed_mask,
350        });
351    }
352    Ok(disclosures)
353}
354
355impl<T: Float + Debug + Send + Sync + 'static> SecureAggregator<T> {
356    /// Build an aggregator, rejecting configurations whose guarantees cannot
357    /// be delivered.
358    pub fn new(config: SecureAggregationConfig) -> Result<Self> {
359        let modulus = validate_config(&config)?;
360        Ok(Self {
361            config,
362            modulus,
363            registered_keys: BTreeMap::new(),
364            current_plan: None,
365            received: BTreeMap::new(),
366            dropped: HashSet::new(),
367            disclosures: Vec::new(),
368            rounds_prepared: 0,
369            _marker: std::marker::PhantomData,
370        })
371    }
372
373    /// Publish a client's per-round public key.
374    ///
375    /// Re-registering the same client replaces its key, which is what happens
376    /// when a client rejoins with a fresh key pair.
377    pub fn register_client_key(
378        &mut self,
379        client_id: &str,
380        public_key: ClientPublicKey,
381    ) -> Result<()> {
382        if client_id.is_empty() {
383            return Err(OptimError::InvalidParameter(
384                "client identifier must not be empty".to_string(),
385            ));
386        }
387        if self
388            .registered_keys
389            .iter()
390            .any(|(id, key)| id != client_id && *key == public_key)
391        {
392            return Err(OptimError::InvalidParameter(format!(
393                "the public key offered by client {client_id} is already registered to another \
394                 client; duplicate keys would make the pairwise masks collide"
395            )));
396        }
397        self.registered_keys
398            .insert(client_id.to_string(), public_key);
399        Ok(())
400    }
401
402    /// Open a round for `selected_clients` and publish the resulting plan.
403    ///
404    /// Clears any state from a previous round. Every selected client must have
405    /// registered a public key, and the cohort must be able to satisfy both
406    /// `min_clients` and the no-wraparound bound.
407    pub fn prepare_round(&mut self, selected_clients: &[String]) -> Result<SecureAggregationPlan> {
408        if !self.config.enabled {
409            return Err(OptimError::InvalidConfig(
410                "secure aggregation is disabled in this configuration; enable it or aggregate \
411                 updates directly instead of routing them through a protocol that is switched off"
412                    .to_string(),
413            ));
414        }
415
416        let mut sorted: Vec<String> = selected_clients.to_vec();
417        sorted.sort();
418        sorted.dedup();
419        if sorted.len() < 2 {
420            return Err(OptimError::InvalidConfig(format!(
421                "pairwise masking needs at least 2 distinct clients, got {}",
422                sorted.len()
423            )));
424        }
425        if sorted.len() < self.config.min_clients {
426            return Err(OptimError::InvalidConfig(format!(
427                "{} clients were selected but min_clients is {}",
428                sorted.len(),
429                self.config.min_clients
430            )));
431        }
432
433        let mut public_keys = BTreeMap::new();
434        for client_id in sorted.iter() {
435            let key = self.registered_keys.get(client_id).ok_or_else(|| {
436                OptimError::InvalidState(format!(
437                    "client {client_id} has not registered a public key for this round; without \
438                     it no pairwise mask can be agreed"
439                ))
440            })?;
441            public_keys.insert(client_id.clone(), *key);
442        }
443
444        // No-wraparound bound: the cohort's summed fixed-point magnitude must
445        // stay inside half the group, otherwise a correct-looking but wrong
446        // aggregate could come back.
447        let worst_case =
448            self.config.quantization_scale * self.config.max_update_magnitude * sorted.len() as f64;
449        let half_modulus = (self.modulus / 2) as f64;
450        if worst_case >= half_modulus {
451            return Err(OptimError::InvalidConfig(format!(
452                "a cohort of {} clients with quantization_scale {} and max_update_magnitude {} \
453                 could sum to {worst_case}, which does not fit in half the modulus \
454                 ({half_modulus}); widen quantization_bits, lower the scale, or shrink the cohort",
455                sorted.len(),
456                self.config.quantization_scale,
457                self.config.max_update_magnitude
458            )));
459        }
460
461        self.received.clear();
462        self.dropped.clear();
463        self.disclosures.clear();
464        self.rounds_prepared = self.rounds_prepared.saturating_add(1);
465
466        let plan = SecureAggregationPlan {
467            round_seed: fresh_round_seed(),
468            participating_clients: sorted,
469            public_keys,
470            min_threshold: self.config.min_clients,
471            masking_enabled: true,
472            masking_dimension: self.config.masking_dimension,
473            modulus: self.modulus,
474            quantization_scale: self.config.quantization_scale,
475            max_update_magnitude: self.config.max_update_magnitude,
476        };
477        self.current_plan = Some(plan.clone());
478        Ok(plan)
479    }
480
481    /// Accept a masked upload.
482    ///
483    /// Refuses uploads from clients already marked as dropped: their pairwise
484    /// masks may already have been disclosed, so accepting a late upload would
485    /// hand the server everything it needs to unmask that single client.
486    pub fn receive(&mut self, submission: MaskedClientUpdate) -> Result<()> {
487        let plan = self.plan()?;
488        if !plan.participating_clients.contains(&submission.client_id) {
489            return Err(OptimError::InvalidParameter(format!(
490                "client {} is not part of the current round",
491                submission.client_id
492            )));
493        }
494        if submission.values.len() != plan.masking_dimension {
495            return Err(OptimError::DimensionMismatch(format!(
496                "submission from client {} has {} values, expected {}",
497                submission.client_id,
498                submission.values.len(),
499                plan.masking_dimension
500            )));
501        }
502        for &value in submission.values.iter() {
503            if value < 0 || value >= self.modulus {
504                return Err(OptimError::InvalidParameter(format!(
505                    "submission value {value} from client {} lies outside [0, {})",
506                    submission.client_id, self.modulus
507                )));
508            }
509        }
510        if self.dropped.contains(&submission.client_id) {
511            return Err(OptimError::InvalidState(format!(
512                "client {} was already marked as dropped and its pairwise masks may have been \
513                 disclosed; accepting this upload would let the server unmask it individually",
514                submission.client_id
515            )));
516        }
517
518        self.received
519            .insert(submission.client_id.clone(), submission);
520        Ok(())
521    }
522
523    /// Mark a participating client as dropped.
524    ///
525    /// Refuses to mark a client that has already submitted, since its mask
526    /// must stay secret for the aggregate to reveal only the sum.
527    pub fn mark_dropped(&mut self, client_id: &str) -> Result<()> {
528        let plan = self.plan()?;
529        if !plan.participating_clients.iter().any(|id| id == client_id) {
530            return Err(OptimError::InvalidParameter(format!(
531                "client {client_id} is not part of the current round"
532            )));
533        }
534        if self.received.contains_key(client_id) {
535            return Err(OptimError::InvalidParameter(format!(
536                "client {client_id} has already submitted; marking it dropped and collecting \
537                 disclosures would expose its individual update"
538            )));
539        }
540        self.dropped.insert(client_id.to_string());
541        Ok(())
542    }
543
544    /// Accept a surviving client's disclosure for a dropped peer.
545    pub fn receive_dropout_disclosure(&mut self, disclosure: DropoutDisclosure) -> Result<()> {
546        let plan = self.plan()?;
547        if !plan.participating_clients.contains(&disclosure.from_client) {
548            return Err(OptimError::InvalidParameter(format!(
549                "client {} is not part of the current round",
550                disclosure.from_client
551            )));
552        }
553        if !self.dropped.contains(&disclosure.dropped_client) {
554            return Err(OptimError::InvalidParameter(format!(
555                "client {} is not marked as dropped, so no disclosure is owed for it",
556                disclosure.dropped_client
557            )));
558        }
559        if disclosure.from_client == disclosure.dropped_client {
560            return Err(OptimError::InvalidParameter(
561                "a client cannot disclose a pairwise mask with itself".to_string(),
562            ));
563        }
564        if disclosure.signed_mask.len() != plan.masking_dimension {
565            return Err(OptimError::DimensionMismatch(format!(
566                "disclosure from client {} has {} values, expected {}",
567                disclosure.from_client,
568                disclosure.signed_mask.len(),
569                plan.masking_dimension
570            )));
571        }
572        if self.disclosures.iter().any(|existing| {
573            existing.from_client == disclosure.from_client
574                && existing.dropped_client == disclosure.dropped_client
575        }) {
576            return Err(OptimError::InvalidState(format!(
577                "client {} has already disclosed its mask with {}",
578                disclosure.from_client, disclosure.dropped_client
579            )));
580        }
581        self.disclosures.push(disclosure);
582        Ok(())
583    }
584
585    /// Sum the received uploads modulo the group order, cancel the residue
586    /// left by dropped clients, and return the dequantised **sum** of the
587    /// received updates.
588    ///
589    /// The server never sees a summand. Errors -- rather than returning a
590    /// wrong-but-plausible vector -- when the round is incomplete, when too
591    /// many clients dropped, or when a dropout's disclosures are missing.
592    pub fn aggregate(&self) -> Result<Array1<T>> {
593        let plan = self.plan()?;
594        if self.received.len() < self.config.min_clients {
595            return Err(OptimError::InvalidConfig(format!(
596                "only {} submissions received but min_clients is {}",
597                self.received.len(),
598                self.config.min_clients
599            )));
600        }
601        if self.dropped.len() > self.config.max_dropouts {
602            return Err(OptimError::InvalidConfig(format!(
603                "{} clients dropped out but max_dropouts is {}",
604                self.dropped.len(),
605                self.config.max_dropouts
606            )));
607        }
608        let accounted = self.received.len() + self.dropped.len();
609        if accounted != plan.participating_clients.len() {
610            let missing: Vec<&str> = plan
611                .participating_clients
612                .iter()
613                .map(|id| id.as_str())
614                .filter(|id| !self.received.contains_key(*id) && !self.dropped.contains(*id))
615                .collect();
616            return Err(OptimError::InvalidState(format!(
617                "the round is incomplete: {} of {} clients neither submitted nor were marked \
618                 dropped ({missing:?}). Their pairwise masks are still in the sum, so the \
619                 aggregate would be meaningless",
620                missing.len(),
621                plan.participating_clients.len()
622            )));
623        }
624
625        // Every surviving client owes one disclosure per dropped client.
626        for dropped in self.dropped.iter() {
627            for survivor in self.received.keys() {
628                let present = self.disclosures.iter().any(|disclosure| {
629                    disclosure.from_client == *survivor && disclosure.dropped_client == *dropped
630                });
631                if !present {
632                    return Err(OptimError::InvalidState(format!(
633                        "client {survivor} has not disclosed its pairwise mask with the dropped \
634                         client {dropped}; without every disclosure the dropout residue cannot \
635                         be cancelled"
636                    )));
637                }
638            }
639        }
640
641        let dimension = plan.masking_dimension;
642        let mut accumulator = vec![0_i64; dimension];
643        for submission in self.received.values() {
644            for (slot, value) in accumulator.iter_mut().zip(submission.values.iter()) {
645                *slot = (*slot + *value).rem_euclid(self.modulus);
646            }
647        }
648        // Each survivor's upload still carries its signed pairwise mask with
649        // every dropped client; subtract exactly those disclosed terms.
650        for disclosure in self.disclosures.iter() {
651            if !self.received.contains_key(&disclosure.from_client) {
652                continue;
653            }
654            for (slot, value) in accumulator.iter_mut().zip(disclosure.signed_mask.iter()) {
655                *slot = (*slot - *value).rem_euclid(self.modulus);
656            }
657        }
658
659        let real_sum = dequantize_gradient(&accumulator, plan.quantization_scale, self.modulus);
660        if real_sum.len() != dimension {
661            return Err(OptimError::ComputationError(format!(
662                "dequantisation returned {} values for a {dimension}-dimensional round",
663                real_sum.len()
664            )));
665        }
666        let mut output = Array1::zeros(dimension);
667        for (slot, value) in output.iter_mut().zip(real_sum.iter()) {
668            *slot = T::from(*value).ok_or_else(|| {
669                OptimError::ComputationError(format!(
670                    "aggregated value {value} is not representable in the target float type"
671                ))
672            })?;
673        }
674        Ok(output)
675    }
676
677    /// The dequantised **mean** of the received updates.
678    pub fn aggregate_mean(&self) -> Result<Array1<T>> {
679        let sum = self.aggregate()?;
680        let count = T::from(self.received.len() as f64).ok_or_else(|| {
681            OptimError::ComputationError("submission count is not representable".to_string())
682        })?;
683        Ok(sum.mapv(|value| value / count))
684    }
685
686    /// Sum of the received uploads modulo the group order, before
687    /// dequantisation. Exposed so that callers (and tests) can verify the
688    /// exact integer identity the protocol guarantees.
689    pub fn masked_sum(&self) -> Result<Vec<i64>> {
690        let plan = self.plan()?;
691        let mut accumulator = vec![0_i64; plan.masking_dimension];
692        for submission in self.received.values() {
693            for (slot, value) in accumulator.iter_mut().zip(submission.values.iter()) {
694                *slot = (*slot + *value).rem_euclid(self.modulus);
695            }
696        }
697        Ok(accumulator)
698    }
699
700    /// Discard the current round's state, keeping registered keys.
701    pub fn reset_round(&mut self) {
702        self.current_plan = None;
703        self.received.clear();
704        self.dropped.clear();
705        self.disclosures.clear();
706    }
707
708    /// Get current configuration
709    pub fn config(&self) -> &SecureAggregationConfig {
710        &self.config
711    }
712
713    /// Modulus of the additive group in use.
714    pub fn modulus(&self) -> i64 {
715        self.modulus
716    }
717
718    /// Number of received submissions.
719    pub fn received_count(&self) -> usize {
720        self.received.len()
721    }
722
723    /// Number of clients marked as dropped.
724    pub fn dropped_count(&self) -> usize {
725        self.dropped.len()
726    }
727
728    /// Number of disclosures collected.
729    pub fn disclosure_count(&self) -> usize {
730        self.disclosures.len()
731    }
732
733    /// Rounds opened by this aggregator.
734    pub fn rounds_prepared(&self) -> u64 {
735        self.rounds_prepared
736    }
737
738    /// The minimum number of submissions required to aggregate.
739    pub fn aggregation_threshold(&self) -> usize {
740        self.config.min_clients
741    }
742
743    /// Check if secure aggregation is enabled
744    pub fn is_enabled(&self) -> bool {
745        self.config.enabled
746    }
747
748    /// The current round's plan, if a round is open.
749    pub fn current_plan(&self) -> Option<&SecureAggregationPlan> {
750        self.current_plan.as_ref()
751    }
752
753    fn plan(&self) -> Result<&SecureAggregationPlan> {
754        self.current_plan.as_ref().ok_or_else(|| {
755            OptimError::InvalidState(
756                "no aggregation round is open; call prepare_round first".to_string(),
757            )
758        })
759    }
760}
761
762impl SecureAggregationConfig {
763    /// Validate the protocol parameters and return the modulus they select.
764    ///
765    /// This is the single source of truth for "is this secure-aggregation
766    /// configuration deliverable?". [`SecureAggregator::new`] calls it, and so
767    /// does the federated-privacy configuration validator
768    /// (`SecureAggregationConfig::validate_for_federation`), so a federation
769    /// cannot be accepted at the configuration layer and then rejected when the
770    /// aggregator is built.
771    pub fn validate_protocol(&self) -> Result<i64> {
772        validate_config(self)
773    }
774}
775
776/// Validate a configuration and return the modulus it selects.
777fn validate_config(config: &SecureAggregationConfig) -> Result<i64> {
778    if config.seed_sharing != SeedSharingMethod::EphemeralDiffieHellman {
779        return Err(OptimError::InvalidConfig(format!(
780            "seed sharing method {:?} is not implemented; only \
781             SeedSharingMethod::EphemeralDiffieHellman (per-round X25519 key agreement between \
782             every pair of clients) is available here",
783            config.seed_sharing
784        )));
785    }
786    if config.aggregate_dp {
787        return Err(OptimError::InvalidConfig(
788            "aggregate_dp is not implemented by this module. Secure aggregation hides individual \
789             updates from the server; it adds no differential privacy noise and claiming \
790             otherwise would overstate the guarantee. Compose this with \
791             crate::privacy::dp_sgd or crate::privacy::noise_mechanisms, which account the \
792             epsilon they spend."
793                .to_string(),
794        ));
795    }
796    if config.masking_dimension == 0 {
797        return Err(OptimError::InvalidConfig(
798            "masking_dimension must be greater than zero".to_string(),
799        ));
800    }
801    if config.min_clients < 2 {
802        return Err(OptimError::InvalidConfig(format!(
803            "min_clients must be at least 2 for pairwise masking to hide anything, got {}",
804            config.min_clients
805        )));
806    }
807    if !config.quantization_scale.is_finite() || config.quantization_scale <= 0.0 {
808        return Err(OptimError::InvalidConfig(format!(
809            "quantization_scale must be positive and finite, got {}",
810            config.quantization_scale
811        )));
812    }
813    if !config.max_update_magnitude.is_finite() || config.max_update_magnitude <= 0.0 {
814        return Err(OptimError::InvalidConfig(format!(
815            "max_update_magnitude must be positive and finite, got {}",
816            config.max_update_magnitude
817        )));
818    }
819
820    let bits = config.quantization_bits.unwrap_or(DEFAULT_MODULUS_BITS);
821    if !(MIN_MODULUS_BITS..=MAX_MODULUS_BITS).contains(&bits) {
822        return Err(OptimError::InvalidConfig(format!(
823            "quantization_bits must be in [{MIN_MODULUS_BITS}, {MAX_MODULUS_BITS}], got {bits}"
824        )));
825    }
826    let modulus = 1_i64 << bits;
827
828    // A round of exactly `min_clients` must at least be representable.
829    let minimum_capacity =
830        config.quantization_scale * config.max_update_magnitude * config.min_clients as f64;
831    if minimum_capacity >= (modulus / 2) as f64 {
832        return Err(OptimError::InvalidConfig(format!(
833            "a cohort of min_clients = {} at quantization_scale {} and max_update_magnitude {} \
834             needs more than half of the {bits}-bit group; widen quantization_bits or lower the \
835             scale",
836            config.min_clients, config.quantization_scale, config.max_update_magnitude
837        )));
838    }
839    Ok(modulus)
840}
841
842impl Default for SecureAggregationConfig {
843    fn default() -> Self {
844        Self {
845            enabled: true,
846            min_clients: 10,
847            max_dropouts: 5,
848            masking_dimension: 1000,
849            seed_sharing: SeedSharingMethod::EphemeralDiffieHellman,
850            quantization_bits: None,
851            // 2^31 / 2 = 1.07e9, so a 10-client cohort of unit-bounded
852            // updates uses at most 1e5 of the available 1.07e9 headroom.
853            quantization_scale: 1.0e4,
854            max_update_magnitude: 1.0,
855            aggregate_dp: false,
856        }
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    const DIM: usize = 32;
865
866    fn config(min_clients: usize) -> SecureAggregationConfig {
867        SecureAggregationConfig {
868            min_clients,
869            max_dropouts: 2,
870            masking_dimension: DIM,
871            quantization_scale: 1.0e6,
872            max_update_magnitude: 10.0,
873            quantization_bits: Some(48),
874            ..SecureAggregationConfig::default()
875        }
876    }
877
878    /// A deterministic but non-trivial update for client `index`.
879    fn update_for(index: usize) -> Array1<f64> {
880        Array1::from_shape_fn(DIM, |k| {
881            ((index + 1) as f64) * 0.125 - (k as f64) * 0.03125 + 0.5
882        })
883    }
884
885    struct Cohort {
886        aggregator: SecureAggregator<f64>,
887        plan: SecureAggregationPlan,
888        ids: Vec<String>,
889        keys: Vec<ClientKeyPair>,
890        updates: Vec<Array1<f64>>,
891    }
892
893    fn cohort(size: usize, min_clients: usize) -> Cohort {
894        let mut aggregator =
895            SecureAggregator::<f64>::new(config(min_clients)).expect("valid config");
896        let ids: Vec<String> = (0..size).map(|i| format!("client{i:02}")).collect();
897        let keys: Vec<ClientKeyPair> = (0..size).map(|_| ClientKeyPair::generate()).collect();
898        for (id, key_pair) in ids.iter().zip(keys.iter()) {
899            aggregator
900                .register_client_key(id, key_pair.public_key())
901                .expect("register");
902        }
903        let plan = aggregator.prepare_round(&ids).expect("plan");
904        let updates: Vec<Array1<f64>> = (0..size).map(update_for).collect();
905        Cohort {
906            aggregator,
907            plan,
908            ids,
909            keys,
910            updates,
911        }
912    }
913
914    fn submit_all(cohort: &mut Cohort) {
915        for index in 0..cohort.ids.len() {
916            let masked = mask_client_update(
917                &cohort.ids[index],
918                &cohort.keys[index],
919                &cohort.updates[index],
920                &cohort.plan,
921            )
922            .expect("mask");
923            cohort.aggregator.receive(masked).expect("receive");
924        }
925    }
926
927    /// The exact fixed-point sum the protocol must reproduce.
928    fn expected_quantised_sum(cohort: &Cohort) -> Vec<i64> {
929        let mut total = vec![0_i64; DIM];
930        for update in cohort.updates.iter() {
931            let quantised =
932                quantize_gradient(update, cohort.plan.quantization_scale, cohort.plan.modulus)
933                    .expect("quantise");
934            for (slot, value) in total.iter_mut().zip(quantised.iter()) {
935                *slot = (*slot + *value).rem_euclid(cohort.plan.modulus);
936            }
937        }
938        total
939    }
940
941    // ---------------------------------------------------------------------
942    // F25: the masks cancel EXACTLY.
943    // ---------------------------------------------------------------------
944
945    #[test]
946    fn masked_sum_equals_the_sum_of_quantised_inputs_exactly() {
947        let mut cohort = cohort(8, 4);
948        submit_all(&mut cohort);
949
950        let masked_sum = cohort.aggregator.masked_sum().expect("masked sum");
951        // Exact integer identity: this is the property the protocol
952        // guarantees, and the one the previous implementation violated.
953        assert_eq!(masked_sum, expected_quantised_sum(&cohort));
954    }
955
956    #[test]
957    fn dequantised_aggregate_matches_the_true_float_sum() {
958        let mut cohort = cohort(8, 4);
959        submit_all(&mut cohort);
960
961        let aggregate = cohort.aggregator.aggregate().expect("aggregate");
962        let mut truth = Array1::<f64>::zeros(DIM);
963        for update in cohort.updates.iter() {
964            truth += update;
965        }
966        assert_eq!(aggregate.len(), DIM);
967        for (got, want) in aggregate.iter().zip(truth.iter()) {
968            // The only error is fixed-point rounding: at most n/2 units of
969            // 1/scale, i.e. 8 / (2 * 1e6).
970            assert!(
971                (got - want).abs() < 1.0e-5,
972                "aggregate {got} differs from the true sum {want}"
973            );
974        }
975
976        let mean = cohort.aggregator.aggregate_mean().expect("mean");
977        for (got, want) in mean.iter().zip(truth.iter()) {
978            assert!((got - want / 8.0).abs() < 1.0e-5);
979        }
980    }
981
982    #[test]
983    fn the_aggregate_is_exact_for_representable_inputs() {
984        // Multiples of 1/1024 are exactly representable at scale 1e6 only up
985        // to rounding, so use integers, which are exact.
986        let mut cohort = cohort(4, 4);
987        cohort.updates = (0..4)
988            .map(|i| Array1::from_shape_fn(DIM, |k| ((i * DIM + k) % 7) as f64 - 3.0))
989            .collect();
990        submit_all(&mut cohort);
991
992        let aggregate = cohort.aggregator.aggregate().expect("aggregate");
993        let mut truth = Array1::<f64>::zeros(DIM);
994        for update in cohort.updates.iter() {
995            truth += update;
996        }
997        for (got, want) in aggregate.iter().zip(truth.iter()) {
998            assert_eq!(got, want, "integers must round-trip exactly");
999        }
1000    }
1001
1002    // ---------------------------------------------------------------------
1003    // F24: an individual upload reveals nothing, and the server holds no key.
1004    // ---------------------------------------------------------------------
1005
1006    #[test]
1007    fn a_single_masked_upload_is_not_its_true_input() {
1008        let cohort = cohort(6, 4);
1009        let masked = mask_client_update(
1010            &cohort.ids[0],
1011            &cohort.keys[0],
1012            &cohort.updates[0],
1013            &cohort.plan,
1014        )
1015        .expect("mask");
1016        let quantised = quantize_gradient(
1017            &cohort.updates[0],
1018            cohort.plan.quantization_scale,
1019            cohort.plan.modulus,
1020        )
1021        .expect("quantise");
1022
1023        assert_eq!(masked.values.len(), DIM);
1024        let coinciding = masked
1025            .values
1026            .iter()
1027            .zip(quantised.iter())
1028            .filter(|(a, b)| a == b)
1029            .count();
1030        assert_eq!(
1031            coinciding, 0,
1032            "{coinciding} of {DIM} coordinates were uploaded unmasked"
1033        );
1034
1035        // The upload is spread over the whole group, not jittered around the
1036        // input. The previous implementation added a mask in [-1, 1] to the
1037        // update, so the "masked" value stayed within 1.0 of the truth --
1038        // which is what this assertion rules out.
1039        let minimum = masked.values.iter().copied().min().expect("non-empty");
1040        let maximum = masked.values.iter().copied().max().expect("non-empty");
1041        let modulus = cohort.plan.modulus;
1042        assert!(
1043            maximum - minimum > modulus / 2,
1044            "masked values span [{minimum}, {maximum}], which is not spread over [0, {modulus})"
1045        );
1046        // And the server-side state that observes the upload carries no key
1047        // material at all, so there is no server-side path back to the input.
1048        let rendered = format!("{:?}", cohort.aggregator);
1049        assert!(rendered.starts_with("SecureAggregator"));
1050        assert!(
1051            !rendered.contains("secret"),
1052            "server state mentions a secret"
1053        );
1054        assert!(!rendered.contains("mask"), "server state mentions a mask");
1055    }
1056
1057    #[test]
1058    fn the_server_cannot_reproduce_a_clients_mask_from_the_plan() {
1059        let cohort = cohort(4, 4);
1060        // Everything the server publishes and observes.
1061        let plan = cohort.plan.clone();
1062        assert!(plan.public_keys.len() == 4 && plan.round_seed != 0);
1063
1064        // A server holding its own key pair and the whole public directory
1065        // cannot derive the mask client00 shares with client01.
1066        let server_keys = ClientKeyPair::generate();
1067        let truth = cohort.keys[0]
1068            .shared_seed_with(&cohort.keys[1].public_key(), plan.round_seed)
1069            .expect("real seed");
1070        let forged = server_keys
1071            .shared_seed_with(&cohort.keys[1].public_key(), plan.round_seed)
1072            .expect("server seed");
1073        assert_ne!(truth, forged);
1074
1075        // And the aggregator itself refuses to produce anything from a single
1076        // upload: with one submission the round is incomplete.
1077        let mut aggregator = cohort.aggregator;
1078        let masked = mask_client_update(&cohort.ids[0], &cohort.keys[0], &cohort.updates[0], &plan)
1079            .expect("mask");
1080        aggregator.receive(masked).expect("receive");
1081        let err = aggregator
1082            .aggregate()
1083            .expect_err("one upload must not be aggregatable");
1084        assert!(format!("{err}").contains("min_clients"));
1085    }
1086
1087    #[test]
1088    fn two_clients_learn_only_the_sum() {
1089        let mut aggregator = SecureAggregator::<f64>::new(SecureAggregationConfig {
1090            min_clients: 2,
1091            ..config(2)
1092        })
1093        .expect("config");
1094        let ids = vec!["a".to_string(), "b".to_string()];
1095        let keys = [ClientKeyPair::generate(), ClientKeyPair::generate()];
1096        for (id, key_pair) in ids.iter().zip(keys.iter()) {
1097            aggregator
1098                .register_client_key(id, key_pair.public_key())
1099                .expect("register");
1100        }
1101        let plan = aggregator.prepare_round(&ids).expect("plan");
1102
1103        let first = Array1::from_shape_fn(DIM, |k| 1.0 + k as f64 * 0.001);
1104        let second = Array1::from_shape_fn(DIM, |k| -0.5 + k as f64 * 0.002);
1105        aggregator
1106            .receive(mask_client_update("a", &keys[0], &first, &plan).expect("mask"))
1107            .expect("receive");
1108        aggregator
1109            .receive(mask_client_update("b", &keys[1], &second, &plan).expect("mask"))
1110            .expect("receive");
1111
1112        let aggregate = aggregator.aggregate().expect("aggregate");
1113        for k in 0..DIM {
1114            assert!((aggregate[k] - (first[k] + second[k])).abs() < 1e-5);
1115            // The sum is not either summand.
1116            assert!((aggregate[k] - first[k]).abs() > 1e-3);
1117            assert!((aggregate[k] - second[k]).abs() > 1e-3);
1118        }
1119    }
1120
1121    // ---------------------------------------------------------------------
1122    // Dropout handling: exact after disclosure, honest error without it.
1123    // ---------------------------------------------------------------------
1124
1125    #[test]
1126    fn a_dropout_without_disclosures_is_an_error_not_a_wrong_answer() {
1127        let mut cohort = cohort(6, 4);
1128        let dropped = cohort.ids[5].clone();
1129        cohort.aggregator.mark_dropped(&dropped).expect("drop");
1130        for index in 0..5 {
1131            let masked = mask_client_update(
1132                &cohort.ids[index],
1133                &cohort.keys[index],
1134                &cohort.updates[index],
1135                &cohort.plan,
1136            )
1137            .expect("mask");
1138            cohort.aggregator.receive(masked).expect("receive");
1139        }
1140        let err = cohort
1141            .aggregator
1142            .aggregate()
1143            .expect_err("residue cannot be cancelled without disclosures");
1144        assert!(format!("{err}").contains("has not disclosed its pairwise mask"));
1145    }
1146
1147    #[test]
1148    fn a_dropout_is_recovered_exactly_once_every_survivor_discloses() {
1149        let mut cohort = cohort(6, 4);
1150        let dropped_index = 5;
1151        let dropped = cohort.ids[dropped_index].clone();
1152        cohort.aggregator.mark_dropped(&dropped).expect("drop");
1153
1154        for index in 0..dropped_index {
1155            let masked = mask_client_update(
1156                &cohort.ids[index],
1157                &cohort.keys[index],
1158                &cohort.updates[index],
1159                &cohort.plan,
1160            )
1161            .expect("mask");
1162            cohort.aggregator.receive(masked).expect("receive");
1163            for disclosure in disclose_dropout_masks(
1164                &cohort.ids[index],
1165                &cohort.keys[index],
1166                std::slice::from_ref(&dropped),
1167                &cohort.plan,
1168            )
1169            .expect("disclose")
1170            {
1171                cohort
1172                    .aggregator
1173                    .receive_dropout_disclosure(disclosure)
1174                    .expect("accept disclosure");
1175            }
1176        }
1177
1178        let aggregate = cohort.aggregator.aggregate().expect("aggregate");
1179        let mut truth = Array1::<f64>::zeros(DIM);
1180        for update in cohort.updates[..dropped_index].iter() {
1181            truth += update;
1182        }
1183        for (got, want) in aggregate.iter().zip(truth.iter()) {
1184            assert!(
1185                (got - want).abs() < 1.0e-5,
1186                "dropout recovery gave {got}, expected {want}"
1187            );
1188        }
1189        assert_eq!(cohort.aggregator.dropped_count(), 1);
1190        assert_eq!(cohort.aggregator.disclosure_count(), dropped_index);
1191    }
1192
1193    #[test]
1194    fn a_dropped_client_cannot_upload_afterwards() {
1195        let mut cohort = cohort(6, 4);
1196        let dropped = cohort.ids[5].clone();
1197        cohort.aggregator.mark_dropped(&dropped).expect("drop");
1198        let late = mask_client_update(
1199            &cohort.ids[5],
1200            &cohort.keys[5],
1201            &cohort.updates[5],
1202            &cohort.plan,
1203        )
1204        .expect("mask");
1205        let err = cohort
1206            .aggregator
1207            .receive(late)
1208            .expect_err("late upload must be refused");
1209        assert!(format!("{err}").contains("already marked as dropped"));
1210    }
1211
1212    #[test]
1213    fn a_client_that_submitted_cannot_be_marked_dropped() {
1214        let mut cohort = cohort(6, 4);
1215        let masked = mask_client_update(
1216            &cohort.ids[0],
1217            &cohort.keys[0],
1218            &cohort.updates[0],
1219            &cohort.plan,
1220        )
1221        .expect("mask");
1222        cohort.aggregator.receive(masked).expect("receive");
1223        let id = cohort.ids[0].clone();
1224        let err = cohort
1225            .aggregator
1226            .mark_dropped(&id)
1227            .expect_err("must be refused");
1228        assert!(format!("{err}").contains("has already submitted"));
1229    }
1230
1231    #[test]
1232    fn too_many_dropouts_is_refused() {
1233        let mut cohort = cohort(8, 4);
1234        for index in 5..8 {
1235            let id = cohort.ids[index].clone();
1236            cohort.aggregator.mark_dropped(&id).expect("drop");
1237        }
1238        for index in 0..5 {
1239            let masked = mask_client_update(
1240                &cohort.ids[index],
1241                &cohort.keys[index],
1242                &cohort.updates[index],
1243                &cohort.plan,
1244            )
1245            .expect("mask");
1246            cohort.aggregator.receive(masked).expect("receive");
1247        }
1248        let err = cohort
1249            .aggregator
1250            .aggregate()
1251            .expect_err("3 dropouts exceeds max_dropouts = 2");
1252        assert!(format!("{err}").contains("max_dropouts"));
1253    }
1254
1255    #[test]
1256    fn an_incomplete_round_is_refused() {
1257        let mut cohort = cohort(8, 4);
1258        for index in 0..5 {
1259            let masked = mask_client_update(
1260                &cohort.ids[index],
1261                &cohort.keys[index],
1262                &cohort.updates[index],
1263                &cohort.plan,
1264            )
1265            .expect("mask");
1266            cohort.aggregator.receive(masked).expect("receive");
1267        }
1268        let err = cohort
1269            .aggregator
1270            .aggregate()
1271            .expect_err("3 clients are unaccounted for");
1272        assert!(format!("{err}").contains("round is incomplete"));
1273    }
1274
1275    // ---------------------------------------------------------------------
1276    // Configuration and plan validation.
1277    // ---------------------------------------------------------------------
1278
1279    #[test]
1280    fn test_secure_aggregation_config() {
1281        let config = SecureAggregationConfig {
1282            enabled: true,
1283            min_clients: 5,
1284            max_dropouts: 2,
1285            masking_dimension: 100,
1286            seed_sharing: SeedSharingMethod::EphemeralDiffieHellman,
1287            quantization_bits: Some(40),
1288            quantization_scale: 1.0e6,
1289            max_update_magnitude: 1.0,
1290            aggregate_dp: false,
1291        };
1292        assert!(config.enabled);
1293        assert_eq!(config.min_clients, 5);
1294        assert_eq!(config.max_dropouts, 2);
1295        assert!(SecureAggregator::<f64>::new(config).is_ok());
1296    }
1297
1298    #[test]
1299    fn test_secure_aggregator_creation() {
1300        let config = SecureAggregationConfig::default();
1301        let aggregator = SecureAggregator::<f64>::new(config.clone()).expect("default config");
1302        assert_eq!(aggregator.aggregation_threshold(), config.min_clients);
1303        assert!(aggregator.is_enabled());
1304        assert_eq!(aggregator.modulus(), 1_i64 << DEFAULT_MODULUS_BITS);
1305    }
1306
1307    #[test]
1308    fn unimplemented_seed_sharing_methods_are_refused() {
1309        for method in [
1310            SeedSharingMethod::ShamirSecretSharing,
1311            SeedSharingMethod::ThresholdEncryption,
1312            SeedSharingMethod::DistributedKeyGeneration,
1313        ] {
1314            let err = SecureAggregator::<f64>::new(SecureAggregationConfig {
1315                seed_sharing: method,
1316                ..SecureAggregationConfig::default()
1317            })
1318            .expect_err("must be refused");
1319            assert!(format!("{err}").contains("is not implemented"));
1320        }
1321    }
1322
1323    #[test]
1324    fn aggregate_dp_is_refused_rather_than_silently_ignored() {
1325        let err = SecureAggregator::<f64>::new(SecureAggregationConfig {
1326            aggregate_dp: true,
1327            ..SecureAggregationConfig::default()
1328        })
1329        .expect_err("must be refused");
1330        let message = format!("{err}");
1331        assert!(message.contains("aggregate_dp is not implemented"));
1332        assert!(message.contains("dp_sgd"));
1333    }
1334
1335    #[test]
1336    fn wraparound_is_refused_at_configuration_and_at_round_time() {
1337        // 2^16 / 2 = 32768, far too small for scale 1e6.
1338        let err = SecureAggregator::<f64>::new(SecureAggregationConfig {
1339            quantization_bits: Some(16),
1340            quantization_scale: 1.0e6,
1341            max_update_magnitude: 1.0,
1342            min_clients: 2,
1343            masking_dimension: 4,
1344            ..SecureAggregationConfig::default()
1345        })
1346        .expect_err("group too small");
1347        assert!(format!("{err}").contains("needs more than half"));
1348
1349        // A configuration that is fine for min_clients but not for the cohort
1350        // actually selected: 2^24 / 2 = 8_388_608 units, scale 1e5, bound 1.0
1351        // => at most 83 clients.
1352        let mut aggregator = SecureAggregator::<f64>::new(SecureAggregationConfig {
1353            quantization_bits: Some(24),
1354            quantization_scale: 1.0e5,
1355            max_update_magnitude: 1.0,
1356            min_clients: 2,
1357            masking_dimension: 4,
1358            ..SecureAggregationConfig::default()
1359        })
1360        .expect("valid for a small cohort");
1361        let ids: Vec<String> = (0..100).map(|i| format!("c{i:03}")).collect();
1362        for id in ids.iter() {
1363            aggregator
1364                .register_client_key(id, ClientKeyPair::generate().public_key())
1365                .expect("register");
1366        }
1367        let err = aggregator
1368            .prepare_round(&ids)
1369            .expect_err("100 clients overflow the group");
1370        assert!(format!("{err}").contains("does not fit in half the modulus"));
1371    }
1372
1373    #[test]
1374    fn an_update_beyond_the_declared_bound_is_refused() {
1375        let cohort = cohort(4, 4);
1376        let mut oversized = cohort.updates[0].clone();
1377        oversized[0] = cohort.plan.max_update_magnitude * 2.0;
1378        let err = mask_client_update(&cohort.ids[0], &cohort.keys[0], &oversized, &cohort.plan)
1379            .expect_err("must be refused");
1380        assert!(format!("{err}").contains("exceeds the round's declared bound"));
1381
1382        let mut infinite = cohort.updates[0].clone();
1383        infinite[1] = f64::INFINITY;
1384        assert!(
1385            mask_client_update(&cohort.ids[0], &cohort.keys[0], &infinite, &cohort.plan).is_err()
1386        );
1387    }
1388
1389    #[test]
1390    fn disabled_secure_aggregation_refuses_to_open_a_round() {
1391        let mut aggregator = SecureAggregator::<f64>::new(SecureAggregationConfig {
1392            enabled: false,
1393            ..SecureAggregationConfig::default()
1394        })
1395        .expect("config");
1396        let ids = vec!["a".to_string(), "b".to_string()];
1397        for id in ids.iter() {
1398            aggregator
1399                .register_client_key(id, ClientKeyPair::generate().public_key())
1400                .expect("register");
1401        }
1402        let err = aggregator
1403            .prepare_round(&ids)
1404            .expect_err("disabled protocol");
1405        assert!(format!("{err}").contains("disabled"));
1406        assert!(!aggregator.is_enabled());
1407    }
1408
1409    #[test]
1410    fn test_secure_aggregation_plan() {
1411        let cohort = cohort(4, 4);
1412        assert_eq!(cohort.plan.participating_clients.len(), 4);
1413        assert!(cohort.plan.masking_enabled);
1414        assert_eq!(cohort.plan.public_keys.len(), 4);
1415        assert_eq!(cohort.plan.masking_dimension, DIM);
1416        assert_eq!(cohort.aggregator.rounds_prepared(), 1);
1417    }
1418
1419    #[test]
1420    fn round_seeds_are_fresh_and_not_a_counter() {
1421        let mut aggregator = SecureAggregator::<f64>::new(config(2)).expect("config");
1422        let ids = vec!["a".to_string(), "b".to_string()];
1423        for id in ids.iter() {
1424            aggregator
1425                .register_client_key(id, ClientKeyPair::generate().public_key())
1426                .expect("register");
1427        }
1428        let mut seeds = std::collections::HashSet::new();
1429        for round in 1..=5_u64 {
1430            let plan = aggregator.prepare_round(&ids).expect("plan");
1431            // The previous implementation returned 1, 2, 3, ... from a
1432            // mutex-guarded counter.
1433            assert_ne!(plan.round_seed, round);
1434            seeds.insert(plan.round_seed);
1435        }
1436        assert_eq!(seeds.len(), 5);
1437        assert_eq!(aggregator.rounds_prepared(), 5);
1438    }
1439
1440    #[test]
1441    fn different_rounds_produce_different_masks_for_the_same_update() {
1442        let mut cohort = cohort(4, 4);
1443        let first = mask_client_update(
1444            &cohort.ids[0],
1445            &cohort.keys[0],
1446            &cohort.updates[0],
1447            &cohort.plan,
1448        )
1449        .expect("mask");
1450        let second_plan = cohort.aggregator.prepare_round(&cohort.ids).expect("plan");
1451        let second = mask_client_update(
1452            &cohort.ids[0],
1453            &cohort.keys[0],
1454            &cohort.updates[0],
1455            &second_plan,
1456        )
1457        .expect("mask");
1458        assert_ne!(first.values, second.values);
1459    }
1460
1461    #[test]
1462    fn a_round_cannot_open_without_registered_keys() {
1463        let mut aggregator = SecureAggregator::<f64>::new(config(2)).expect("config");
1464        let ids = vec!["a".to_string(), "b".to_string()];
1465        aggregator
1466            .register_client_key("a", ClientKeyPair::generate().public_key())
1467            .expect("register");
1468        let err = aggregator.prepare_round(&ids).expect_err("b has no key");
1469        assert!(format!("{err}").contains("has not registered a public key"));
1470    }
1471
1472    #[test]
1473    fn duplicate_public_keys_are_refused() {
1474        let mut aggregator = SecureAggregator::<f64>::new(config(2)).expect("config");
1475        let shared = ClientKeyPair::generate().public_key();
1476        aggregator
1477            .register_client_key("a", shared)
1478            .expect("register a");
1479        let err = aggregator
1480            .register_client_key("b", shared)
1481            .expect_err("duplicate key");
1482        assert!(format!("{err}").contains("already registered to another client"));
1483    }
1484
1485    #[test]
1486    fn submissions_are_validated_against_the_open_round() {
1487        let mut cohort = cohort(4, 4);
1488        let stranger = MaskedClientUpdate {
1489            client_id: "nobody".to_string(),
1490            values: vec![0; DIM],
1491        };
1492        assert!(cohort.aggregator.receive(stranger).is_err());
1493
1494        let wrong_length = MaskedClientUpdate {
1495            client_id: cohort.ids[0].clone(),
1496            values: vec![0; DIM + 1],
1497        };
1498        assert!(cohort.aggregator.receive(wrong_length).is_err());
1499
1500        let out_of_range = MaskedClientUpdate {
1501            client_id: cohort.ids[0].clone(),
1502            values: vec![cohort.plan.modulus; DIM],
1503        };
1504        let err = cohort
1505            .aggregator
1506            .receive(out_of_range)
1507            .expect_err("out of range");
1508        assert!(format!("{err}").contains("lies outside"));
1509    }
1510
1511    #[test]
1512    fn operations_before_prepare_round_are_refused() {
1513        let mut aggregator = SecureAggregator::<f64>::new(config(2)).expect("config");
1514        let submission = MaskedClientUpdate {
1515            client_id: "a".to_string(),
1516            values: vec![0; DIM],
1517        };
1518        assert!(aggregator.receive(submission).is_err());
1519        assert!(aggregator.mark_dropped("a").is_err());
1520        let err = aggregator.aggregate().expect_err("no round");
1521        assert!(format!("{err}").contains("no aggregation round is open"));
1522    }
1523
1524    #[test]
1525    fn disclosures_are_validated() {
1526        let mut cohort = cohort(6, 4);
1527        let dropped = cohort.ids[5].clone();
1528
1529        // No disclosure is owed before the client is marked dropped.
1530        let premature = DropoutDisclosure {
1531            from_client: cohort.ids[0].clone(),
1532            dropped_client: dropped.clone(),
1533            signed_mask: vec![0; DIM],
1534        };
1535        let err = cohort
1536            .aggregator
1537            .receive_dropout_disclosure(premature)
1538            .expect_err("not dropped yet");
1539        assert!(format!("{err}").contains("is not marked as dropped"));
1540
1541        cohort.aggregator.mark_dropped(&dropped).expect("drop");
1542        let real = disclose_dropout_masks(
1543            &cohort.ids[0],
1544            &cohort.keys[0],
1545            std::slice::from_ref(&dropped),
1546            &cohort.plan,
1547        )
1548        .expect("disclose")
1549        .remove(0);
1550        cohort
1551            .aggregator
1552            .receive_dropout_disclosure(real.clone())
1553            .expect("first");
1554        let err = cohort
1555            .aggregator
1556            .receive_dropout_disclosure(real)
1557            .expect_err("duplicate");
1558        assert!(format!("{err}").contains("has already disclosed"));
1559
1560        // A client cannot disclose a mask with itself.
1561        assert!(disclose_dropout_masks(
1562            &dropped,
1563            &cohort.keys[5],
1564            std::slice::from_ref(&dropped),
1565            &cohort.plan
1566        )
1567        .is_err());
1568    }
1569
1570    #[test]
1571    fn reset_round_clears_state_but_keeps_keys() {
1572        let mut cohort = cohort(4, 4);
1573        submit_all(&mut cohort);
1574        assert_eq!(cohort.aggregator.received_count(), 4);
1575        cohort.aggregator.reset_round();
1576        assert_eq!(cohort.aggregator.received_count(), 0);
1577        assert!(cohort.aggregator.current_plan().is_none());
1578        // Keys survive, so a new round can open immediately.
1579        assert!(cohort.aggregator.prepare_round(&cohort.ids).is_ok());
1580    }
1581
1582    #[test]
1583    fn aggregation_is_independent_of_submission_order() {
1584        let mut forward = cohort(6, 4);
1585        // Reuse the *same* plan so the masks match, then submit in reverse.
1586        submit_all(&mut forward);
1587        let expected = forward.aggregator.aggregate().expect("aggregate");
1588
1589        forward.aggregator.reset_round();
1590        let plan = forward.plan.clone();
1591        let mut aggregator = forward.aggregator;
1592        aggregator.current_plan = Some(plan.clone());
1593        for index in (0..forward.ids.len()).rev() {
1594            let masked = mask_client_update(
1595                &forward.ids[index],
1596                &forward.keys[index],
1597                &forward.updates[index],
1598                &plan,
1599            )
1600            .expect("mask");
1601            aggregator.receive(masked).expect("receive");
1602        }
1603        let actual = aggregator.aggregate().expect("aggregate");
1604        assert_eq!(actual.to_vec(), expected.to_vec());
1605    }
1606}