rose_squared_sdk/protocol/swissse.rs
1// SWiSSSE: System-Wide Security for Searchable Symmetric Encryption.
2// (PoPETS 2024 — upgrade layer on top of RO(SE)²)
3//
4// ── What SWiSSSE adds ─────────────────────────────────────────────────────────
5// RO(SE)² achieves forward/backward security but still leaks:
6// (a) Volume leakage — the number of results per keyword.
7// (b) Access-pattern leakage — which EDB addresses are fetched per query,
8// allowing a persistent server to link repeated searches.
9//
10// SWiSSSE suppresses both by:
11// (a) Padding every write batch to a fixed size `N_max`.
12// (b) Padding every read batch to `N_max` dummy lookups.
13//
14// ── Design decisions (locked) ─────────────────────────────────────────────────
15// • `N_max` is fixed at vault creation time. It bounds the maximum number
16// of distinct documents that can be indexed under any single keyword.
17// • Dummy reads: the client generates `N_max - |real_results|` fake tags
18// (random bytes) and includes them in the search token. The server fetches
19// all tags uniformly; misses on dummy tags are silently ignored.
20// • Dummy writes: `padded_put_batch` on the EDB trait fills up to `N_max`.
21// • Both dummies are cryptographically indistinguishable from real ones —
22// random tags in a tag space of 2^256 are pseudo-random under PRF security.
23
24use rand::RngCore;
25
26use crate::crypto::primitives::{SearchToken, Tag, LAMBDA};
27use crate::error::VaultError;
28
29// ── VolumeConfig ─────────────────────────────────────────────────────────────
30
31/// Parameters that control the SWiSSSE volume-hiding behaviour.
32///
33/// Set once at vault creation and stored alongside the encrypted state.
34/// Changing `n_max` after vault creation requires a full re-index.
35#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
36pub struct VolumeConfig {
37 /// The padded batch size for both reads and writes.
38 /// Every search fetches exactly `n_max` tags from the server,
39 /// and every write batch sends exactly `n_max` entries.
40 ///
41 /// Choose to be ≥ the 99th-percentile keyword result count.
42 /// Typical value for enterprise docs: 256–1024.
43 pub n_max: usize,
44}
45
46impl Default for VolumeConfig {
47 fn default() -> Self {
48 Self { n_max: 256 }
49 }
50}
51
52// ── Read padding ──────────────────────────────────────────────────────────────
53
54/// Pad a `SearchToken` with dummy (tag, key) pairs up to `n_max`.
55///
56/// The returned token has exactly `n_max` pairs. The server fetches all
57/// of them; missing entries for dummy tags are silently dropped by
58/// `SearchProtocol::finalize_search` (they return `None` from the EDB).
59///
60/// The dummy keys are random — even if a dummy tag accidentally collides
61/// with a real entry (probability 2^{-256}), decryption will fail the GCM
62/// check and return `Tampered`, which the caller can distinguish and ignore.
63pub fn pad_search_token(
64 token: SearchToken,
65 config: &VolumeConfig,
66) -> Result<SearchToken, VaultError> {
67 let real_count = token.pairs.len();
68 if real_count > config.n_max {
69 return Err(VaultError::VolumeLimitExceeded { max: config.n_max });
70 }
71
72 let pad_count = config.n_max - real_count;
73 let mut rng = rand::thread_rng();
74 let mut pairs = token.pairs;
75
76 for _ in 0..pad_count {
77 let mut dummy_tag = [0u8; LAMBDA];
78 let mut dummy_key = [0u8; LAMBDA];
79 rng.fill_bytes(&mut dummy_tag);
80 rng.fill_bytes(&mut dummy_key);
81 pairs.push((Tag(dummy_tag), dummy_key));
82 }
83
84 // Shuffle so real entries are not always at the front.
85 // A deterministic server correlating position across queries would otherwise
86 // learn the result count even with padding.
87 fisher_yates_shuffle(&mut pairs, &mut rng);
88
89 Ok(SearchToken { pairs })
90}
91
92/// Fisher-Yates shuffle for the token pairs.
93fn fisher_yates_shuffle<T>(v: &mut Vec<T>, rng: &mut impl RngCore) {
94 let n = v.len();
95 for i in (1..n).rev() {
96 let j = (rng.next_u64() as usize) % (i + 1);
97 v.swap(i, j);
98 }
99}