lib

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

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:
Mcrates/secrets/src/error.rs | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/memory.rs | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/secrets/tests/memory_adapter.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(&current_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(&current_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( + &current, + SecretMaterial::from_slice(&[0x11; 32]).expect("material"), + ) + .expect("provision"); + provider + .rotate( + &current, + &next, + SecretMaterial::from_slice(&[0x22; 32]).expect("material"), + ) + .expect("rotate"); + + assert!(!provider.contains(&current).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>)"); +}