Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/m1-realdata-e2e/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions tinycloud-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -58,6 +62,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
Expand Down
74 changes: 74 additions & 0 deletions tinycloud-core/src/at_rest/key.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
12 changes: 12 additions & 0 deletions tinycloud-core/src/at_rest/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! 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;
#[cfg(any(test, feature = "test-util"))]
mod testing;

pub use key::{DataKey, DekId, ObjectNameKey};
pub use provider::{DataKeyError, DataKeyProvider};
#[cfg(any(test, feature = "test-util"))]
pub use testing::FixedKeyProvider;
35 changes: 35 additions & 0 deletions tinycloud-core/src/at_rest/provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! 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;

/// 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<DataKey, DataKeyError>;

/// The DEK a stored blob names in its header. Never creates a key.
async fn key(&self, space: &str, dek_id: DekId) -> Result<DataKey, DataKeyError>;

/// The object-name key for sealed S3 names. Constant for a deployment.
fn object_name_key(&self) -> Result<ObjectNameKey, DataKeyError>;
}

#[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<dyn std::error::Error + Send + Sync>),
}
147 changes: 147 additions & 0 deletions tinycloud-core/src/at_rest/testing.rs
Original file line number Diff line number Diff line change
@@ -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<DataKey, DataKeyError> {
self.key(space, self.current).await
}

async fn key(&self, space: &str, dek_id: DekId) -> Result<DataKey, DataKeyError> {
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<ObjectNameKey, DataKeyError> {
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)));
}
}
1 change: 1 addition & 0 deletions tinycloud-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod admission;
pub mod at_rest;
pub(crate) mod auth_graph;
pub mod database_artifacts;
pub mod database_identity;
Expand Down
Loading