Skip to main content

spacedb_store/
collection.rs

1//! [`Collection<K, V>`] — the encrypted typed table.
2//!
3//! A `Collection` is a [`crate::Table`] whose values are sealed under a
4//! per-collection DEK ([`crate::crypto`]): the engine only ever stores
5//! `nonce ‖ ciphertext`, so a host that holds the bytes (a replica on a
6//! stranger's machine, in later milestones) stores something it cannot read.
7//!
8//! The DEK is wrapped under the vault key and persisted in the reserved
9//! `_dek_wrappings` table; the `Collection` caches that **ciphertext** wrapping.
10//! On every row operation it fetches the vault key through the [`KeyProvider`]
11//! seam and unwraps the DEK — so a vault that locks mid-session (cold-gate)
12//! immediately blocks reads and writes, rather than being bypassed by a cached
13//! plaintext key.
14//!
15//! Keys are **not** encrypted (the engine needs them in the clear to index and
16//! range-scan); only values are. Key privacy, where needed, is achieved by
17//! hashing the key before it reaches the store (the ADR 0005 `blake3(rp_origin)`
18//! pattern) — a caller concern, not this layer's.
19
20use std::marker::PhantomData;
21use std::sync::Arc;
22
23use serde::{de::DeserializeOwned, Serialize};
24use zeroize::Zeroizing;
25
26use crate::codec::{decode_value, encode_value, KeyDecode, KeyEncode};
27use crate::compress::{pack_value, unpack_value, Compression};
28use crate::crypto::{open_row, seal_row, unwrap_dek, wrap_fresh_dek, KeyProvider, WrappedDek, KEY_LEN};
29use crate::engine::{Durability, KvEngine, Readable, WriteTx};
30use crate::error::{StoreError, StoreResult};
31use crate::table::Table;
32
33/// The reserved table that stores each collection's wrapped DEK, keyed by
34/// collection name. Collection names may not collide with reserved (`_`-prefixed)
35/// tables.
36pub const DEK_WRAPPINGS_TABLE: &str = "_dek_wrappings";
37
38/// The reserved table recording each collection's sealed-value format, keyed by
39/// collection name. **Absent entry = legacy**: the sealed plaintext is the
40/// encoded value verbatim (every pre-v2 collection). Value `2` = prefixed: the
41/// sealed plaintext is `format_byte ‖ payload` ([`crate::compress`]). The entry
42/// is plaintext metadata; tampering with it can only make rows fail to decode
43/// (an attacker with engine write access can already destroy ciphertext), never
44/// leak or forge a value — decode failures are loud ([`StoreError::Compression`]).
45pub const COLLECTION_FORMATS_TABLE: &str = "_collection_formats";
46
47/// The `_collection_formats` value for a prefixed collection.
48const VALUE_FORMAT_PREFIXED: u8 = 2;
49
50/// How a collection's sealed plaintexts are laid out.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52enum ValueFormat {
53    /// Pre-v2: the sealed plaintext is the encoded value, verbatim.
54    Legacy,
55    /// v2+: the sealed plaintext is `format_byte ‖ payload`; rows may be
56    /// zstd-compressed per the collection's write [`Compression`] policy.
57    Prefixed,
58}
59
60fn wrappings_table() -> Table<String, WrappedDek> {
61    Table::new(DEK_WRAPPINGS_TABLE)
62}
63
64fn formats_table() -> Table<String, u8> {
65    Table::new(COLLECTION_FORMATS_TABLE)
66}
67
68fn read_format(tx: &impl Readable, name: &str) -> StoreResult<ValueFormat> {
69    Ok(match formats_table().get(tx, &name.to_string())? {
70        Some(VALUE_FORMAT_PREFIXED) => ValueFormat::Prefixed,
71        // An unknown format number would mean a newer binary wrote this
72        // collection — but that binary also bumped `STORE_FORMAT_VERSION`, so
73        // the `_meta` gate refuses the whole store before we get here. Treat
74        // anything else as legacy rather than inventing a second gate.
75        _ => ValueFormat::Legacy,
76    })
77}
78
79/// An encrypted, typed collection. Rows are AEAD-sealed under a per-collection
80/// DEK; see the module docs for the trust model.
81pub struct Collection<K, V> {
82    name: String,
83    schema_version: u32,
84    /// The DEK wrapped under the vault key — ciphertext, safe to hold in memory.
85    wrapped_dek: WrappedDek,
86    key_provider: Arc<dyn KeyProvider>,
87    /// How this collection's sealed plaintexts are laid out (fixed at creation).
88    format: ValueFormat,
89    /// Write-side compression policy; reads always honor the per-row format
90    /// byte, so this can differ between openers without stranding rows.
91    compression: Compression,
92    _types: PhantomData<fn() -> (K, V)>,
93}
94
95impl<K, V> std::fmt::Debug for Collection<K, V> {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        // Deliberately omits the key provider and the wrapped DEK.
98        f.debug_struct("Collection")
99            .field("name", &self.name)
100            .field("schema_version", &self.schema_version)
101            .finish_non_exhaustive()
102    }
103}
104
105impl<K, V> Collection<K, V>
106where
107    K: KeyEncode + KeyDecode,
108    V: Serialize + DeserializeOwned,
109{
110    /// Open an **existing** collection with the default write [`Compression`].
111    /// Errors with [`StoreError::CollectionNotFound`] if no DEK wrapping has
112    /// been provisioned. The collection's sealed-value format (legacy vs
113    /// prefixed) was fixed at creation and is read from `_collection_formats`.
114    pub fn open<E: KvEngine>(
115        engine: &E,
116        key_provider: Arc<dyn KeyProvider>,
117        name: impl Into<String>,
118        schema_version: u32,
119    ) -> StoreResult<Self> {
120        Self::open_with(engine, key_provider, name, schema_version, Compression::default())
121    }
122
123    /// [`Collection::open`] with an explicit write [`Compression`] policy.
124    /// The policy affects only what this handle writes (and only on prefixed
125    /// collections); reads always honor each row's own format byte.
126    pub fn open_with<E: KvEngine>(
127        engine: &E,
128        key_provider: Arc<dyn KeyProvider>,
129        name: impl Into<String>,
130        schema_version: u32,
131        compression: Compression,
132    ) -> StoreResult<Self> {
133        let name = Self::checked_name(name)?;
134        let r = engine.begin_read()?;
135        let wrapped = wrappings_table()
136            .get(&r, &name)?
137            .ok_or_else(|| StoreError::CollectionNotFound(name.clone()))?;
138        let format = read_format(&r, &name)?;
139        Ok(Self::assemble(name, schema_version, wrapped, key_provider, format, compression))
140    }
141
142    /// Open a collection with the default write [`Compression`], provisioning a
143    /// fresh DEK on first use. The check and the create happen in one write
144    /// transaction, so a collection is never double-provisioned with
145    /// conflicting DEKs by a concurrent opener. A collection **created** here
146    /// is prefixed (v2): its rows may compress. An existing collection keeps
147    /// the format it was created with.
148    pub fn open_or_create<E: KvEngine>(
149        engine: &E,
150        key_provider: Arc<dyn KeyProvider>,
151        name: impl Into<String>,
152        schema_version: u32,
153    ) -> StoreResult<Self> {
154        Self::open_or_create_with(engine, key_provider, name, schema_version, Compression::default())
155    }
156
157    /// [`Collection::open_or_create`] with an explicit write [`Compression`]
158    /// policy. Use [`Compression::Off`] for collections whose rows mix
159    /// user-secret and attacker-influenced bytes (the length side-channel rule
160    /// in [`crate::compress`]).
161    pub fn open_or_create_with<E: KvEngine>(
162        engine: &E,
163        key_provider: Arc<dyn KeyProvider>,
164        name: impl Into<String>,
165        schema_version: u32,
166        compression: Compression,
167    ) -> StoreResult<Self> {
168        let name = Self::checked_name(name)?;
169        let table = wrappings_table();
170
171        let mut w = engine.begin_write(Durability::Immediate)?;
172        if let Some(existing) = table.get(&w, &name)? {
173            // Already provisioned — nothing to write. Drop the txn (no commit).
174            let format = read_format(&w, &name)?;
175            drop(w);
176            return Ok(Self::assemble(name, schema_version, existing, key_provider, format, compression));
177        }
178
179        // First use: generate + wrap a fresh DEK under the vault key, and stamp
180        // the collection as prefixed — same transaction, so a crash can't leave
181        // a collection whose format is ambiguous.
182        let vault_key = key_provider.vault_key()?;
183        let (wrapped, _dek) = wrap_fresh_dek(&vault_key, &name)?;
184        table.put(&mut w, &name, &wrapped)?;
185        formats_table().put(&mut w, &name, &VALUE_FORMAT_PREFIXED)?;
186        w.commit()?;
187
188        Ok(Self::assemble(
189            name,
190            schema_version,
191            wrapped,
192            key_provider,
193            ValueFormat::Prefixed,
194            compression,
195        ))
196    }
197
198    fn assemble(
199        name: String,
200        schema_version: u32,
201        wrapped_dek: WrappedDek,
202        key_provider: Arc<dyn KeyProvider>,
203        format: ValueFormat,
204        compression: Compression,
205    ) -> Self {
206        Self {
207            name,
208            schema_version,
209            wrapped_dek,
210            key_provider,
211            format,
212            compression,
213            _types: PhantomData,
214        }
215    }
216
217    fn checked_name(name: impl Into<String>) -> StoreResult<String> {
218        let name = name.into();
219        if name.starts_with('_') {
220            return Err(StoreError::ReservedName(name));
221        }
222        Ok(name)
223    }
224
225    /// The collection's name (its table name).
226    pub fn name(&self) -> &str {
227        &self.name
228    }
229
230    /// The schema version bound into every row's AAD.
231    pub fn schema_version(&self) -> u32 {
232        self.schema_version
233    }
234
235    /// Fetch the vault key (cold-gated) and unwrap this collection's DEK. Done
236    /// per operation so a mid-session lock takes effect immediately.
237    fn dek(&self) -> StoreResult<Zeroizing<[u8; KEY_LEN]>> {
238        let vault_key = self.key_provider.vault_key()?;
239        Ok(unwrap_dek(&vault_key, &self.name, &self.wrapped_dek)?)
240    }
241
242    /// Encode a value into the plaintext this collection seals — verbatim for
243    /// legacy collections, `format_byte ‖ payload` (compressing per policy) for
244    /// prefixed ones.
245    fn plaintext_for_store(&self, value: &V) -> StoreResult<Vec<u8>> {
246        let plain = encode_value(value)?;
247        Ok(match self.format {
248            ValueFormat::Legacy => plain,
249            ValueFormat::Prefixed => pack_value(&plain, self.compression),
250        })
251    }
252
253    /// Decode a sealed-and-opened plaintext back into a value.
254    fn value_from_plaintext(&self, plain: &[u8]) -> StoreResult<V> {
255        match self.format {
256            ValueFormat::Legacy => decode_value(plain),
257            ValueFormat::Prefixed => decode_value(&unpack_value(plain)?),
258        }
259    }
260
261    /// Fetch and decrypt the value for `key`, or `None` if absent. A missing row
262    /// returns `None` **without** touching the vault — only a present row requires
263    /// an unlock to decrypt.
264    pub fn get(&self, tx: &impl Readable, key: &K) -> StoreResult<Option<V>> {
265        let key_bytes = key.encode();
266        let sealed = match tx.get_raw(&self.name, &key_bytes)? {
267            Some(bytes) => bytes,
268            None => return Ok(None),
269        };
270        let dek = self.dek()?;
271        let plain = open_row(&dek, &self.name, &key_bytes, self.schema_version, &sealed)?;
272        Ok(Some(self.value_from_plaintext(&plain)?))
273    }
274
275    /// Encrypt and store `value` under `key`.
276    pub fn put(&self, tx: &mut impl WriteTx, key: &K, value: &V) -> StoreResult<()> {
277        let key_bytes = key.encode();
278        let dek = self.dek()?;
279        let sealed = seal_row(
280            &dek,
281            &self.name,
282            &key_bytes,
283            self.schema_version,
284            &self.plaintext_for_store(value)?,
285        )?;
286        tx.put_raw(&self.name, &key_bytes, &sealed)
287    }
288
289    /// Remove `key`. Returns `true` if a value was present. No key material is
290    /// needed to delete a ciphertext row.
291    pub fn delete(&self, tx: &mut impl WriteTx, key: &K) -> StoreResult<bool> {
292        tx.delete_raw(&self.name, &key.encode())
293    }
294
295    /// Decrypt and return the `(key, value)` pairs in `[lo, hi)`, in ascending
296    /// logical key order. The DEK is unwrapped once for the whole scan.
297    pub fn range(&self, tx: &impl Readable, lo: &K, hi: &K) -> StoreResult<Vec<(K, V)>> {
298        let raw = tx.range_raw(&self.name, &lo.encode(), &hi.encode())?;
299        if raw.is_empty() {
300            return Ok(Vec::new());
301        }
302        let dek = self.dek()?;
303        raw.into_iter()
304            .map(|(key_bytes, sealed)| {
305                let plain = open_row(&dek, &self.name, &key_bytes, self.schema_version, &sealed)?;
306                Ok((K::decode(&key_bytes)?, self.value_from_plaintext(&plain)?))
307            })
308            .collect()
309    }
310}