Skip to main content

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}