optirs_core/privacy/federated/
pairwise_masking.rs1use crate::error::{OptimError, Result};
64use serde::{Deserialize, Serialize};
65use sha2::{Digest, Sha256};
66use std::collections::BTreeMap;
67use x25519_dalek::{PublicKey, StaticSecret};
68
69use scirs2_core::random::thread_rng;
70
71const SEED_DOMAIN: &[u8] = b"OPTIRS-FED-SECAGG-PAIRWISE-SEED-v1";
73
74const PRG_DOMAIN: &[u8] = b"OPTIRS-FED-SECAGG-MASK-PRG-v1";
76
77const WORDS_PER_BLOCK: usize = 4;
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85pub struct ClientPublicKey([u8; 32]);
86
87impl ClientPublicKey {
88 pub fn from_bytes(bytes: [u8; 32]) -> Self {
90 Self(bytes)
91 }
92
93 pub fn as_bytes(&self) -> &[u8; 32] {
95 &self.0
96 }
97}
98
99pub struct ClientKeyPair {
105 secret: StaticSecret,
106 public: ClientPublicKey,
107}
108
109impl std::fmt::Debug for ClientKeyPair {
110 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 formatter
112 .debug_struct("ClientKeyPair")
113 .field("public", &self.public)
114 .field("secret", &"<redacted>")
115 .finish()
116 }
117}
118
119impl ClientKeyPair {
120 pub fn generate() -> Self {
126 let mut bytes = [0_u8; 32];
127 thread_rng().fill(&mut bytes[..]);
130 Self::from_secret_bytes(bytes)
131 }
132
133 pub fn from_secret_bytes(bytes: [u8; 32]) -> Self {
139 let secret = StaticSecret::from(bytes);
140 let public = ClientPublicKey(PublicKey::from(&secret).to_bytes());
141 Self { secret, public }
142 }
143
144 pub fn public_key(&self) -> ClientPublicKey {
146 self.public
147 }
148
149 pub fn shared_seed_with(&self, peer: &ClientPublicKey, round_seed: u64) -> Result<[u8; 32]> {
158 if *peer == self.public {
159 return Err(OptimError::InvalidParameter(
160 "a client cannot derive a pairwise mask with its own public key".to_string(),
161 ));
162 }
163 let shared = self.secret.diffie_hellman(&PublicKey::from(peer.0));
164 let shared_bytes = shared.as_bytes();
165 if shared_bytes.iter().all(|&byte| byte == 0) {
166 return Err(OptimError::InvalidParameter(
167 "X25519 key agreement produced the all-zero shared secret; the peer supplied a \
168 low-order public key and the resulting mask would be predictable"
169 .to_string(),
170 ));
171 }
172
173 let (low, high) = if self.public.0 <= peer.0 {
174 (&self.public.0, &peer.0)
175 } else {
176 (&peer.0, &self.public.0)
177 };
178
179 let mut hasher = Sha256::new();
180 hasher.update(SEED_DOMAIN);
181 hasher.update(round_seed.to_le_bytes());
182 hasher.update(low);
183 hasher.update(high);
184 hasher.update(shared_bytes);
185 let digest = hasher.finalize();
186
187 let mut seed = [0_u8; 32];
188 seed.copy_from_slice(&digest);
189 Ok(seed)
190 }
191}
192
193pub fn expand_mask(seed: &[u8; 32], dim: usize, modulus: i64) -> Result<Vec<i64>> {
200 if modulus <= 1 {
201 return Err(OptimError::InvalidParameter(format!(
202 "mask modulus must be greater than 1, got {modulus}"
203 )));
204 }
205 if dim == 0 {
206 return Ok(Vec::new());
207 }
208
209 let modulus_u = modulus as u64;
210 let acceptance_bound = (u64::MAX / modulus_u) * modulus_u;
213
214 let mut mask = Vec::with_capacity(dim);
215 let mut counter = 0_u64;
216 while mask.len() < dim {
217 let mut hasher = Sha256::new();
218 hasher.update(PRG_DOMAIN);
219 hasher.update(seed);
220 hasher.update(counter.to_le_bytes());
221 let block = hasher.finalize();
222 counter = counter.wrapping_add(1);
223
224 for word_index in 0..WORDS_PER_BLOCK {
225 if mask.len() == dim {
226 break;
227 }
228 let start = word_index * 8;
229 let mut word_bytes = [0_u8; 8];
230 word_bytes.copy_from_slice(&block[start..start + 8]);
231 let word = u64::from_le_bytes(word_bytes);
232 if word < acceptance_bound {
233 mask.push((word % modulus_u) as i64);
234 }
235 }
236 }
237 Ok(mask)
238}
239
240pub fn signed_pairwise_mask(
245 own_id: &str,
246 own_keys: &ClientKeyPair,
247 peer_id: &str,
248 peer_key: &ClientPublicKey,
249 round_seed: u64,
250 dim: usize,
251 modulus: i64,
252) -> Result<Vec<i64>> {
253 if own_id == peer_id {
254 return Err(OptimError::InvalidParameter(format!(
255 "client {own_id} cannot hold a pairwise mask with itself"
256 )));
257 }
258 let seed = own_keys.shared_seed_with(peer_key, round_seed)?;
259 let mask = expand_mask(&seed, dim, modulus)?;
260 let positive = own_id < peer_id;
261 Ok(mask
262 .into_iter()
263 .map(|value| {
264 if positive {
265 value.rem_euclid(modulus)
266 } else {
267 (-value).rem_euclid(modulus)
268 }
269 })
270 .collect())
271}
272
273pub fn compute_client_mask(
283 own_id: &str,
284 own_keys: &ClientKeyPair,
285 peers: &BTreeMap<String, ClientPublicKey>,
286 round_seed: u64,
287 dim: usize,
288 modulus: i64,
289) -> Result<Vec<i64>> {
290 if modulus <= 1 {
291 return Err(OptimError::InvalidParameter(format!(
292 "mask modulus must be greater than 1, got {modulus}"
293 )));
294 }
295 match peers.get(own_id) {
296 None => {
297 return Err(OptimError::InvalidParameter(format!(
298 "client {own_id} is not in the round's public-key directory"
299 )));
300 }
301 Some(published) if *published != own_keys.public_key() => {
302 return Err(OptimError::InvalidParameter(format!(
303 "the public key published for client {own_id} does not match the supplied key \
304 pair; the derived masks would not cancel"
305 )));
306 }
307 Some(_) => {}
308 }
309 if peers.len() < 2 {
310 return Err(OptimError::InvalidConfig(format!(
311 "client {own_id} has no peers in this round; a single-client cohort cannot be \
312 masked, so the upload would be the raw update"
313 )));
314 }
315
316 let mut total = vec![0_i64; dim];
317 for (peer_id, peer_key) in peers.iter() {
318 if peer_id == own_id {
319 continue;
320 }
321 let signed = signed_pairwise_mask(
322 own_id, own_keys, peer_id, peer_key, round_seed, dim, modulus,
323 )?;
324 for (accumulator, value) in total.iter_mut().zip(signed.iter()) {
325 *accumulator = (*accumulator + *value).rem_euclid(modulus);
326 }
327 }
328 Ok(total)
329}
330
331pub fn fresh_round_seed() -> u64 {
337 thread_rng().random::<u64>()
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn keys(tag: u8) -> ClientKeyPair {
345 let mut bytes = [0_u8; 32];
346 bytes[0] = tag;
347 bytes[31] = tag.wrapping_mul(7).wrapping_add(1);
348 ClientKeyPair::from_secret_bytes(bytes)
349 }
350
351 const MODULUS: i64 = 1 << 31;
352
353 #[test]
354 fn key_agreement_is_symmetric() {
355 let alice = keys(1);
356 let bob = keys(2);
357 let seed_ab = alice
358 .shared_seed_with(&bob.public_key(), 7)
359 .expect("alice -> bob");
360 let seed_ba = bob
361 .shared_seed_with(&alice.public_key(), 7)
362 .expect("bob -> alice");
363 assert_eq!(seed_ab, seed_ba);
364 }
365
366 #[test]
367 fn the_shared_seed_requires_a_secret_key_so_the_server_cannot_derive_it() {
368 let alice = keys(1);
369 let bob = keys(2);
370 let server = keys(3);
373
374 let truth = alice
375 .shared_seed_with(&bob.public_key(), 7)
376 .expect("alice -> bob");
377 let server_attempt = server
378 .shared_seed_with(&bob.public_key(), 7)
379 .expect("server -> bob");
380 assert_ne!(
381 truth, server_attempt,
382 "the pairwise seed must depend on a client secret, not only on public data"
383 );
384
385 let real = expand_mask(&truth, 64, MODULUS).expect("real mask");
387 let forged = expand_mask(&server_attempt, 64, MODULUS).expect("forged mask");
388 let matches = real
389 .iter()
390 .zip(forged.iter())
391 .filter(|(a, b)| a == b)
392 .count();
393 assert!(
394 matches < 4,
395 "a mask derived without the secret should not coincide with the real one \
396 ({matches}/64 coordinates matched)"
397 );
398 }
399
400 #[test]
401 fn seeds_differ_across_rounds_and_across_pairs() {
402 let alice = keys(1);
403 let bob = keys(2);
404 let carol = keys(3);
405
406 let round_one = alice
407 .shared_seed_with(&bob.public_key(), 1)
408 .expect("round 1");
409 let round_two = alice
410 .shared_seed_with(&bob.public_key(), 2)
411 .expect("round 2");
412 assert_ne!(round_one, round_two);
413
414 let with_carol = alice
415 .shared_seed_with(&carol.public_key(), 1)
416 .expect("alice -> carol");
417 assert_ne!(round_one, with_carol);
418 }
419
420 #[test]
421 fn a_client_cannot_pair_with_itself() {
422 let alice = keys(1);
423 let err = alice
424 .shared_seed_with(&alice.public_key(), 1)
425 .expect_err("self pairing must fail");
426 assert!(format!("{err}").contains("own public key"));
427 }
428
429 #[test]
430 fn low_order_public_keys_are_rejected() {
431 let alice = keys(1);
432 let malicious = ClientPublicKey::from_bytes([0_u8; 32]);
435 let err = alice
436 .shared_seed_with(&malicious, 1)
437 .expect_err("low-order key must be rejected");
438 assert!(format!("{err}").contains("all-zero shared secret"));
439 }
440
441 #[test]
442 fn expand_mask_is_deterministic_and_in_range() {
443 let seed = [0x5A_u8; 32];
444 let first = expand_mask(&seed, 1000, MODULUS).expect("mask");
445 let second = expand_mask(&seed, 1000, MODULUS).expect("mask");
446 assert_eq!(first, second);
447 assert_eq!(first.len(), 1000);
448 assert!(first.iter().all(|&value| (0..MODULUS).contains(&value)));
449
450 assert!(expand_mask(&seed, 0, MODULUS).expect("empty").is_empty());
451 assert!(expand_mask(&seed, 4, 1).is_err());
452 }
453
454 #[test]
455 fn expand_mask_output_covers_the_whole_group() {
456 let seed = [0x11_u8; 32];
459 let mask = expand_mask(&seed, 4096, MODULUS).expect("mask");
460 let minimum = mask.iter().copied().min().expect("non-empty");
461 let maximum = mask.iter().copied().max().expect("non-empty");
462 assert!(
463 minimum < MODULUS / 100,
464 "minimum {minimum} is not near zero"
465 );
466 assert!(
467 maximum > MODULUS - MODULUS / 100,
468 "maximum {maximum} is not near the modulus"
469 );
470
471 let mut buckets = [0_usize; 8];
473 for &value in mask.iter() {
474 let bucket = ((value as i128 * 8) / MODULUS as i128) as usize;
475 buckets[bucket.min(7)] += 1;
476 }
477 for (index, &count) in buckets.iter().enumerate() {
478 assert!(
479 count > 4096 / 16 && count < 4096 / 4,
480 "bucket {index} holds {count} of 4096 samples, which is not roughly uniform"
481 );
482 }
483 }
484
485 #[test]
486 fn expand_mask_changes_with_the_seed() {
487 let a = expand_mask(&[1_u8; 32], 64, MODULUS).expect("mask");
488 let b = expand_mask(&[2_u8; 32], 64, MODULUS).expect("mask");
489 assert_ne!(a, b);
490 }
491
492 #[test]
493 fn signed_masks_of_a_pair_are_additive_inverses() {
494 let alice = keys(1);
495 let bob = keys(2);
496 let from_alice =
497 signed_pairwise_mask("alice", &alice, "bob", &bob.public_key(), 42, 32, MODULUS)
498 .expect("alice mask");
499 let from_bob =
500 signed_pairwise_mask("bob", &bob, "alice", &alice.public_key(), 42, 32, MODULUS)
501 .expect("bob mask");
502
503 assert_eq!(from_alice.len(), 32);
504 for (a, b) in from_alice.iter().zip(from_bob.iter()) {
505 assert_eq!(
506 (a + b).rem_euclid(MODULUS),
507 0,
508 "pairwise masks must cancel: {a} + {b} != 0 mod {MODULUS}"
509 );
510 }
511 }
512
513 #[test]
514 fn every_clients_mask_sums_to_zero_over_the_cohort() {
515 let dim = 128;
516 let round_seed = 0xDEAD_BEEF;
517 let pairs: Vec<(String, ClientKeyPair)> = (1..=6_u8)
518 .map(|tag| (format!("client{tag:02}"), keys(tag)))
519 .collect();
520 let directory: BTreeMap<String, ClientPublicKey> = pairs
521 .iter()
522 .map(|(id, keys)| (id.clone(), keys.public_key()))
523 .collect();
524
525 let mut total = vec![0_i64; dim];
526 for (id, key_pair) in pairs.iter() {
527 let mask = compute_client_mask(id, key_pair, &directory, round_seed, dim, MODULUS)
528 .expect("client mask");
529 assert_eq!(mask.len(), dim);
530 for (accumulator, value) in total.iter_mut().zip(mask.iter()) {
531 *accumulator = (*accumulator + *value).rem_euclid(MODULUS);
532 }
533 }
534 assert!(
535 total.iter().all(|&value| value == 0),
536 "cohort masks did not telescope to zero"
537 );
538 }
539
540 #[test]
541 fn an_individual_mask_is_not_trivial() {
542 let dim = 256;
543 let pairs: Vec<(String, ClientKeyPair)> = (1..=4_u8)
544 .map(|tag| (format!("client{tag}"), keys(tag)))
545 .collect();
546 let directory: BTreeMap<String, ClientPublicKey> = pairs
547 .iter()
548 .map(|(id, keys)| (id.clone(), keys.public_key()))
549 .collect();
550 let (id, key_pair) = &pairs[0];
551 let mask = compute_client_mask(id, key_pair, &directory, 1, dim, MODULUS).expect("mask");
552 let zeros = mask.iter().filter(|&&value| value == 0).count();
553 assert!(zeros < 4, "{zeros} of {dim} mask coordinates were zero");
554 }
555
556 #[test]
557 fn compute_client_mask_validates_the_directory() {
558 let alice = keys(1);
559 let bob = keys(2);
560 let mut directory = BTreeMap::new();
561 directory.insert("bob".to_string(), bob.public_key());
562
563 let err = compute_client_mask("alice", &alice, &directory, 1, 8, MODULUS)
565 .expect_err("missing from directory");
566 assert!(format!("{err}").contains("not in the round's public-key directory"));
567
568 directory.insert("alice".to_string(), keys(9).public_key());
570 let err = compute_client_mask("alice", &alice, &directory, 1, 8, MODULUS)
571 .expect_err("key mismatch");
572 assert!(format!("{err}").contains("does not match the supplied key pair"));
573
574 let mut solo = BTreeMap::new();
576 solo.insert("alice".to_string(), alice.public_key());
577 let err =
578 compute_client_mask("alice", &alice, &solo, 1, 8, MODULUS).expect_err("solo cohort");
579 assert!(format!("{err}").contains("no peers"));
580 }
581
582 #[test]
583 fn generated_key_pairs_are_distinct() {
584 let first = ClientKeyPair::generate();
585 let second = ClientKeyPair::generate();
586 assert_ne!(first.public_key(), second.public_key());
587 assert!(format!("{first:?}").contains("<redacted>"));
589 }
590
591 #[test]
592 fn fresh_round_seeds_are_not_a_counter() {
593 let seeds: Vec<u64> = (0..8).map(|_| fresh_round_seed()).collect();
594 let distinct: std::collections::HashSet<u64> = seeds.iter().copied().collect();
595 assert_eq!(distinct.len(), seeds.len());
596 assert!(seeds.iter().any(|&seed| seed > u64::MAX / 1024));
598 }
599}