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
// Forward-Secure Add and Backward-Secure Delete.
//
// ── Forward Security ──────────────────────────────────────────────────────────
// A search token issued at time T cannot retrieve entries written AFTER T.
// This is structurally guaranteed: each add allocates a fresh `total_writes`
// index.  A token captures the live_indices snapshot at generation time —
// it has no knowledge of indices > snapshot.
//
// ── Backward Security Type-II (locked decision) ──────────────────────────────
// After deletion of doc D from keyword W:
//   1.  D's index is removed from live_indices.
//   2.  The epoch is bumped for W.
//   3.  All surviving entries are re-written to the EDB under the new epoch.
//   4.  Any future search token uses the new epoch — it cannot address the
//       old epoch's tags (they are orphaned on the server forever).
//
// Cost: O(|remaining results|) EDB writes per delete.
// Benefit: unconditional backward privacy — even an adaptive server that
// stores *all* historical access patterns learns nothing about deleted docs.
//
// Type-III would avoid re-writes but requires a more complex "forward pointer"
// state; Type-II is simpler, auditable, and sufficient for our threat model.

use sha2::{Sha256, Digest};
use uuid::Uuid;

use crate::crypto::{aead, primitives::{EntryOp, EntryPayload}};
use crate::crypto::kdf::MasterKeySet;
use crate::client::{state::KeywordState, trapdoor::TrapdoorEngine};
use crate::error::VaultError;
use crate::server::edb::RawEdbEntry;

// Re-export for protocol layer convenience.
pub use crate::crypto::primitives::Tag;

// ── UpdateEngine ──────────────────────────────────────────────────────────────

pub struct UpdateEngine<'a> {
    pub trapdoor: TrapdoorEngine<'a>,
}

impl<'a> UpdateEngine<'a> {
    pub fn new(keys: &'a MasterKeySet) -> Self {
        Self { trapdoor: TrapdoorEngine::new(keys) }
    }

    // ── Add ───────────────────────────────────────────────────────────────────

    /// Prepare one EDB entry to index `doc_id` under `keyword`.
    ///
    /// Mutates `state` (increments counters, records the new index).
    /// Returns the `RawEdbEntry` to be sent to the server.
    ///
    /// The caller must persist the updated `state` before (or atomically with)
    /// sending the entry to the server.  If the state is lost after the server
    /// write, use `TrapdoorEngine::generate_recovery_probe` to reconstruct it.
    pub fn prepare_add(
        &self,
        keyword: &[u8],
        doc_id:  Uuid,
        state:   &mut KeywordState,
    ) -> Result<RawEdbEntry, VaultError> {
        let idx = state.next_index();
        let epoch = state.epoch;

        // Derive the fresh address + key for this entry.
        let tag     = self.trapdoor.derive_tag(keyword, idx, epoch);
        let val_key = self.trapdoor.derive_val_key(keyword, idx, epoch);

        // Encrypt the payload.
        let payload = EntryPayload {
            doc_id,
            op:        EntryOp::Add,
            timestamp: now_ms(),
        };
        let plaintext = bincode::serialize(&payload)?;
        let enc_value = aead::encrypt(&val_key, &plaintext)?;

        // Register the new index in client state.
        state.record_add(idx, doc_id);

        Ok(RawEdbEntry { tag, value: enc_value })
    }

    // ── Delete (Backward Security Type-II) ────────────────────────────────────

    /// Remove `doc_id` from `keyword`'s result set.
    ///
    /// Returns a batch of EDB entries to:
    ///   (a) DELETE the old epoch's entries (server removes them by tag).
    ///   (b) PUT new epoch's entries for all surviving docs.
    ///
    /// The batch must be applied atomically on the server side.
    /// If an entry was not indexed under this keyword, returns an empty Vec.
    pub fn prepare_delete(
        &self,
        keyword: &[u8],
        doc_id:  Uuid,
        state:   &mut KeywordState,
    ) -> Result<DeleteBatch, VaultError> {
        // Step 1: collect the old tags that need to be removed.
        let old_epoch = state.epoch;
        let old_tags: Vec<Tag> = state
            .live_indices
            .iter()
            .map(|&i| self.trapdoor.derive_tag(keyword, i, old_epoch))
            .collect();

        // Step 2: evict the doc from state.  This also bumps the epoch.
        let was_indexed = state.evict_doc(doc_id);
        if !was_indexed {
            return Ok(DeleteBatch { removes: vec![], adds: vec![] });
        }

        // Step 3: re-encrypt surviving entries under the new epoch.
        let new_epoch = state.epoch;
        let mut new_entries = Vec::with_capacity(state.live_indices.len());

        for &idx in &state.live_indices {
            // Reconstruct the doc_id for this surviving index.
            let surviving_doc = Uuid::from_bytes(
                *state.index_to_doc.get(&idx)
                    .ok_or(VaultError::DocNotFound(format!("index {idx}")))?
            );

            let tag     = self.trapdoor.derive_tag(keyword, idx, new_epoch);
            let val_key = self.trapdoor.derive_val_key(keyword, idx, new_epoch);

            let payload = EntryPayload {
                doc_id:    surviving_doc,
                op:        EntryOp::Add,
                timestamp: now_ms(),
            };
            let plaintext = bincode::serialize(&payload)?;
            let enc_value = aead::encrypt(&val_key, &plaintext)?;

            new_entries.push(RawEdbEntry { tag, value: enc_value });
        }

        Ok(DeleteBatch {
            removes: old_tags,
            adds:    new_entries,
        })
    }
}

// ── DeleteBatch ───────────────────────────────────────────────────────────────

/// Atomic update set produced by a delete operation.
///
/// The server must apply `removes` and `adds` in a single transaction.
/// Partial application would leave the index in an inconsistent state.
pub struct DeleteBatch {
    /// Old-epoch tags to erase from the EDB.
    pub removes: Vec<Tag>,
    /// New-epoch ciphertexts to insert.
    pub adds: Vec<RawEdbEntry>,
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Keyword → 32-byte hash used as the state-table key.
/// SHA-256 hides keyword lengths and content at rest.
pub fn hash_keyword(keyword: &str) -> [u8; 32] {
    let mut h = Sha256::new();
    h.update(keyword.as_bytes());
    h.finalize().into()
}

/// Current time in milliseconds since UNIX epoch.
#[cfg(target_arch = "wasm32")]
pub fn now_ms() -> u64 {
    (js_sys::Date::now()) as u64
}

#[cfg(not(target_arch = "wasm32"))]
pub fn now_ms() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}