rose-squared-sdk 0.1.0

Privacy-preserving encrypted search SDK implementing the SWiSSSE protocol with forward/backward security and volume-hiding, compilable to WebAssembly
Documentation
// SWiSSSE: System-Wide Security for Searchable Symmetric Encryption.
// (PoPETS 2024 — upgrade layer on top of RO(SE)²)
//
// ── What SWiSSSE adds ─────────────────────────────────────────────────────────
// RO(SE)² achieves forward/backward security but still leaks:
//   (a) Volume leakage — the number of results per keyword.
//   (b) Access-pattern leakage — which EDB addresses are fetched per query,
//       allowing a persistent server to link repeated searches.
//
// SWiSSSE suppresses both by:
//   (a) Padding every write batch to a fixed size `N_max`.
//   (b) Padding every read batch to `N_max` dummy lookups.
//
// ── Design decisions (locked) ─────────────────────────────────────────────────
//   • `N_max` is fixed at vault creation time.  It bounds the maximum number
//     of distinct documents that can be indexed under any single keyword.
//   • Dummy reads: the client generates `N_max - |real_results|` fake tags
//     (random bytes) and includes them in the search token.  The server fetches
//     all tags uniformly; misses on dummy tags are silently ignored.
//   • Dummy writes: `padded_put_batch` on the EDB trait fills up to `N_max`.
//   • Both dummies are cryptographically indistinguishable from real ones —
//     random tags in a tag space of 2^256 are pseudo-random under PRF security.

use rand::RngCore;

use crate::crypto::primitives::{SearchToken, Tag, LAMBDA};
use crate::error::VaultError;

// ── VolumeConfig ─────────────────────────────────────────────────────────────

/// Parameters that control the SWiSSSE volume-hiding behaviour.
///
/// Set once at vault creation and stored alongside the encrypted state.
/// Changing `n_max` after vault creation requires a full re-index.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct VolumeConfig {
    /// The padded batch size for both reads and writes.
    /// Every search fetches exactly `n_max` tags from the server,
    /// and every write batch sends exactly `n_max` entries.
    ///
    /// Choose to be ≥ the 99th-percentile keyword result count.
    /// Typical value for enterprise docs: 256–1024.
    pub n_max: usize,
}

impl Default for VolumeConfig {
    fn default() -> Self {
        Self { n_max: 256 }
    }
}

// ── Read padding ──────────────────────────────────────────────────────────────

/// Pad a `SearchToken` with dummy (tag, key) pairs up to `n_max`.
///
/// The returned token has exactly `n_max` pairs.  The server fetches all
/// of them; missing entries for dummy tags are silently dropped by
/// `SearchProtocol::finalize_search` (they return `None` from the EDB).
///
/// The dummy keys are random — even if a dummy tag accidentally collides
/// with a real entry (probability 2^{-256}), decryption will fail the GCM
/// check and return `Tampered`, which the caller can distinguish and ignore.
pub fn pad_search_token(
    token:  SearchToken,
    config: &VolumeConfig,
) -> Result<SearchToken, VaultError> {
    let real_count = token.pairs.len();
    if real_count > config.n_max {
        return Err(VaultError::VolumeLimitExceeded { max: config.n_max });
    }

    let pad_count = config.n_max - real_count;
    let mut rng   = rand::thread_rng();
    let mut pairs = token.pairs;

    for _ in 0..pad_count {
        let mut dummy_tag = [0u8; LAMBDA];
        let mut dummy_key = [0u8; LAMBDA];
        rng.fill_bytes(&mut dummy_tag);
        rng.fill_bytes(&mut dummy_key);
        pairs.push((Tag(dummy_tag), dummy_key));
    }

    // Shuffle so real entries are not always at the front.
    // A deterministic server correlating position across queries would otherwise
    // learn the result count even with padding.
    fisher_yates_shuffle(&mut pairs, &mut rng);

    Ok(SearchToken { pairs })
}

/// Fisher-Yates shuffle for the token pairs.
fn fisher_yates_shuffle<T>(v: &mut Vec<T>, rng: &mut impl RngCore) {
    let n = v.len();
    for i in (1..n).rev() {
        let j = (rng.next_u64() as usize) % (i + 1);
        v.swap(i, j);
    }
}