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}