Skip to main content

optirs_core/privacy/federated/
pairwise_masking.rs

1// Pairwise mask derivation for Bonawitz-style secure aggregation.
2//
3// This module supplies the cryptographic half of
4// [`super::secure_aggregation`]: real X25519 elliptic-curve Diffie-Hellman key
5// agreement between every pair of participating clients, a SHA-256
6// counter-mode PRG that expands each agreed secret into a mask vector, and the
7// signed accumulation rule that makes those masks cancel exactly when the
8// server adds the uploads together.
9//
10// Why key agreement, and not a published seed
11// -------------------------------------------
12// The security of pairwise masking rests entirely on the server being unable
13// to reproduce the masks. If the pairwise seed is a function of public data
14// only -- client identifiers plus a round salt the server itself published --
15// then the server can recompute every mask and subtract it from any single
16// upload, recovering that client's gradient exactly. That is a masking
17// *protocol* with zero confidentiality.
18//
19// Here each client generates a fresh X25519 key pair per round and publishes
20// only the public half. The seed shared by clients `i` and `j` is
21//
22// ```text
23// seed_ij = SHA-256( DOMAIN || round_seed || min(pk_i, pk_j) || max(pk_i, pk_j)
24//                    || X25519(sk_i, pk_j) )
25// ```
26//
27// which is symmetric (X25519 is, and the public keys are sorted) yet requires
28// one of the two secret keys. The aggregation server holds neither, so it can
29// verify nothing about, and reconstruct nothing from, an individual upload.
30//
31// Mask expansion
32// --------------
33// The 32-byte seed is stretched with SHA-256 in counter mode:
34// `block_k = SHA-256(PRG_DOMAIN || seed || k)`, each block yielding four
35// little-endian `u64` words. Each word is reduced into `[0, modulus)` by
36// rejection sampling, so the mask is uniform over the additive group rather
37// than modulo-biased.
38//
39// Sign rule
40// ---------
41// Client `i` adds `+mask_ij` for every peer `j` whose identifier sorts after
42// its own and `-mask_ij` for every peer that sorts before it, all modulo the
43// group order. Each unordered pair therefore contributes `+mask_ij` exactly
44// once and `-mask_ij` exactly once to the server's sum, so the masks telescope
45// to zero and the server recovers the exact sum of the quantised inputs -- and
46// nothing else.
47//
48// Reference
49// ---------
50//   * Bonawitz, K., Ivanov, V., Kreuter, B., Marcedone, A., McMahan, H. B.,
51//     Patel, S., Ramage, D., Segal, A., Seth, K. "Practical Secure Aggregation
52//     for Privacy-Preserving Machine Learning." CCS 2017.
53//
54// Relation to `privacy::secure_aggregation`
55// -----------------------------------------
56// The sibling module `crate::privacy::secure_aggregation` implements the same
57// aggregation arithmetic over `u64` client identifiers, but derives its
58// pairwise seeds from a *public* formula, which it documents as a deliberate
59// demo simplification. This module reuses that module's quantisation
60// primitives verbatim (see `super::secure_aggregation`) and replaces exactly
61// the part that cannot be left simplified: the seed derivation.
62
63use 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
71/// Domain separation tag for pairwise seed derivation.
72const SEED_DOMAIN: &[u8] = b"OPTIRS-FED-SECAGG-PAIRWISE-SEED-v1";
73
74/// Domain separation tag for the mask-expansion PRG.
75const PRG_DOMAIN: &[u8] = b"OPTIRS-FED-SECAGG-MASK-PRG-v1";
76
77/// Words produced per SHA-256 PRG block.
78const WORDS_PER_BLOCK: usize = 4;
79
80/// A participant's X25519 public key, as published to the server.
81///
82/// Byte-comparable so that the pairwise seed derivation can canonicalise the
83/// unordered pair `{pk_i, pk_j}` without extra state.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85pub struct ClientPublicKey([u8; 32]);
86
87impl ClientPublicKey {
88    /// Wrap raw key bytes received from a peer.
89    pub fn from_bytes(bytes: [u8; 32]) -> Self {
90        Self(bytes)
91    }
92
93    /// The raw key bytes, for transport.
94    pub fn as_bytes(&self) -> &[u8; 32] {
95        &self.0
96    }
97}
98
99/// A participant's per-round X25519 key pair.
100///
101/// The secret half never leaves the client. There is intentionally no
102/// accessor for it and no `Debug` output that could print it: the whole point
103/// of this type is that the aggregation server cannot obtain it.
104pub 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    /// Generate a fresh key pair from operating-system entropy.
121    ///
122    /// A new pair per round is what makes the masks of different rounds
123    /// independent; reusing one across rounds would let a server that
124    /// observes two rounds cancel the shared structure.
125    pub fn generate() -> Self {
126        let mut bytes = [0_u8; 32];
127        // `thread_rng` is rand's cryptographically secure thread-local
128        // generator, seeded from the operating system.
129        thread_rng().fill(&mut bytes[..]);
130        Self::from_secret_bytes(bytes)
131    }
132
133    /// Build a key pair from explicit secret bytes.
134    ///
135    /// Intended for reproducible protocol transcripts in tests and for
136    /// callers that already derive client secrets from their own key
137    /// hierarchy. Production clients should prefer [`Self::generate`].
138    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    /// The public half, to be published to the aggregation server.
145    pub fn public_key(&self) -> ClientPublicKey {
146        self.public
147    }
148
149    /// Derive the 32-byte seed shared with `peer` for `round_seed`.
150    ///
151    /// Symmetric: `a.shared_seed_with(b.public, r) == b.shared_seed_with(a.public, r)`.
152    ///
153    /// Errors when `peer` is this client's own key (a client has no pairwise
154    /// mask with itself) or when the Diffie-Hellman exchange yields the
155    /// all-zero shared secret, which is what a low-order (small-subgroup)
156    /// public key produces and would make the mask predictable.
157    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
193/// Expand a 32-byte seed into a `dim`-long mask over `[0, modulus)`.
194///
195/// SHA-256 counter mode with rejection sampling, so the output is uniform over
196/// the additive group. Deterministic in `(seed, dim, modulus)` -- that
197/// determinism is exactly what makes the two holders of the seed produce
198/// identical masks and therefore what makes the masks cancel.
199pub 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    // Largest multiple of `modulus` that fits in a u64; words at or above it
211    // are rejected so no residue is over-represented.
212    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
240/// The signed pairwise mask that `own_id` contributes for peer `peer_id`.
241///
242/// `+mask` when `own_id` sorts before `peer_id`, `-mask` otherwise, reduced
243/// into `[0, modulus)`.
244pub 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
273/// The complete additive mask client `own_id` applies to its quantised
274/// update.
275///
276/// `peers` is the round's published public-key directory. `own_id`'s own
277/// entry, if present, is skipped; every other entry contributes one signed
278/// pairwise mask. Errors when the directory does not contain `own_id` (the
279/// client is not part of this round) or when it contains no peers (a cohort of
280/// one cannot be masked, and pretending otherwise would upload the raw
281/// gradient).
282pub 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
331/// Draw a fresh public per-round salt from operating-system entropy.
332///
333/// Published to every client. It does not need to be secret -- the masks are
334/// protected by the Diffie-Hellman secrets -- but it must be unpredictable
335/// enough that two rounds never reuse a salt with the same key pairs.
336pub 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        // The server: it sees both public keys and the round seed, and may of
371        // course generate a key pair of its own.
372        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        // And the masks it expands to differ everywhere.
386        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        // The all-zero point is the canonical low-order X25519 public key; it
433        // drives every shared secret to zero.
434        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        // A real PRG mask is spread over [0, modulus); an implementation that
457        // only jittered by a small amount would fail this.
458        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        // Rough uniformity: each of eight buckets should hold ~12.5%.
472        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        // Alice is not in the directory.
564        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        // Alice is in the directory under someone else's public key.
569        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        // A cohort of one cannot be masked.
575        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        // And the secret is never printed.
588        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        // A wrapping counter starting at zero would produce 1, 2, 3, ...
597        assert!(seeds.iter().any(|&seed| seed > u64::MAX / 1024));
598    }
599}