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
// Encrypted Database (EDB) abstraction layer.
//
// The server is FULLY UNTRUSTED.  It stores (Tag → EncValue) pairs and
// executes fetch/put/delete operations on tags it cannot interpret.
//
// The `EncryptedStore` trait is the single extension point for storage
// backends.  Implement it for:
//   • IndexedDB   (browser WASM, see wasm/js_store.rs)
//   • Redis       (server-side proxy for high-throughput deployments)
//   • S3          (blob store with DynamoDB index for tag lookups)
//   • SQLite      (local native testing)
//   • HashMap     (in-memory mock — shipped here for unit tests)
//
// Design: storage-agnostic trait with async methods.
// In WASM, async fns resolve to JS Promises automatically via wasm-bindgen.

use std::collections::HashMap;
use async_trait::async_trait;

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

// ── Raw EDB entry (wire type) ─────────────────────────────────────────────────

/// A single (tag, ciphertext) pair ready to be written to the EDB.
pub struct RawEdbEntry {
    pub tag:   Tag,
    pub value: EncValue,
}

// ── Storage trait ─────────────────────────────────────────────────────────────

/// Implement this trait for any key-value store that will back the EDB.
///
/// All inputs and outputs are opaque byte arrays — the store never sees
/// plaintext keywords, document IDs, or user data.
#[async_trait(?Send)]    // ?Send because WASM is single-threaded
pub trait EncryptedStore {
    /// Fetch the encrypted value stored at `tag`, if any.
    async fn get(&self, tag: &Tag) -> Result<Option<EncValue>, VaultError>;

    /// Store a single (tag, value) pair.  Overwrites any existing entry.
    async fn put(&self, tag: Tag, value: EncValue) -> Result<(), VaultError>;

    /// Remove the entry at `tag`.  No-op if tag does not exist.
    async fn delete(&self, tag: &Tag) -> Result<(), VaultError>;

    /// Fetch multiple tags in a single round-trip.
    ///
    /// The default implementation issues sequential GETs.
    /// Backends should override this with a real batch read (e.g., Redis MGET).
    ///
    /// Returns a Vec aligned with `tags`: `None` for any tag not present.
    async fn get_batch(&self, tags: &[Tag]) -> Result<Vec<Option<EncValue>>, VaultError> {
        let mut out = Vec::with_capacity(tags.len());
        for tag in tags {
            out.push(self.get(tag).await?);
        }
        Ok(out)
    }

    /// Write multiple entries and delete a set of old tags atomically.
    ///
    /// Used by the delete protocol (Backward Security Type-II) where we must
    /// atomically retire old-epoch entries and write new-epoch entries.
    ///
    /// Default: sequential puts then deletes (not truly atomic — override for
    /// production stores that support transactions).
    async fn atomic_update(
        &self,
        puts:    Vec<RawEdbEntry>,
        removes: Vec<Tag>,
    ) -> Result<(), VaultError> {
        for entry in puts {
            self.put(entry.tag, entry.value).await?;
        }
        for tag in removes {
            self.delete(&tag).await?;
        }
        Ok(())
    }

    // ── SWiSSSE: volume-hiding batch write ────────────────────────────────────

    /// Write exactly `target_count` entries, padding with dummy entries if needed.
    ///
    /// This is the key SWiSSSE primitive: every write to the EDB has the same
    /// observable volume (number of entries written), suppressing the volume
    /// leakage that lets a passive server distinguish large vs. small updates.
    ///
    /// Dummy entries are (random_tag, random_ciphertext) pairs that are
    /// cryptographically indistinguishable from real entries.
    async fn padded_put_batch(
        &self,
        real_entries: Vec<RawEdbEntry>,
        target_count: usize,
    ) -> Result<(), VaultError> {
        use rand::RngCore;
        use crate::crypto::primitives::LAMBDA;

        if real_entries.len() > target_count {
            return Err(VaultError::VolumeLimitExceeded { max: target_count });
        }

        let pad_count = target_count - real_entries.len();
        let mut rng = rand::thread_rng();

        // Write real entries.
        for entry in real_entries {
            self.put(entry.tag, entry.value).await?;
        }

        // Write dummy entries: both tag and ciphertext are uniformly random.
        // The server cannot distinguish these from real writes.
        for _ in 0..pad_count {
            let mut dummy_tag = [0u8; LAMBDA];
            let mut dummy_val = vec![0u8; 60]; // matches real entry size
            rng.fill_bytes(&mut dummy_tag);
            rng.fill_bytes(&mut dummy_val);
            self.put(Tag(dummy_tag), EncValue(dummy_val)).await?;
        }

        Ok(())
    }
}

// ── In-memory mock store ──────────────────────────────────────────────────────

/// A `HashMap`-backed `EncryptedStore` for unit tests and local development.
///
/// NOT suitable for production — no persistence, no concurrency safety.
pub struct MockStore {
    inner: std::sync::Mutex<HashMap<[u8; 32], Vec<u8>>>,
}

impl MockStore {
    pub fn new() -> Self {
        Self { inner: std::sync::Mutex::new(HashMap::new()) }
    }

    /// Number of entries currently stored.  Useful for test assertions.
    pub fn len(&self) -> usize {
        self.inner.lock().unwrap().len()
    }
}

impl Default for MockStore {
    fn default() -> Self { Self::new() }
}

#[async_trait(?Send)]
impl EncryptedStore for MockStore {
    async fn get(&self, tag: &Tag) -> Result<Option<EncValue>, VaultError> {
        let map = self.inner.lock().unwrap();
        Ok(map.get(&tag.0).map(|v| EncValue(v.clone())))
    }

    async fn put(&self, tag: Tag, value: EncValue) -> Result<(), VaultError> {
        let mut map = self.inner.lock().unwrap();
        map.insert(tag.0, value.0);
        Ok(())
    }

    async fn delete(&self, tag: &Tag) -> Result<(), VaultError> {
        let mut map = self.inner.lock().unwrap();
        map.remove(&tag.0);
        Ok(())
    }
}