From db568545c286e5dc7ca804c0549e75a8bdef1748 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 05:28:37 +0000 Subject: [PATCH 1/7] 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/7] 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))); + } +} From 6aa42f5a97a758787c155938448b0af64bc7d3e2 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 05:37:39 +0000 Subject: [PATCH 3/7] feat(TC-887): TCE1 segmented AES-256-GCM envelope Adds the at_rest envelope: 64-byte TCE1 header, 64 KiB segments with counter nonces and a last-segment flag, per-blob HKDF-SHA256 key from the DEK and a random salt, AAD binding to the blob identity (kv-block, sql-checkpoint, sql-wal) plus the header. Whole-buffer seal/open in place, streaming EncryptReader/DecryptReader, plaintext-to-segment Range mapping, PayloadEncoding column flag, and NK-derived sealed object names. Known-answer vectors come from an independent Python reference. Nothing in the node calls the module yet. --- Cargo.lock | 3 + tinycloud-core/Cargo.toml | 6 +- tinycloud-core/src/at_rest/envelope.rs | 396 +++++++++++ tinycloud-core/src/at_rest/key.rs | 2 - tinycloud-core/src/at_rest/mod.rs | 11 + tinycloud-core/src/at_rest/names.rs | 64 ++ tinycloud-core/src/at_rest/stream.rs | 376 ++++++++++ .../src/at_rest/testdata/gen_vectors.py | 139 ++++ .../src/at_rest/testdata/vectors.json | 158 +++++ tinycloud-core/src/at_rest/tests.rs | 669 ++++++++++++++++++ 10 files changed, 1821 insertions(+), 3 deletions(-) create mode 100644 tinycloud-core/src/at_rest/envelope.rs create mode 100644 tinycloud-core/src/at_rest/names.rs create mode 100644 tinycloud-core/src/at_rest/stream.rs create mode 100755 tinycloud-core/src/at_rest/testdata/gen_vectors.py create mode 100644 tinycloud-core/src/at_rest/testdata/vectors.json create mode 100644 tinycloud-core/src/at_rest/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 6406139e..7adf039c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9662,12 +9662,15 @@ dependencies = [ "duckdb", "futures", "hex", + "hkdf", + "hmac", "iri-string", "k256", "libp2p", "multihash-derive 0.9.1", "pin-project", "prometheus", + "proptest", "rand 0.8.5", "rusqlite", "ryu-js", diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 5cadbf94..44ebb3e5 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -62,7 +62,10 @@ base64.workspace = true sha2 = "0.10" sha3 = "0.10" k256 = "0.13" -# TC-887: zeroize at-rest key material on drop. +# TC-887: at-rest envelope key derivation and object names; zeroize key +# material on drop. +hkdf = "0.12" +hmac = "0.12" zeroize = "1" prometheus = { version = "0.13.0", features = ["process"] } ryu-js = "0.2.2" @@ -76,3 +79,4 @@ sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-sqlite"] async-std = { version = "1", features = ["attributes"] } tokio.workspace = true iri-string.workspace = true +proptest = "1" diff --git a/tinycloud-core/src/at_rest/envelope.rs b/tinycloud-core/src/at_rest/envelope.rs new file mode 100644 index 00000000..754a0bf4 --- /dev/null +++ b/tinycloud-core/src/at_rest/envelope.rs @@ -0,0 +1,396 @@ +//! TCE1: a segmented AES-256-GCM envelope (TC-887 plan §1.2–1.3; normative +//! format in `INTERFACES.md` §4). +//! +//! ```text +//! header(64) ‖ seg_0 ‖ … ‖ seg_{n-1} seg_i = AES-GCM(ct_i) ‖ tag16 +//! K_obj = HKDF-SHA256(ikm = DEK, salt = header.salt, info = SHA-256(ctx)) +//! nonce_i = u32 BE i ‖ u8 last(i) ‖ 0^7 +//! AAD = ctx ‖ header +//! ``` +//! The per-blob random salt makes K_obj unique per blob, so counter nonces +//! never repeat under one key. The index in the nonce stops reordering, the +//! last flag stops truncation, and the header (inside the AAD) binds the +//! declared length and key id. + +use super::key::{DataKey, DekId}; +use crate::hash::Hash; +use aes_gcm::{ + aead::{AeadInPlace, KeyInit}, + Aes256Gcm, Nonce, Tag, +}; +use hkdf::Hkdf; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use zeroize::{Zeroize, Zeroizing}; + +pub const HEADER_LEN: usize = 64; +pub const SEGMENT_LEN: usize = 1 << 16; +pub const TAG_LEN: usize = 16; + +const MAGIC: &[u8; 4] = b"TCE1"; +const VERSION: u8 = 1; +const ALG_AES256GCM_HKDF_SHA256: u8 = 1; +const SEG_LOG2: u8 = 16; +const MAX_SEGMENTS: u64 = 1 << 32; +const CONTEXT_LABEL: &[u8] = b"tinycloud/at-rest/v1"; +const SEGMENT_CT_LEN: u64 = (SEGMENT_LEN + TAG_LEN) as u64; + +#[derive(Debug, thiserror::Error)] +pub enum EnvelopeError { + /// Not a TCE1 header this build understands. + #[error("invalid TCE1 header: {0}")] + Header(&'static str), + /// The header names a different data key than the one supplied. + #[error("TCE1 header names a different data key")] + KeyMismatch, + /// Authentication failed: tampering, truncation, reordering, trailing + /// bytes, or the blob belongs to another identity. + #[error("TCE1 integrity check failed")] + Integrity, + /// More than 2^32 segments. + #[error("plaintext too large for TCE1")] + TooLarge, + /// The streaming input ended before the declared length or ran past it. + #[error("plaintext length differs from the declared length")] + LengthMismatch, +} + +/// What a ciphertext is bound to. A blob opened under any other identity +/// fails authentication. +#[derive(Clone, Copy, Debug)] +pub enum BlobIdentity<'a> { + KvBlock { + space: &'a str, + content_hash: &'a Hash, + }, + SqlCheckpoint { + service: &'a str, + space: &'a str, + name: &'a str, + checkpoint_content_hash: &'a str, + checkpoint_size_bytes: u64, + }, + SqlWal { + service: &'a str, + space: &'a str, + name: &'a str, + revision: i64, + checkpoint_content_hash: &'a str, + delta_content_hash: &'a str, + }, +} + +pub(crate) fn push_lp(out: &mut Vec, field: &[u8]) { + out.extend_from_slice(&(field.len() as u32).to_be_bytes()); + out.extend_from_slice(field); +} + +impl BlobIdentity<'_> { + /// `lp(label) ‖ lp(purpose) ‖ lp(field)…`, every field length-prefixed. + pub(crate) fn context(&self) -> Vec { + let mut c = Vec::with_capacity(256); + push_lp(&mut c, CONTEXT_LABEL); + match *self { + Self::KvBlock { + space, + content_hash, + } => { + push_lp(&mut c, b"kv-block"); + push_lp(&mut c, space.as_bytes()); + push_lp(&mut c, &Vec::::from(*content_hash)); + } + Self::SqlCheckpoint { + service, + space, + name, + checkpoint_content_hash, + checkpoint_size_bytes, + } => { + push_lp(&mut c, b"sql-checkpoint"); + push_lp(&mut c, service.as_bytes()); + push_lp(&mut c, space.as_bytes()); + push_lp(&mut c, name.as_bytes()); + push_lp(&mut c, checkpoint_content_hash.as_bytes()); + push_lp(&mut c, &checkpoint_size_bytes.to_be_bytes()); + } + Self::SqlWal { + service, + space, + name, + revision, + checkpoint_content_hash, + delta_content_hash, + } => { + push_lp(&mut c, b"sql-wal"); + push_lp(&mut c, service.as_bytes()); + push_lp(&mut c, space.as_bytes()); + push_lp(&mut c, name.as_bytes()); + push_lp(&mut c, &revision.to_be_bytes()); + push_lp(&mut c, checkpoint_content_hash.as_bytes()); + push_lp(&mut c, delta_content_hash.as_bytes()); + } + } + c + } +} + +/// A parsed and validated TCE1 header. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Header { + bytes: [u8; HEADER_LEN], + dek_id: DekId, + plaintext_len: u64, +} + +impl Header { + pub(crate) fn build(dek_id: DekId, plaintext_len: u64, salt: &[u8; 32]) -> Self { + let mut b = [0u8; HEADER_LEN]; + b[0..4].copy_from_slice(MAGIC); + b[4] = VERSION; + b[5] = ALG_AES256GCM_HKDF_SHA256; + b[8..12].copy_from_slice(&dek_id.get().to_be_bytes()); + b[12..20].copy_from_slice(&plaintext_len.to_be_bytes()); + b[20..52].copy_from_slice(salt); + b[52] = SEG_LOG2; + Self { + bytes: b, + dek_id, + plaintext_len, + } + } + + /// Parses the first [`HEADER_LEN`] bytes of `bytes`. Every field this + /// version does not define must be zero. + pub fn parse(bytes: &[u8]) -> Result { + let b: [u8; HEADER_LEN] = bytes + .get(..HEADER_LEN) + .ok_or(EnvelopeError::Header("short header"))? + .try_into() + .expect("64 bytes"); + if &b[0..4] != MAGIC { + return Err(EnvelopeError::Header("magic")); + } + if b[4] != VERSION { + return Err(EnvelopeError::Header("version")); + } + if b[5] != ALG_AES256GCM_HKDF_SHA256 { + return Err(EnvelopeError::Header("algorithm")); + } + if b[6] != 0 || b[7] != 0 { + return Err(EnvelopeError::Header("flags")); + } + if b[52] != SEG_LOG2 { + return Err(EnvelopeError::Header("segment size")); + } + if b[53..].iter().any(|&x| x != 0) { + return Err(EnvelopeError::Header("padding")); + } + let dek_id = DekId::new(u32::from_be_bytes(b[8..12].try_into().expect("4"))); + let plaintext_len = u64::from_be_bytes(b[12..20].try_into().expect("8")); + if segment_count(plaintext_len) > MAX_SEGMENTS { + return Err(EnvelopeError::Header("length")); + } + Ok(Self { + bytes: b, + dek_id, + plaintext_len, + }) + } + + pub fn as_bytes(&self) -> &[u8; HEADER_LEN] { + &self.bytes + } + + pub fn dek_id(&self) -> DekId { + self.dek_id + } + + pub fn plaintext_len(&self) -> u64 { + self.plaintext_len + } + + pub fn ciphertext_len(&self) -> u64 { + ciphertext_len(self.plaintext_len).expect("validated in parse/build") + } + + pub(crate) fn segment_count(&self) -> u64 { + segment_count(self.plaintext_len) + } + + fn salt(&self) -> &[u8] { + &self.bytes[20..52] + } +} + +/// Number of segments for a plaintext length; an empty plaintext is one +/// empty segment. +pub(crate) fn segment_count(plaintext_len: u64) -> u64 { + plaintext_len.div_ceil(SEGMENT_LEN as u64).max(1) +} + +/// Plaintext length of segment `i` of `n` for a blob of `plaintext_len`. +pub(crate) fn segment_plain_len(plaintext_len: u64, i: u64) -> usize { + let start = i * SEGMENT_LEN as u64; + (plaintext_len - start).min(SEGMENT_LEN as u64) as usize +} + +/// Absolute offset of segment `i` in the object. +pub(crate) fn segment_offset(i: u64) -> u64 { + HEADER_LEN as u64 + i * SEGMENT_CT_LEN +} + +/// Total object size for a plaintext length. +pub fn ciphertext_len(plaintext_len: u64) -> Result { + let n = segment_count(plaintext_len); + if n > MAX_SEGMENTS { + return Err(EnvelopeError::TooLarge); + } + plaintext_len + .checked_add(HEADER_LEN as u64 + n * TAG_LEN as u64) + .ok_or(EnvelopeError::TooLarge) +} + +/// Inverse of [`ciphertext_len`]: `None` if no plaintext length produces +/// exactly `ciphertext_len` bytes. Used to size sealed objects from a listing. +pub fn plaintext_len(ciphertext_len: u64) -> Option { + let body = ciphertext_len.checked_sub(HEADER_LEN as u64)?; + let n = body.div_ceil(SEGMENT_CT_LEN).max(1); + let p = body.checked_sub(n * TAG_LEN as u64)?; + (n <= MAX_SEGMENTS && segment_count(p) == n).then_some(p) +} + +/// Per-blob AEAD state shared by the whole-buffer and streaming paths. +pub(crate) struct SegmentCipher { + cipher: Aes256Gcm, + aad: Vec, + segments: u64, +} + +impl SegmentCipher { + pub(crate) fn new(key: &DataKey, id: &BlobIdentity<'_>, header: &Header) -> Self { + let ctx = id.context(); + let info: [u8; 32] = Sha256::digest(&ctx).into(); + let mut k_obj = Zeroizing::new([0u8; 32]); + Hkdf::::new(Some(header.salt()), key.bytes()) + .expand(&info, k_obj.as_mut()) + .expect("32 bytes is a valid HKDF-SHA256 length"); + let mut aad = ctx; + aad.extend_from_slice(header.as_bytes()); + Self { + cipher: Aes256Gcm::new(k_obj.as_ref().into()), + aad, + segments: header.segment_count(), + } + } + + fn nonce(&self, i: u64) -> Nonce { + debug_assert!(i < self.segments); + let mut n = [0u8; 12]; + n[0..4].copy_from_slice(&(i as u32).to_be_bytes()); + n[4] = u8::from(i + 1 == self.segments); + n.into() + } + + /// Encrypts `buf` in place as segment `i`; returns the tag. + pub(crate) fn seal_segment(&self, i: u64, buf: &mut [u8]) -> [u8; TAG_LEN] { + self.cipher + .encrypt_in_place_detached(&self.nonce(i), &self.aad, buf) + .expect("segment is below the AES-GCM length limit") + .into() + } + + /// Decrypts `buf` in place as segment `i`. On failure `buf` is zeroed. + pub(crate) fn open_segment( + &self, + i: u64, + buf: &mut [u8], + tag: &[u8], + ) -> Result<(), EnvelopeError> { + let tag: [u8; TAG_LEN] = match tag.try_into() { + Ok(t) if i < self.segments => t, + _ => return Err(EnvelopeError::Integrity), + }; + self.cipher + .decrypt_in_place_detached(&self.nonce(i), &self.aad, buf, &Tag::from(tag)) + .map_err(|_| { + buf.zeroize(); + EnvelopeError::Integrity + }) + } +} + +pub(crate) fn new_header(key: &DataKey, plaintext_len: u64) -> Result { + ciphertext_len(plaintext_len)?; + let mut salt = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut salt); + Ok(Header::build(key.id(), plaintext_len, &salt)) +} + +/// Seals a whole buffer, reusing its allocation. +pub fn seal( + key: &DataKey, + id: &BlobIdentity<'_>, + plaintext: Vec, +) -> Result, EnvelopeError> { + let header = new_header(key, plaintext.len() as u64)?; + Ok(seal_with_header(key, id, &header, plaintext)) +} + +pub(crate) fn seal_with_header( + key: &DataKey, + id: &BlobIdentity<'_>, + header: &Header, + mut buf: Vec, +) -> Vec { + let p = header.plaintext_len(); + debug_assert_eq!(p, buf.len() as u64); + let n = header.segment_count(); + buf.resize(header.ciphertext_len() as usize, 0); + // Move segments to their final offsets, last first: every destination is + // at or after its source, so nothing unmoved is overwritten. + for i in (0..n).rev() { + let src = i as usize * SEGMENT_LEN; + let len = segment_plain_len(p, i); + buf.copy_within(src..src + len, segment_offset(i) as usize); + } + let cipher = SegmentCipher::new(key, id, header); + for i in 0..n { + let off = segment_offset(i) as usize; + let len = segment_plain_len(p, i); + let tag = cipher.seal_segment(i, &mut buf[off..off + len]); + buf[off + len..off + len + TAG_LEN].copy_from_slice(&tag); + } + buf[..HEADER_LEN].copy_from_slice(header.as_bytes()); + buf +} + +/// Opens a whole buffer in place. Fails unless the header names `key`, the +/// length is exact, and every segment authenticates under `id`. Never returns +/// partial plaintext. +pub fn open( + key: &DataKey, + id: &BlobIdentity<'_>, + mut buf: Vec, +) -> Result, EnvelopeError> { + let header = Header::parse(&buf)?; + if header.dek_id() != key.id() { + return Err(EnvelopeError::KeyMismatch); + } + if buf.len() as u64 != header.ciphertext_len() { + return Err(EnvelopeError::Integrity); + } + let p = header.plaintext_len(); + let cipher = SegmentCipher::new(key, id, &header); + for i in 0..header.segment_count() { + let off = segment_offset(i) as usize; + let len = segment_plain_len(p, i); + let (body, rest) = buf[off..].split_at_mut(len); + if let Err(e) = cipher.open_segment(i, body, &rest[..TAG_LEN]) { + buf.zeroize(); + return Err(e); + } + buf.copy_within(off..off + len, i as usize * SEGMENT_LEN); + } + buf.truncate(p as usize); + Ok(buf) +} diff --git a/tinycloud-core/src/at_rest/key.rs b/tinycloud-core/src/at_rest/key.rs index 6479b5b8..3a9c61d4 100644 --- a/tinycloud-core/src/at_rest/key.rs +++ b/tinycloud-core/src/at_rest/key.rs @@ -37,7 +37,6 @@ impl DataKey { self.id } - #[allow(dead_code)] // read by the envelope module pub(crate) fn bytes(&self) -> &[u8; 32] { &self.bytes } @@ -61,7 +60,6 @@ impl ObjectNameKey { Self(Zeroizing::new(bytes)) } - #[allow(dead_code)] // read by the object-name module pub(crate) fn bytes(&self) -> &[u8; 32] { &self.0 } diff --git a/tinycloud-core/src/at_rest/mod.rs b/tinycloud-core/src/at_rest/mod.rs index e30529d0..65d88a41 100644 --- a/tinycloud-core/src/at_rest/mod.rs +++ b/tinycloud-core/src/at_rest/mod.rs @@ -1,12 +1,23 @@ //! Encryption at rest (TC-887). Nothing in the node calls this module yet; it //! is switched off until the key-continuity gate exists. +mod envelope; mod key; +mod names; mod provider; +mod stream; #[cfg(any(test, feature = "test-util"))] mod testing; +#[cfg(test)] +mod tests; +pub use envelope::{ + ciphertext_len, open, plaintext_len, seal, BlobIdentity, EnvelopeError, Header, HEADER_LEN, + SEGMENT_LEN, TAG_LEN, +}; pub use key::{DataKey, DekId, ObjectNameKey}; +pub use names::{sealed_object_name, space_tag, PayloadEncoding, SEALED_NAME_PREFIX}; pub use provider::{DataKeyError, DataKeyProvider}; +pub use stream::{DecryptReader, EncryptReader, SegmentRange}; #[cfg(any(test, feature = "test-util"))] pub use testing::FixedKeyProvider; diff --git a/tinycloud-core/src/at_rest/names.rs b/tinycloud-core/src/at_rest/names.rs new file mode 100644 index 00000000..547c4037 --- /dev/null +++ b/tinycloud-core/src/at_rest/names.rs @@ -0,0 +1,64 @@ +//! How a reader knows whether stored bytes are TCE1: from where they live +//! (S3 name namespace) or an explicit column, never from the bytes. + +use super::envelope::{push_lp, EnvelopeError}; +use super::key::ObjectNameKey; +use crate::hash::Hash; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +/// `database_artifact.payload_encoding` / `delta_encoding` (SMALLINT). +/// `NULL` is legacy plaintext; `1` is TCE1. Any other value is refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PayloadEncoding { + LegacyPlaintext, + Tce1, +} + +impl PayloadEncoding { + pub fn from_column(value: Option) -> Result { + match value { + None => Ok(Self::LegacyPlaintext), + Some(1) => Ok(Self::Tce1), + Some(_) => Err(EnvelopeError::Header("unknown payload encoding")), + } + } + + pub fn to_column(self) -> Option { + match self { + Self::LegacyPlaintext => None, + Self::Tce1 => Some(1), + } + } +} + +/// Prefix of every sealed KV object name. +pub const SEALED_NAME_PREFIX: &str = "v1/"; + +fn hmac(nk: &ObjectNameKey, fields: &[&[u8]]) -> [u8; 32] { + let mut msg = Vec::new(); + for f in fields { + push_lp(&mut msg, f); + } + let mut mac = Hmac::::new_from_slice(nk.bytes()).expect("any key length"); + mac.update(&msg); + mac.finalize().into_bytes().into() +} + +/// Pseudonymous per-space name prefix: `b64url(HMAC(NK, lp("space") ‖ lp(space))[..16])`. +pub fn space_tag(nk: &ObjectNameKey, space: &str) -> String { + URL_SAFE_NO_PAD.encode(&hmac(nk, &[b"space", space.as_bytes()])[..16]) +} + +/// `v1/{space_tag}/{b64url(HMAC(NK, lp("obj") ‖ lp(space) ‖ lp(multihash)))}`. +/// Legacy plaintext objects keep `{space}/{b64url(digest)}`, which never +/// starts with `v1/` because space ids start with `tinycloud:`. +pub fn sealed_object_name(nk: &ObjectNameKey, space: &str, hash: &Hash) -> String { + let obj = hmac(nk, &[b"obj", space.as_bytes(), &Vec::::from(*hash)]); + format!( + "{SEALED_NAME_PREFIX}{}/{}", + space_tag(nk, space), + URL_SAFE_NO_PAD.encode(obj) + ) +} diff --git a/tinycloud-core/src/at_rest/stream.rs b/tinycloud-core/src/at_rest/stream.rs new file mode 100644 index 00000000..fc3b8a8b --- /dev/null +++ b/tinycloud-core/src/at_rest/stream.rs @@ -0,0 +1,376 @@ +//! Streaming TCE1 encryption/decryption over `futures::io::AsyncRead`, and +//! plaintext→ciphertext Range mapping. +//! +//! Both readers hold at most one segment (64 KiB + tag) in memory. A +//! `DecryptReader` only yields a segment after it authenticates; an error part +//! way through means the object is corrupt or truncated, and a consumer must +//! abort (e.g. reset the HTTP response) rather than finish normally. + +use super::envelope::{ + new_header, segment_offset, segment_plain_len, BlobIdentity, EnvelopeError, Header, + SegmentCipher, SEGMENT_LEN, TAG_LEN, +}; +use super::key::DataKey; +use crate::storage::{ByteRangeSpec, ResolvedByteRange}; +use futures::io::AsyncRead; +use std::{ + io, + pin::Pin, + task::{ready, Context, Poll}, +}; + +fn invalid(e: EnvelopeError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, e) +} + +fn poisoned() -> io::Error { + io::Error::other("TCE1 stream already failed") +} + +/// Copies from `out[*pos..]` into `buf`. +fn drain(out: &[u8], pos: &mut usize, buf: &mut [u8]) -> usize { + let n = (out.len() - *pos).min(buf.len()); + buf[..n].copy_from_slice(&out[*pos..*pos + n]); + *pos += n; + n +} + +/// Reads one byte to check that `inner` is exhausted. +fn poll_eof(inner: &mut R, cx: &mut Context<'_>) -> Poll> { + let mut probe = [0u8; 1]; + let n = ready!(Pin::new(inner).poll_read(cx, &mut probe))?; + Poll::Ready(Ok(n == 0)) +} + +/// Reads into `seg[*filled..]` until full. `Ok(false)` means EOF came first. +fn poll_fill( + inner: &mut R, + cx: &mut Context<'_>, + seg: &mut [u8], + filled: &mut usize, +) -> Poll> { + while *filled < seg.len() { + let n = ready!(Pin::new(&mut *inner).poll_read(cx, &mut seg[*filled..]))?; + if n == 0 { + return Poll::Ready(Ok(false)); + } + *filled += n; + } + Poll::Ready(Ok(true)) +} + +/// Turns a plaintext reader of a declared length into `header ‖ segments`. +/// Fails if the reader yields fewer or more bytes than declared. +/// `R` must be `Unpin`; wrap with `Box::pin` if needed. +pub struct EncryptReader { + inner: R, + cipher: SegmentCipher, + plaintext_len: u64, + segments: u64, + next: u64, + seg: Vec, + filled: usize, + filling: bool, + out: Vec, + out_pos: usize, + finished: bool, + failed: bool, +} + +impl EncryptReader { + pub fn new( + key: &DataKey, + id: &BlobIdentity<'_>, + plaintext_len: u64, + inner: R, + ) -> Result { + let header = new_header(key, plaintext_len)?; + Ok(Self::with_header(key, id, &header, inner)) + } + + pub(crate) fn with_header( + key: &DataKey, + id: &BlobIdentity<'_>, + header: &Header, + inner: R, + ) -> Self { + Self { + inner, + cipher: SegmentCipher::new(key, id, header), + plaintext_len: header.plaintext_len(), + segments: header.segment_count(), + next: 0, + seg: Vec::with_capacity(SEGMENT_LEN + TAG_LEN), + filled: 0, + filling: false, + out: header.as_bytes().to_vec(), + out_pos: 0, + finished: false, + failed: false, + } + } + + fn step(&mut self, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll> { + loop { + if self.out_pos < self.out.len() { + return Poll::Ready(Ok(drain(&self.out, &mut self.out_pos, buf))); + } + if self.next == self.segments { + if !self.finished { + if !ready!(poll_eof(&mut self.inner, cx))? { + return Poll::Ready(Err(invalid(EnvelopeError::LengthMismatch))); + } + self.finished = true; + } + return Poll::Ready(Ok(0)); + } + if !self.filling { + self.seg.clear(); + self.seg + .resize(segment_plain_len(self.plaintext_len, self.next), 0); + self.filled = 0; + self.filling = true; + } + if !ready!(poll_fill( + &mut self.inner, + cx, + &mut self.seg, + &mut self.filled + ))? { + return Poll::Ready(Err(invalid(EnvelopeError::LengthMismatch))); + } + let tag = self.cipher.seal_segment(self.next, &mut self.seg); + self.seg.extend_from_slice(&tag); + std::mem::swap(&mut self.seg, &mut self.out); + self.out_pos = 0; + self.next += 1; + self.filling = false; + } + } +} + +impl AsyncRead for EncryptReader { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + let this = self.get_mut(); + if this.failed { + return Poll::Ready(Err(poisoned())); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + let r = this.step(cx, buf); + if matches!(r, Poll::Ready(Err(_))) { + this.failed = true; + } + r + } +} + +/// The ciphertext span and trimming for a plaintext byte range of one blob. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SegmentRange { + plaintext_len: u64, + start: u64, + end: u64, + first: u64, + last: u64, +} + +impl SegmentRange { + /// First ciphertext byte to fetch (absolute, inclusive). + pub fn ciphertext_start(&self) -> u64 { + segment_offset(self.first) + } + + /// Last ciphertext byte to fetch (absolute, inclusive). + pub fn ciphertext_end(&self) -> u64 { + segment_offset(self.last) + + (segment_plain_len(self.plaintext_len, self.last) + TAG_LEN) as u64 + - 1 + } + + /// The plaintext range that will be yielded, for `Content-Range`. + pub fn plaintext_range(&self) -> ResolvedByteRange { + ByteRangeSpec::Inclusive { + start: self.start, + end: self.end, + } + .resolve(self.plaintext_len) + .expect("constructed from a satisfiable range") + } + + /// Total plaintext size of the blob, for `Content-Range` and 416. + pub fn total_size(&self) -> u64 { + self.plaintext_len + } +} + +impl Header { + /// Maps a requested plaintext range onto whole segments. `None` means the + /// range is unsatisfiable against the plaintext length (answer 416 with + /// [`Header::plaintext_len`] as the total). + pub fn map_range(&self, spec: ByteRangeSpec) -> Option { + let r = spec.resolve(self.plaintext_len())?; + let seg = SEGMENT_LEN as u64; + Some(SegmentRange { + plaintext_len: self.plaintext_len(), + start: r.start(), + end: r.end(), + first: r.start() / seg, + last: r.end() / seg, + }) + } +} + +/// Turns TCE1 ciphertext into authenticated plaintext, one segment at a time. +/// `R` must be `Unpin`; wrap with `Box::pin` if needed. +pub struct DecryptReader { + inner: R, + cipher: SegmentCipher, + plaintext_len: u64, + next: u64, + last: u64, + skip: usize, + remaining: u64, + seg: Vec, + filled: usize, + filling: bool, + out: Vec, + out_pos: usize, + finished: bool, + failed: bool, +} + +impl DecryptReader { + /// Whole object. `inner` is positioned just after the header. + pub fn new( + key: &DataKey, + id: &BlobIdentity<'_>, + header: &Header, + inner: R, + ) -> Result { + let p = header.plaintext_len(); + Self::build(key, id, header, inner, 0, header.segment_count() - 1, 0, p) + } + + /// A plaintext range. `inner` yields exactly the bytes + /// `range.ciphertext_start()..=range.ciphertext_end()` of the object. + pub fn range( + key: &DataKey, + id: &BlobIdentity<'_>, + header: &Header, + range: SegmentRange, + inner: R, + ) -> Result { + if range.plaintext_len != header.plaintext_len() { + return Err(EnvelopeError::Header( + "range was mapped from another header", + )); + } + let skip = (range.start - range.first * SEGMENT_LEN as u64) as usize; + let len = range.end - range.start + 1; + Self::build(key, id, header, inner, range.first, range.last, skip, len) + } + + #[allow(clippy::too_many_arguments)] + fn build( + key: &DataKey, + id: &BlobIdentity<'_>, + header: &Header, + inner: R, + first: u64, + last: u64, + skip: usize, + remaining: u64, + ) -> Result { + if header.dek_id() != key.id() { + return Err(EnvelopeError::KeyMismatch); + } + Ok(Self { + inner, + cipher: SegmentCipher::new(key, id, header), + plaintext_len: header.plaintext_len(), + next: first, + last, + skip, + remaining, + seg: Vec::with_capacity(SEGMENT_LEN + TAG_LEN), + filled: 0, + filling: false, + out: Vec::with_capacity(SEGMENT_LEN + TAG_LEN), + out_pos: 0, + finished: false, + failed: false, + }) + } + + fn step(&mut self, cx: &mut Context<'_>, buf: &mut [u8]) -> Poll> { + loop { + if self.out_pos < self.out.len() { + return Poll::Ready(Ok(drain(&self.out, &mut self.out_pos, buf))); + } + if self.next > self.last { + if !self.finished { + if !ready!(poll_eof(&mut self.inner, cx))? { + return Poll::Ready(Err(invalid(EnvelopeError::Integrity))); + } + self.finished = true; + } + return Poll::Ready(Ok(0)); + } + let len = segment_plain_len(self.plaintext_len, self.next); + if !self.filling { + self.seg.clear(); + self.seg.resize(len + TAG_LEN, 0); + self.filled = 0; + self.filling = true; + } + if !ready!(poll_fill( + &mut self.inner, + cx, + &mut self.seg, + &mut self.filled + ))? { + return Poll::Ready(Err(invalid(EnvelopeError::Integrity))); + } + let (body, tag) = self.seg.split_at_mut(len); + self.cipher + .open_segment(self.next, body, tag) + .map_err(invalid)?; + let start = self.skip; + let end = len.min(start + self.remaining.min(usize::MAX as u64) as usize); + self.seg.truncate(end); + std::mem::swap(&mut self.seg, &mut self.out); + self.out_pos = start; + self.skip = 0; + self.remaining -= (end - start) as u64; + self.next += 1; + self.filling = false; + } + } +} + +impl AsyncRead for DecryptReader { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + let this = self.get_mut(); + if this.failed { + return Poll::Ready(Err(poisoned())); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + let r = this.step(cx, buf); + if matches!(r, Poll::Ready(Err(_))) { + this.failed = true; + } + r + } +} diff --git a/tinycloud-core/src/at_rest/testdata/gen_vectors.py b/tinycloud-core/src/at_rest/testdata/gen_vectors.py new file mode 100755 index 00000000..1cce0c9f --- /dev/null +++ b/tinycloud-core/src/at_rest/testdata/gen_vectors.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Independent reference implementation of TCE1 v1 (TC-887), written from the +format spec, not from the Rust code. Regenerates vectors.json: + + python3 gen_vectors.py > vectors.json + +Needs the `cryptography` package. All inputs are fixed, so output is stable. +""" +import hashlib +import hmac +import json +import struct + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +SEG = 1 << 16 + + +def lp(b: bytes) -> bytes: + return struct.pack(">I", len(b)) + b + + +def context(ident: dict) -> bytes: + p = ident["purpose"] + c = lp(b"tinycloud/at-rest/v1") + lp(p.encode()) + if p == "kv-block": + c += lp(ident["space"].encode()) + lp(bytes.fromhex(ident["content_hash"])) + elif p == "sql-checkpoint": + for f in ("service", "space", "name", "checkpoint_content_hash"): + c += lp(ident[f].encode()) + c += lp(struct.pack(">Q", ident["checkpoint_size_bytes"])) + elif p == "sql-wal": + for f in ("service", "space", "name"): + c += lp(ident[f].encode()) + c += lp(struct.pack(">q", ident["revision"])) + for f in ("checkpoint_content_hash", "delta_content_hash"): + c += lp(ident[f].encode()) + else: + raise ValueError(p) + return c + + +def header(dek_id: int, plen: int, salt: bytes) -> bytes: + h = b"TCE1" + bytes([1, 1, 0, 0]) + struct.pack(">IQ", dek_id, plen) + salt + bytes([16]) + h += bytes(64 - len(h)) + assert len(h) == 64 + return h + + +def seal(dek: bytes, dek_id: int, salt: bytes, ident: dict, pt: bytes) -> bytes: + ctx = context(ident) + hdr = header(dek_id, len(pt), salt) + k = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, + info=hashlib.sha256(ctx).digest()).derive(dek) + aead = AESGCM(k) + aad = ctx + hdr + n = max(1, -(-len(pt) // SEG)) + out = hdr + for i in range(n): + nonce = struct.pack(">I", i) + bytes([1 if i == n - 1 else 0]) + bytes(7) + out += aead.encrypt(nonce, pt[i * SEG:(i + 1) * SEG], aad) + assert len(out) == 64 + len(pt) + 16 * n + return out + + +def pattern(n: int) -> bytes: + return bytes(i % 251 for i in range(n)) + + +def blake3_multihash(digest_hex: str) -> str: + # multihash: varint code 0x1e (blake3-256), varint length 0x20, digest + return "1e20" + digest_hex + + +SPACE = "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default" +DEK = bytes(range(32)) +SALT = bytes(range(0x40, 0x60)) +HASH = blake3_multihash("ab" * 32) + +KV = {"purpose": "kv-block", "space": SPACE, "content_hash": HASH} +CKPT = {"purpose": "sql-checkpoint", "service": "sqlite", "space": SPACE, "name": "notes", + "checkpoint_content_hash": "bafkreiexamplecheckpoint", "checkpoint_size_bytes": 200000} +WAL = {"purpose": "sql-wal", "service": "sqlite", "space": SPACE, "name": "notes", "revision": 42, + "checkpoint_content_hash": "bafkreiexamplecheckpoint", + "delta_content_hash": "bafkreiexampledelta"} + +CASES = [ + ("kv-empty", 1, KV, {"hex": ""}), + ("kv-one-byte", 1, KV, {"hex": "2a"}), + ("kv-short-text", 7, KV, {"hex": b"hello, tinycloud at rest".hex()}), + ("kv-one-short-of-segment", 1, KV, {"pattern_len": SEG - 1}), + ("kv-exact-segment", 1, KV, {"pattern_len": SEG}), + ("kv-segment-plus-one", 1, KV, {"pattern_len": SEG + 1}), + ("sql-checkpoint-multi-segment", 3, CKPT, {"pattern_len": 200000}), + ("sql-wal-short", 0xFFFFFFFF, WAL, {"hex": b"wal frame bytes".hex()}), +] + + +def main(): + vectors = [] + for name, dek_id, ident, ptspec in CASES: + pt = bytes.fromhex(ptspec["hex"]) if "hex" in ptspec else pattern(ptspec["pattern_len"]) + ct = seal(DEK, dek_id, SALT, ident, pt) + v = {"name": name, "dek": DEK.hex(), "dek_id": dek_id, "salt": SALT.hex(), + "identity": ident, "plaintext": ptspec, + "ciphertext_len": len(ct), "ciphertext_sha256": hashlib.sha256(ct).hexdigest()} + if len(ct) <= 256: + v["ciphertext"] = ct.hex() + else: + v["ciphertext_head"] = ct[:96].hex() + vectors.append(v) + + nk = bytes(range(0x80, 0xA0)) + + def tag(fields): + return hmac.new(nk, b"".join(lp(f) for f in fields), hashlib.sha256).digest() + + import base64 + + def b64(b): + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + space_tag = b64(tag([b"space", SPACE.encode()])[:16]) + obj = b64(tag([b"obj", SPACE.encode(), bytes.fromhex(HASH)])) + names = [{"nk": nk.hex(), "space": SPACE, "content_hash": HASH, "space_tag": space_tag, + "object_name": f"v1/{space_tag}/{obj}"}] + + print(json.dumps({ + "format": "TCE1 v1 (TC-887)", + "pattern": "plaintext byte i = i mod 251", + "vectors": vectors, + "object_names": names, + }, indent=1)) + + +if __name__ == "__main__": + main() diff --git a/tinycloud-core/src/at_rest/testdata/vectors.json b/tinycloud-core/src/at_rest/testdata/vectors.json new file mode 100644 index 00000000..11749e2a --- /dev/null +++ b/tinycloud-core/src/at_rest/testdata/vectors.json @@ -0,0 +1,158 @@ +{ + "format": "TCE1 v1 (TC-887)", + "pattern": "plaintext byte i = i mod 251", + "vectors": [ + { + "name": "kv-empty", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 1, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "kv-block", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab" + }, + "plaintext": { + "hex": "" + }, + "ciphertext_len": 80, + "ciphertext_sha256": "7df316a09f4cf53027960d6901501f0bb6113b074fa1c837606de81e8a8680d5", + "ciphertext": "5443453101010000000000010000000000000000404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f100000000000000000000000254167d64f27d9d21f4b08707a147b73" + }, + { + "name": "kv-one-byte", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 1, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "kv-block", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab" + }, + "plaintext": { + "hex": "2a" + }, + "ciphertext_len": 81, + "ciphertext_sha256": "b1e2a8cf5f2fb34c9d89bf03e158c669f6889213461a31960007efd8424d8054", + "ciphertext": "5443453101010000000000010000000000000001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1000000000000000000000005390d958a935974da502da687c4baa0a09" + }, + { + "name": "kv-short-text", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 7, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "kv-block", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab" + }, + "plaintext": { + "hex": "68656c6c6f2c2074696e79636c6f75642061742072657374" + }, + "ciphertext_len": 104, + "ciphertext_sha256": "f3c0257678efd386ff69888b56403db2ac737bd72311d58d8c7e7d9ccf8ec229", + "ciphertext": "5443453101010000000000070000000000000018404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f100000000000000000000000115b653920750195026e22a574ba9c17c9f049c7bb1f46cd235a7c07cd7c138604b0136774217b67" + }, + { + "name": "kv-one-short-of-segment", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 1, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "kv-block", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab" + }, + "plaintext": { + "pattern_len": 65535 + }, + "ciphertext_len": 65615, + "ciphertext_sha256": "c1622e4396923eb9d8bf453bc72760cd1a3eb0722a938869c84bb219be4e1771", + "ciphertext_head": "544345310101000000000001000000000000ffff404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f100000000000000000000000793f0b564b5c27e6630951cd14d8e77cf9802ff4dd6f23ae390b7af2d2755ef1" + }, + { + "name": "kv-exact-segment", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 1, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "kv-block", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab" + }, + "plaintext": { + "pattern_len": 65536 + }, + "ciphertext_len": 65616, + "ciphertext_sha256": "a6d3c304b7bbb80cf4436696d647a9fa59e7be6e8ce9fa2f995f6df5524cb1e4", + "ciphertext_head": "5443453101010000000000010000000000010000404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f100000000000000000000000793f0b564b5c27e6630951cd14d8e77cf9802ff4dd6f23ae390b7af2d2755ef1" + }, + { + "name": "kv-segment-plus-one", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 1, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "kv-block", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab" + }, + "plaintext": { + "pattern_len": 65537 + }, + "ciphertext_len": 65633, + "ciphertext_sha256": "f3a1b7944b17fad176b6f720430985b7cc277d8fa5b794f8fb874fd206d3a73b", + "ciphertext_head": "5443453101010000000000010000000000010001404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f10000000000000000000000043d710a7e75233345056e6215c3eca03d7afb3a73fdf0305e00f6b454e92d876" + }, + { + "name": "sql-checkpoint-multi-segment", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 3, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "sql-checkpoint", + "service": "sqlite", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "name": "notes", + "checkpoint_content_hash": "bafkreiexamplecheckpoint", + "checkpoint_size_bytes": 200000 + }, + "plaintext": { + "pattern_len": 200000 + }, + "ciphertext_len": 200128, + "ciphertext_sha256": "48a6dbc161e0ccc388eebb77b25984c21114a247e8fdd5a8827418a424bbf4f5", + "ciphertext_head": "5443453101010000000000030000000000030d40404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f1000000000000000000000006d948e8835fc6ab0eb886e2d3f63d335b360332c2034c820c3dc74a54a5a5467" + }, + { + "name": "sql-wal-short", + "dek": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "dek_id": 4294967295, + "salt": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "identity": { + "purpose": "sql-wal", + "service": "sqlite", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "name": "notes", + "revision": 42, + "checkpoint_content_hash": "bafkreiexamplecheckpoint", + "delta_content_hash": "bafkreiexampledelta" + }, + "plaintext": { + "hex": "77616c206672616d65206279746573" + }, + "ciphertext_len": 95, + "ciphertext_sha256": "af0a95ae37ccd587377d67752de78e73c6828a33e16eb007ae5786d5005bc572", + "ciphertext": "5443453101010000ffffffff000000000000000f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f100000000000000000000000db56826432a30e8d1a00143f51d2ae1665783c299e727a22eeb3d49167eb78" + } + ], + "object_names": [ + { + "nk": "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", + "space": "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default", + "content_hash": "1e20abababababababababababababababababababababababababababababababab", + "space_tag": "YXP0Kg8hqZ76EELP_UoPvg", + "object_name": "v1/YXP0Kg8hqZ76EELP_UoPvg/slhZoR_St8omnDU5b5SvW_YQQ-n4_UppbG4EjyVgppU" + } + ] +} diff --git a/tinycloud-core/src/at_rest/tests.rs b/tinycloud-core/src/at_rest/tests.rs new file mode 100644 index 00000000..5296c9e9 --- /dev/null +++ b/tinycloud-core/src/at_rest/tests.rs @@ -0,0 +1,669 @@ +//! TCE1 envelope tests: known-answer vectors from an independent Python +//! implementation (`testdata/gen_vectors.py`), round-trip/stream/range +//! properties, and rejection of tampered, truncated, reordered, swapped or +//! misbound ciphertext. + +use super::envelope::{seal_with_header, segment_offset, Header}; +use super::stream::{DecryptReader, EncryptReader}; +use super::*; +use crate::hash::{hash, Hash}; +use crate::storage::ByteRangeSpec; +use futures::io::{AsyncRead, AsyncReadExt}; +use proptest::prelude::*; +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; + +const SEG: usize = SEGMENT_LEN; +const SPACE: &str = "tinycloud:pkh:eip155:1:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:default"; + +fn key(id: u32, fill: u8) -> DataKey { + DataKey::new(DekId::new(id), [fill; 32]) +} + +fn bytes(len: usize, seed: u64) -> Vec { + let mut x = seed | 1; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x as u8 + }) + .collect() +} + +fn kv(h: &Hash) -> BlobIdentity<'_> { + BlobIdentity::KvBlock { + space: SPACE, + content_hash: h, + } +} + +/// Splits reads into caller-chosen sizes and returns `Pending` before every +/// other read, to exercise resumption. +struct Chunked { + data: Vec, + pos: usize, + sizes: Vec, + calls: usize, +} + +impl Chunked { + fn new(data: Vec, sizes: Vec) -> Self { + Self { + data, + pos: 0, + sizes, + calls: 0, + } + } +} + +impl AsyncRead for Chunked { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + self.calls += 1; + if self.calls.is_multiple_of(2) { + cx.waker().wake_by_ref(); + return Poll::Pending; + } + let want = self.sizes[self.calls % self.sizes.len()].max(1); + let n = want.min(buf.len()).min(self.data.len() - self.pos); + let pos = self.pos; + buf[..n].copy_from_slice(&self.data[pos..pos + n]); + self.pos += n; + Poll::Ready(Ok(n)) + } +} + +/// Reads to the end with caller buffers of varying size. +fn read_all(mut r: R, bufs: &[usize]) -> io::Result> { + futures::executor::block_on(async { + let mut out = Vec::new(); + let mut i = 0; + loop { + let mut buf = vec![0u8; bufs[i % bufs.len()].max(1)]; + i += 1; + let n = r.read(&mut buf).await?; + if n == 0 { + return Ok(out); + } + out.extend_from_slice(&buf[..n]); + } + }) +} + +fn stream_open(k: &DataKey, id: &BlobIdentity<'_>, ct: &[u8]) -> io::Result> { + let header = Header::parse(ct).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let r = DecryptReader::new( + k, + id, + &header, + Chunked::new(ct[HEADER_LEN..].to_vec(), vec![4093]), + ) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + read_all(r, &[8192]) +} + +fn is_integrity(r: io::Result>) -> bool { + match r { + Err(e) => e + .get_ref() + .and_then(|i| i.downcast_ref::()) + .is_some_and(|e| matches!(e, EnvelopeError::Integrity)), + Ok(_) => false, + } +} + +fn segment(p: usize, i: usize) -> std::ops::Range { + let off = segment_offset(i as u64) as usize; + let len = (p - i * SEG).min(SEG) + TAG_LEN; + off..off + len +} + +// ---------- known-answer vectors ---------- + +#[derive(serde::Deserialize)] +struct Vectors { + vectors: Vec, + object_names: Vec, +} + +#[derive(serde::Deserialize)] +struct Vector { + name: String, + dek: String, + dek_id: u32, + salt: String, + identity: serde_json::Value, + plaintext: serde_json::Value, + ciphertext_len: u64, + ciphertext_sha256: String, + ciphertext: Option, + ciphertext_head: Option, +} + +#[derive(serde::Deserialize)] +struct NameVector { + nk: String, + space: String, + content_hash: String, + space_tag: String, + object_name: String, +} + +fn arr32(h: &str) -> [u8; 32] { + hex::decode(h).unwrap().try_into().unwrap() +} + +fn s<'a>(v: &'a serde_json::Value, k: &str) -> &'a str { + v[k].as_str().unwrap() +} + +#[test] +fn known_answer_vectors_match_independent_reference() { + use sha2::{Digest, Sha256}; + let v: Vectors = serde_json::from_str(include_str!("testdata/vectors.json")).unwrap(); + assert_eq!(v.vectors.len(), 8); + for t in &v.vectors { + let k = DataKey::new(DekId::new(t.dek_id), arr32(&t.dek)); + let pt = match t.plaintext.get("hex") { + Some(h) => hex::decode(h.as_str().unwrap()).unwrap(), + None => (0..t.plaintext["pattern_len"].as_u64().unwrap()) + .map(|i| (i % 251) as u8) + .collect(), + }; + let i = &t.identity; + let content_hash; + let id = match s(i, "purpose") { + "kv-block" => { + content_hash = Hash::try_from(hex::decode(s(i, "content_hash")).unwrap()).unwrap(); + BlobIdentity::KvBlock { + space: s(i, "space"), + content_hash: &content_hash, + } + } + "sql-checkpoint" => BlobIdentity::SqlCheckpoint { + service: s(i, "service"), + space: s(i, "space"), + name: s(i, "name"), + checkpoint_content_hash: s(i, "checkpoint_content_hash"), + checkpoint_size_bytes: i["checkpoint_size_bytes"].as_u64().unwrap(), + }, + "sql-wal" => BlobIdentity::SqlWal { + service: s(i, "service"), + space: s(i, "space"), + name: s(i, "name"), + revision: i["revision"].as_i64().unwrap(), + checkpoint_content_hash: s(i, "checkpoint_content_hash"), + delta_content_hash: s(i, "delta_content_hash"), + }, + p => panic!("unknown purpose {p}"), + }; + let header = Header::build(k.id(), pt.len() as u64, &arr32(&t.salt)); + + let ct = seal_with_header(&k, &id, &header, pt.clone()); + assert_eq!(ct.len() as u64, t.ciphertext_len, "{}", t.name); + assert_eq!( + hex::encode(Sha256::digest(&ct)), + t.ciphertext_sha256, + "{}", + t.name + ); + if let Some(full) = &t.ciphertext { + assert_eq!(&hex::encode(&ct), full, "{}", t.name); + } + if let Some(head) = &t.ciphertext_head { + assert!(hex::encode(&ct).starts_with(head), "{}", t.name); + } + + let streamed = read_all( + EncryptReader::with_header(&k, &id, &header, Chunked::new(pt.clone(), vec![777])), + &[1000], + ) + .unwrap(); + assert_eq!(streamed, ct, "{} streaming", t.name); + assert_eq!(open(&k, &id, ct.clone()).unwrap(), pt, "{}", t.name); + assert_eq!(stream_open(&k, &id, &ct).unwrap(), pt, "{}", t.name); + } + + for n in &v.object_names { + let nk = ObjectNameKey::new(arr32(&n.nk)); + let h = Hash::try_from(hex::decode(&n.content_hash).unwrap()).unwrap(); + assert_eq!(space_tag(&nk, &n.space), n.space_tag); + assert_eq!(sealed_object_name(&nk, &n.space, &h), n.object_name); + } +} + +// ---------- round trip, streaming, sizes ---------- + +fn edge_len() -> impl Strategy { + prop_oneof![ + (0usize..4, -1i64..=1).prop_map(|(k, d)| (k as i64 * SEG as i64 + d).max(0) as usize), + 0usize..(3 * SEG + 100), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + #[test] + fn seal_open_round_trip(len in edge_len(), seed: u64) { + let k = key(1, 9); + let h = hash(b"x"); + let pt = bytes(len, seed); + let ct = seal(&k, &kv(&h), pt.clone()).unwrap(); + prop_assert_eq!(ct.len() as u64, ciphertext_len(len as u64).unwrap()); + prop_assert_eq!(plaintext_len(ct.len() as u64), Some(len as u64)); + prop_assert_eq!(Header::parse(&ct).unwrap().plaintext_len(), len as u64); + prop_assert_eq!(open(&k, &kv(&h), ct).unwrap(), pt); + } + + #[test] + fn streaming_matches_whole_buffer( + len in edge_len(), + seed: u64, + sizes in prop::collection::vec(1usize..70_000, 1..6), + bufs in prop::collection::vec(1usize..70_000, 1..6), + ) { + let k = key(2, 3); + let h = hash(b"y"); + let pt = bytes(len, seed); + let header = Header::build(k.id(), len as u64, &[seed as u8; 32]); + let whole = seal_with_header(&k, &kv(&h), &header, pt.clone()); + let enc = EncryptReader::with_header(&k, &kv(&h), &header, Chunked::new(pt.clone(), sizes.clone())); + prop_assert_eq!(&read_all(enc, &bufs).unwrap(), &whole); + let dec = DecryptReader::new(&k, &kv(&h), &header, Chunked::new(whole[HEADER_LEN..].to_vec(), sizes)).unwrap(); + prop_assert_eq!(read_all(dec, &bufs).unwrap(), pt); + } + + #[test] + fn length_inverse(p in 0u64..(1u64 << 44), c in 0u64..(1u64 << 44)) { + prop_assert_eq!(plaintext_len(ciphertext_len(p).unwrap()), Some(p)); + if let Some(q) = plaintext_len(c) { + prop_assert_eq!(ciphertext_len(q).unwrap(), c); + } + } + + #[test] + fn range_reads_equal_plaintext_slice( + len in 1usize..(3 * SEG + 100), + seed: u64, + a in 0u64..(3 * SEG as u64 + 200), + b in 0u64..(3 * SEG as u64 + 200), + kind in 0u8..3, + ) { + let k = key(4, 4); + let h = hash(b"r"); + let pt = bytes(len, seed); + let ct = seal(&k, &kv(&h), pt.clone()).unwrap(); + let header = Header::parse(&ct).unwrap(); + let spec = match kind { + 0 => ByteRangeSpec::Inclusive { start: a.min(b), end: a.max(b) }, + 1 => ByteRangeSpec::From { start: a }, + _ => ByteRangeSpec::Suffix { length: a }, + }; + match (spec.resolve(len as u64), header.map_range(spec)) { + (None, None) => {} + (Some(want), Some(r)) => { + prop_assert_eq!(r.plaintext_range(), want); + prop_assert_eq!(r.total_size(), len as u64); + let fetched = ct[r.ciphertext_start() as usize..=r.ciphertext_end() as usize].to_vec(); + let dec = DecryptReader::range(&k, &kv(&h), &header, r, Chunked::new(fetched, vec![5000])).unwrap(); + let got = read_all(dec, &[3000]).unwrap(); + prop_assert_eq!(&got[..], &pt[want.start() as usize..=want.end() as usize]); + } + (want, got) => prop_assert!(false, "resolve {want:?} vs map {got:?}"), + } + } + + #[test] + fn any_single_byte_flip_is_rejected(len in 0usize..(2 * SEG + 50), seed: u64, at: usize, x in 1u8..=255) { + let k = key(5, 5); + let h = hash(b"t"); + let mut ct = seal(&k, &kv(&h), bytes(len, seed)).unwrap(); + let at = at % ct.len(); + ct[at] ^= x; + prop_assert!(open(&k, &kv(&h), ct.clone()).is_err()); + prop_assert!(stream_open(&k, &kv(&h), &ct).is_err()); + } +} + +#[test] +fn salt_is_fresh_per_seal() { + let k = key(1, 1); + let h = hash(b"same"); + let a = seal(&k, &kv(&h), b"same plaintext".to_vec()).unwrap(); + let b = seal(&k, &kv(&h), b"same plaintext".to_vec()).unwrap(); + assert_ne!(a[20..52], b[20..52]); + assert_ne!(a[HEADER_LEN..], b[HEADER_LEN..]); +} + +// ---------- truncation, reordering, swapping ---------- + +#[test] +fn truncation_and_trailing_bytes_are_rejected() { + let k = key(1, 7); + let h = hash(b"trunc"); + let p = 3 * SEG; // exactly three full segments + let ct = seal(&k, &kv(&h), bytes(p, 1)).unwrap(); + let cuts = [ + segment(p, 2).start, // drop the last segment + segment(p, 1).start, // drop the last two + HEADER_LEN, // header only + ct.len() - 1, // drop one tag byte + segment(p, 1).start + 100, + ]; + for cut in cuts { + let short = ct[..cut].to_vec(); + assert!( + matches!( + open(&k, &kv(&h), short.clone()), + Err(EnvelopeError::Integrity) + ), + "cut {cut}" + ); + assert!( + is_integrity(stream_open(&k, &kv(&h), &short)), + "stream cut {cut}" + ); + } + let mut long = ct.clone(); + long.push(0); + assert!(matches!( + open(&k, &kv(&h), long.clone()), + Err(EnvelopeError::Integrity) + )); + assert!(is_integrity(stream_open(&k, &kv(&h), &long))); + + // A range read whose fetched span is cut short fails too. + let header = Header::parse(&ct).unwrap(); + let r = header + .map_range(ByteRangeSpec::From { + start: SEG as u64 + 5, + }) + .unwrap(); + let fetched = ct[r.ciphertext_start() as usize..r.ciphertext_end() as usize].to_vec(); + let dec = + DecryptReader::range(&k, &kv(&h), &header, r, Chunked::new(fetched, vec![9000])).unwrap(); + assert!(is_integrity(read_all(dec, &[4096]))); +} + +#[test] +fn reordered_duplicated_and_foreign_segments_are_rejected() { + let k = key(1, 8); + let h = hash(b"order"); + let p = 3 * SEG; + let ct = seal(&k, &kv(&h), bytes(p, 2)).unwrap(); + let (s0, s1, s2) = (segment(p, 0), segment(p, 1), segment(p, 2)); + + let mut swapped = ct.clone(); + swapped[s0.clone()].copy_from_slice(&ct[s1.clone()]); + swapped[s1.clone()].copy_from_slice(&ct[s0.clone()]); + assert!(matches!( + open(&k, &kv(&h), swapped.clone()), + Err(EnvelopeError::Integrity) + )); + assert!(is_integrity(stream_open(&k, &kv(&h), &swapped))); + + let mut dup = ct.clone(); + dup[s1.clone()].copy_from_slice(&ct[s0.clone()]); + assert!(matches!( + open(&k, &kv(&h), dup), + Err(EnvelopeError::Integrity) + )); + + // Last segment moved into a middle slot: its last-flag nonce no longer fits. + let mut last_mid = ct.clone(); + last_mid[s1.clone()].copy_from_slice(&ct[s2.clone()]); + assert!(matches!( + open(&k, &kv(&h), last_mid), + Err(EnvelopeError::Integrity) + )); + + // Same key, same identity, same plaintext, another blob: segments don't transplant. + let other = seal(&k, &kv(&h), bytes(p, 2)).unwrap(); + let mut spliced = ct.clone(); + spliced[s1.clone()].copy_from_slice(&other[s1.clone()]); + assert!(matches!( + open(&k, &kv(&h), spliced), + Err(EnvelopeError::Integrity) + )); + let mut header_swap = other.clone(); + header_swap[..HEADER_LEN].copy_from_slice(&ct[..HEADER_LEN]); + assert!(matches!( + open(&k, &kv(&h), header_swap), + Err(EnvelopeError::Integrity) + )); +} + +// ---------- identity and key binding ---------- + +#[test] +fn ciphertext_is_bound_to_its_identity() { + let k = key(1, 6); + let h = hash(b"content"); + let h2 = hash(b"other content"); + let ckpt = |space, name, hash, size| BlobIdentity::SqlCheckpoint { + service: "sqlite", + space, + name, + checkpoint_content_hash: hash, + checkpoint_size_bytes: size, + }; + let wal = |rev, base, delta| BlobIdentity::SqlWal { + service: "sqlite", + space: SPACE, + name: "db", + revision: rev, + checkpoint_content_hash: base, + delta_content_hash: delta, + }; + let cases: Vec<(BlobIdentity<'_>, Vec>)> = vec![ + ( + kv(&h), + vec![ + BlobIdentity::KvBlock { + space: + "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:default", + content_hash: &h, + }, + kv(&h2), + ckpt(SPACE, "", "", 0), + ], + ), + ( + ckpt(SPACE, "db", "c1", 10), + vec![ + ckpt(SPACE, "db2", "c1", 10), + ckpt(SPACE, "db", "c2", 10), + ckpt(SPACE, "db", "c1", 11), + ckpt("tinycloud:other:x:default", "db", "c1", 10), + BlobIdentity::SqlCheckpoint { + service: "duckdb", + space: SPACE, + name: "db", + checkpoint_content_hash: "c1", + checkpoint_size_bytes: 10, + }, + wal(10, "c1", ""), + ], + ), + ( + wal(5, "c1", "d1"), + vec![wal(6, "c1", "d1"), wal(5, "c2", "d1"), wal(5, "c1", "d2")], + ), + // Length prefixes: moving bytes between adjacent fields changes the identity. + (ckpt(SPACE, "ab", "c", 1), vec![ckpt(SPACE, "a", "bc", 1)]), + ]; + for (good, bad) in cases { + let ct = seal(&k, &good, bytes(SEG + 10, 3)).unwrap(); + assert!(open(&k, &good, ct.clone()).is_ok()); + for b in bad { + assert!( + matches!(open(&k, &b, ct.clone()), Err(EnvelopeError::Integrity)), + "{good:?} opened as {b:?}" + ); + assert!(is_integrity(stream_open(&k, &b, &ct))); + } + } +} + +#[test] +fn wrong_key_is_rejected() { + let h = hash(b"k"); + let ct = seal(&key(3, 1), &kv(&h), b"secret".to_vec()).unwrap(); + assert!(matches!( + open(&key(3, 2), &kv(&h), ct.clone()), + Err(EnvelopeError::Integrity) + )); + assert!(matches!( + open(&key(4, 1), &kv(&h), ct.clone()), + Err(EnvelopeError::KeyMismatch) + )); + let header = Header::parse(&ct).unwrap(); + assert_eq!(header.dek_id(), DekId::new(3)); + assert!(matches!( + DecryptReader::new(&key(4, 1), &kv(&h), &header, &ct[HEADER_LEN..]), + Err(EnvelopeError::KeyMismatch) + )); +} + +// ---------- header and legacy handling ---------- + +#[test] +fn malformed_headers_are_refused() { + let k = key(1, 1); + let h = hash(b"h"); + let ct = seal(&k, &kv(&h), b"abc".to_vec()).unwrap(); + for (at, val, what) in [ + (0, b'X', "magic"), + (4, 2, "version"), + (5, 2, "algorithm"), + (6, 1, "flags"), + (7, 1, "flags"), + (52, 15, "segment size"), + (63, 1, "padding"), + ] { + let mut bad = ct.clone(); + bad[at] = val; + match Header::parse(&bad) { + Err(EnvelopeError::Header(w)) => assert_eq!(w, what, "byte {at}"), + other => panic!("byte {at}: {other:?}"), + } + assert!(open(&k, &kv(&h), bad).is_err()); + } + assert!(matches!( + Header::parse(&ct[..63]), + Err(EnvelopeError::Header("short header")) + )); +} + +#[test] +fn legacy_plaintext_is_never_returned_as_plaintext() { + let k = key(1, 1); + let h = hash(b"legacy"); + let mut sqlite = b"SQLite format 3\0".to_vec(); + sqlite.resize(4096, 0); + for legacy in [ + sqlite, + b"just some user bytes".to_vec(), + vec![0x01; 100], + Vec::new(), + ] { + assert!(matches!( + open(&k, &kv(&h), legacy), + Err(EnvelopeError::Header(_)) + )); + } +} + +#[test] +fn payload_encoding_column_is_explicit() { + assert_eq!( + PayloadEncoding::from_column(None).unwrap(), + PayloadEncoding::LegacyPlaintext + ); + assert_eq!( + PayloadEncoding::from_column(Some(1)).unwrap(), + PayloadEncoding::Tce1 + ); + for v in [0, 2, -1, i16::MAX] { + assert!(PayloadEncoding::from_column(Some(v)).is_err(), "{v}"); + } + for e in [PayloadEncoding::LegacyPlaintext, PayloadEncoding::Tce1] { + assert_eq!(PayloadEncoding::from_column(e.to_column()).unwrap(), e); + } +} + +#[test] +fn encrypt_reader_enforces_declared_length() { + let k = key(1, 1); + let h = hash(b"len"); + for (declared, actual) in [(10u64, 9usize), (10, 11), (SEG as u64, SEG + 1), (0, 1)] { + let r = EncryptReader::new( + &k, + &kv(&h), + declared, + Chunked::new(vec![7; actual], vec![3]), + ) + .unwrap(); + let err = read_all(r, &[100]).unwrap_err(); + assert!( + matches!( + err.get_ref() + .and_then(|e| e.downcast_ref::()), + Some(EnvelopeError::LengthMismatch) + ), + "{declared}/{actual}: {err}" + ); + } +} + +#[test] +fn range_from_another_header_is_refused() { + let k = key(1, 1); + let h = hash(b"rr"); + let a = Header::parse(&seal(&k, &kv(&h), vec![1; 100]).unwrap()).unwrap(); + let b = Header::parse(&seal(&k, &kv(&h), vec![1; 200]).unwrap()).unwrap(); + let r = b.map_range(ByteRangeSpec::From { start: 150 }).unwrap(); + assert!(matches!( + DecryptReader::range(&k, &kv(&h), &a, r, &[][..]), + Err(EnvelopeError::Header(_)) + )); +} + +#[test] +fn sealed_names_hide_space_and_hash() { + let nk = ObjectNameKey::new([3; 32]); + let h = hash(b"file"); + let name = sealed_object_name(&nk, SPACE, &h); + let parts: Vec<_> = name.split('/').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], "v1"); + assert_eq!(parts[1], space_tag(&nk, SPACE)); + assert_eq!((parts[1].len(), parts[2].len()), (22, 43)); + assert!(!name + .to_lowercase() + .contains("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")); + let legacy_digest = + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE, h.as_ref()); + assert!(!name.contains(legacy_digest.trim_end_matches('='))); + assert_eq!(name, sealed_object_name(&nk, SPACE, &h)); + assert_ne!(name, sealed_object_name(&nk, SPACE, &hash(b"file2"))); + assert_ne!( + name, + sealed_object_name(&ObjectNameKey::new([4; 32]), SPACE, &h) + ); + let other = "tinycloud:pkh:eip155:1:0x0000000000000000000000000000000000000001:default"; + assert_ne!(space_tag(&nk, SPACE), space_tag(&nk, other)); + assert!( + sealed_object_name(&nk, other, &h).starts_with(&format!("v1/{}/", space_tag(&nk, other))) + ); +} From 7050fb805561f333cea289cd3693dc483a5209e3 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 06:26:43 +0000 Subject: [PATCH 4/7] fix(TC-887): seal the final segment only after EOF; zeroize AES state EncryptReader now probes the source for EOF before sealing the final segment, so over-long input fails before a complete object is yielded. Enable aes/aes-gcm zeroize (aes-gcm >= 0.10.3). seal_with_header and EncryptReader::with_header are test-only. Document that header fields are unverified until a segment decrypts. Refresh the m1 lockfile. --- Cargo.lock | 3 ++ test/m1-realdata-e2e/Cargo.lock | 5 +++ tinycloud-core/Cargo.toml | 5 ++- tinycloud-core/src/at_rest/envelope.rs | 17 +++++++-- tinycloud-core/src/at_rest/stream.rs | 29 ++++++++++------ tinycloud-core/src/at_rest/tests.rs | 48 ++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7adf039c..6bb70ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures", + "zeroize", ] [[package]] @@ -60,6 +61,7 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", ] [[package]] @@ -9652,6 +9654,7 @@ dependencies = [ name = "tinycloud-core" version = "1.3.0" dependencies = [ + "aes", "aes-gcm", "arrow", "async-std", diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index 133f99bd..34ed970a 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -46,6 +46,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures", + "zeroize", ] [[package]] @@ -60,6 +61,7 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", ] [[package]] @@ -7730,6 +7732,7 @@ dependencies = [ name = "tinycloud-core" version = "1.3.0" dependencies = [ + "aes", "aes-gcm", "async-trait", "base64 0.22.1", @@ -7737,6 +7740,8 @@ dependencies = [ "dashmap", "futures", "hex", + "hkdf", + "hmac", "k256", "libp2p", "multihash-derive 0.9.1", diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 44ebb3e5..4f573e85 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -54,7 +54,10 @@ tokio.workspace = true duckdb = { version = "1.1", features = ["bundled", "appender-arrow"], optional = true } arrow = { version = "56", features = ["ipc"], optional = true } tempfile = "3" -aes-gcm = "0.10" +# TC-887: `zeroize` wipes the AES key schedule and GHASH key on drop; `aes` is +# listed only to enable its `zeroize` feature (feature unification). +aes-gcm = { version = "0.10.3", features = ["zeroize"] } +aes = { version = "0.8", features = ["zeroize"] } rand = "0.8" x25519-dalek = { version = "2.0", features = ["static_secrets"] } hex.workspace = true diff --git a/tinycloud-core/src/at_rest/envelope.rs b/tinycloud-core/src/at_rest/envelope.rs index 754a0bf4..f3093bbd 100644 --- a/tinycloud-core/src/at_rest/envelope.rs +++ b/tinycloud-core/src/at_rest/envelope.rs @@ -135,6 +135,12 @@ impl BlobIdentity<'_> { } /// A parsed and validated TCE1 header. +/// +/// Parsing checks structure only. The header is authenticated as part of every +/// segment's AAD, so its fields (`plaintext_len`, `dek_id`) are **unverified +/// hints until a segment has decrypted**. A size, `Content-Length` or 416 +/// total taken from a header alone may be forged by whoever stores the bytes; +/// verify a segment (for an exact size, the last one) before relying on it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Header { bytes: [u8; HEADER_LEN], @@ -333,15 +339,22 @@ pub fn seal( plaintext: Vec, ) -> Result, EnvelopeError> { let header = new_header(key, plaintext.len() as u64)?; - Ok(seal_with_header(key, id, &header, plaintext)) + Ok(seal_into(key, id, &header, plaintext)) } +/// Test-only: a caller-chosen header reuses its salt, and so its per-blob key +/// and nonces. Production always goes through [`seal`]. +#[cfg(test)] pub(crate) fn seal_with_header( key: &DataKey, id: &BlobIdentity<'_>, header: &Header, - mut buf: Vec, + buf: Vec, ) -> Vec { + seal_into(key, id, header, buf) +} + +fn seal_into(key: &DataKey, id: &BlobIdentity<'_>, header: &Header, mut buf: Vec) -> Vec { let p = header.plaintext_len(); debug_assert_eq!(p, buf.len() as u64); let n = header.segment_count(); diff --git a/tinycloud-core/src/at_rest/stream.rs b/tinycloud-core/src/at_rest/stream.rs index fc3b8a8b..04fb5924 100644 --- a/tinycloud-core/src/at_rest/stream.rs +++ b/tinycloud-core/src/at_rest/stream.rs @@ -60,7 +60,9 @@ fn poll_fill( } /// Turns a plaintext reader of a declared length into `header ‖ segments`. -/// Fails if the reader yields fewer or more bytes than declared. +/// Fails if the reader yields fewer or more bytes than declared. The final +/// segment is sealed only after the reader reports EOF, so over-long input +/// fails before a complete object has been yielded. /// `R` must be `Unpin`; wrap with `Box::pin` if needed. pub struct EncryptReader { inner: R, @@ -73,7 +75,6 @@ pub struct EncryptReader { filling: bool, out: Vec, out_pos: usize, - finished: bool, failed: bool, } @@ -85,15 +86,22 @@ impl EncryptReader { inner: R, ) -> Result { let header = new_header(key, plaintext_len)?; - Ok(Self::with_header(key, id, &header, inner)) + Ok(Self::from_header(key, id, &header, inner)) } + /// Test-only: a caller-chosen header reuses its salt, and so its per-blob + /// key and nonces. Production always goes through [`Self::new`]. + #[cfg(test)] pub(crate) fn with_header( key: &DataKey, id: &BlobIdentity<'_>, header: &Header, inner: R, ) -> Self { + Self::from_header(key, id, header, inner) + } + + fn from_header(key: &DataKey, id: &BlobIdentity<'_>, header: &Header, inner: R) -> Self { Self { inner, cipher: SegmentCipher::new(key, id, header), @@ -105,7 +113,6 @@ impl EncryptReader { filling: false, out: header.as_bytes().to_vec(), out_pos: 0, - finished: false, failed: false, } } @@ -116,12 +123,6 @@ impl EncryptReader { return Poll::Ready(Ok(drain(&self.out, &mut self.out_pos, buf))); } if self.next == self.segments { - if !self.finished { - if !ready!(poll_eof(&mut self.inner, cx))? { - return Poll::Ready(Err(invalid(EnvelopeError::LengthMismatch))); - } - self.finished = true; - } return Poll::Ready(Ok(0)); } if !self.filling { @@ -139,6 +140,10 @@ impl EncryptReader { ))? { return Poll::Ready(Err(invalid(EnvelopeError::LengthMismatch))); } + // Check for over-long input before releasing the final segment. + if self.next + 1 == self.segments && !ready!(poll_eof(&mut self.inner, cx))? { + return Poll::Ready(Err(invalid(EnvelopeError::LengthMismatch))); + } let tag = self.cipher.seal_segment(self.next, &mut self.seg); self.seg.extend_from_slice(&tag); std::mem::swap(&mut self.seg, &mut self.out); @@ -259,6 +264,8 @@ impl DecryptReader { /// A plaintext range. `inner` yields exactly the bytes /// `range.ciphertext_start()..=range.ciphertext_end()` of the object. + /// Refused if `range` was mapped for a different plaintext length (the + /// mapping depends on nothing else in the header). pub fn range( key: &DataKey, id: &BlobIdentity<'_>, @@ -268,7 +275,7 @@ impl DecryptReader { ) -> Result { if range.plaintext_len != header.plaintext_len() { return Err(EnvelopeError::Header( - "range was mapped from another header", + "range was mapped for another plaintext length", )); } let skip = (range.start - range.first * SEGMENT_LEN as u64) as usize; diff --git a/tinycloud-core/src/at_rest/tests.rs b/tinycloud-core/src/at_rest/tests.rs index 5296c9e9..56f49dc3 100644 --- a/tinycloud-core/src/at_rest/tests.rs +++ b/tinycloud-core/src/at_rest/tests.rs @@ -626,6 +626,54 @@ fn encrypt_reader_enforces_declared_length() { } } +/// Over-long input must fail before the final segment is released, so a +/// consumer that stops reading at `ciphertext_len` never holds a complete, +/// valid object of a prefix. +#[test] +fn over_long_input_never_yields_the_final_segment() { + let k = key(1, 1); + let h = hash(b"long"); + for declared in [0usize, 10, SEG - 1, SEG, SEG + 10, 2 * SEG] { + let n = declared.div_ceil(SEG).max(1); + let final_start = segment_offset(n as u64 - 1) as usize; + for sizes in [vec![1], vec![3], vec![SEG + 100]] { + let mut r = EncryptReader::new( + &k, + &kv(&h), + declared as u64, + Chunked::new(vec![7; declared + 1], sizes.clone()), + ) + .unwrap(); + let mut got = Vec::new(); + let err = futures::executor::block_on(async { + let mut buf = [0u8; 4096]; + loop { + match r.read(&mut buf).await { + Ok(0) => return None, + Ok(m) => got.extend_from_slice(&buf[..m]), + Err(e) => return Some(e), + } + } + }) + .unwrap_or_else(|| panic!("{declared}/{sizes:?}: no error")); + assert!( + matches!( + err.get_ref() + .and_then(|e| e.downcast_ref::()), + Some(EnvelopeError::LengthMismatch) + ), + "{declared}: {err}" + ); + assert!( + got.len() <= final_start, + "{declared}/{sizes:?}: {} bytes released, final segment starts at {final_start}", + got.len() + ); + assert!(open(&k, &kv(&h), got).is_err()); + } + } +} + #[test] fn range_from_another_header_is_refused() { let k = key(1, 1); From d32790653c208d32c71f456c77f7b4ff4ea75726 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 06:49:11 +0000 Subject: [PATCH 5/7] fix(TC-887): Unsupported format error, more boundary and reference tests Add EnvelopeError::Unsupported for a well-formed later TCE generation (format_version dispatches on the first 5 bytes) and for payload encodings 2..=255. Mark EnvelopeError and DataKeyError non_exhaustive. seal reserves exactly ciphertext_len; DecryptReader trims without 32-bit overflow; docs say any segment authenticates the header and readers hold two segment buffers. Tests: edge-biased range proptest, empty-blob ranges, no output from a failing segment and sticky errors, range with trailing bytes, the 2^48 size limits, later generations. The Python reference now also emits length, range-mapping and negative vectors (checked by its own opener). --- tinycloud-core/src/at_rest/envelope.rs | 53 +- tinycloud-core/src/at_rest/mod.rs | 4 +- tinycloud-core/src/at_rest/names.rs | 7 +- tinycloud-core/src/at_rest/provider.rs | 1 + tinycloud-core/src/at_rest/stream.rs | 6 +- .../src/at_rest/testdata/gen_vectors.py | 195 + .../src/at_rest/testdata/vectors.json | 4881 +++++++++++++++++ tinycloud-core/src/at_rest/tests.rs | 532 +- 8 files changed, 5598 insertions(+), 81 deletions(-) diff --git a/tinycloud-core/src/at_rest/envelope.rs b/tinycloud-core/src/at_rest/envelope.rs index f3093bbd..d795535a 100644 --- a/tinycloud-core/src/at_rest/envelope.rs +++ b/tinycloud-core/src/at_rest/envelope.rs @@ -27,6 +27,9 @@ pub const HEADER_LEN: usize = 64; pub const SEGMENT_LEN: usize = 1 << 16; pub const TAG_LEN: usize = 16; +/// Every TCE generation starts `"TCE" ‖ ASCII digit g ‖ version byte g`; this +/// build reads and writes generation 1 only. See `INTERFACES.md` §4 (TCE2 rule). +const FAMILY: &[u8; 3] = b"TCE"; const MAGIC: &[u8; 4] = b"TCE1"; const VERSION: u8 = 1; const ALG_AES256GCM_HKDF_SHA256: u8 = 1; @@ -36,10 +39,15 @@ const CONTEXT_LABEL: &[u8] = b"tinycloud/at-rest/v1"; const SEGMENT_CT_LEN: u64 = (SEGMENT_LEN + TAG_LEN) as u64; #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum EnvelopeError { - /// Not a TCE1 header this build understands. + /// Malformed or not a TCE header at all. #[error("invalid TCE1 header: {0}")] Header(&'static str), + /// A well-formed TCE header (or payload encoding) of a later format + /// generation than this build reads. Not corruption: upgrade the node. + #[error("unsupported at-rest format version {version}")] + Unsupported { version: u8 }, /// The header names a different data key than the one supplied. #[error("TCE1 header names a different data key")] KeyMismatch, @@ -138,9 +146,9 @@ impl BlobIdentity<'_> { /// /// Parsing checks structure only. The header is authenticated as part of every /// segment's AAD, so its fields (`plaintext_len`, `dek_id`) are **unverified -/// hints until a segment has decrypted**. A size, `Content-Length` or 416 -/// total taken from a header alone may be forged by whoever stores the bytes; -/// verify a segment (for an exact size, the last one) before relying on it. +/// hints until a segment has decrypted**; any one authenticated segment +/// authenticates them. A size, `Content-Length` or 416 total taken from a +/// header alone may be forged by whoever stores the bytes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Header { bytes: [u8; HEADER_LEN], @@ -166,19 +174,19 @@ impl Header { } /// Parses the first [`HEADER_LEN`] bytes of `bytes`. Every field this - /// version does not define must be zero. + /// version does not define must be zero. A later TCE generation is + /// [`EnvelopeError::Unsupported`], never a malformed header. pub fn parse(bytes: &[u8]) -> Result { + let version = format_version(bytes)?; + if version != VERSION { + return Err(EnvelopeError::Unsupported { version }); + } let b: [u8; HEADER_LEN] = bytes .get(..HEADER_LEN) .ok_or(EnvelopeError::Header("short header"))? .try_into() .expect("64 bytes"); - if &b[0..4] != MAGIC { - return Err(EnvelopeError::Header("magic")); - } - if b[4] != VERSION { - return Err(EnvelopeError::Header("version")); - } + debug_assert_eq!(&b[0..4], MAGIC); if b[5] != ALG_AES256GCM_HKDF_SHA256 { return Err(EnvelopeError::Header("algorithm")); } @@ -228,6 +236,22 @@ impl Header { } } +/// The TCE format generation from the first 5 bytes, before any +/// generation-specific parsing: `"TCE" ‖ ASCII digit g ‖ byte g`, with +/// `g` in 1..=9. Anything else is not a TCE header. +pub fn format_version(bytes: &[u8]) -> Result { + let b = bytes + .get(..5) + .ok_or(EnvelopeError::Header("short header"))?; + if &b[0..3] != FAMILY { + return Err(EnvelopeError::Header("magic")); + } + match b[3] { + d @ b'1'..=b'9' if b[4] == d - b'0' => Ok(b[4]), + _ => Err(EnvelopeError::Header("version")), + } +} + /// Number of segments for a plaintext length; an empty plaintext is one /// empty segment. pub(crate) fn segment_count(plaintext_len: u64) -> u64 { @@ -332,7 +356,8 @@ pub(crate) fn new_header(key: &DataKey, plaintext_len: u64) -> Result, @@ -358,7 +383,9 @@ fn seal_into(key: &DataKey, id: &BlobIdentity<'_>, header: &Header, mut buf: Vec let p = header.plaintext_len(); debug_assert_eq!(p, buf.len() as u64); let n = header.segment_count(); - buf.resize(header.ciphertext_len() as usize, 0); + let ct_len = header.ciphertext_len() as usize; + buf.reserve_exact(ct_len - buf.len()); + buf.resize(ct_len, 0); // Move segments to their final offsets, last first: every destination is // at or after its source, so nothing unmoved is overwritten. for i in (0..n).rev() { diff --git a/tinycloud-core/src/at_rest/mod.rs b/tinycloud-core/src/at_rest/mod.rs index 65d88a41..d63ad1fd 100644 --- a/tinycloud-core/src/at_rest/mod.rs +++ b/tinycloud-core/src/at_rest/mod.rs @@ -12,8 +12,8 @@ mod testing; mod tests; pub use envelope::{ - ciphertext_len, open, plaintext_len, seal, BlobIdentity, EnvelopeError, Header, HEADER_LEN, - SEGMENT_LEN, TAG_LEN, + ciphertext_len, format_version, open, plaintext_len, seal, BlobIdentity, EnvelopeError, Header, + HEADER_LEN, SEGMENT_LEN, TAG_LEN, }; pub use key::{DataKey, DekId, ObjectNameKey}; pub use names::{sealed_object_name, space_tag, PayloadEncoding, SEALED_NAME_PREFIX}; diff --git a/tinycloud-core/src/at_rest/names.rs b/tinycloud-core/src/at_rest/names.rs index 547c4037..dd591204 100644 --- a/tinycloud-core/src/at_rest/names.rs +++ b/tinycloud-core/src/at_rest/names.rs @@ -9,7 +9,9 @@ use hmac::{Hmac, Mac}; use sha2::Sha256; /// `database_artifact.payload_encoding` / `delta_encoding` (SMALLINT). -/// `NULL` is legacy plaintext; `1` is TCE1. Any other value is refused. +/// `NULL` is legacy plaintext; `g` in 1..=255 is TCE generation `g`, of which +/// this build reads only 1 (later ones are [`EnvelopeError::Unsupported`]). +/// Zero, negative and larger values are refused as invalid. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PayloadEncoding { LegacyPlaintext, @@ -21,7 +23,8 @@ impl PayloadEncoding { match value { None => Ok(Self::LegacyPlaintext), Some(1) => Ok(Self::Tce1), - Some(_) => Err(EnvelopeError::Header("unknown payload encoding")), + Some(v @ 2..=255) => Err(EnvelopeError::Unsupported { version: v as u8 }), + Some(_) => Err(EnvelopeError::Header("invalid payload encoding")), } } diff --git a/tinycloud-core/src/at_rest/provider.rs b/tinycloud-core/src/at_rest/provider.rs index f97785d4..47f34a2f 100644 --- a/tinycloud-core/src/at_rest/provider.rs +++ b/tinycloud-core/src/at_rest/provider.rs @@ -21,6 +21,7 @@ pub trait DataKeyProvider: Send + Sync { } #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum DataKeyError { /// The node booted locked: its root cannot unseal the at-rest root key. #[error("at-rest keys are locked")] diff --git a/tinycloud-core/src/at_rest/stream.rs b/tinycloud-core/src/at_rest/stream.rs index 04fb5924..396e1c03 100644 --- a/tinycloud-core/src/at_rest/stream.rs +++ b/tinycloud-core/src/at_rest/stream.rs @@ -1,7 +1,8 @@ //! Streaming TCE1 encryption/decryption over `futures::io::AsyncRead`, and //! plaintext→ciphertext Range mapping. //! -//! Both readers hold at most one segment (64 KiB + tag) in memory. A +//! Each reader keeps two segment-sized buffers (2 × (64 KiB + 16 B), one being +//! filled, one being drained), allocated once. A //! `DecryptReader` only yields a segment after it authenticates; an error part //! way through means the object is corrupt or truncated, and a consumer must //! abort (e.g. reset the HTTP response) rather than finish normally. @@ -349,7 +350,8 @@ impl DecryptReader { .open_segment(self.next, body, tag) .map_err(invalid)?; let start = self.skip; - let end = len.min(start + self.remaining.min(usize::MAX as u64) as usize); + let end = len + .min(start.saturating_add(usize::try_from(self.remaining).unwrap_or(usize::MAX))); self.seg.truncate(end); std::mem::swap(&mut self.seg, &mut self.out); self.out_pos = start; diff --git a/tinycloud-core/src/at_rest/testdata/gen_vectors.py b/tinycloud-core/src/at_rest/testdata/gen_vectors.py index 1cce0c9f..1e74a68f 100755 --- a/tinycloud-core/src/at_rest/testdata/gen_vectors.py +++ b/tinycloud-core/src/at_rest/testdata/gen_vectors.py @@ -96,13 +96,186 @@ def blake3_multihash(digest_hex: str) -> str: ("sql-checkpoint-multi-segment", 3, CKPT, {"pattern_len": 200000}), ("sql-wal-short", 0xFFFFFFFF, WAL, {"hex": b"wal frame bytes".hex()}), ] +MAX_PLAIN = (1 << 32) * SEG # 2^48: the largest plaintext TCE1 can hold +U64_MAX = (1 << 64) - 1 + + +def n_segments(plen: int) -> int: + return max(1, -(-plen // SEG)) + + +def ct_len(plen: int): + """None if too large for TCE1.""" + if n_segments(plen) > (1 << 32): + return None + c = 64 + plen + 16 * n_segments(plen) + return c if c <= U64_MAX else None + + +def pt_len(clen: int): + """The plaintext length whose ciphertext is exactly clen, else None. + Searched over the two candidate segment counts, not by formula inversion.""" + if clen < 64 + 16: + return None + body = clen - 64 + base = body // (SEG + 16) + for n in (base, base + 1): + p = body - 16 * n + if n >= 1 and p >= 0 and ct_len(p) == clen: + return p + return None + + +def resolve(spec, total): + """HTTP byte-range semantics: (start, end) inclusive, or None (416).""" + if total == 0: + return None + kind = spec["kind"] + if kind == "inclusive": + s, e = spec["start"], spec["end"] + return (s, min(e, total - 1)) if s <= e and s < total else None + if kind == "from": + s = spec["start"] + return (s, total - 1) if s < total else None + if kind == "suffix": + n = spec["length"] + return (total - min(n, total), total - 1) if n > 0 else None + raise ValueError(kind) + + +def map_range(spec, total): + r = resolve(spec, total) + if r is None: + return None + s, e = r + first, last = s // SEG, e // SEG + last_len = min(SEG, total - last * SEG) + return {"plaintext_start": s, "plaintext_end": e, + "ciphertext_start": 64 + first * (SEG + 16), + "ciphertext_end": 64 + last * (SEG + 16) + last_len + 16 - 1} + + +class Reject(Exception): + def __init__(self, kind): + super().__init__(kind) + self.kind = kind + + +def open_ct(dek: bytes, ident: dict, ct: bytes) -> bytes: + """Reference opener: returns plaintext or raises Reject(kind) with kind in + header / unsupported / integrity.""" + if len(ct) < 5 or ct[0:3] != b"TCE": + raise Reject("header") + if not (0x31 <= ct[3] <= 0x39) or ct[4] != ct[3] - 0x30: + raise Reject("header") + if ct[4] != 1: + raise Reject("unsupported") + if len(ct) < 64: + raise Reject("header") + hdr = ct[:64] + if hdr[5] != 1 or hdr[6] or hdr[7] or hdr[52] != 16 or any(hdr[53:]): + raise Reject("header") + dek_id, plen = struct.unpack(">IQ", hdr[8:20]) + if ct_len(plen) is None: + raise Reject("header") + if len(ct) != ct_len(plen): + raise Reject("integrity") + ctx = context(ident) + k = HKDF(algorithm=hashes.SHA256(), length=32, salt=hdr[20:52], + info=hashlib.sha256(ctx).digest()).derive(dek) + aead = AESGCM(k) + n = n_segments(plen) + out = b"" + for i in range(n): + off = 64 + i * (SEG + 16) + seg_len = min(SEG, plen - i * SEG) + 16 + nonce = struct.pack(">I", i) + bytes([1 if i == n - 1 else 0]) + bytes(7) + try: + out += aead.decrypt(nonce, ct[off:off + seg_len], ctx + hdr) + except Exception: + raise Reject("integrity") + return out + + +def mutate(ct: bytes, op: dict) -> bytes: + kind = op["op"] + if kind == "truncate": + return ct[:op["len"]] + if kind == "append": + return ct + bytes.fromhex(op["hex"]) + if kind == "xor": + b = bytearray(ct) + b[op["at"]] ^= op["mask"] + return bytes(b) + if kind == "set": + b = bytearray(ct) + b[op["at"]:op["at"] + len(bytes.fromhex(op["hex"]))] = bytes.fromhex(op["hex"]) + return bytes(b) + if kind == "swap_segments": + a, b_ = op["a"], op["b"] + sa = slice(64 + a * (SEG + 16), 64 + (a + 1) * (SEG + 16)) + sb = slice(64 + b_ * (SEG + 16), 64 + (b_ + 1) * (SEG + 16)) + b = bytearray(ct) + b[sa], b[sb] = ct[sb], ct[sa] + return bytes(b) + raise ValueError(kind) + + +NEGATIVE = [ + # (base vector, op, expected rejection) + ("kv-short-text", {"op": "truncate", "len": 103}, "integrity"), + ("kv-short-text", {"op": "truncate", "len": 64}, "integrity"), + ("kv-short-text", {"op": "append", "hex": "00"}, "integrity"), + ("kv-short-text", {"op": "xor", "at": 103, "mask": 1}, "integrity"), + ("kv-short-text", {"op": "xor", "at": 64, "mask": 0x80}, "integrity"), + ("kv-short-text", {"op": "xor", "at": 30, "mask": 1}, "integrity"), + ("kv-short-text", {"op": "xor", "at": 12 + 7, "mask": 1}, "integrity"), + ("kv-short-text", {"op": "set", "at": 3, "hex": "3202"}, "unsupported"), + ("kv-short-text", {"op": "set", "at": 4, "hex": "02"}, "header"), + ("kv-short-text", {"op": "set", "at": 0, "hex": "58"}, "header"), + ("kv-short-text", {"op": "set", "at": 6, "hex": "01"}, "header"), + ("sql-checkpoint-multi-segment", {"op": "swap_segments", "a": 0, "b": 1}, "integrity"), + ("sql-checkpoint-multi-segment", {"op": "truncate", "len": 64 + 3 * (SEG + 16) - 1}, "integrity"), + ("sql-checkpoint-multi-segment", {"op": "truncate", "len": 64 + 3 * (SEG + 16)}, "integrity"), + ("sql-checkpoint-multi-segment", {"op": "xor", "at": 64 + SEG + 16 + 5, "mask": 4}, "integrity"), +] + +RANGE_TOTALS = [0, 1, SEG - 1, SEG, SEG + 1, 2 * SEG, 3 * SEG + 5] + + +def range_specs(total): + pts = sorted({0, 1, SEG - 1, SEG, SEG + 1, 2 * SEG - 1, 2 * SEG, max(total - 1, 0), total, total + 1}) + specs = [{"kind": "inclusive", "start": 5, "end": 3}, + {"kind": "inclusive", "start": 0, "end": U64_MAX}, + {"kind": "from", "start": U64_MAX}, + {"kind": "suffix", "length": 0}, + {"kind": "suffix", "length": U64_MAX}] + for p in pts: + specs.append({"kind": "from", "start": p}) + specs.append({"kind": "suffix", "length": p}) + for q in pts: + if p <= q: + specs.append({"kind": "inclusive", "start": p, "end": q}) + return specs + + +LENGTH_PLAINTEXTS = [0, 1, SEG - 1, SEG, SEG + 1, 2 * SEG, MAX_PLAIN - 1, MAX_PLAIN, + MAX_PLAIN + 1, U64_MAX] +LENGTH_CIPHERTEXTS = [0, 63, 64, 79, 80, 81, 64 + SEG + 16, 64 + SEG + 17, 64 + SEG + 32, + 64 + SEG + 33, 64 + 2 * (SEG + 16), ct_len(MAX_PLAIN), ct_len(MAX_PLAIN) + 1, + U64_MAX] + + def main(): vectors = [] + cts = {} for name, dek_id, ident, ptspec in CASES: pt = bytes.fromhex(ptspec["hex"]) if "hex" in ptspec else pattern(ptspec["pattern_len"]) ct = seal(DEK, dek_id, SALT, ident, pt) + assert open_ct(DEK, ident, ct) == pt + cts[name] = (ct, ident) v = {"name": name, "dek": DEK.hex(), "dek_id": dek_id, "salt": SALT.hex(), "identity": ident, "plaintext": ptspec, "ciphertext_len": len(ct), "ciphertext_sha256": hashlib.sha256(ct).hexdigest()} @@ -112,6 +285,25 @@ def main(): v["ciphertext_head"] = ct[:96].hex() vectors.append(v) + negative = [] + for base, op, expected in NEGATIVE: + ct, ident = cts[base] + try: + open_ct(DEK, ident, mutate(ct, op)) + raise AssertionError(f"reference accepted {base} {op}") + except Reject as r: + assert r.kind == expected, (base, op, r.kind) + negative.append({"base": base, **op, "expect": expected}) + + lengths = { + "plaintext_to_ciphertext": [{"plaintext_len": p, "ciphertext_len": ct_len(p)} + for p in LENGTH_PLAINTEXTS], + "ciphertext_to_plaintext": [{"ciphertext_len": c, "plaintext_len": pt_len(c)} + for c in LENGTH_CIPHERTEXTS], + } + ranges = [{"total": t, "spec": s, "mapped": map_range(s, t)} + for t in RANGE_TOTALS for s in range_specs(t)] + nk = bytes(range(0x80, 0xA0)) def tag(fields): @@ -132,6 +324,9 @@ def b64(b): "pattern": "plaintext byte i = i mod 251", "vectors": vectors, "object_names": names, + "negative": negative, + "lengths": lengths, + "ranges": ranges, }, indent=1)) diff --git a/tinycloud-core/src/at_rest/testdata/vectors.json b/tinycloud-core/src/at_rest/testdata/vectors.json index 11749e2a..c5a22211 100644 --- a/tinycloud-core/src/at_rest/testdata/vectors.json +++ b/tinycloud-core/src/at_rest/testdata/vectors.json @@ -154,5 +154,4886 @@ "space_tag": "YXP0Kg8hqZ76EELP_UoPvg", "object_name": "v1/YXP0Kg8hqZ76EELP_UoPvg/slhZoR_St8omnDU5b5SvW_YQQ-n4_UppbG4EjyVgppU" } + ], + "negative": [ + { + "base": "kv-short-text", + "op": "truncate", + "len": 103, + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "truncate", + "len": 64, + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "append", + "hex": "00", + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "xor", + "at": 103, + "mask": 1, + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "xor", + "at": 64, + "mask": 128, + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "xor", + "at": 30, + "mask": 1, + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "xor", + "at": 19, + "mask": 1, + "expect": "integrity" + }, + { + "base": "kv-short-text", + "op": "set", + "at": 3, + "hex": "3202", + "expect": "unsupported" + }, + { + "base": "kv-short-text", + "op": "set", + "at": 4, + "hex": "02", + "expect": "header" + }, + { + "base": "kv-short-text", + "op": "set", + "at": 0, + "hex": "58", + "expect": "header" + }, + { + "base": "kv-short-text", + "op": "set", + "at": 6, + "hex": "01", + "expect": "header" + }, + { + "base": "sql-checkpoint-multi-segment", + "op": "swap_segments", + "a": 0, + "b": 1, + "expect": "integrity" + }, + { + "base": "sql-checkpoint-multi-segment", + "op": "truncate", + "len": 196719, + "expect": "integrity" + }, + { + "base": "sql-checkpoint-multi-segment", + "op": "truncate", + "len": 196720, + "expect": "integrity" + }, + { + "base": "sql-checkpoint-multi-segment", + "op": "xor", + "at": 65621, + "mask": 4, + "expect": "integrity" + } + ], + "lengths": { + "plaintext_to_ciphertext": [ + { + "plaintext_len": 0, + "ciphertext_len": 80 + }, + { + "plaintext_len": 1, + "ciphertext_len": 81 + }, + { + "plaintext_len": 65535, + "ciphertext_len": 65615 + }, + { + "plaintext_len": 65536, + "ciphertext_len": 65616 + }, + { + "plaintext_len": 65537, + "ciphertext_len": 65633 + }, + { + "plaintext_len": 131072, + "ciphertext_len": 131168 + }, + { + "plaintext_len": 281474976710655, + "ciphertext_len": 281543696187455 + }, + { + "plaintext_len": 281474976710656, + "ciphertext_len": 281543696187456 + }, + { + "plaintext_len": 281474976710657, + "ciphertext_len": null + }, + { + "plaintext_len": 18446744073709551615, + "ciphertext_len": null + } + ], + "ciphertext_to_plaintext": [ + { + "ciphertext_len": 0, + "plaintext_len": null + }, + { + "ciphertext_len": 63, + "plaintext_len": null + }, + { + "ciphertext_len": 64, + "plaintext_len": null + }, + { + "ciphertext_len": 79, + "plaintext_len": null + }, + { + "ciphertext_len": 80, + "plaintext_len": 0 + }, + { + "ciphertext_len": 81, + "plaintext_len": 1 + }, + { + "ciphertext_len": 65616, + "plaintext_len": 65536 + }, + { + "ciphertext_len": 65617, + "plaintext_len": null + }, + { + "ciphertext_len": 65632, + "plaintext_len": null + }, + { + "ciphertext_len": 65633, + "plaintext_len": 65537 + }, + { + "ciphertext_len": 131168, + "plaintext_len": 131072 + }, + { + "ciphertext_len": 281543696187456, + "plaintext_len": 281474976710656 + }, + { + "ciphertext_len": 281543696187457, + "plaintext_len": null + }, + { + "ciphertext_len": 18446744073709551615, + "plaintext_len": null + } + ] + }, + "ranges": [ + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": null + }, + { + "total": 0, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 2 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 2 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 2 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 2 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 2, + "end": 2 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 2, + "end": 65535 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 2, + "end": 65536 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 2, + "end": 65537 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 2, + "end": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 2, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": null + }, + { + "total": 1, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 80 + } + }, + { + "total": 1, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65534 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65534 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 65534 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 65534 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65534, + "end": 65534 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65534, + "end": 65535 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65534, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65534, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65534, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65534, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65534, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": null + }, + { + "total": 65535, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65534, + "ciphertext_start": 64, + "ciphertext_end": 65614 + } + }, + { + "total": 65535, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": null + }, + { + "total": 65536, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65536, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65538 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65538 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": { + "plaintext_start": 2, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65538 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65538 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65538 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 65538 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 65538 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65538, + "end": 65538 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65538, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 65538, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": null + }, + { + "total": 65537, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 65632 + } + }, + { + "total": 65537, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65537, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131073 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65537, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131073 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65537, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131073 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65537, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131073 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 65537, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131073 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131073 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131073 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "from", + "start": 131073 + }, + "mapped": null + }, + { + "total": 131072, + "spec": { + "kind": "suffix", + "length": 131073 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 131072, + "spec": { + "kind": "inclusive", + "start": 131073, + "end": 131073 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 5, + "end": 3 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 18446744073709551615 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 18446744073709551615 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 0 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 0 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 0, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 1 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65535 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65536 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 65537 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 65537, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131071 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 131072 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 131072, + "ciphertext_start": 64, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 196612 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 196613 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 0, + "end": 196614 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 1 + }, + "mapped": { + "plaintext_start": 196612, + "plaintext_end": 196612, + "ciphertext_start": 196720, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 1 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 1, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65535 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65536 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 65537 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 65537, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131071 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 131072 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 131072, + "ciphertext_start": 64, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 196612 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 196613 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 1, + "end": 196614 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 65535 + }, + "mapped": { + "plaintext_start": 131078, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65535 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65535, + "ciphertext_start": 64, + "ciphertext_end": 65615 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65536, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 65537, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131071, + "ciphertext_start": 64, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 131072, + "ciphertext_start": 64, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 196612 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 196613 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65535, + "end": 196614 + }, + "mapped": { + "plaintext_start": 65535, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 65536 + }, + "mapped": { + "plaintext_start": 131077, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65536 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65536, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 65537, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 131072, + "ciphertext_start": 65616, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 196612 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 196613 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65536, + "end": 196614 + }, + "mapped": { + "plaintext_start": 65536, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 65537 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 65537 + }, + "mapped": { + "plaintext_start": 131076, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 65537 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 65537, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131071 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 131072 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 131072, + "ciphertext_start": 65616, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 196612 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 196613 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 65537, + "end": 196614 + }, + "mapped": { + "plaintext_start": 65537, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 131071 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 131071 + }, + "mapped": { + "plaintext_start": 65542, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131071 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131071, + "ciphertext_start": 65616, + "ciphertext_end": 131167 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 131072 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 131072, + "ciphertext_start": 65616, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 196612 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 196613 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131071, + "end": 196614 + }, + "mapped": { + "plaintext_start": 131071, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 131072 + }, + "mapped": { + "plaintext_start": 131072, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 131072 + }, + "mapped": { + "plaintext_start": 65541, + "plaintext_end": 196612, + "ciphertext_start": 65616, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 131072 + }, + "mapped": { + "plaintext_start": 131072, + "plaintext_end": 131072, + "ciphertext_start": 131168, + "ciphertext_end": 196719 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 196612 + }, + "mapped": { + "plaintext_start": 131072, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 196613 + }, + "mapped": { + "plaintext_start": 131072, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 131072, + "end": 196614 + }, + "mapped": { + "plaintext_start": 131072, + "plaintext_end": 196612, + "ciphertext_start": 131168, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 196612 + }, + "mapped": { + "plaintext_start": 196612, + "plaintext_end": 196612, + "ciphertext_start": 196720, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 196612 + }, + "mapped": { + "plaintext_start": 1, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 196612, + "end": 196612 + }, + "mapped": { + "plaintext_start": 196612, + "plaintext_end": 196612, + "ciphertext_start": 196720, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 196612, + "end": 196613 + }, + "mapped": { + "plaintext_start": 196612, + "plaintext_end": 196612, + "ciphertext_start": 196720, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 196612, + "end": 196614 + }, + "mapped": { + "plaintext_start": 196612, + "plaintext_end": 196612, + "ciphertext_start": 196720, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 196613 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 196613 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 196613, + "end": 196613 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 196613, + "end": 196614 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "from", + "start": 196614 + }, + "mapped": null + }, + { + "total": 196613, + "spec": { + "kind": "suffix", + "length": 196614 + }, + "mapped": { + "plaintext_start": 0, + "plaintext_end": 196612, + "ciphertext_start": 64, + "ciphertext_end": 196740 + } + }, + { + "total": 196613, + "spec": { + "kind": "inclusive", + "start": 196614, + "end": 196614 + }, + "mapped": null + } ] } diff --git a/tinycloud-core/src/at_rest/tests.rs b/tinycloud-core/src/at_rest/tests.rs index 56f49dc3..424ccff7 100644 --- a/tinycloud-core/src/at_rest/tests.rs +++ b/tinycloud-core/src/at_rest/tests.rs @@ -133,6 +133,94 @@ fn segment(p: usize, i: usize) -> std::ops::Range { struct Vectors { vectors: Vec, object_names: Vec, + negative: Vec, + lengths: Lengths, + ranges: Vec, +} + +#[derive(serde::Deserialize)] +struct Lengths { + plaintext_to_ciphertext: Vec, + ciphertext_to_plaintext: Vec, +} + +#[derive(serde::Deserialize)] +struct LengthPair { + plaintext_len: Option, + ciphertext_len: Option, +} + +#[derive(serde::Deserialize)] +struct RangeVector { + total: u64, + spec: serde_json::Value, + mapped: Option, +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +struct MappedRange { + plaintext_start: u64, + plaintext_end: u64, + ciphertext_start: u64, + ciphertext_end: u64, +} + +fn vectors() -> Vectors { + serde_json::from_str(include_str!("testdata/vectors.json")).unwrap() +} + +fn vector_plaintext(t: &Vector) -> Vec { + match t.plaintext.get("hex") { + Some(h) => hex::decode(h.as_str().unwrap()).unwrap(), + None => (0..t.plaintext["pattern_len"].as_u64().unwrap()) + .map(|i| (i % 251) as u8) + .collect(), + } +} + +fn with_identity(i: &serde_json::Value, f: impl FnOnce(&BlobIdentity<'_>) -> T) -> T { + let content_hash; + let id = match s(i, "purpose") { + "kv-block" => { + content_hash = Hash::try_from(hex::decode(s(i, "content_hash")).unwrap()).unwrap(); + BlobIdentity::KvBlock { + space: s(i, "space"), + content_hash: &content_hash, + } + } + "sql-checkpoint" => BlobIdentity::SqlCheckpoint { + service: s(i, "service"), + space: s(i, "space"), + name: s(i, "name"), + checkpoint_content_hash: s(i, "checkpoint_content_hash"), + checkpoint_size_bytes: i["checkpoint_size_bytes"].as_u64().unwrap(), + }, + "sql-wal" => BlobIdentity::SqlWal { + service: s(i, "service"), + space: s(i, "space"), + name: s(i, "name"), + revision: i["revision"].as_i64().unwrap(), + checkpoint_content_hash: s(i, "checkpoint_content_hash"), + delta_content_hash: s(i, "delta_content_hash"), + }, + p => panic!("unknown purpose {p}"), + }; + f(&id) +} + +fn range_spec(v: &serde_json::Value) -> ByteRangeSpec { + let n = |k: &str| v[k].as_u64().unwrap(); + match s(v, "kind") { + "inclusive" => ByteRangeSpec::Inclusive { + start: n("start"), + end: n("end"), + }, + "from" => ByteRangeSpec::From { start: n("start") }, + "suffix" => ByteRangeSpec::Suffix { + length: n("length"), + }, + k => panic!("unknown range kind {k}"), + } } #[derive(serde::Deserialize)] @@ -168,69 +256,12 @@ fn s<'a>(v: &'a serde_json::Value, k: &str) -> &'a str { #[test] fn known_answer_vectors_match_independent_reference() { - use sha2::{Digest, Sha256}; - let v: Vectors = serde_json::from_str(include_str!("testdata/vectors.json")).unwrap(); + let v = vectors(); assert_eq!(v.vectors.len(), 8); for t in &v.vectors { let k = DataKey::new(DekId::new(t.dek_id), arr32(&t.dek)); - let pt = match t.plaintext.get("hex") { - Some(h) => hex::decode(h.as_str().unwrap()).unwrap(), - None => (0..t.plaintext["pattern_len"].as_u64().unwrap()) - .map(|i| (i % 251) as u8) - .collect(), - }; - let i = &t.identity; - let content_hash; - let id = match s(i, "purpose") { - "kv-block" => { - content_hash = Hash::try_from(hex::decode(s(i, "content_hash")).unwrap()).unwrap(); - BlobIdentity::KvBlock { - space: s(i, "space"), - content_hash: &content_hash, - } - } - "sql-checkpoint" => BlobIdentity::SqlCheckpoint { - service: s(i, "service"), - space: s(i, "space"), - name: s(i, "name"), - checkpoint_content_hash: s(i, "checkpoint_content_hash"), - checkpoint_size_bytes: i["checkpoint_size_bytes"].as_u64().unwrap(), - }, - "sql-wal" => BlobIdentity::SqlWal { - service: s(i, "service"), - space: s(i, "space"), - name: s(i, "name"), - revision: i["revision"].as_i64().unwrap(), - checkpoint_content_hash: s(i, "checkpoint_content_hash"), - delta_content_hash: s(i, "delta_content_hash"), - }, - p => panic!("unknown purpose {p}"), - }; - let header = Header::build(k.id(), pt.len() as u64, &arr32(&t.salt)); - - let ct = seal_with_header(&k, &id, &header, pt.clone()); - assert_eq!(ct.len() as u64, t.ciphertext_len, "{}", t.name); - assert_eq!( - hex::encode(Sha256::digest(&ct)), - t.ciphertext_sha256, - "{}", - t.name - ); - if let Some(full) = &t.ciphertext { - assert_eq!(&hex::encode(&ct), full, "{}", t.name); - } - if let Some(head) = &t.ciphertext_head { - assert!(hex::encode(&ct).starts_with(head), "{}", t.name); - } - - let streamed = read_all( - EncryptReader::with_header(&k, &id, &header, Chunked::new(pt.clone(), vec![777])), - &[1000], - ) - .unwrap(); - assert_eq!(streamed, ct, "{} streaming", t.name); - assert_eq!(open(&k, &id, ct.clone()).unwrap(), pt, "{}", t.name); - assert_eq!(stream_open(&k, &id, &ct).unwrap(), pt, "{}", t.name); + let pt = vector_plaintext(t); + with_identity(&t.identity, |id| kat_one(t, &k, id, &pt)); } for n in &v.object_names { @@ -241,6 +272,147 @@ fn known_answer_vectors_match_independent_reference() { } } +fn kat_one(t: &Vector, k: &DataKey, id: &BlobIdentity<'_>, pt: &[u8]) { + use sha2::{Digest, Sha256}; + let header = Header::build(k.id(), pt.len() as u64, &arr32(&t.salt)); + + let ct = seal_with_header(k, id, &header, pt.to_vec()); + assert_eq!(ct.len() as u64, t.ciphertext_len, "{}", t.name); + assert_eq!( + hex::encode(Sha256::digest(&ct)), + t.ciphertext_sha256, + "{}", + t.name + ); + if let Some(full) = &t.ciphertext { + assert_eq!(&hex::encode(&ct), full, "{}", t.name); + } + if let Some(head) = &t.ciphertext_head { + assert!(hex::encode(&ct).starts_with(head), "{}", t.name); + } + + let streamed = read_all( + EncryptReader::with_header(k, id, &header, Chunked::new(pt.to_vec(), vec![777])), + &[1000], + ) + .unwrap(); + assert_eq!(streamed, ct, "{} streaming", t.name); + assert_eq!(open(k, id, ct.clone()).unwrap(), pt, "{}", t.name); + assert_eq!(stream_open(k, id, &ct).unwrap(), pt, "{}", t.name); +} + +fn error_kind(e: &EnvelopeError) -> &'static str { + match e { + EnvelopeError::Header(_) => "header", + EnvelopeError::Unsupported { .. } => "unsupported", + EnvelopeError::Integrity => "integrity", + other => panic!("unexpected {other:?}"), + } +} + +/// Mutations the Python reference opener rejects must be rejected the same +/// way here, whole-buffer and streaming. +#[test] +fn negative_vectors_are_refused_like_the_reference() { + let v = vectors(); + assert!(v.negative.len() >= 15); + for n in &v.negative { + let base = v.vectors.iter().find(|t| t.name == s(n, "base")).unwrap(); + let k = DataKey::new(DekId::new(base.dek_id), arr32(&base.dek)); + let header = Header::build( + k.id(), + vector_plaintext(base).len() as u64, + &arr32(&base.salt), + ); + let mut ct = with_identity(&base.identity, |id| { + seal_with_header(&k, id, &header, vector_plaintext(base)) + }); + let at = || n["at"].as_u64().unwrap() as usize; + match s(n, "op") { + "truncate" => ct.truncate(n["len"].as_u64().unwrap() as usize), + "append" => ct.extend(hex::decode(s(n, "hex")).unwrap()), + "xor" => ct[at()] ^= n["mask"].as_u64().unwrap() as u8, + "set" => { + let b = hex::decode(s(n, "hex")).unwrap(); + ct[at()..at() + b.len()].copy_from_slice(&b); + } + "swap_segments" => { + let p = vector_plaintext(base).len(); + let (a, b) = ( + n["a"].as_u64().unwrap() as usize, + n["b"].as_u64().unwrap() as usize, + ); + let (ra, rb) = (segment(p, a), segment(p, b)); + let (sa, sb) = (ct[ra.clone()].to_vec(), ct[rb.clone()].to_vec()); + ct[ra].copy_from_slice(&sb); + ct[rb].copy_from_slice(&sa); + } + op => panic!("unknown op {op}"), + } + let expect = s(n, "expect"); + with_identity(&base.identity, |id| { + let e = open(&k, id, ct.clone()).unwrap_err(); + assert_eq!(error_kind(&e), expect, "{n}: {e:?}"); + let se = stream_open(&k, id, &ct).unwrap_err(); + let inner = se.get_ref().and_then(|e| e.downcast_ref::()); + assert_eq!(inner.map(error_kind), Some(expect), "{n} streaming: {se}"); + }); + } +} + +#[test] +fn length_vectors_match_reference() { + let v = vectors(); + for l in &v.lengths.plaintext_to_ciphertext { + let p = l.plaintext_len.unwrap(); + match (ciphertext_len(p), l.ciphertext_len) { + (Ok(c), Some(want)) => assert_eq!(c, want, "p={p}"), + (Err(EnvelopeError::TooLarge), None) => {} + (got, want) => panic!("p={p}: {got:?} vs {want:?}"), + } + } + for l in &v.lengths.ciphertext_to_plaintext { + let c = l.ciphertext_len.unwrap(); + assert_eq!(plaintext_len(c), l.plaintext_len, "c={c}"); + } +} + +/// Range mapping against the reference, then a real ranged decrypt of each +/// satisfiable range. +#[test] +fn range_vectors_match_reference() { + let v = vectors(); + assert!(v.ranges.len() > 300); + let k = key(1, 2); + let h = hash(b"range-kat"); + let mut sealed = std::collections::HashMap::new(); + for r in &v.ranges { + let pt = bytes(r.total as usize, r.total); + let ct = sealed + .entry(r.total) + .or_insert_with(|| seal(&k, &kv(&h), pt.clone()).unwrap()) + .clone(); + let header = Header::parse(&ct).unwrap(); + let spec = range_spec(&r.spec); + let got = header.map_range(spec).map(|m| MappedRange { + plaintext_start: m.plaintext_range().start(), + plaintext_end: m.plaintext_range().end(), + ciphertext_start: m.ciphertext_start(), + ciphertext_end: m.ciphertext_end(), + }); + assert_eq!(got, r.mapped, "total {} spec {}", r.total, r.spec); + if let Some(m) = header.map_range(spec) { + assert_eq!(m.total_size(), r.total); + let fetched = ct[m.ciphertext_start() as usize..=m.ciphertext_end() as usize].to_vec(); + let dec = DecryptReader::range(&k, &kv(&h), &header, m, &fetched[..]).unwrap(); + let out = read_all(dec, &[65536]).unwrap(); + let pr = m.plaintext_range(); + assert_eq!(out.len() as u64, pr.len()); + assert_eq!(&out[..], &pt[pr.start() as usize..=pr.end() as usize]); + } + } +} + // ---------- round trip, streaming, sizes ---------- fn edge_len() -> impl Strategy { @@ -594,8 +766,23 @@ fn payload_encoding_column_is_explicit() { PayloadEncoding::from_column(Some(1)).unwrap(), PayloadEncoding::Tce1 ); - for v in [0, 2, -1, i16::MAX] { - assert!(PayloadEncoding::from_column(Some(v)).is_err(), "{v}"); + for v in [2i16, 3, 255] { + assert!( + matches!( + PayloadEncoding::from_column(Some(v)), + Err(EnvelopeError::Unsupported { version }) if version as i16 == v + ), + "{v}" + ); + } + for v in [0, -1, 256, i16::MAX, i16::MIN] { + assert!( + matches!( + PayloadEncoding::from_column(Some(v)), + Err(EnvelopeError::Header(_)) + ), + "{v}" + ); } for e in [PayloadEncoding::LegacyPlaintext, PayloadEncoding::Tce1] { assert_eq!(PayloadEncoding::from_column(e.to_column()).unwrap(), e); @@ -715,3 +902,224 @@ fn sealed_names_hide_space_and_hash() { sealed_object_name(&nk, other, &h).starts_with(&format!("v1/{}/", space_tag(&nk, other))) ); } + +// ---------- review round: boundaries, partial output, versions ---------- + +/// Lengths and range endpoints biased to segment edges (k·SEG−1, k·SEG, +/// k·SEG+1) and to the end of the blob (len−1, len, len+1). +fn len_and_points() -> impl Strategy { + let edge = |k: u64, d: i64| (k as i64 * SEG as i64 + d).max(1) as u64; + (1u64..4, -1i64..=1) + .prop_map(move |(k, d)| edge(k, d) as usize) + .prop_flat_map(move |len| { + let point = prop_oneof![ + (0u64..4, -1i64..=1) + .prop_map(move |(k, d)| (k as i64 * SEG as i64 + d).max(0) as u64), + (-1i64..=1).prop_map(move |d| (len as i64 + d) as u64), + 0u64..(len as u64 + 2), + ]; + (Just(len), point.clone(), point) + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(96))] + + #[test] + fn edge_biased_ranges_equal_plaintext_slice((len, a, b) in len_and_points(), kind in 0u8..3, seed: u64) { + let k = key(4, 5); + let h = hash(b"edge"); + let pt = bytes(len, seed); + let ct = seal(&k, &kv(&h), pt.clone()).unwrap(); + let header = Header::parse(&ct).unwrap(); + let spec = match kind { + 0 => ByteRangeSpec::Inclusive { start: a.min(b), end: a.max(b) }, + 1 => ByteRangeSpec::From { start: a }, + _ => ByteRangeSpec::Suffix { length: a }, + }; + let want = spec.resolve(len as u64); + let got = header.map_range(spec); + prop_assert_eq!(want.is_some(), got.is_some()); + if let (Some(want), Some(r)) = (want, got) { + prop_assert_eq!(r.plaintext_range(), want); + prop_assert_eq!(r.ciphertext_start() as usize % (SEG + TAG_LEN), HEADER_LEN % (SEG + TAG_LEN)); + prop_assert_eq!(r.ciphertext_end() as usize + 1 == ct.len(), want.end() as usize / SEG == (len - 1) / SEG); + let fetched = ct[r.ciphertext_start() as usize..=r.ciphertext_end() as usize].to_vec(); + let dec = DecryptReader::range(&k, &kv(&h), &header, r, Chunked::new(fetched, vec![7000])).unwrap(); + let out = read_all(dec, &[4096]).unwrap(); + prop_assert_eq!(out.len() as u64, want.len()); + prop_assert_eq!(&out[..], &pt[want.start() as usize..=want.end() as usize]); + } + } +} + +#[test] +fn empty_blob_has_no_satisfiable_range() { + let k = key(1, 1); + let h = hash(b"empty"); + let ct = seal(&k, &kv(&h), Vec::new()).unwrap(); + let header = Header::parse(&ct).unwrap(); + assert_eq!(header.plaintext_len(), 0); + for spec in [ + ByteRangeSpec::Inclusive { start: 0, end: 0 }, + ByteRangeSpec::Inclusive { + start: 0, + end: u64::MAX, + }, + ByteRangeSpec::From { start: 0 }, + ByteRangeSpec::Suffix { length: 0 }, + ByteRangeSpec::Suffix { length: 1 }, + ByteRangeSpec::Suffix { length: u64::MAX }, + ] { + assert_eq!(header.map_range(spec), None, "{spec:?}"); + } + assert_eq!(stream_open(&k, &kv(&h), &ct).unwrap(), Vec::::new()); +} + +/// Reads until the first error; returns what was yielded and the error. +fn read_until_err(r: &mut R) -> (Vec, io::Error) { + futures::executor::block_on(async { + let mut got = Vec::new(); + let mut buf = [0u8; 3000]; + loop { + match r.read(&mut buf).await { + Ok(0) => panic!("stream ended without an error"), + Ok(n) => got.extend_from_slice(&buf[..n]), + Err(e) => return (got, e), + } + } + }) +} + +#[test] +fn failing_segment_releases_nothing_and_errors_stick() { + let k = key(1, 3); + let h = hash(b"partial"); + let p = 4 * SEG; + let pt = bytes(p, 9); + let ct = seal(&k, &kv(&h), pt.clone()).unwrap(); + let header = Header::parse(&ct).unwrap(); + for bad in 0..4 { + let mut c = ct.clone(); + c[segment(p, bad).start + 17] ^= 0x40; + + let mut whole = DecryptReader::new( + &k, + &kv(&h), + &header, + Chunked::new(c[HEADER_LEN..].to_vec(), vec![5000]), + ) + .unwrap(); + let (got, err) = read_until_err(&mut whole); + assert_eq!(got, pt[..bad * SEG], "segment {bad}"); + assert!(is_integrity(Err(err))); + let again = futures::executor::block_on(whole.read(&mut [0u8; 10])); + assert!(again.is_err(), "error must be sticky"); + + // A range starting mid-segment 0 and spanning everything. + let r = header + .map_range(ByteRangeSpec::From { start: 100 }) + .unwrap(); + let fetched = c[r.ciphertext_start() as usize..].to_vec(); + let mut ranged = + DecryptReader::range(&k, &kv(&h), &header, r, Chunked::new(fetched, vec![5000])) + .unwrap(); + let (got, err) = read_until_err(&mut ranged); + assert_eq!( + got, + pt[100.min(bad * SEG)..bad * SEG], + "range, segment {bad}" + ); + assert!(is_integrity(Err(err))); + assert!(futures::executor::block_on(ranged.read(&mut [0u8; 10])).is_err()); + } +} + +#[test] +fn range_with_trailing_bytes_is_refused() { + let k = key(1, 4); + let h = hash(b"trail"); + let p = 2 * SEG + 10; + let pt = bytes(p, 4); + let ct = seal(&k, &kv(&h), pt.clone()).unwrap(); + let header = Header::parse(&ct).unwrap(); + for spec in [ + ByteRangeSpec::Inclusive { + start: 10, + end: SEG as u64 + 5, + }, + ByteRangeSpec::Suffix { length: 3 }, + ] { + let r = header.map_range(spec).unwrap(); + let mut fetched = ct[r.ciphertext_start() as usize..=r.ciphertext_end() as usize].to_vec(); + fetched.push(0); + let dec = DecryptReader::range(&k, &kv(&h), &header, r, &fetched[..]).unwrap(); + assert!(is_integrity(read_all(dec, &[4096])), "{spec:?}"); + } +} + +#[test] +fn size_limits_at_two_to_the_48() { + let max = (1u64 << 32) * SEG as u64; + let c = ciphertext_len(max).unwrap(); + assert_eq!(c, max + HEADER_LEN as u64 + (1u64 << 32) * TAG_LEN as u64); + assert_eq!(plaintext_len(c), Some(max)); + assert_eq!(plaintext_len(c + 1), None); + for p in [max + 1, u64::MAX] { + assert!( + matches!(ciphertext_len(p), Err(EnvelopeError::TooLarge)), + "{p}" + ); + } + assert_eq!(plaintext_len(u64::MAX), None); + + let k = key(1, 1); + let ok = Header::build(k.id(), max, &[0; 32]); + assert_eq!(Header::parse(ok.as_bytes()).unwrap().plaintext_len(), max); + for p in [max + 1, u64::MAX] { + let mut b = *ok.as_bytes(); + b[12..20].copy_from_slice(&p.to_be_bytes()); + assert!( + matches!(Header::parse(&b), Err(EnvelopeError::Header("length"))), + "{p}" + ); + } +} + +#[test] +fn later_generations_are_unsupported_not_corrupt() { + let k = key(1, 1); + let h = hash(b"gen"); + let ct = seal(&k, &kv(&h), b"abc".to_vec()).unwrap(); + assert_eq!(format_version(&ct).unwrap(), 1); + for g in [2u8, 9] { + let mut c = ct.clone(); + c[3] = b'0' + g; + c[4] = g; + assert_eq!(format_version(&c).unwrap(), g); + assert!( + matches!(Header::parse(&c), Err(EnvelopeError::Unsupported { version }) if version == g) + ); + assert!( + matches!(open(&k, &kv(&h), c.clone()), Err(EnvelopeError::Unsupported { version }) if version == g) + ); + // Dispatch needs only the first 5 bytes, even if the rest differs in size. + assert!(matches!( + Header::parse(&c[..5]), + Err(EnvelopeError::Unsupported { .. }) + )); + } + for (b3, b4) in [(b'2', 1u8), (b'1', 2), (b'0', 0), (b'A', 17)] { + let mut c = ct.clone(); + c[3] = b3; + c[4] = b4; + assert!( + matches!(Header::parse(&c), Err(EnvelopeError::Header("version"))), + "{b3} {b4}" + ); + } + assert!(matches!( + format_version(b"TCE"), + Err(EnvelopeError::Header("short header")) + )); +} From 9966fe2730fa9d55ad5b19fbd850d646dd8e2e53 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 07:02:43 +0000 Subject: [PATCH 6/7] fix(TC-887): allowlist the at-rest byte-range resolve in the DID resolver guard SegmentRange keeps the ResolvedByteRange from map_range, so map_range holds the module's only ByteRangeSpec::resolve call; add that exact call to did_resolution_single_entry's allowlist like the storage backends' range calls. --- tinycloud-core/src/at_rest/stream.rs | 17 +++++------------ .../tests/did_resolution_single_entry.rs | 2 ++ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/tinycloud-core/src/at_rest/stream.rs b/tinycloud-core/src/at_rest/stream.rs index 396e1c03..4bf120eb 100644 --- a/tinycloud-core/src/at_rest/stream.rs +++ b/tinycloud-core/src/at_rest/stream.rs @@ -180,8 +180,7 @@ impl AsyncRead for EncryptReader { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SegmentRange { plaintext_len: u64, - start: u64, - end: u64, + range: ResolvedByteRange, first: u64, last: u64, } @@ -201,12 +200,7 @@ impl SegmentRange { /// The plaintext range that will be yielded, for `Content-Range`. pub fn plaintext_range(&self) -> ResolvedByteRange { - ByteRangeSpec::Inclusive { - start: self.start, - end: self.end, - } - .resolve(self.plaintext_len) - .expect("constructed from a satisfiable range") + self.range } /// Total plaintext size of the blob, for `Content-Range` and 416. @@ -224,8 +218,7 @@ impl Header { let seg = SEGMENT_LEN as u64; Some(SegmentRange { plaintext_len: self.plaintext_len(), - start: r.start(), - end: r.end(), + range: r, first: r.start() / seg, last: r.end() / seg, }) @@ -279,8 +272,8 @@ impl DecryptReader { "range was mapped for another plaintext length", )); } - let skip = (range.start - range.first * SEGMENT_LEN as u64) as usize; - let len = range.end - range.start + 1; + let skip = (range.range.start() - range.first * SEGMENT_LEN as u64) as usize; + let len = range.range.len(); Self::build(key, id, header, inner, range.first, range.last, skip, len) } diff --git a/tinycloud-node-server/tests/did_resolution_single_entry.rs b/tinycloud-node-server/tests/did_resolution_single_entry.rs index 9eba02ac..dd6f3719 100644 --- a/tinycloud-node-server/tests/did_resolution_single_entry.rs +++ b/tinycloud-node-server/tests/did_resolution_single_entry.rs @@ -146,6 +146,8 @@ impl Guard { | ("tinycloud-core/src/storage/memory.rs", "read_range", "range . resolve (total_size)") | ("tinycloud-node-server/src/storage/file_system.rs", "read_range", "range . resolve (total_size)") | ("tinycloud-node-server/src/storage/s3.rs", "parse_content_range", "ByteRangeSpec :: Inclusive { start , end } . resolve (total_size)") + // TC-887: at-rest Range mapping resolves an HTTP byte range against the plaintext length. + | ("tinycloud-core/src/at_rest/stream.rs", "map_range", "spec . resolve (self . plaintext_len ())") | ("tinycloud-node-server/src/lib.rs", "resolve_runtime_config", "resolved . storage . resolve ()") | ("tinycloud-node-server/src/runtime.rs", "serve_config_figment", "resolved . storage . resolve ()") | ("tinycloud-node-server/src/runtime.rs", "launch_with_figment", "tinycloud_config . storage . resolve ()") From a018b5ec9051d904eac54668c60c76ab81d9c402 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sat, 17 Oct 2026 07:14:04 +0000 Subject: [PATCH 7/7] docs(TC-887): GHASH key is not wiped; Unsupported is unauthenticated Correct the aes-gcm zeroize comment: polyval keeps H without a Drop, listed as an accepted residual with the hkdf PRK and hmac state. Note on EnvelopeError::Unsupported that callers must fail closed. --- tinycloud-core/Cargo.toml | 6 ++++-- tinycloud-core/src/at_rest/envelope.rs | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 4f573e85..1efb54dc 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -54,8 +54,10 @@ tokio.workspace = true duckdb = { version = "1.1", features = ["bundled", "appender-arrow"], optional = true } arrow = { version = "56", features = ["ipc"], optional = true } tempfile = "3" -# TC-887: `zeroize` wipes the AES key schedule and GHASH key on drop; `aes` is -# listed only to enable its `zeroize` feature (feature unification). +# TC-887: `zeroize` wipes the AES key schedule on drop; `aes` is listed only to +# enable its `zeroize` feature (feature unification). Not wiped (accepted +# residual): the GHASH key H (polyval 0.6.2's autodetect backend keeps it in a +# ManuallyDrop union with no Drop), the hkdf PRK and the hmac state. aes-gcm = { version = "0.10.3", features = ["zeroize"] } aes = { version = "0.8", features = ["zeroize"] } rand = "0.8" diff --git a/tinycloud-core/src/at_rest/envelope.rs b/tinycloud-core/src/at_rest/envelope.rs index d795535a..c1f401c6 100644 --- a/tinycloud-core/src/at_rest/envelope.rs +++ b/tinycloud-core/src/at_rest/envelope.rs @@ -46,6 +46,8 @@ pub enum EnvelopeError { Header(&'static str), /// A well-formed TCE header (or payload encoding) of a later format /// generation than this build reads. Not corruption: upgrade the node. + /// Unauthenticated (anyone who can write the bytes can produce it), so + /// callers must fail closed: never treat it as legacy, re-seal or skip. #[error("unsupported at-rest format version {version}")] Unsupported { version: u8 }, /// The header names a different data key than the one supplied.