lib

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

commit 800adb92c4bf3569ac225b77e0a5986b502ed3fb
parent a82499a6523ee68a224f32588a5e4f98b47a2fce
Author: triesap <tyson@radroots.org>
Date:   Wed,  5 Aug 2026 18:04:07 +0000

refactor(secrets): bind providers to envelope context

Diffstat:
Mcrates/secrets/src/envelope.rs | 1+
Mcrates/secrets/src/error.rs | 2++
Mcrates/secrets/src/file.rs | 12++++++++++--
Mcrates/secrets/src/keyring.rs | 31+++++++++++++++++++++++++++++--
Mcrates/secrets/src/memory.rs | 12++++++++----
Mcrates/secrets/src/wrapping.rs | 1+
Mcrates/secrets/tests/file_adapter.rs | 32++++++++++++++++++++++++++++++++
Mcrates/secrets/tests/memory_adapter.rs | 34++++++++++++++++++++++++++++++++++
Mcrates/secrets/tests/provider_contract.rs | 51+++++++++++++++++++++++++++++++++++++++++++++------
9 files changed, 162 insertions(+), 14 deletions(-)

diff --git a/crates/secrets/src/envelope.rs b/crates/secrets/src/envelope.rs @@ -566,6 +566,7 @@ impl<'a> Decoder<'a> { #[cfg(test)] mod tests { use super::*; + use alloc::vec; fn context() -> EnvelopeContext { EnvelopeContext::new( diff --git a/crates/secrets/src/error.rs b/crates/secrets/src/error.rs @@ -372,6 +372,8 @@ impl std::error::Error for Error {} #[cfg(test)] mod tests { use super::*; + use alloc::format; + use alloc::string::ToString; #[test] fn every_normalized_error_has_a_secret_safe_message() { diff --git a/crates/secrets/src/file.rs b/crates/secrets/src/file.rs @@ -293,7 +293,7 @@ impl KeyWrapping for FileProvider { if !matches { return Err(backend_failure(Operation::Wrap)); } - WrappedSecret::from_bytes(entry_token(request.reference())) + WrappedSecret::from_bytes(wrapping_token(request.reference(), request.context())) }) } @@ -303,7 +303,9 @@ impl KeyWrapping for FileProvider { ) -> BoxFuture<'a, Result<SecretMaterial, Error>> { Box::pin(async move { validate_file_reference(request.reference())?; - if request.wrapped().as_bytes() != entry_token(request.reference()).as_slice() { + if request.wrapped().as_bytes() + != wrapping_token(request.reference(), request.context()).as_slice() + { return Err(backend_failure(Operation::Unwrap)); } self.read_entry(request.reference()) @@ -414,6 +416,12 @@ fn entry_token(reference: &SecretRef) -> Vec<u8> { token } +fn wrapping_token(reference: &SecretRef, context: &crate::context::EnvelopeContext) -> Vec<u8> { + let mut token = entry_token(reference); + token.extend_from_slice(&context.authentication_digest()); + token +} + fn entry_aad(reference: &SecretRef) -> Vec<u8> { entry_token(reference) } diff --git a/crates/secrets/src/keyring.rs b/crates/secrets/src/keyring.rs @@ -147,7 +147,7 @@ impl KeyWrapping for KeyringProvider { if !matches { return Err(backend_failure(Operation::Wrap)); } - WrappedSecret::from_bytes(reference_token(request.reference())) + WrappedSecret::from_bytes(wrapping_token(request.reference(), request.context())) }) } @@ -157,7 +157,9 @@ impl KeyWrapping for KeyringProvider { ) -> BoxFuture<'a, Result<SecretMaterial, Error>> { Box::pin(async move { validate_keyring_reference(request.reference())?; - if request.wrapped().as_bytes() != reference_token(request.reference()).as_slice() { + if request.wrapped().as_bytes() + != wrapping_token(request.reference(), request.context()).as_slice() + { return Err(backend_failure(Operation::Unwrap)); } self.read_material(request.reference()) @@ -165,6 +167,12 @@ impl KeyWrapping for KeyringProvider { } } +fn wrapping_token(reference: &SecretRef, context: &crate::context::EnvelopeContext) -> Vec<u8> { + let mut token = reference_token(reference); + token.extend_from_slice(&context.authentication_digest()); + token +} + impl SecretProvider for KeyringProvider { fn backend_kind(&self) -> BackendKind { BackendKind::Keyring @@ -397,6 +405,14 @@ mod tests { ) } + fn different_context() -> EnvelopeContext { + EnvelopeContext::new( + EnvelopePurpose::parse("radroots.keyring_test").expect("purpose"), + EnvelopeSubject::parse("keyring_test", "different").expect("subject"), + PayloadSchemaId::parse("radroots.keyring_test.v1").expect("schema"), + ) + } + #[test] fn construction_has_no_store_side_effect() { let store = MockStore::default(); @@ -441,6 +457,17 @@ mod tests { ) .expect("unwrap"); opened.expose_secret(|bytes| assert_eq!(bytes, &[0x11; 32])); + assert!(matches!( + futures_executor::block_on(provider.unwrap(UnwrapRequest::new( + &current, + &different_context(), + &wrapped, + ))), + Err(Error::BackendFailure { + backend: BackendKind::Keyring, + operation: Operation::Unwrap, + }) + )); provider .rotate(&current, &next, &next_material) diff --git a/crates/secrets/src/memory.rs b/crates/secrets/src/memory.rs @@ -102,14 +102,18 @@ impl MemoryProvider { .map_err(|_| backend_failure(Operation::Unwrap)) } - fn wrapped_token(reference: &SecretRef) -> Result<WrappedSecret, Error> { + fn wrapped_token( + reference: &SecretRef, + context: &crate::context::EnvelopeContext, + ) -> Result<WrappedSecret, Error> { let id = reference.id().as_str().as_bytes(); - let mut token = Vec::with_capacity(TOKEN_MAGIC.len() + 4 + 2 + id.len()); + let mut token = Vec::with_capacity(TOKEN_MAGIC.len() + 4 + 2 + id.len() + 32); token.extend_from_slice(TOKEN_MAGIC); token.extend_from_slice(&reference.key_version().get().to_be_bytes()); let id_len = u16::try_from(id.len()).map_err(|_| backend_failure(Operation::Wrap))?; token.extend_from_slice(&id_len.to_be_bytes()); token.extend_from_slice(id); + token.extend_from_slice(&context.authentication_digest()); WrappedSecret::from_bytes(token) } @@ -148,7 +152,7 @@ impl KeyWrapping for MemoryProvider { if !matches { return Err(backend_failure(Operation::Wrap)); } - Self::wrapped_token(request.reference()) + Self::wrapped_token(request.reference(), request.context()) }) } @@ -158,7 +162,7 @@ impl KeyWrapping for MemoryProvider { ) -> BoxFuture<'a, Result<SecretMaterial, Error>> { Box::pin(async move { validate_memory_reference(request.reference())?; - let expected = Self::wrapped_token(request.reference())?; + let expected = Self::wrapped_token(request.reference(), request.context())?; if expected.as_bytes() != request.wrapped().as_bytes() { return Err(backend_failure(Operation::Unwrap)); } diff --git a/crates/secrets/src/wrapping.rs b/crates/secrets/src/wrapping.rs @@ -213,6 +213,7 @@ mod tests { use super::{ SECRET_MATERIAL_MAX_BYTES, SecretMaterial, WRAPPED_SECRET_MAX_BYTES, WrappedSecret, }; + use alloc::vec; use alloc::vec::Vec; use zeroize::Zeroize; diff --git a/crates/secrets/tests/file_adapter.rs b/crates/secrets/tests/file_adapter.rs @@ -27,6 +27,14 @@ fn context() -> EnvelopeContext { ) } +fn different_context() -> EnvelopeContext { + EnvelopeContext::new( + EnvelopePurpose::parse("radroots.file_test").expect("purpose"), + EnvelopeSubject::parse("file_test", "different").expect("subject"), + PayloadSchemaId::parse("radroots.file_test.v1").expect("schema"), + ) +} + fn provider(root: &std::path::Path, mode: FileOpenMode) -> FileProvider { FileProvider::open( root, @@ -122,6 +130,30 @@ fn rotation_resumes_after_new_version_commit_and_removes_old_version() { assert!(block_on(provider.unwrap(UnwrapRequest::new(&next, &context, &wrapped))).is_ok()); } +#[test] +fn wrapped_tokens_are_bound_to_the_exact_context() { + let temp = tempfile::tempdir().expect("tempdir"); + let provider = provider(&temp.path().join("vault"), FileOpenMode::CreateNew); + let reference = reference("context-key", 1); + let material = SecretMaterial::from_slice(&[0x41; 32]).expect("material"); + provider + .provision(&reference, &material, Nonce::new([0x11; 24])) + .expect("provision"); + let wrapped = + block_on(provider.wrap(WrapRequest::new(&reference, &context(), &material))).expect("wrap"); + assert!(matches!( + block_on(provider.unwrap(UnwrapRequest::new( + &reference, + &different_context(), + &wrapped, + ))), + Err(Error::BackendFailure { + backend: BackendKind::File, + .. + }) + )); +} + #[cfg(unix)] #[test] fn symlink_entries_and_insecure_permissions_are_rejected() { diff --git a/crates/secrets/tests/memory_adapter.rs b/crates/secrets/tests/memory_adapter.rs @@ -25,6 +25,14 @@ fn context() -> EnvelopeContext { ) } +fn different_context() -> EnvelopeContext { + EnvelopeContext::new( + EnvelopePurpose::parse("radroots.memory_test").expect("purpose"), + EnvelopeSubject::parse("memory_test", "different").expect("subject"), + PayloadSchemaId::parse("radroots.memory_test.v1").expect("schema"), + ) +} + #[test] fn memory_provider_has_explicit_lifecycle_and_round_trips() { let provider = MemoryProvider::new(); @@ -130,3 +138,29 @@ fn provider_capabilities_and_diagnostics_are_explicit() { assert!(provider.capabilities().is_available()); assert_eq!(format!("{provider:?}"), "MemoryProvider(<redacted>)"); } + +#[test] +fn wrapped_tokens_are_bound_to_the_exact_context() { + let provider = MemoryProvider::new(); + let reference = reference("context-key", 1); + let material = SecretMaterial::from_slice(&[0x41; 32]).expect("material"); + provider + .provision( + &reference, + SecretMaterial::from_slice(&[0x41; 32]).expect("material"), + ) + .expect("provision"); + let wrapped = + block_on(provider.wrap(WrapRequest::new(&reference, &context(), &material))).expect("wrap"); + assert!(matches!( + block_on(provider.unwrap(UnwrapRequest::new( + &reference, + &different_context(), + &wrapped, + ))), + Err(Error::BackendFailure { + backend: BackendKind::Memory, + .. + }) + )); +} diff --git a/crates/secrets/tests/provider_contract.rs b/crates/secrets/tests/provider_contract.rs @@ -29,9 +29,10 @@ impl KeyWrapping for MockProvider { operation: Operation::Wrap, }); } - let encoded = request.plaintext().expose_secret(|plaintext| { + let mut encoded = Vec::from(request.context().authentication_digest()); + encoded.extend(request.plaintext().expose_secret(|plaintext| { plaintext.iter().map(|byte| byte ^ 0xA5).collect::<Vec<_>>() - }); + })); WrappedSecret::from_bytes(encoded) }) } @@ -48,12 +49,22 @@ impl KeyWrapping for MockProvider { operation: Operation::Unwrap, }); } - let decoded = request + let (context_digest, wrapped) = request .wrapped() .as_bytes() - .iter() - .map(|byte| byte ^ 0xA5) - .collect::<Vec<_>>(); + .split_at_checked(32) + .ok_or(Error::BackendFailure { + backend: self.backend, + operation: Operation::Unwrap, + })?; + let expected_digest = request.context().authentication_digest(); + if context_digest != expected_digest { + return Err(Error::BackendFailure { + backend: self.backend, + operation: Operation::Unwrap, + }); + } + let decoded = wrapped.iter().map(|byte| byte ^ 0xA5).collect::<Vec<_>>(); SecretMaterial::from_slice(decoded.as_slice()) }) } @@ -263,3 +274,31 @@ fn reference_backend_mismatch_fails_before_wrapping() { }) ); } + +#[test] +fn external_wrapping_contract_rejects_cross_context_copy() { + let provider = provider( + BackendKind::Memory, + SecretCapabilities::available( + ResidencySupport::Volatile, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ), + ); + let reference = reference(BackendKind::Memory); + let plaintext = SecretMaterial::from_slice(b"data-key").expect("material"); + let wrapped = block_on(provider.wrap(WrapRequest::new(&reference, &context(), &plaintext))) + .expect("wrap"); + let different = EnvelopeContext::new( + EnvelopePurpose::parse("radroots.provider_test").expect("purpose"), + EnvelopeSubject::parse("provider_test", "different").expect("subject"), + PayloadSchemaId::parse("radroots.provider_test.v1").expect("schema"), + ); + assert!(matches!( + block_on(provider.unwrap(UnwrapRequest::new(&reference, &different, &wrapped,))), + Err(Error::BackendFailure { + backend: BackendKind::Memory, + operation: Operation::Unwrap, + }) + )); +}