From db568545c286e5dc7ca804c0549e75a8bdef1748 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 05:28:37 +0000 Subject: [PATCH 1/2] feat(TC-887): at_rest data key types and DataKeyProvider trait Adds tinycloud_core::at_rest with DekId, DataKey and ObjectNameKey (zeroized on drop, redacted Debug), the DataKeyProvider trait with DataKeyError, and a deterministic FixedKeyProvider for tests. Nothing in the node uses the module yet. --- Cargo.lock | 1 + tinycloud-core/Cargo.toml | 2 + tinycloud-core/src/at_rest/key.rs | 74 +++++++++++ tinycloud-core/src/at_rest/mod.rs | 8 ++ tinycloud-core/src/at_rest/provider.rs | 165 +++++++++++++++++++++++++ tinycloud-core/src/lib.rs | 1 + 6 files changed, 251 insertions(+) create mode 100644 tinycloud-core/src/at_rest/key.rs create mode 100644 tinycloud-core/src/at_rest/mod.rs create mode 100644 tinycloud-core/src/at_rest/provider.rs diff --git a/Cargo.lock b/Cargo.lock index 2f23023d..6406139e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9688,6 +9688,7 @@ dependencies = [ "ucan-capabilities-object", "unicode-normalization", "x25519-dalek", + "zeroize", ] [[package]] diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index d166e69a..2731b95c 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -58,6 +58,8 @@ base64.workspace = true sha2 = "0.10" sha3 = "0.10" k256 = "0.13" +# TC-887: zeroize at-rest key material on drop. +zeroize = "1" prometheus = { version = "0.13.0", features = ["process"] } ryu-js = "0.2.2" # W1 (audit P1): full Unicode NFC normalization for policy capability path diff --git a/tinycloud-core/src/at_rest/key.rs b/tinycloud-core/src/at_rest/key.rs new file mode 100644 index 00000000..6479b5b8 --- /dev/null +++ b/tinycloud-core/src/at_rest/key.rs @@ -0,0 +1,74 @@ +//! Key material handed to the at-rest envelope (TC-887). + +use zeroize::Zeroizing; + +/// Per-space data-key id, stored in the TCE1 header. Unique within a space, +/// not globally. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DekId(u32); + +impl DekId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn get(self) -> u32 { + self.0 + } +} + +/// An unwrapped 32-byte data key (DEK) and its id. The bytes are zeroized on +/// drop and never exposed outside this module. +#[derive(Clone)] +pub struct DataKey { + id: DekId, + bytes: Zeroizing<[u8; 32]>, +} + +impl DataKey { + pub fn new(id: DekId, bytes: [u8; 32]) -> Self { + Self { + id, + bytes: Zeroizing::new(bytes), + } + } + + pub fn id(&self) -> DekId { + self.id + } + + #[allow(dead_code)] // read by the envelope module + pub(crate) fn bytes(&self) -> &[u8; 32] { + &self.bytes + } +} + +impl std::fmt::Debug for DataKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DataKey") + .field("id", &self.id) + .finish_non_exhaustive() + } +} + +/// The object-name key (NK) used to derive sealed S3 object names. Zeroized on +/// drop; never printed. +#[derive(Clone)] +pub struct ObjectNameKey(Zeroizing<[u8; 32]>); + +impl ObjectNameKey { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } + + #[allow(dead_code)] // read by the object-name module + pub(crate) fn bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl std::fmt::Debug for ObjectNameKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ObjectNameKey").finish_non_exhaustive() + } +} diff --git a/tinycloud-core/src/at_rest/mod.rs b/tinycloud-core/src/at_rest/mod.rs new file mode 100644 index 00000000..0fa69362 --- /dev/null +++ b/tinycloud-core/src/at_rest/mod.rs @@ -0,0 +1,8 @@ +//! Encryption at rest (TC-887). Nothing in the node calls this module yet; it +//! is switched off until the key-continuity gate exists. + +mod key; +mod provider; + +pub use key::{DataKey, DekId, ObjectNameKey}; +pub use provider::{DataKeyError, DataKeyProvider, FixedKeyProvider}; diff --git a/tinycloud-core/src/at_rest/provider.rs b/tinycloud-core/src/at_rest/provider.rs new file mode 100644 index 00000000..7ce8dfb5 --- /dev/null +++ b/tinycloud-core/src/at_rest/provider.rs @@ -0,0 +1,165 @@ +//! The boundary between key continuity (who holds DEKs) and the envelope +//! (what a DEK encrypts). See `INTERFACES.md` ยง2 on TC-887. + +use super::key::{DataKey, DekId, ObjectNameKey}; +use async_trait::async_trait; +use sha2::{Digest, Sha256}; + +/// Supplies unwrapped data keys. `space` is always the canonical +/// `SpaceId::to_string()` form. +#[async_trait] +pub trait DataKeyProvider: Send + Sync { + /// The DEK new writes in `space` must use. If the space has none, the + /// implementation creates one and stores it durably before returning; + /// concurrent first calls converge on one `DekId`. + async fn current_key(&self, space: &str) -> Result; + + /// The DEK a stored blob names in its header. Never creates a key. + async fn key(&self, space: &str, dek_id: DekId) -> Result; + + /// The object-name key for sealed S3 names. Constant for a deployment. + fn object_name_key(&self) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum DataKeyError { + /// The node booted locked: its root cannot unseal the at-rest root key. + #[error("at-rest keys are locked")] + Locked, + /// The space never had this key, or it was shredded. Callers treat this as + /// an integrity failure of the blob, never as "not found". + #[error("unknown data key {dek_id:?} for space")] + UnknownKey { dek_id: DekId }, + /// Storage or unwrap failure. Carries no key bytes. + #[error("data key backend: {0}")] + Backend(#[source] Box), +} + +/// Deterministic in-memory provider for tests. Not for production: every key +/// is derived from a public seed. +/// +/// Each space has keys `1..=current`; `current_key` returns `current`. +#[derive(Clone, Debug)] +pub struct FixedKeyProvider { + seed: [u8; 32], + current: DekId, + locked: bool, +} + +impl FixedKeyProvider { + pub fn new(seed: [u8; 32]) -> Self { + Self { + seed, + current: DekId::new(1), + locked: false, + } + } + + /// Every space has keys `1..=current`. Panics on 0. + pub fn with_current(mut self, current: DekId) -> Self { + assert!(current.get() > 0, "dek ids start at 1"); + self.current = current; + self + } + + /// A provider that behaves like a node booted locked. + pub fn locked(mut self) -> Self { + self.locked = true; + self + } + + fn derive(&self, label: &[u8], space: &str, dek_id: u32) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(label); + h.update(self.seed); + h.update((space.len() as u32).to_be_bytes()); + h.update(space.as_bytes()); + h.update(dek_id.to_be_bytes()); + h.finalize().into() + } +} + +#[async_trait] +impl DataKeyProvider for FixedKeyProvider { + async fn current_key(&self, space: &str) -> Result { + self.key(space, self.current).await + } + + async fn key(&self, space: &str, dek_id: DekId) -> Result { + if self.locked { + return Err(DataKeyError::Locked); + } + if dek_id.get() == 0 || dek_id > self.current { + return Err(DataKeyError::UnknownKey { dek_id }); + } + Ok(DataKey::new( + dek_id, + self.derive(b"tinycloud/at-rest/test-fixed/dek", space, dek_id.get()), + )) + } + + fn object_name_key(&self) -> Result { + if self.locked { + return Err(DataKeyError::Locked); + } + Ok(ObjectNameKey::new(self.derive( + b"tinycloud/at-rest/test-fixed/nk", + "", + 0, + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_never_prints_key_bytes() { + let bytes = [0xA5u8; 32]; + let dek = format!("{:?}", DataKey::new(DekId::new(7), bytes)); + let nk = format!("{:?}", ObjectNameKey::new(bytes)); + for out in [&dek, &nk] { + assert!(!out.contains("165"), "{out}"); + assert!(!out.to_lowercase().contains("a5"), "{out}"); + } + assert!(dek.contains("7"), "{dek}"); + } + + #[tokio::test] + async fn fixed_provider_serves_issued_keys_only() { + let p = FixedKeyProvider::new([1; 32]).with_current(DekId::new(2)); + let cur = p.current_key("space-a").await.unwrap(); + assert_eq!(cur.id(), DekId::new(2)); + let old = p.key("space-a", DekId::new(1)).await.unwrap(); + assert_ne!(old.bytes(), cur.bytes()); + assert_eq!( + p.key("space-a", DekId::new(2)).await.unwrap().bytes(), + cur.bytes() + ); + assert_ne!( + p.key("space-b", DekId::new(2)).await.unwrap().bytes(), + cur.bytes() + ); + for id in [0, 3] { + assert!(matches!( + p.key("space-a", DekId::new(id)).await, + Err(DataKeyError::UnknownKey { dek_id }) if dek_id == DekId::new(id) + )); + } + } + + #[tokio::test] + async fn locked_provider_refuses_everything() { + let p = FixedKeyProvider::new([1; 32]).locked(); + assert!(matches!( + p.current_key("s").await, + Err(DataKeyError::Locked) + )); + assert!(matches!( + p.key("s", DekId::new(1)).await, + Err(DataKeyError::Locked) + )); + assert!(matches!(p.object_name_key(), Err(DataKeyError::Locked))); + } +} diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 3cacd735..d727556e 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod admission; +pub mod at_rest; pub(crate) mod auth_graph; pub mod database_artifacts; pub mod database_identity; From bc618de4c6b4d2203b62e78f3385e56a6818a7e6 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 06:22:25 +0000 Subject: [PATCH 2/2] fix(TC-887): gate FixedKeyProvider behind test-util, redact its Debug FixedKeyProvider moves to at_rest/testing.rs, compiled only for this crate's tests or with the new test-util feature. Its Debug no longer prints the seed. Refresh test/m1-realdata-e2e/Cargo.lock for zeroize. --- test/m1-realdata-e2e/Cargo.lock | 1 + tinycloud-core/Cargo.toml | 4 + tinycloud-core/src/at_rest/mod.rs | 6 +- tinycloud-core/src/at_rest/provider.rs | 130 ---------------------- tinycloud-core/src/at_rest/testing.rs | 147 +++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 131 deletions(-) create mode 100644 tinycloud-core/src/at_rest/testing.rs diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index 5941dace..133f99bd 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -7762,6 +7762,7 @@ dependencies = [ "ucan-capabilities-object", "unicode-normalization", "x25519-dalek", + "zeroize", ] [[package]] diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 2731b95c..5cadbf94 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -16,6 +16,10 @@ duckdb = ["dep:arrow", "dep:duckdb"] # build. Downstream crates enable it only on their `tinycloud-core` # dev-dependency so their own tests can assert exactly-once verification. verification-count-test-hook = [] +# TC-887: `at_rest::FixedKeyProvider`, a deterministic test key provider. +# Never part of a production build; downstream crates enable it only on their +# `tinycloud-core` dev-dependency. +test-util = [] tokio = [ "sea-orm/runtime-tokio-rustls", diff --git a/tinycloud-core/src/at_rest/mod.rs b/tinycloud-core/src/at_rest/mod.rs index 0fa69362..e30529d0 100644 --- a/tinycloud-core/src/at_rest/mod.rs +++ b/tinycloud-core/src/at_rest/mod.rs @@ -3,6 +3,10 @@ mod key; mod provider; +#[cfg(any(test, feature = "test-util"))] +mod testing; pub use key::{DataKey, DekId, ObjectNameKey}; -pub use provider::{DataKeyError, DataKeyProvider, FixedKeyProvider}; +pub use provider::{DataKeyError, DataKeyProvider}; +#[cfg(any(test, feature = "test-util"))] +pub use testing::FixedKeyProvider; diff --git a/tinycloud-core/src/at_rest/provider.rs b/tinycloud-core/src/at_rest/provider.rs index 7ce8dfb5..f97785d4 100644 --- a/tinycloud-core/src/at_rest/provider.rs +++ b/tinycloud-core/src/at_rest/provider.rs @@ -3,7 +3,6 @@ use super::key::{DataKey, DekId, ObjectNameKey}; use async_trait::async_trait; -use sha2::{Digest, Sha256}; /// Supplies unwrapped data keys. `space` is always the canonical /// `SpaceId::to_string()` form. @@ -34,132 +33,3 @@ pub enum DataKeyError { #[error("data key backend: {0}")] Backend(#[source] Box), } - -/// Deterministic in-memory provider for tests. Not for production: every key -/// is derived from a public seed. -/// -/// Each space has keys `1..=current`; `current_key` returns `current`. -#[derive(Clone, Debug)] -pub struct FixedKeyProvider { - seed: [u8; 32], - current: DekId, - locked: bool, -} - -impl FixedKeyProvider { - pub fn new(seed: [u8; 32]) -> Self { - Self { - seed, - current: DekId::new(1), - locked: false, - } - } - - /// Every space has keys `1..=current`. Panics on 0. - pub fn with_current(mut self, current: DekId) -> Self { - assert!(current.get() > 0, "dek ids start at 1"); - self.current = current; - self - } - - /// A provider that behaves like a node booted locked. - pub fn locked(mut self) -> Self { - self.locked = true; - self - } - - fn derive(&self, label: &[u8], space: &str, dek_id: u32) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(label); - h.update(self.seed); - h.update((space.len() as u32).to_be_bytes()); - h.update(space.as_bytes()); - h.update(dek_id.to_be_bytes()); - h.finalize().into() - } -} - -#[async_trait] -impl DataKeyProvider for FixedKeyProvider { - async fn current_key(&self, space: &str) -> Result { - self.key(space, self.current).await - } - - async fn key(&self, space: &str, dek_id: DekId) -> Result { - if self.locked { - return Err(DataKeyError::Locked); - } - if dek_id.get() == 0 || dek_id > self.current { - return Err(DataKeyError::UnknownKey { dek_id }); - } - Ok(DataKey::new( - dek_id, - self.derive(b"tinycloud/at-rest/test-fixed/dek", space, dek_id.get()), - )) - } - - fn object_name_key(&self) -> Result { - if self.locked { - return Err(DataKeyError::Locked); - } - Ok(ObjectNameKey::new(self.derive( - b"tinycloud/at-rest/test-fixed/nk", - "", - 0, - ))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn debug_never_prints_key_bytes() { - let bytes = [0xA5u8; 32]; - let dek = format!("{:?}", DataKey::new(DekId::new(7), bytes)); - let nk = format!("{:?}", ObjectNameKey::new(bytes)); - for out in [&dek, &nk] { - assert!(!out.contains("165"), "{out}"); - assert!(!out.to_lowercase().contains("a5"), "{out}"); - } - assert!(dek.contains("7"), "{dek}"); - } - - #[tokio::test] - async fn fixed_provider_serves_issued_keys_only() { - let p = FixedKeyProvider::new([1; 32]).with_current(DekId::new(2)); - let cur = p.current_key("space-a").await.unwrap(); - assert_eq!(cur.id(), DekId::new(2)); - let old = p.key("space-a", DekId::new(1)).await.unwrap(); - assert_ne!(old.bytes(), cur.bytes()); - assert_eq!( - p.key("space-a", DekId::new(2)).await.unwrap().bytes(), - cur.bytes() - ); - assert_ne!( - p.key("space-b", DekId::new(2)).await.unwrap().bytes(), - cur.bytes() - ); - for id in [0, 3] { - assert!(matches!( - p.key("space-a", DekId::new(id)).await, - Err(DataKeyError::UnknownKey { dek_id }) if dek_id == DekId::new(id) - )); - } - } - - #[tokio::test] - async fn locked_provider_refuses_everything() { - let p = FixedKeyProvider::new([1; 32]).locked(); - assert!(matches!( - p.current_key("s").await, - Err(DataKeyError::Locked) - )); - assert!(matches!( - p.key("s", DekId::new(1)).await, - Err(DataKeyError::Locked) - )); - assert!(matches!(p.object_name_key(), Err(DataKeyError::Locked))); - } -} diff --git a/tinycloud-core/src/at_rest/testing.rs b/tinycloud-core/src/at_rest/testing.rs new file mode 100644 index 00000000..ef7054a8 --- /dev/null +++ b/tinycloud-core/src/at_rest/testing.rs @@ -0,0 +1,147 @@ +//! Test-only key provider. Compiled only for this crate's tests or with the +//! `test-util` feature, which downstream crates enable on their +//! `tinycloud-core` dev-dependency only. + +use super::key::{DataKey, DekId, ObjectNameKey}; +use super::provider::{DataKeyError, DataKeyProvider}; +use async_trait::async_trait; +use sha2::{Digest, Sha256}; + +/// Deterministic in-memory provider for tests. Not for production: every key +/// is derived from a public seed. +/// +/// Each space has keys `1..=current`; `current_key` returns `current`. +#[derive(Clone)] +pub struct FixedKeyProvider { + seed: [u8; 32], + current: DekId, + locked: bool, +} + +impl std::fmt::Debug for FixedKeyProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FixedKeyProvider") + .field("current", &self.current) + .field("locked", &self.locked) + .finish_non_exhaustive() + } +} + +impl FixedKeyProvider { + pub fn new(seed: [u8; 32]) -> Self { + Self { + seed, + current: DekId::new(1), + locked: false, + } + } + + /// Every space has keys `1..=current`. Panics on 0. + pub fn with_current(mut self, current: DekId) -> Self { + assert!(current.get() > 0, "dek ids start at 1"); + self.current = current; + self + } + + /// A provider that behaves like a node booted locked. + pub fn locked(mut self) -> Self { + self.locked = true; + self + } + + fn derive(&self, label: &[u8], space: &str, dek_id: u32) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(label); + h.update(self.seed); + h.update((space.len() as u32).to_be_bytes()); + h.update(space.as_bytes()); + h.update(dek_id.to_be_bytes()); + h.finalize().into() + } +} + +#[async_trait] +impl DataKeyProvider for FixedKeyProvider { + async fn current_key(&self, space: &str) -> Result { + self.key(space, self.current).await + } + + async fn key(&self, space: &str, dek_id: DekId) -> Result { + if self.locked { + return Err(DataKeyError::Locked); + } + if dek_id.get() == 0 || dek_id > self.current { + return Err(DataKeyError::UnknownKey { dek_id }); + } + Ok(DataKey::new( + dek_id, + self.derive(b"tinycloud/at-rest/test-fixed/dek", space, dek_id.get()), + )) + } + + fn object_name_key(&self) -> Result { + if self.locked { + return Err(DataKeyError::Locked); + } + Ok(ObjectNameKey::new(self.derive( + b"tinycloud/at-rest/test-fixed/nk", + "", + 0, + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_never_prints_key_bytes() { + let bytes = [0xA5u8; 32]; + let dek = format!("{:?}", DataKey::new(DekId::new(7), bytes)); + let nk = format!("{:?}", ObjectNameKey::new(bytes)); + let provider = format!("{:?}", FixedKeyProvider::new(bytes)); + for out in [&dek, &nk, &provider] { + assert!(!out.contains("165"), "{out}"); + assert!(!out.to_lowercase().contains("a5"), "{out}"); + } + assert!(dek.contains("7"), "{dek}"); + } + + #[tokio::test] + async fn fixed_provider_serves_issued_keys_only() { + let p = FixedKeyProvider::new([1; 32]).with_current(DekId::new(2)); + let cur = p.current_key("space-a").await.unwrap(); + assert_eq!(cur.id(), DekId::new(2)); + let old = p.key("space-a", DekId::new(1)).await.unwrap(); + assert_ne!(old.bytes(), cur.bytes()); + assert_eq!( + p.key("space-a", DekId::new(2)).await.unwrap().bytes(), + cur.bytes() + ); + assert_ne!( + p.key("space-b", DekId::new(2)).await.unwrap().bytes(), + cur.bytes() + ); + for id in [0, 3] { + assert!(matches!( + p.key("space-a", DekId::new(id)).await, + Err(DataKeyError::UnknownKey { dek_id }) if dek_id == DekId::new(id) + )); + } + } + + #[tokio::test] + async fn locked_provider_refuses_everything() { + let p = FixedKeyProvider::new([1; 32]).locked(); + assert!(matches!( + p.current_key("s").await, + Err(DataKeyError::Locked) + )); + assert!(matches!( + p.key("s", DekId::new(1)).await, + Err(DataKeyError::Locked) + )); + assert!(matches!(p.object_name_key(), Err(DataKeyError::Locked))); + } +}