commit a7202e832a15f52984ffa12be920e1faac275d5e
parent edf3b360b3a3dc9f9655dc0aedea7dea0706de2b
Author: triesap <tyson@radroots.org>
Date: Sat, 1 Aug 2026 08:32:43 +0000
secrets: implement the memory secret adapter
- Add an empty-by-default memory provider with explicit provisioning and removal.
- Require exact provisioned material before producing deterministic reference tokens.
- Make rotation monotonic atomic and zeroize displaced owned material.
- Verify lifecycle, missing-key, rotation, redaction, clippy, no_std, wasm, and architecture.
Diffstat:
3 files changed, 378 insertions(+), 0 deletions(-)
diff --git a/crates/secrets/src/error.rs b/crates/secrets/src/error.rs
@@ -39,6 +39,12 @@ pub enum PolicyRequirement {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Operation {
+ /// Provision caller-supplied material.
+ Provision,
+ /// Rotate caller-supplied material.
+ Rotate,
+ /// Remove provider-owned material.
+ Remove,
/// Wrap plaintext key material.
Wrap,
/// Unwrap protected key material.
@@ -93,6 +99,22 @@ pub enum Error {
/// Normalized operation that failed.
operation: Operation,
},
+ /// The referenced provider-owned key was not found.
+ SecretNotFound {
+ /// Provider that did not contain the key.
+ backend: BackendKind,
+ /// Missing key revision.
+ key_version: u32,
+ },
+ /// The referenced provider-owned key already exists.
+ SecretAlreadyExists {
+ /// Provider that already contains the key.
+ backend: BackendKind,
+ /// Existing key revision.
+ key_version: u32,
+ },
+ /// A key rotation did not preserve identity or advance the version.
+ InvalidRotation,
/// Envelope data exceeded the package-wide bound.
EnvelopeTooLarge {
/// Observed byte length.
@@ -192,6 +214,23 @@ impl fmt::Display for Error {
formatter,
"secret backend {backend:?} failed during {operation:?}"
),
+ Self::SecretNotFound {
+ backend,
+ key_version,
+ } => write!(
+ formatter,
+ "secret backend {backend:?} has no key at version {key_version}"
+ ),
+ Self::SecretAlreadyExists {
+ backend,
+ key_version,
+ } => write!(
+ formatter,
+ "secret backend {backend:?} already has key version {key_version}"
+ ),
+ Self::InvalidRotation => {
+ formatter.write_str("secret rotation must preserve identity and advance version")
+ }
Self::EnvelopeTooLarge {
actual_bytes,
max_bytes,
diff --git a/crates/secrets/src/memory.rs b/crates/secrets/src/memory.rs
@@ -1 +1,221 @@
//! Deterministic in-process secret adapters.
+
+use crate::SecretRef;
+use crate::error::{Error, Operation};
+use crate::id::BackendKind;
+use crate::provider::{CapabilitySupport, ResidencySupport, SecretCapabilities, SecretProvider};
+use crate::wrapping::{
+ BoxFuture, KeyWrapping, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret,
+};
+use alloc::collections::BTreeMap;
+use alloc::string::{String, ToString};
+use alloc::vec::Vec;
+use core::fmt;
+use std::sync::RwLock;
+
+const TOKEN_MAGIC: &[u8] = b"radroots-memory-key-v1\0";
+
+/// Explicit-lifecycle, in-process provider for development and tests.
+#[derive(Default)]
+pub struct MemoryProvider {
+ entries: RwLock<BTreeMap<MemoryKey, SecretMaterial>>,
+}
+
+impl MemoryProvider {
+ /// Creates an empty provider without generating or installing keys.
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Installs caller-owned material at one exact reference.
+ pub fn provision(&self, reference: &SecretRef, material: SecretMaterial) -> Result<(), Error> {
+ validate_memory_reference(reference)?;
+ let key = MemoryKey::from_reference(reference);
+ let mut entries = self
+ .entries
+ .write()
+ .map_err(|_| backend_failure(Operation::Provision))?;
+ if entries.contains_key(&key) {
+ return Err(Error::SecretAlreadyExists {
+ backend: BackendKind::Memory,
+ key_version: reference.key_version().get(),
+ });
+ }
+ entries.insert(key, material);
+ Ok(())
+ }
+
+ /// Atomically replaces one version with a higher version of the same ID.
+ pub fn rotate(
+ &self,
+ current: &SecretRef,
+ next: &SecretRef,
+ material: SecretMaterial,
+ ) -> Result<(), Error> {
+ validate_memory_reference(current)?;
+ validate_memory_reference(next)?;
+ if current.id().as_str() != next.id().as_str()
+ || next.key_version() <= current.key_version()
+ {
+ return Err(Error::InvalidRotation);
+ }
+ let current_key = MemoryKey::from_reference(current);
+ let next_key = MemoryKey::from_reference(next);
+ let mut entries = self
+ .entries
+ .write()
+ .map_err(|_| backend_failure(Operation::Rotate))?;
+ if !entries.contains_key(¤t_key) {
+ return Err(not_found(current));
+ }
+ if entries.contains_key(&next_key) {
+ return Err(Error::SecretAlreadyExists {
+ backend: BackendKind::Memory,
+ key_version: next.key_version().get(),
+ });
+ }
+ entries.remove(¤t_key);
+ entries.insert(next_key, material);
+ Ok(())
+ }
+
+ /// Removes and zeroizes provider-owned material when present.
+ pub fn remove(&self, reference: &SecretRef) -> Result<bool, Error> {
+ validate_memory_reference(reference)?;
+ self.entries
+ .write()
+ .map(|mut entries| {
+ entries
+ .remove(&MemoryKey::from_reference(reference))
+ .is_some()
+ })
+ .map_err(|_| backend_failure(Operation::Remove))
+ }
+
+ /// Reports whether one exact reference is provisioned.
+ pub fn contains(&self, reference: &SecretRef) -> Result<bool, Error> {
+ validate_memory_reference(reference)?;
+ self.entries
+ .read()
+ .map(|entries| entries.contains_key(&MemoryKey::from_reference(reference)))
+ .map_err(|_| backend_failure(Operation::Unwrap))
+ }
+
+ fn wrapped_token(reference: &SecretRef) -> Result<WrappedSecret, Error> {
+ let id = reference.id().as_str().as_bytes();
+ let mut token = Vec::with_capacity(TOKEN_MAGIC.len() + 4 + 2 + id.len());
+ 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);
+ WrappedSecret::from_bytes(token)
+ }
+
+ fn clone_material(
+ &self,
+ reference: &SecretRef,
+ operation: Operation,
+ ) -> Result<SecretMaterial, Error> {
+ let entries = self
+ .entries
+ .read()
+ .map_err(|_| backend_failure(operation))?;
+ let material = entries
+ .get(&MemoryKey::from_reference(reference))
+ .ok_or_else(|| not_found(reference))?;
+ material.expose_secret(SecretMaterial::from_slice)
+ }
+}
+
+impl fmt::Debug for MemoryProvider {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MemoryProvider(<redacted>)")
+ }
+}
+
+impl KeyWrapping for MemoryProvider {
+ fn wrap<'a>(&'a self, request: WrapRequest<'a>) -> BoxFuture<'a, Result<WrappedSecret, Error>> {
+ Box::pin(async move {
+ validate_memory_reference(request.reference())?;
+ let provisioned = self.clone_material(request.reference(), Operation::Wrap)?;
+ let matches = provisioned.expose_secret(|expected| {
+ request
+ .plaintext()
+ .expose_secret(|actual| expected == actual)
+ });
+ if !matches {
+ return Err(backend_failure(Operation::Wrap));
+ }
+ Self::wrapped_token(request.reference())
+ })
+ }
+
+ fn unwrap<'a>(
+ &'a self,
+ request: UnwrapRequest<'a>,
+ ) -> BoxFuture<'a, Result<SecretMaterial, Error>> {
+ Box::pin(async move {
+ validate_memory_reference(request.reference())?;
+ let expected = Self::wrapped_token(request.reference())?;
+ if expected.as_bytes() != request.wrapped().as_bytes() {
+ return Err(backend_failure(Operation::Unwrap));
+ }
+ self.clone_material(request.reference(), Operation::Unwrap)
+ })
+ }
+}
+
+impl SecretProvider for MemoryProvider {
+ fn backend_kind(&self) -> BackendKind {
+ BackendKind::Memory
+ }
+
+ fn capabilities(&self) -> SecretCapabilities {
+ SecretCapabilities::available(
+ ResidencySupport::Volatile,
+ CapabilitySupport::Unavailable,
+ CapabilitySupport::Unavailable,
+ )
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+struct MemoryKey {
+ id: String,
+ version: u32,
+}
+
+impl MemoryKey {
+ fn from_reference(reference: &SecretRef) -> Self {
+ Self {
+ id: reference.id().as_str().to_string(),
+ version: reference.key_version().get(),
+ }
+ }
+}
+
+fn validate_memory_reference(reference: &SecretRef) -> Result<(), Error> {
+ if reference.backend() != BackendKind::Memory {
+ return Err(Error::BackendMismatch {
+ provider: BackendKind::Memory,
+ reference: reference.backend(),
+ });
+ }
+ Ok(())
+}
+
+const fn backend_failure(operation: Operation) -> Error {
+ Error::BackendFailure {
+ backend: BackendKind::Memory,
+ operation,
+ }
+}
+
+fn not_found(reference: &SecretRef) -> Error {
+ Error::SecretNotFound {
+ backend: BackendKind::Memory,
+ key_version: reference.key_version().get(),
+ }
+}
diff --git a/crates/secrets/tests/memory_adapter.rs b/crates/secrets/tests/memory_adapter.rs
@@ -0,0 +1,119 @@
+#![cfg(feature = "memory")]
+
+use futures_executor::block_on;
+use radroots_secrets::id::{BackendKind, KeyVersion};
+use radroots_secrets::memory::MemoryProvider;
+use radroots_secrets::wrapping::{SecretMaterial, UnwrapRequest, WrapRequest};
+use radroots_secrets::{Error, KeyWrapping, SecretId, SecretProvider, SecretRef};
+
+fn reference(id: &str, version: u32) -> SecretRef {
+ SecretRef::new(
+ SecretId::parse(id).expect("valid id"),
+ BackendKind::Memory,
+ KeyVersion::new(version).expect("valid version"),
+ )
+}
+
+#[test]
+fn memory_provider_has_explicit_lifecycle_and_round_trips() {
+ let provider = MemoryProvider::new();
+ let reference = reference("memory-key", 1);
+ assert!(!provider.contains(&reference).expect("contains"));
+
+ provider
+ .provision(
+ &reference,
+ SecretMaterial::from_slice(&[0x41; 32]).expect("material"),
+ )
+ .expect("provision");
+ assert!(provider.contains(&reference).expect("contains"));
+
+ let plaintext = SecretMaterial::from_slice(&[0x41; 32]).expect("material");
+ let wrapped = block_on(provider.wrap(WrapRequest::new(&reference, &plaintext))).expect("wrap");
+ let opened =
+ block_on(provider.unwrap(UnwrapRequest::new(&reference, &wrapped))).expect("unwrap");
+ opened.expose_secret(|bytes| assert_eq!(bytes, &[0x41; 32]));
+
+ assert!(provider.remove(&reference).expect("remove"));
+ assert!(!provider.remove(&reference).expect("idempotent remove"));
+ assert!(!provider.contains(&reference).expect("contains"));
+}
+
+#[test]
+fn missing_and_mismatched_material_fail_closed() {
+ let provider = MemoryProvider::new();
+ let reference = reference("missing-key", 1);
+ let plaintext = SecretMaterial::from_slice(&[0x11; 32]).expect("material");
+ assert!(matches!(
+ block_on(provider.wrap(WrapRequest::new(&reference, &plaintext))),
+ Err(Error::SecretNotFound {
+ backend: BackendKind::Memory,
+ key_version: 1,
+ })
+ ));
+
+ provider
+ .provision(
+ &reference,
+ SecretMaterial::from_slice(&[0x22; 32]).expect("material"),
+ )
+ .expect("provision");
+ assert!(matches!(
+ block_on(provider.wrap(WrapRequest::new(&reference, &plaintext))),
+ Err(Error::BackendFailure { .. })
+ ));
+ assert!(matches!(
+ provider.provision(
+ &reference,
+ SecretMaterial::from_slice(&[0x33; 32]).expect("material"),
+ ),
+ Err(Error::SecretAlreadyExists {
+ backend: BackendKind::Memory,
+ key_version: 1,
+ })
+ ));
+}
+
+#[test]
+fn rotation_is_monotonic_atomic_and_invalidates_the_old_version() {
+ let provider = MemoryProvider::new();
+ let current = reference("rotating-key", 1);
+ let next = reference("rotating-key", 2);
+ provider
+ .provision(
+ ¤t,
+ SecretMaterial::from_slice(&[0x11; 32]).expect("material"),
+ )
+ .expect("provision");
+ provider
+ .rotate(
+ ¤t,
+ &next,
+ SecretMaterial::from_slice(&[0x22; 32]).expect("material"),
+ )
+ .expect("rotate");
+
+ assert!(!provider.contains(¤t).expect("old contains"));
+ assert!(provider.contains(&next).expect("new contains"));
+ let new_material = SecretMaterial::from_slice(&[0x22; 32]).expect("material");
+ assert!(block_on(provider.wrap(WrapRequest::new(&next, &new_material))).is_ok());
+
+ let invalid = reference("different-key", 3);
+ assert_eq!(
+ provider.rotate(
+ &next,
+ &invalid,
+ SecretMaterial::from_slice(&[0x33; 32]).expect("material"),
+ ),
+ Err(Error::InvalidRotation)
+ );
+ assert!(provider.contains(&next).expect("rotation stayed atomic"));
+}
+
+#[test]
+fn provider_capabilities_and_diagnostics_are_explicit() {
+ let provider = MemoryProvider::new();
+ assert_eq!(provider.backend_kind(), BackendKind::Memory);
+ assert!(provider.capabilities().is_available());
+ assert_eq!(format!("{provider:?}"), "MemoryProvider(<redacted>)");
+}