Skip to main content

rose_squared_sdk/protocol/
search.rs

1// Full Search Round-Trip: client → server → client.
2//
3// Protocol steps (per RO(SE)² §4):
4//
5//   1. Client calls `prepare_search(keyword)`:
6//        - Hashes the keyword to look up its KeywordState.
7//        - Generates a SearchToken from the live indices.
8//        - Returns the token (a list of pseudorandom tag/key pairs).
9//
10//   2. Client sends the token's tags to the server.
11//      The server fetches each EncValue by tag (O(1) per fetch) and returns them.
12//
13//   3. Client calls `finalize_search(token, enc_values)`:
14//        - Decrypts each EncValue with the corresponding per-entry key.
15//        - Verifies the GCM tag — rejects tampered entries (`VaultError::Tampered`).
16//        - Extracts the doc_id from each EntryPayload.
17//        - Returns the set of document UUIDs matching the keyword.
18//
19// The server observes only a set of random-looking byte strings.
20// It learns: (a) the number of results (result-size leakage, acceptable under
21// standard SSE security); and (b) access patterns across queries ONLY if the
22// same keyword is searched twice — use token rotation to mitigate (Phase 4).
23
24use uuid::Uuid;
25
26use crate::client::{
27    state::ClientStateTable,
28    trapdoor::TrapdoorEngine,
29    updates::hash_keyword,
30};
31use crate::crypto::{
32    aead,
33    kdf::MasterKeySet,
34    primitives::{EncValue, EntryPayload, SearchToken},
35};
36use crate::error::VaultError;
37use crate::server::edb::EncryptedStore;
38
39// ── Search coordinator ────────────────────────────────────────────────────────
40
41pub struct SearchProtocol<'a> {
42    keys:  &'a MasterKeySet,
43    state: &'a ClientStateTable,
44}
45
46impl<'a> SearchProtocol<'a> {
47    pub fn new(keys: &'a MasterKeySet, state: &'a ClientStateTable) -> Self {
48        Self { keys, state }
49    }
50
51    // ── Phase 1: generate token ───────────────────────────────────────────────
52
53    /// Generate a single-use search token for `keyword`.
54    ///
55    /// Returns `Ok(None)` if the keyword has no live results (never written or
56    /// all documents deleted), so the caller can skip the network round-trip.
57    pub fn prepare_search(&self, keyword: &str) -> Result<Option<SearchToken>, VaultError> {
58        let kh = hash_keyword(keyword);
59
60        let state = match self.state.get(&kh) {
61            Some(s) => s,
62            None    => return Ok(None),  // keyword not in index
63        };
64
65        if state.live_indices.is_empty() {
66            return Ok(None);  // all results deleted
67        }
68
69        let engine = TrapdoorEngine::new(self.keys);
70        let token  = engine.generate_search_token(keyword.as_bytes(), state);
71        Ok(Some(token))
72    }
73
74    // ── Phase 2: server fetch (caller's responsibility) ───────────────────────
75    //
76    // The caller fetches enc_values from the store using token.pairs.
77    // We provide `fetch_token_results` as a convenience that calls the store.
78
79    /// Convenience: fetch all tagged entries for a token from an EDB in one call.
80    pub async fn fetch_token_results<S: EncryptedStore>(
81        &self,
82        token: &SearchToken,
83        store: &S,
84    ) -> Result<Vec<Option<EncValue>>, VaultError> {
85        let tags: Vec<_> = token.pairs.iter().map(|(t, _)| t.clone()).collect();
86        store.get_batch(&tags).await
87    }
88
89    // ── Phase 3: decrypt and assemble results ─────────────────────────────────
90
91    /// Decrypt and verify the server's response.
92    ///
93    /// Each `enc_values[i]` corresponds to `token.pairs[i]`.
94    /// Entries that the server reports as missing are silently skipped
95    /// (the server may have lost them; this is the "robustness" the paper addresses).
96    /// Entries that fail GCM verification return `VaultError::Tampered`.
97    pub fn finalize_search(
98        &self,
99        token:      &SearchToken,
100        enc_values: Vec<Option<EncValue>>,
101    ) -> Result<Vec<SearchResult>, VaultError> {
102        let mut results = Vec::new();
103
104        for ((_, key), maybe_enc) in token.pairs.iter().zip(enc_values.into_iter()) {
105            let enc = match maybe_enc {
106                Some(e) => e,
107                None    => continue,   // entry missing from server (robustness case)
108            };
109
110            let plaintext = aead::decrypt(key, &enc)?;   // Tampered returns Err here
111            let payload: EntryPayload = bincode::deserialize(&plaintext)?;
112
113            results.push(SearchResult {
114                doc_id:    payload.doc_id,
115                timestamp: payload.timestamp,
116            });
117        }
118
119        // Sort newest first.
120        results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
121        Ok(results)
122    }
123
124    // ── One-shot convenience ──────────────────────────────────────────────────
125
126    /// Full search: prepare token → fetch from store → decrypt results.
127    ///
128    /// Equivalent to calling `prepare_search` + `fetch_token_results` +
129    /// `finalize_search` in sequence.  Useful for single-threaded environments.
130    pub async fn search<S: EncryptedStore>(
131        &self,
132        keyword: &str,
133        store:   &S,
134    ) -> Result<Vec<SearchResult>, VaultError> {
135        let token = match self.prepare_search(keyword)? {
136            Some(t) => t,
137            None    => return Ok(vec![]),
138        };
139
140        let enc_values = self.fetch_token_results(&token, store).await?;
141        self.finalize_search(&token, enc_values)
142    }
143}
144
145// ── Search result ─────────────────────────────────────────────────────────────
146
147/// One document matching the searched keyword.
148#[derive(Debug, Clone)]
149pub struct SearchResult {
150    /// The unique identifier of the matching document.
151    pub doc_id:    Uuid,
152    /// Write timestamp in ms since UNIX epoch (for ordering, not displayed in clear to server).
153    pub timestamp: u64,
154}