rose_squared_sdk/client/updates.rs
1// Forward-Secure Add and Backward-Secure Delete.
2//
3// ── Forward Security ──────────────────────────────────────────────────────────
4// A search token issued at time T cannot retrieve entries written AFTER T.
5// This is structurally guaranteed: each add allocates a fresh `total_writes`
6// index. A token captures the live_indices snapshot at generation time —
7// it has no knowledge of indices > snapshot.
8//
9// ── Backward Security Type-II (locked decision) ──────────────────────────────
10// After deletion of doc D from keyword W:
11// 1. D's index is removed from live_indices.
12// 2. The epoch is bumped for W.
13// 3. All surviving entries are re-written to the EDB under the new epoch.
14// 4. Any future search token uses the new epoch — it cannot address the
15// old epoch's tags (they are orphaned on the server forever).
16//
17// Cost: O(|remaining results|) EDB writes per delete.
18// Benefit: unconditional backward privacy — even an adaptive server that
19// stores *all* historical access patterns learns nothing about deleted docs.
20//
21// Type-III would avoid re-writes but requires a more complex "forward pointer"
22// state; Type-II is simpler, auditable, and sufficient for our threat model.
23
24use sha2::{Sha256, Digest};
25use uuid::Uuid;
26
27use crate::crypto::{aead, primitives::{EntryOp, EntryPayload}};
28use crate::crypto::kdf::MasterKeySet;
29use crate::client::{state::KeywordState, trapdoor::TrapdoorEngine};
30use crate::error::VaultError;
31use crate::server::edb::RawEdbEntry;
32
33// Re-export for protocol layer convenience.
34pub use crate::crypto::primitives::Tag;
35
36// ── UpdateEngine ──────────────────────────────────────────────────────────────
37
38pub struct UpdateEngine<'a> {
39 pub trapdoor: TrapdoorEngine<'a>,
40}
41
42impl<'a> UpdateEngine<'a> {
43 pub fn new(keys: &'a MasterKeySet) -> Self {
44 Self { trapdoor: TrapdoorEngine::new(keys) }
45 }
46
47 // ── Add ───────────────────────────────────────────────────────────────────
48
49 /// Prepare one EDB entry to index `doc_id` under `keyword`.
50 ///
51 /// Mutates `state` (increments counters, records the new index).
52 /// Returns the `RawEdbEntry` to be sent to the server.
53 ///
54 /// The caller must persist the updated `state` before (or atomically with)
55 /// sending the entry to the server. If the state is lost after the server
56 /// write, use `TrapdoorEngine::generate_recovery_probe` to reconstruct it.
57 pub fn prepare_add(
58 &self,
59 keyword: &[u8],
60 doc_id: Uuid,
61 state: &mut KeywordState,
62 ) -> Result<RawEdbEntry, VaultError> {
63 let idx = state.next_index();
64 let epoch = state.epoch;
65
66 // Derive the fresh address + key for this entry.
67 let tag = self.trapdoor.derive_tag(keyword, idx, epoch);
68 let val_key = self.trapdoor.derive_val_key(keyword, idx, epoch);
69
70 // Encrypt the payload.
71 let payload = EntryPayload {
72 doc_id,
73 op: EntryOp::Add,
74 timestamp: now_ms(),
75 };
76 let plaintext = bincode::serialize(&payload)?;
77 let enc_value = aead::encrypt(&val_key, &plaintext)?;
78
79 // Register the new index in client state.
80 state.record_add(idx, doc_id);
81
82 Ok(RawEdbEntry { tag, value: enc_value })
83 }
84
85 // ── Delete (Backward Security Type-II) ────────────────────────────────────
86
87 /// Remove `doc_id` from `keyword`'s result set.
88 ///
89 /// Returns a batch of EDB entries to:
90 /// (a) DELETE the old epoch's entries (server removes them by tag).
91 /// (b) PUT new epoch's entries for all surviving docs.
92 ///
93 /// The batch must be applied atomically on the server side.
94 /// If an entry was not indexed under this keyword, returns an empty Vec.
95 pub fn prepare_delete(
96 &self,
97 keyword: &[u8],
98 doc_id: Uuid,
99 state: &mut KeywordState,
100 ) -> Result<DeleteBatch, VaultError> {
101 // Step 1: collect the old tags that need to be removed.
102 let old_epoch = state.epoch;
103 let old_tags: Vec<Tag> = state
104 .live_indices
105 .iter()
106 .map(|&i| self.trapdoor.derive_tag(keyword, i, old_epoch))
107 .collect();
108
109 // Step 2: evict the doc from state. This also bumps the epoch.
110 let was_indexed = state.evict_doc(doc_id);
111 if !was_indexed {
112 return Ok(DeleteBatch { removes: vec![], adds: vec![] });
113 }
114
115 // Step 3: re-encrypt surviving entries under the new epoch.
116 let new_epoch = state.epoch;
117 let mut new_entries = Vec::with_capacity(state.live_indices.len());
118
119 for &idx in &state.live_indices {
120 // Reconstruct the doc_id for this surviving index.
121 let surviving_doc = Uuid::from_bytes(
122 *state.index_to_doc.get(&idx)
123 .ok_or(VaultError::DocNotFound(format!("index {idx}")))?
124 );
125
126 let tag = self.trapdoor.derive_tag(keyword, idx, new_epoch);
127 let val_key = self.trapdoor.derive_val_key(keyword, idx, new_epoch);
128
129 let payload = EntryPayload {
130 doc_id: surviving_doc,
131 op: EntryOp::Add,
132 timestamp: now_ms(),
133 };
134 let plaintext = bincode::serialize(&payload)?;
135 let enc_value = aead::encrypt(&val_key, &plaintext)?;
136
137 new_entries.push(RawEdbEntry { tag, value: enc_value });
138 }
139
140 Ok(DeleteBatch {
141 removes: old_tags,
142 adds: new_entries,
143 })
144 }
145}
146
147// ── DeleteBatch ───────────────────────────────────────────────────────────────
148
149/// Atomic update set produced by a delete operation.
150///
151/// The server must apply `removes` and `adds` in a single transaction.
152/// Partial application would leave the index in an inconsistent state.
153pub struct DeleteBatch {
154 /// Old-epoch tags to erase from the EDB.
155 pub removes: Vec<Tag>,
156 /// New-epoch ciphertexts to insert.
157 pub adds: Vec<RawEdbEntry>,
158}
159
160// ── Helpers ───────────────────────────────────────────────────────────────────
161
162/// Keyword → 32-byte hash used as the state-table key.
163/// SHA-256 hides keyword lengths and content at rest.
164pub fn hash_keyword(keyword: &str) -> [u8; 32] {
165 let mut h = Sha256::new();
166 h.update(keyword.as_bytes());
167 h.finalize().into()
168}
169
170/// Current time in milliseconds since UNIX epoch.
171#[cfg(target_arch = "wasm32")]
172pub fn now_ms() -> u64 {
173 (js_sys::Date::now()) as u64
174}
175
176#[cfg(not(target_arch = "wasm32"))]
177pub fn now_ms() -> u64 {
178 use std::time::{SystemTime, UNIX_EPOCH};
179 SystemTime::now()
180 .duration_since(UNIX_EPOCH)
181 .map(|d| d.as_millis() as u64)
182 .unwrap_or(0)
183}