rose_squared_sdk/client/state.rs
1// Client-side state management.
2//
3// The client is the ONLY entity that ever sees plaintext keyword state.
4// This module manages two layers:
5//
6// KeywordState — per-keyword counters and live-index bookkeeping
7// ClientStateTable — the full map, serialised and AES-GCM encrypted before
8// any persistence (IndexedDB, localStorage, file, etc.)
9//
10// Design decisions (locked):
11// • Live indices are explicit (Vec<u64>) rather than a single counter.
12// This is required for Backward Security Type-II: on delete we re-derive
13// tags only for *surviving* indices, not all historical ones.
14// • The deletion epoch is per-keyword. A delete on keyword "invoice" does
15// not force re-keying of keyword "report".
16// • The full state table is encrypted with K_state before leaving the client,
17// so even if the persistence layer is compromised the state is opaque.
18
19use std::collections::HashMap;
20use serde::{Deserialize, Serialize};
21use zeroize::{Zeroize, ZeroizeOnDrop};
22use uuid::Uuid;
23
24use crate::crypto::{aead, primitives::Epoch};
25use crate::crypto::kdf::MasterKeySet;
26use crate::error::VaultError;
27
28// ── Per-keyword state ─────────────────────────────────────────────────────────
29
30/// Everything the client knows about one keyword.
31///
32/// Serialised as part of `ClientStateTable` and encrypted with K_state.
33#[derive(Clone, Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
34pub struct KeywordState {
35 /// Indices of *live* (non-deleted) entries for this keyword in the EDB.
36 /// Each index `i` maps to EDB tag: PRF(K_tag, keyword || i || epoch)
37 /// This is the authoritative source for O(1) search token generation.
38 pub live_indices: Vec<u64>,
39
40 /// Maps EDB index → document UUID. Used during delete to find which
41 /// indices to evict from `live_indices`.
42 #[zeroize(skip)]
43 pub index_to_doc: HashMap<u64, [u8; 16]>,
44
45 /// Monotonically increasing total write counter (never decremented).
46 /// Provides unique, fresh indices for every add even after deletes.
47 pub total_writes: u64,
48
49 /// Current epoch. Incremented on every delete for this keyword.
50 /// Old tags (from prior epochs) become permanently unreachable — this
51 /// is the mechanism behind Backward Security Type-II.
52 pub epoch: Epoch,
53}
54
55impl KeywordState {
56 /// Create fresh state for a keyword that has never been written.
57 pub fn new() -> Self {
58 Self {
59 live_indices: Vec::new(),
60 index_to_doc: HashMap::new(),
61 total_writes: 0,
62 epoch: 0,
63 }
64 }
65
66 /// Allocate the next fresh index for an add operation.
67 /// Returns the index; callers must push it into `live_indices` themselves.
68 pub fn next_index(&mut self) -> u64 {
69 let idx = self.total_writes;
70 self.total_writes += 1;
71 idx
72 }
73
74 /// Register a newly written index → doc mapping.
75 pub fn record_add(&mut self, index: u64, doc_id: Uuid) {
76 let bytes: [u8; 16] = *doc_id.as_bytes();
77 self.live_indices.push(index);
78 self.index_to_doc.insert(index, bytes);
79 }
80
81 /// Remove all indices that map to `doc_id`.
82 /// Returns `true` if at least one index was removed (doc was indexed here).
83 pub fn evict_doc(&mut self, doc_id: Uuid) -> bool {
84 let target: [u8; 16] = *doc_id.as_bytes();
85 let before = self.live_indices.len();
86 self.live_indices.retain(|idx| {
87 self.index_to_doc.get(idx) != Some(&target)
88 });
89 // Clean up the reverse map too.
90 self.index_to_doc.retain(|_, v| v != &target);
91 // Bump epoch — all old tags are now retired.
92 if self.live_indices.len() < before {
93 self.epoch += 1;
94 true
95 } else {
96 false
97 }
98 }
99}
100
101impl Default for KeywordState {
102 fn default() -> Self { Self::new() }
103}
104
105// ── Full state table ──────────────────────────────────────────────────────────
106
107/// The client's complete encrypted state: a map from H(keyword) → KeywordState.
108///
109/// Stored encrypted on the client device. The encryption key is K_state from
110/// the MasterKeySet, which is derived from the user's password and never leaves
111/// the client.
112pub struct ClientStateTable {
113 /// Keyed by SHA-256(keyword) to avoid leaking keyword lengths at rest.
114 inner: HashMap<[u8; 32], KeywordState>,
115}
116
117impl ClientStateTable {
118 /// Create an empty state table (new vault).
119 pub fn new() -> Self {
120 Self { inner: HashMap::new() }
121 }
122
123 /// Get or create the state for a keyword.
124 pub fn get_or_create(&mut self, keyword_hash: [u8; 32]) -> &mut KeywordState {
125 self.inner.entry(keyword_hash).or_default()
126 }
127
128 /// Get an existing state (read-only).
129 pub fn get(&self, keyword_hash: &[u8; 32]) -> Option<&KeywordState> {
130 self.inner.get(keyword_hash)
131 }
132
133 // ── Persistence ───────────────────────────────────────────────────────────
134
135 /// Serialise + AES-256-GCM encrypt the entire table.
136 /// The returned bytes can be stored anywhere (IndexedDB, disk, etc.).
137 pub fn export_encrypted(&self, keys: &MasterKeySet) -> Result<Vec<u8>, VaultError> {
138 let plaintext = bincode::serialize(&self.inner)?;
139 let enc = aead::encrypt(keys.k_state.as_bytes(), &plaintext)?;
140 Ok(enc.0)
141 }
142
143 /// Decrypt and deserialise a blob produced by `export_encrypted`.
144 pub fn import_encrypted(blob: &[u8], keys: &MasterKeySet) -> Result<Self, VaultError> {
145 use crate::crypto::primitives::EncValue;
146 let plaintext = aead::decrypt(keys.k_state.as_bytes(), &EncValue(blob.to_vec()))?;
147 let inner: HashMap<[u8; 32], KeywordState> = bincode::deserialize(&plaintext)?;
148 Ok(Self { inner })
149 }
150}
151
152impl Default for ClientStateTable {
153 fn default() -> Self { Self::new() }
154}