cube-store-seal: the transparent layer that makes "sealed" mean the store
A `Store` wrapper that seals what is written and opens what is read, so the
machine's services keep using the ordinary verbs and never know the store is
encrypted. The rules, tested rather than stated:
* reserved spaces (keystore 0xFD, null/portal/edge 0xFE) are never sealed;
* a value that is already an envelope is never sealed again, so `put` is
idempotent and a store can be converted in place;
* reads open envelopes, so a half-converted store reads identically to a
fully converted one;
* with no key (`plaintext`), reads pass envelopes through — the honest
"after the kill" behaviour — and with the wrong key, an open is an error,
because silence there would look like data when it is a mistake.
Five tests hold those claims, including the one that matters most: the plaintext
is never in the backing store, and a locked store returns ciphertext rather than
a guess.
This commit is contained in:
@@ -15,6 +15,7 @@ members = [
|
||||
"crates/cube-names",
|
||||
"crates/cube-image",
|
||||
"crates/cube-crypt",
|
||||
"crates/cube-store-seal",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -29,6 +30,7 @@ cube-store = { path = "crates/cube-store" }
|
||||
cube-store-raw = { path = "crates/cube-store-raw" }
|
||||
cube-header = { path = "crates/cube-header" }
|
||||
cube-crypt = { path = "crates/cube-crypt" }
|
||||
cube-store-seal = { path = "crates/cube-store-seal" }
|
||||
cube-cli = { path = "crates/cube-cli" }
|
||||
cube-command = { path = "crates/cube-command" }
|
||||
cube-index = { path = "crates/cube-index" }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "cube-store-seal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "A store whose records are sealed transparently: writes seal, reads open, reserved spaces pass through."
|
||||
|
||||
[dependencies]
|
||||
cube-core = { workspace = true }
|
||||
cube-store = { workspace = true }
|
||||
cube-crypt = { workspace = true }
|
||||
@@ -0,0 +1,232 @@
|
||||
//! A store whose records are sealed, transparently.
|
||||
//!
|
||||
//! This is the layer that turns "records can be sealed" into "the store is encrypted": the caller
|
||||
//! keeps using the ordinary `Store` verbs, and this wrapper seals what they write and opens what
|
||||
//! they read. The machine's services never have to know the store is encrypted, which is what
|
||||
//! makes a conversion of a live store possible without touching every client.
|
||||
//!
|
||||
//! Rules, stated rather than implied:
|
||||
//!
|
||||
//! * a **reserved** space is never sealed — the keystore (`0xFD…`) and the null/portal/edge
|
||||
//! space (`0xFE…`) hold structure, not data, and sealing the map is not sealing the ground;
|
||||
//! * a value that is **already an envelope** is never sealed again, so `put` is idempotent and a
|
||||
//! store can be converted in place without double-sealing;
|
||||
//! * a read that finds an **envelope** opens it, so a half-converted store reads the same as a
|
||||
//! fully converted one — plaintext records stay plaintext on the way out, sealed ones open;
|
||||
//! * with **no key** (`SealedStore::plaintext`), reads pass envelopes through untouched — the
|
||||
//! honest "after the kill" behaviour: the store is ciphertext and this process has no way to
|
||||
//! open it. With the **wrong** key, an open fails, because silence here would look like data
|
||||
//! when it is a mistake.
|
||||
use cube_core::{Coord, SpaceId};
|
||||
use cube_store::Region;
|
||||
use cube_crypt::{is_envelope, CubeEnv, Selector};
|
||||
use cube_store::{Store, StoreError};
|
||||
|
||||
fn io(what: impl ToString) -> StoreError {
|
||||
StoreError::Io(std::io::Error::other(what.to_string()))
|
||||
}
|
||||
|
||||
/// True for the spaces that hold structure rather than data: the keystore and the null space (which
|
||||
/// is also where portals and association edges live). These must not be sealed, or the readers that
|
||||
/// interpret them would be reading ciphertext where they expect a table.
|
||||
pub fn is_reserved(space: &SpaceId) -> bool {
|
||||
let b = space.as_bytes();
|
||||
b.iter().all(|x| *x == 0xFD) || b.iter().all(|x| *x == 0xFE)
|
||||
}
|
||||
|
||||
/// A [`Store`] that seals writes and opens reads.
|
||||
pub struct SealedStore<S: Store> {
|
||||
inner: S,
|
||||
env: Option<CubeEnv>,
|
||||
}
|
||||
|
||||
impl<S: Store> SealedStore<S> {
|
||||
/// A sealed store: writes seal, reads open, under the given environment.
|
||||
pub fn sealed(inner: S, env: CubeEnv) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
env: Some(env),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same store with no key: reads pass envelopes through untouched.
|
||||
pub fn plaintext(inner: S) -> Self {
|
||||
Self { inner, env: None }
|
||||
}
|
||||
|
||||
/// True when this store has no key, which is the state a kill leaves behind.
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.env.is_none()
|
||||
}
|
||||
|
||||
fn seal_value(&self, coord: &Coord, value: Vec<u8>) -> Result<Vec<u8>, StoreError> {
|
||||
match &self.env {
|
||||
Some(env) if !is_reserved(&coord.space) && !is_envelope(&value) => {
|
||||
env.seal(&self.inner, Selector::Slot(0), &value).map_err(io)
|
||||
}
|
||||
_ => Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_value(&self, value: Vec<u8>) -> Result<Vec<u8>, StoreError> {
|
||||
match &self.env {
|
||||
Some(env) if is_envelope(&value) => {
|
||||
env.open(&self.inner, Selector::Slot(0), &value).map_err(io)
|
||||
}
|
||||
_ => Ok(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Store> Store for SealedStore<S> {
|
||||
fn put(&mut self, coord: Coord, value: Vec<u8>) -> Result<(), StoreError> {
|
||||
let value = self.seal_value(&coord, value)?;
|
||||
self.inner.put(coord, value)
|
||||
}
|
||||
|
||||
fn get(&self, coord: &Coord) -> Result<Option<Vec<u8>>, StoreError> {
|
||||
match self.inner.get(coord)? {
|
||||
Some(value) => self.open_value(value).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&mut self, coord: &Coord) -> Result<Option<Vec<u8>>, StoreError> {
|
||||
match self.inner.delete(coord)? {
|
||||
Some(value) => self.open_value(value).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn range(
|
||||
&self,
|
||||
space: &SpaceId,
|
||||
region: &Region,
|
||||
) -> Result<Vec<(Coord, Vec<u8>)>, StoreError> {
|
||||
self.inner
|
||||
.range(space, region)?
|
||||
.into_iter()
|
||||
.map(|(coord, value)| Ok((coord, self.open_value(value)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spaces(&self) -> Result<Vec<SpaceId>, StoreError> {
|
||||
self.inner.spaces()
|
||||
}
|
||||
|
||||
fn entries(&self, space: &SpaceId) -> Result<Vec<(Coord, Vec<u8>)>, StoreError> {
|
||||
self.inner
|
||||
.entries(space)?
|
||||
.into_iter()
|
||||
.map(|(coord, value)| Ok((coord, self.open_value(value)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn checkpoint(&mut self) -> Result<(), StoreError> {
|
||||
self.inner.checkpoint()
|
||||
}
|
||||
|
||||
fn flush(&self) -> Result<(), StoreError> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cube_core::{Morton, Point};
|
||||
use cube_crypt::{TransformId, Vault};
|
||||
|
||||
fn coord(x: u64) -> Coord {
|
||||
Coord::new(SpaceId::ROOT, Point::new(x, 0, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_are_sealed_and_reads_open() {
|
||||
let vault = Vault::generate();
|
||||
let mut store = SealedStore::sealed(
|
||||
cube_store::MemStore::<Morton>::new(),
|
||||
vault.env(TransformId::Aes256Gcm),
|
||||
);
|
||||
let note = b"a note that must not be plaintext".to_vec();
|
||||
store.put(coord(1), note.clone()).unwrap();
|
||||
|
||||
// What the caller sees is the plaintext…
|
||||
assert_eq!(store.get(&coord(1)).unwrap().unwrap(), note);
|
||||
// …but what the backing store holds is an envelope, not the note.
|
||||
let at_rest = store.inner.get(&coord(1)).unwrap().unwrap();
|
||||
assert!(is_envelope(&at_rest), "the record is not sealed at rest");
|
||||
assert!(!at_rest.windows(note.len()).any(|w| w == note), "the plaintext is at rest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_store_passes_ciphertext_through() {
|
||||
let vault = Vault::generate();
|
||||
let mut sealed = SealedStore::sealed(
|
||||
cube_store::MemStore::<Morton>::new(),
|
||||
vault.env(TransformId::Aes256Gcm),
|
||||
);
|
||||
sealed.put(coord(1), b"the secret".to_vec()).unwrap();
|
||||
let at_rest = sealed.inner.get(&coord(1)).unwrap().unwrap();
|
||||
assert!(is_envelope(&at_rest));
|
||||
|
||||
// The same backing bytes, read by a process with no key: ciphertext out, never a guess.
|
||||
let mut backing = cube_store::MemStore::<Morton>::new();
|
||||
backing.put(coord(1), at_rest).unwrap();
|
||||
let locked = SealedStore::plaintext(backing);
|
||||
let out = locked.get(&coord(1)).unwrap().unwrap();
|
||||
assert!(is_envelope(&out), "a locked store returns the envelope, not a plaintext guess");
|
||||
assert!(locked.is_locked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_key_fails_loud_instead_of_returning_garbage() {
|
||||
let right = Vault::generate();
|
||||
let wrong = Vault::generate();
|
||||
let mut sealed = SealedStore::sealed(
|
||||
cube_store::MemStore::<Morton>::new(),
|
||||
right.env(TransformId::Aes256Gcm),
|
||||
);
|
||||
sealed.put(coord(1), b"the secret".to_vec()).unwrap();
|
||||
let at_rest = sealed.inner.get(&coord(1)).unwrap().unwrap();
|
||||
|
||||
let mut backing = cube_store::MemStore::<Morton>::new();
|
||||
backing.put(coord(1), at_rest).unwrap();
|
||||
let misconfigured = SealedStore::sealed(backing, wrong.env(TransformId::Aes256Gcm));
|
||||
assert!(misconfigured.get(&coord(1)).is_err(), "a wrong key must be an error, not silence");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_spaces_are_never_sealed() {
|
||||
let vault = Vault::generate();
|
||||
let keystore = Coord::new(SpaceId([0xFD; 32]), Point::new(0, 0, 0));
|
||||
let null = Coord::new(SpaceId([0xFE; 32]), Point::new(0, 0, 0));
|
||||
let mut store = SealedStore::sealed(
|
||||
cube_store::MemStore::<Morton>::new(),
|
||||
vault.env(TransformId::Aes256Gcm),
|
||||
);
|
||||
store.put(keystore, vec![7u8; 32]).unwrap();
|
||||
store.put(null, vec![9u8; 32]).unwrap();
|
||||
assert_eq!(store.get(&keystore).unwrap().unwrap(), vec![7u8; 32]);
|
||||
assert_eq!(store.get(&null).unwrap().unwrap(), vec![9u8; 32]);
|
||||
// And the backing store holds them exactly as written — no envelope.
|
||||
assert!(!is_envelope(&store.inner.get(&keystore).unwrap().unwrap()));
|
||||
assert!(!is_envelope(&store.inner.get(&null).unwrap().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_is_idempotent_and_half_converted_stores_read_cleanly() {
|
||||
let vault = Vault::generate();
|
||||
let mut store = SealedStore::sealed(
|
||||
cube_store::MemStore::<Morton>::new(),
|
||||
vault.env(TransformId::Aes256Gcm),
|
||||
);
|
||||
// A record that predates sealing, written as plaintext into the backing store.
|
||||
store.inner.put(coord(0), b"old plaintext".to_vec()).unwrap();
|
||||
store.put(coord(1), b"once".to_vec()).unwrap();
|
||||
store.put(coord(1), b"once".to_vec()).unwrap();
|
||||
|
||||
assert_eq!(store.get(&coord(0)).unwrap().unwrap(), b"old plaintext");
|
||||
assert_eq!(store.get(&coord(1)).unwrap().unwrap(), b"once");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user