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:
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(
+ ¤t,
+ &different_context(),
+ &wrapped,
+ ))),
+ Err(Error::BackendFailure {
+ backend: BackendKind::Keyring,
+ operation: Operation::Unwrap,
+ })
+ ));
provider
.rotate(¤t, &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,
+ })
+ ));
+}