lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit edf3b360b3a3dc9f9655dc0aedea7dea0706de2b
parent 801d208f950dd4b57b6fb4393897c6dd99c8a070
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 08:17:32 +0000

secrets: move encrypted envelope semantics

- Add bounded versioned XChaCha20-Poly1305 envelope encoding and validation.
- Bind typed references, wrapped keys, nonces, and lengths into authenticated data.
- Require explicit host-supplied key and nonce material with no hidden generation.
- Verify fixed vectors, tamper cases, serde, clippy, no_std, wasm, and architecture.

Diffstat:
MCargo.lock | 2++
Mcrates/secrets/Cargo.toml | 2++
Mcrates/secrets/src/envelope.rs | 466+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/error.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/id.rs | 21+++++++++++++++++++++
Mcrates/secrets/src/lib.rs | 1+
Acrates/secrets/tests/envelope_contract.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/tests/package_boundary.rs | 1+
8 files changed, 743 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4972,7 +4972,9 @@ dependencies = [ name = "radroots_secrets" version = "0.1.0-alpha" dependencies = [ + "chacha20poly1305", "futures-executor", + "hex", "serde", "serde_json", "zeroize", diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml @@ -24,6 +24,7 @@ file = ["std"] keyring = ["std"] [dependencies] +chacha20poly1305 = { workspace = true } serde = { workspace = true, default-features = false, features = [ "alloc", "derive", @@ -32,6 +33,7 @@ zeroize = { workspace = true } [dev-dependencies] futures-executor = { workspace = true } +hex = { workspace = true } serde_json = { workspace = true, features = ["std"] } [lints] diff --git a/crates/secrets/src/envelope.rs b/crates/secrets/src/envelope.rs @@ -1 +1,467 @@ //! Versioned encrypted-envelope contracts. + +use crate::error::Error; +use crate::id::{BackendKind, KeyVersion}; +use crate::wrapping::{KeyWrapping, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret}; +use crate::{SecretId, SecretRef}; +use alloc::vec::Vec; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use core::fmt; + +const MAGIC: [u8; 4] = *b"RRS1"; +const DATA_KEY_BYTES: usize = 32; +const AEAD_TAG_BYTES: usize = 16; +const NONCE_BYTES: usize = 24; + +/// Current authenticated envelope format version. +pub const ENVELOPE_VERSION: u16 = 1; +/// Maximum encoded envelope size accepted from storage. +pub const ENVELOPE_MAX_BYTES: usize = 256 * 1024; + +/// Authenticated-encryption algorithm used by an envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Cipher { + /// XChaCha20-Poly1305 with a 192-bit nonce. + XChaCha20Poly1305, +} + +impl Cipher { + const fn code(self) -> u8 { + match self { + Self::XChaCha20Poly1305 => 1, + } + } + + const fn from_code(code: u8) -> Result<Self, Error> { + match code { + 1 => Ok(Self::XChaCha20Poly1305), + cipher => Err(Error::UnsupportedCipher { cipher }), + } + } +} + +/// How the data-encryption key is protected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum KeySource { + /// The host-selected [`KeyWrapping`] provider protects the data key. + ProviderWrapped, +} + +impl KeySource { + const fn code(self) -> u8 { + match self { + Self::ProviderWrapped => 1, + } + } + + const fn from_code(code: u8) -> Result<Self, Error> { + match code { + 1 => Ok(Self::ProviderWrapped), + key_source => Err(Error::UnsupportedKeySource { key_source }), + } + } +} + +/// Explicit 192-bit nonce supplied by the host. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Nonce([u8; NONCE_BYTES]); + +impl Nonce { + /// Creates a nonce from exact caller-supplied bytes. + #[must_use] + pub const fn new(bytes: [u8; NONCE_BYTES]) -> Self { + Self(bytes) + } + + /// Returns the nonce bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; NONCE_BYTES] { + &self.0 + } +} + +impl fmt::Debug for Nonce { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Nonce(<redacted>)") + } +} + +/// Caller-supplied cryptographic material for one sealing operation. +pub struct SealMaterial { + data_key: SecretMaterial, + nonce: Nonce, +} + +impl SealMaterial { + /// Couples an explicitly generated data key and nonce. + #[must_use] + pub const fn new(data_key: SecretMaterial, nonce: Nonce) -> Self { + Self { data_key, nonce } + } +} + +impl fmt::Debug for SealMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SealMaterial(<redacted>)") + } +} + +/// Complete input for one envelope sealing operation. +pub struct SealRequest<'a> { + reference: SecretRef, + plaintext: &'a SecretMaterial, + material: SealMaterial, +} + +impl<'a> SealRequest<'a> { + /// Creates a request without generating entropy or selecting a provider. + #[must_use] + pub const fn new( + reference: SecretRef, + plaintext: &'a SecretMaterial, + material: SealMaterial, + ) -> Self { + Self { + reference, + plaintext, + material, + } + } +} + +impl fmt::Debug for SealRequest<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SealRequest(<redacted>)") + } +} + +/// A versioned authenticated envelope with provider-wrapped key material. +pub struct EncryptedEnvelope { + version: u16, + cipher: Cipher, + key_source: KeySource, + reference: SecretRef, + nonce: Nonce, + wrapped_key: WrappedSecret, + ciphertext: Vec<u8>, +} + +impl EncryptedEnvelope { + /// Seals plaintext using only explicit host-supplied key and nonce material. + pub async fn seal(wrapping: &dyn KeyWrapping, request: SealRequest<'_>) -> Result<Self, Error> { + let SealRequest { + reference, + plaintext, + material, + } = request; + validate_data_key(&material.data_key)?; + let wrapped_key = wrapping + .wrap(WrapRequest::new(&reference, &material.data_key)) + .await?; + let ciphertext_len = + plaintext + .len() + .checked_add(AEAD_TAG_BYTES) + .ok_or(Error::EnvelopeTooLarge { + actual_bytes: usize::MAX, + max_bytes: ENVELOPE_MAX_BYTES, + })?; + let aad = encode_header( + ENVELOPE_VERSION, + Cipher::XChaCha20Poly1305, + KeySource::ProviderWrapped, + &reference, + material.nonce, + &wrapped_key, + ciphertext_len, + )?; + let ciphertext = material.data_key.expose_secret(|data_key| { + plaintext.expose_secret(|plaintext| { + let cipher = XChaCha20Poly1305::new(Key::from_slice(data_key)); + cipher + .encrypt( + XNonce::from_slice(material.nonce.as_bytes()), + Payload { + msg: plaintext, + aad: aad.as_slice(), + }, + ) + .map_err(|_| Error::EncryptFailed) + }) + })?; + let envelope = Self { + version: ENVELOPE_VERSION, + cipher: Cipher::XChaCha20Poly1305, + key_source: KeySource::ProviderWrapped, + reference, + nonce: material.nonce, + wrapped_key, + ciphertext, + }; + envelope.validate()?; + Ok(envelope) + } + + /// Authenticates and decrypts into a single-owner zeroizing value. + pub async fn open(&self, wrapping: &dyn KeyWrapping) -> Result<SecretMaterial, Error> { + self.validate()?; + let data_key = wrapping + .unwrap(UnwrapRequest::new(&self.reference, &self.wrapped_key)) + .await?; + validate_data_key(&data_key)?; + let aad = self.encoded_header()?; + let plaintext = data_key.expose_secret(|data_key| { + let cipher = XChaCha20Poly1305::new(Key::from_slice(data_key)); + cipher + .decrypt( + XNonce::from_slice(self.nonce.as_bytes()), + Payload { + msg: self.ciphertext.as_slice(), + aad: aad.as_slice(), + }, + ) + .map_err(|_| Error::DecryptFailed) + })?; + SecretMaterial::from_slice(plaintext.as_slice()) + } + + /// Returns the authenticated provider reference. + #[must_use] + pub const fn reference(&self) -> &SecretRef { + &self.reference + } + + /// Returns the format version. + #[must_use] + pub const fn version(&self) -> u16 { + self.version + } + + /// Returns the authenticated cipher identifier. + #[must_use] + pub const fn cipher(&self) -> Cipher { + self.cipher + } + + /// Returns the authenticated key-source identifier. + #[must_use] + pub const fn key_source(&self) -> KeySource { + self.key_source + } + + /// Encodes the validated envelope into its deterministic binary form. + pub fn encode(&self) -> Result<Vec<u8>, Error> { + self.validate()?; + let mut encoded = self.encoded_header()?; + encoded.extend_from_slice(self.ciphertext.as_slice()); + if encoded.len() > ENVELOPE_MAX_BYTES { + return Err(Error::EnvelopeTooLarge { + actual_bytes: encoded.len(), + max_bytes: ENVELOPE_MAX_BYTES, + }); + } + Ok(encoded) + } + + /// Decodes and validates an envelope without accessing a provider. + pub fn decode(encoded: &[u8]) -> Result<Self, Error> { + if encoded.len() > ENVELOPE_MAX_BYTES { + return Err(Error::EnvelopeTooLarge { + actual_bytes: encoded.len(), + max_bytes: ENVELOPE_MAX_BYTES, + }); + } + let mut decoder = Decoder::new(encoded); + if decoder.take_array::<4>()? != MAGIC { + return Err(Error::EnvelopeMalformed); + } + let version = decoder.u16()?; + if version != ENVELOPE_VERSION { + return Err(Error::UnsupportedEnvelopeVersion { version }); + } + let cipher = Cipher::from_code(decoder.u8()?)?; + let key_source = KeySource::from_code(decoder.u8()?)?; + let backend = BackendKind::from_code(decoder.u8()?)?; + let key_version = KeyVersion::new(decoder.u32()?)?; + let id_len = usize::from(decoder.u16()?); + let id_bytes = decoder.take(id_len)?; + let id = core::str::from_utf8(id_bytes).map_err(|_| Error::EnvelopeMalformed)?; + let reference = SecretRef::new(SecretId::parse(id)?, backend, key_version); + let nonce = Nonce::new(decoder.take_array::<NONCE_BYTES>()?); + let wrapped_len = usize::try_from(decoder.u32()?).map_err(|_| Error::EnvelopeMalformed)?; + let wrapped_key = WrappedSecret::from_bytes(decoder.take(wrapped_len)?.to_vec())?; + let ciphertext_len = + usize::try_from(decoder.u32()?).map_err(|_| Error::EnvelopeMalformed)?; + let ciphertext = decoder.take(ciphertext_len)?.to_vec(); + if !decoder.is_empty() { + return Err(Error::EnvelopeMalformed); + } + let envelope = Self { + version, + cipher, + key_source, + reference, + nonce, + wrapped_key, + ciphertext, + }; + envelope.validate()?; + Ok(envelope) + } + + fn encoded_header(&self) -> Result<Vec<u8>, Error> { + encode_header( + self.version, + self.cipher, + self.key_source, + &self.reference, + self.nonce, + &self.wrapped_key, + self.ciphertext.len(), + ) + } + + fn validate(&self) -> Result<(), Error> { + if self.version != ENVELOPE_VERSION { + return Err(Error::UnsupportedEnvelopeVersion { + version: self.version, + }); + } + if self.ciphertext.len() < AEAD_TAG_BYTES { + return Err(Error::EnvelopeMalformed); + } + let total = self.encoded_header()?.len() + self.ciphertext.len(); + if total > ENVELOPE_MAX_BYTES { + return Err(Error::EnvelopeTooLarge { + actual_bytes: total, + max_bytes: ENVELOPE_MAX_BYTES, + }); + } + Ok(()) + } +} + +impl fmt::Debug for EncryptedEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EncryptedEnvelope") + .field("version", &self.version) + .field("cipher", &self.cipher) + .field("key_source", &self.key_source) + .field("reference", &self.reference) + .field("nonce", &"<redacted>") + .field("wrapped_key", &"<redacted>") + .field("ciphertext", &"<redacted>") + .finish() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for EncryptedEnvelope { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let encoded = self.encode().map_err(serde::ser::Error::custom)?; + serde::Serialize::serialize(&encoded, serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for EncryptedEnvelope { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let encoded = <Vec<u8> as serde::Deserialize>::deserialize(deserializer)?; + Self::decode(encoded.as_slice()).map_err(serde::de::Error::custom) + } +} + +fn validate_data_key(data_key: &SecretMaterial) -> Result<(), Error> { + if data_key.len() != DATA_KEY_BYTES { + return Err(Error::InvalidDataKeyLength { + actual_bytes: data_key.len(), + }); + } + Ok(()) +} + +fn encode_header( + version: u16, + cipher: Cipher, + key_source: KeySource, + reference: &SecretRef, + nonce: Nonce, + wrapped_key: &WrappedSecret, + ciphertext_len: usize, +) -> Result<Vec<u8>, Error> { + let id = reference.id().as_str().as_bytes(); + let id_len = u16::try_from(id.len()).map_err(|_| Error::EnvelopeMalformed)?; + let wrapped_len = + u32::try_from(wrapped_key.as_bytes().len()).map_err(|_| Error::EnvelopeMalformed)?; + let ciphertext_len = u32::try_from(ciphertext_len).map_err(|_| Error::EnvelopeTooLarge { + actual_bytes: ciphertext_len, + max_bytes: ENVELOPE_MAX_BYTES, + })?; + let capacity = + 4 + 2 + 1 + 1 + 1 + 4 + 2 + id.len() + NONCE_BYTES + 4 + wrapped_key.as_bytes().len() + 4; + let mut encoded = Vec::with_capacity(capacity); + encoded.extend_from_slice(&MAGIC); + encoded.extend_from_slice(&version.to_be_bytes()); + encoded.push(cipher.code()); + encoded.push(key_source.code()); + encoded.push(reference.backend().code()); + encoded.extend_from_slice(&reference.key_version().get().to_be_bytes()); + encoded.extend_from_slice(&id_len.to_be_bytes()); + encoded.extend_from_slice(id); + encoded.extend_from_slice(nonce.as_bytes()); + encoded.extend_from_slice(&wrapped_len.to_be_bytes()); + encoded.extend_from_slice(wrapped_key.as_bytes()); + encoded.extend_from_slice(&ciphertext_len.to_be_bytes()); + Ok(encoded) +} + +struct Decoder<'a> { + remaining: &'a [u8], +} + +impl<'a> Decoder<'a> { + const fn new(encoded: &'a [u8]) -> Self { + Self { remaining: encoded } + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], Error> { + if length > self.remaining.len() { + return Err(Error::EnvelopeMalformed); + } + let (value, remaining) = self.remaining.split_at(length); + self.remaining = remaining; + Ok(value) + } + + fn take_array<const N: usize>(&mut self) -> Result<[u8; N], Error> { + self.take(N)? + .try_into() + .map_err(|_| Error::EnvelopeMalformed) + } + + fn u8(&mut self) -> Result<u8, Error> { + Ok(self.take_array::<1>()?[0]) + } + + fn u16(&mut self) -> Result<u16, Error> { + Ok(u16::from_be_bytes(self.take_array()?)) + } + + fn u32(&mut self) -> Result<u32, Error> { + Ok(u32::from_be_bytes(self.take_array()?)) + } + + const fn is_empty(&self) -> bool { + self.remaining.is_empty() + } +} diff --git a/crates/secrets/src/error.rs b/crates/secrets/src/error.rs @@ -93,6 +93,44 @@ pub enum Error { /// Normalized operation that failed. operation: Operation, }, + /// Envelope data exceeded the package-wide bound. + EnvelopeTooLarge { + /// Observed byte length. + actual_bytes: usize, + /// Maximum accepted byte length. + max_bytes: usize, + }, + /// Envelope bytes were truncated or structurally invalid. + EnvelopeMalformed, + /// The encoded envelope version is not supported. + UnsupportedEnvelopeVersion { + /// Observed version number. + version: u16, + }, + /// The encoded cipher identifier is not supported. + UnsupportedCipher { + /// Observed cipher identifier. + cipher: u8, + }, + /// The encoded key-source identifier is not supported. + UnsupportedKeySource { + /// Observed key-source identifier. + key_source: u8, + }, + /// The encoded backend identifier is not supported. + UnsupportedBackend { + /// Observed backend identifier. + backend: u8, + }, + /// A data-encryption key had an invalid length. + InvalidDataKeyLength { + /// Observed byte length. + actual_bytes: usize, + }, + /// Authenticated encryption failed. + EncryptFailed, + /// Authentication or decryption failed. + DecryptFailed, } impl fmt::Display for SecretIdError { @@ -154,6 +192,42 @@ impl fmt::Display for Error { formatter, "secret backend {backend:?} failed during {operation:?}" ), + Self::EnvelopeTooLarge { + actual_bytes, + max_bytes, + } => write!( + formatter, + "encrypted envelope is too large: {actual_bytes} bytes; maximum is {max_bytes}" + ), + Self::EnvelopeMalformed => formatter.write_str("encrypted envelope is malformed"), + Self::UnsupportedEnvelopeVersion { version } => { + write!( + formatter, + "encrypted envelope version {version} is unsupported" + ) + } + Self::UnsupportedCipher { cipher } => { + write!( + formatter, + "encrypted envelope cipher {cipher} is unsupported" + ) + } + Self::UnsupportedKeySource { key_source } => write!( + formatter, + "encrypted envelope key source {key_source} is unsupported" + ), + Self::UnsupportedBackend { backend } => { + write!( + formatter, + "encrypted envelope backend {backend} is unsupported" + ) + } + Self::InvalidDataKeyLength { actual_bytes } => write!( + formatter, + "envelope data key must be 32 bytes; got {actual_bytes}" + ), + Self::EncryptFailed => formatter.write_str("encrypted envelope sealing failed"), + Self::DecryptFailed => formatter.write_str("encrypted envelope authentication failed"), } } } diff --git a/crates/secrets/src/id.rs b/crates/secrets/src/id.rs @@ -126,6 +126,27 @@ pub enum BackendKind { External, } +impl BackendKind { + pub(crate) const fn code(self) -> u8 { + match self { + Self::Memory => 1, + Self::File => 2, + Self::Keyring => 3, + Self::External => 4, + } + } + + pub(crate) const fn from_code(code: u8) -> Result<Self, Error> { + match code { + 1 => Ok(Self::Memory), + 2 => Ok(Self::File), + 3 => Ok(Self::Keyring), + 4 => Ok(Self::External), + backend => Err(Error::UnsupportedBackend { backend }), + } + } +} + /// A single-owner capability handle for a secret held by a provider. /// /// Cloning and ordinary serialization are intentionally unavailable. Debug diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs @@ -16,6 +16,7 @@ pub mod memory; pub mod provider; pub mod wrapping; +pub use envelope::EncryptedEnvelope; pub use error::Error; pub use id::{SecretId, SecretRef}; pub use provider::SecretProvider; diff --git a/crates/secrets/tests/envelope_contract.rs b/crates/secrets/tests/envelope_contract.rs @@ -0,0 +1,176 @@ +use futures_executor::block_on; +use radroots_secrets::envelope::{ + Cipher, ENVELOPE_VERSION, KeySource, Nonce, SealMaterial, SealRequest, +}; +use radroots_secrets::error::Operation; +use radroots_secrets::id::{BackendKind, KeyVersion}; +use radroots_secrets::wrapping::{ + BoxFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, +}; +use radroots_secrets::{EncryptedEnvelope, Error, KeyWrapping, SecretId, SecretRef}; + +struct VectorWrapping; + +impl KeyWrapping for VectorWrapping { + fn wrap<'a>(&'a self, request: WrapRequest<'a>) -> BoxFuture<'a, Result<WrappedSecret, Error>> { + Box::pin(async move { + if request.reference().id().as_str() != "envelope-key" { + return Err(Error::BackendFailure { + backend: BackendKind::Memory, + operation: Operation::Wrap, + }); + } + let wrapped = request + .plaintext() + .expose_secret(|bytes| bytes.iter().map(|byte| byte ^ 0x5A).collect::<Vec<_>>()); + WrappedSecret::from_bytes(wrapped) + }) + } + + fn unwrap<'a>( + &'a self, + request: UnwrapRequest<'a>, + ) -> BoxFuture<'a, Result<SecretMaterial, Error>> { + Box::pin(async move { + if request.reference().id().as_str() != "envelope-key" { + return Err(Error::BackendFailure { + backend: BackendKind::Memory, + operation: Operation::Unwrap, + }); + } + let plaintext = request + .wrapped() + .as_bytes() + .iter() + .map(|byte| byte ^ 0x5A) + .collect::<Vec<_>>(); + SecretMaterial::from_slice(plaintext.as_slice()) + }) + } +} + +fn reference() -> SecretRef { + SecretRef::new( + SecretId::parse("envelope-key").expect("valid id"), + BackendKind::Memory, + KeyVersion::new(7).expect("valid version"), + ) +} + +fn seal(plaintext: &[u8]) -> EncryptedEnvelope { + let plaintext = SecretMaterial::from_slice(plaintext).expect("plaintext"); + let data_key = SecretMaterial::from_slice(&[0x11; 32]).expect("data key"); + block_on(EncryptedEnvelope::seal( + &VectorWrapping, + SealRequest::new( + reference(), + &plaintext, + SealMaterial::new(data_key, Nonce::new([0x22; 24])), + ), + )) + .expect("seal") +} + +#[test] +fn deterministic_envelope_vector_round_trips() { + let envelope = seal(b"radroots envelope vector"); + assert_eq!(envelope.version(), ENVELOPE_VERSION); + assert_eq!(envelope.cipher(), Cipher::XChaCha20Poly1305); + assert_eq!(envelope.key_source(), KeySource::ProviderWrapped); + assert_eq!(envelope.reference().id().as_str(), "envelope-key"); + + let encoded = envelope.encode().expect("encode"); + assert_eq!( + hex::encode(&encoded), + "52525331000101010100000007000c656e76656c6f70652d6b6579222222222222222222222222222222222222222222222222000000204b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b00000028f106837e33d690e7c5287abdd815ce9257b7b5b176ea9596abf3b7fe745aec5a8c2487a553d4659d" + ); + let decoded = EncryptedEnvelope::decode(&encoded).expect("decode"); + let opened = block_on(decoded.open(&VectorWrapping)).expect("open"); + opened.expose_secret(|bytes| assert_eq!(bytes, b"radroots envelope vector")); +} + +#[test] +fn tamper_nonce_and_ciphertext_are_rejected() { + let encoded = seal(b"authenticated plaintext").encode().expect("encode"); + + let mut nonce_tamper = encoded.clone(); + let nonce_offset = 4 + 2 + 1 + 1 + 1 + 4 + 2 + "envelope-key".len(); + nonce_tamper[nonce_offset] ^= 0x01; + let envelope = EncryptedEnvelope::decode(&nonce_tamper).expect("decode nonce tamper"); + assert!(matches!( + block_on(envelope.open(&VectorWrapping)), + Err(Error::DecryptFailed) + )); + + let mut ciphertext_tamper = encoded; + let last = ciphertext_tamper.last_mut().expect("ciphertext byte"); + *last ^= 0x01; + let envelope = EncryptedEnvelope::decode(&ciphertext_tamper).expect("decode ciphertext tamper"); + assert!(matches!( + block_on(envelope.open(&VectorWrapping)), + Err(Error::DecryptFailed) + )); +} + +#[test] +fn wrong_key_slot_and_invalid_version_fail_closed() { + let encoded = seal(b"slot-bound plaintext").encode().expect("encode"); + let id_offset = 4 + 2 + 1 + 1 + 1 + 4 + 2; + let mut wrong_slot = encoded.clone(); + wrong_slot[id_offset] = b'x'; + let envelope = EncryptedEnvelope::decode(&wrong_slot).expect("decode slot tamper"); + assert!(matches!( + block_on(envelope.open(&VectorWrapping)), + Err(Error::BackendFailure { + backend: BackendKind::Memory, + operation: Operation::Unwrap, + }) + )); + + let mut bad_version = encoded; + bad_version[5] = 2; + assert!(matches!( + EncryptedEnvelope::decode(&bad_version), + Err(Error::UnsupportedEnvelopeVersion { version: 2 }) + )); +} + +#[test] +fn malformed_lengths_and_wrong_data_key_lengths_are_rejected() { + assert!(matches!( + EncryptedEnvelope::decode(b"short"), + Err(Error::EnvelopeMalformed) + )); + + let plaintext = SecretMaterial::from_slice(b"payload").expect("plaintext"); + let short_key = SecretMaterial::from_slice(&[0x11; 31]).expect("short key"); + assert!(matches!( + block_on(EncryptedEnvelope::seal( + &VectorWrapping, + SealRequest::new( + reference(), + &plaintext, + SealMaterial::new(short_key, Nonce::new([0x22; 24])), + ), + )), + Err(Error::InvalidDataKeyLength { actual_bytes: 31 }) + )); +} + +#[cfg(feature = "serde")] +#[test] +fn serde_uses_the_validated_binary_envelope() { + let envelope = seal(b"serde envelope"); + let json = serde_json::to_vec(&envelope).expect("serialize envelope"); + let decoded: EncryptedEnvelope = serde_json::from_slice(&json).expect("deserialize envelope"); + let opened = block_on(decoded.open(&VectorWrapping)).expect("open"); + opened.expose_secret(|bytes| assert_eq!(bytes, b"serde envelope")); +} + +#[test] +fn envelope_diagnostics_are_redacted() { + let diagnostic = format!("{:?}", seal(b"must-not-appear")); + assert!(diagnostic.contains("<redacted>")); + assert!(!diagnostic.contains("must-not-appear")); + assert!(!diagnostic.contains("envelope-key")); +} diff --git a/crates/secrets/tests/package_boundary.rs b/crates/secrets/tests/package_boundary.rs @@ -49,6 +49,7 @@ fn crate_root_contains_only_the_approved_module_skeleton() { .filter(|line| line.starts_with("pub use ")) .collect::<BTreeSet<_>>(), BTreeSet::from([ + "pub use envelope::EncryptedEnvelope;", "pub use error::Error;", "pub use id::{SecretId, SecretRef};", "pub use provider::SecretProvider;",