1use 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
73pub const DEFAULT_MODULUS_BITS: u8 = 31;
75
76pub const MIN_MODULUS_BITS: u8 = 8;
80pub const MAX_MODULUS_BITS: u8 = 62;
82
83#[derive(Debug, Clone)]
85pub struct SecureAggregationConfig {
86 pub enabled: bool,
89
90 pub min_clients: usize,
92
93 pub max_dropouts: usize,
96
97 pub masking_dimension: usize,
99
100 pub seed_sharing: SeedSharingMethod,
102
103 pub quantization_bits: Option<u8>,
106
107 pub quantization_scale: f64,
110
111 pub max_update_magnitude: f64,
115
116 pub aggregate_dp: bool,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum SeedSharingMethod {
124 EphemeralDiffieHellman,
127
128 ShamirSecretSharing,
131
132 ThresholdEncryption,
134
135 DistributedKeyGeneration,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct MaskedClientUpdate {
145 pub client_id: String,
147 pub values: Vec<i64>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct DropoutDisclosure {
160 pub from_client: String,
162 pub dropped_client: String,
164 pub signed_mask: Vec<i64>,
167}
168
169#[derive(Debug, Clone)]
174pub struct SecureAggregationPlan {
175 pub round_seed: u64,
177
178 pub participating_clients: Vec<String>,
180
181 pub public_keys: BTreeMap<String, ClientPublicKey>,
183
184 pub min_threshold: usize,
186
187 pub masking_enabled: bool,
189
190 pub masking_dimension: usize,
192
193 pub modulus: i64,
195
196 pub quantization_scale: f64,
198
199 pub max_update_magnitude: f64,
201}
202
203pub 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 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
240pub 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
314pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn config(&self) -> &SecureAggregationConfig {
710 &self.config
711 }
712
713 pub fn modulus(&self) -> i64 {
715 self.modulus
716 }
717
718 pub fn received_count(&self) -> usize {
720 self.received.len()
721 }
722
723 pub fn dropped_count(&self) -> usize {
725 self.dropped.len()
726 }
727
728 pub fn disclosure_count(&self) -> usize {
730 self.disclosures.len()
731 }
732
733 pub fn rounds_prepared(&self) -> u64 {
735 self.rounds_prepared
736 }
737
738 pub fn aggregation_threshold(&self) -> usize {
740 self.config.min_clients
741 }
742
743 pub fn is_enabled(&self) -> bool {
745 self.config.enabled
746 }
747
748 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 pub fn validate_protocol(&self) -> Result<i64> {
772 validate_config(self)
773 }
774}
775
776fn 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 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 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 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 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 #[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 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 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 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 #[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 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 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 let plan = cohort.plan.clone();
1062 assert!(plan.public_keys.len() == 4 && plan.round_seed != 0);
1063
1064 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 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 assert!((aggregate[k] - first[k]).abs() > 1e-3);
1117 assert!((aggregate[k] - second[k]).abs() > 1e-3);
1118 }
1119 }
1120
1121 #[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 #[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 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 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 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 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 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 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 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}