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}