lib

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

commit 801d208f950dd4b57b6fb4393897c6dd99c8a070
parent 411130bc045b27b4bcdd2078fd45c366d050dac9
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 07:53:53 +0000

secrets: move provider and key-wrapping SPIs

- Add dyn-compatible executor-neutral provider and wrapping contracts.
- Require exact backend selection with typed residency and hardware policy.
- Contain plaintext in a redacted single-owner zeroizing value.
- Verify mock conformance, error normalization, clippy, no_std, wasm, and architecture.

Diffstat:
MCargo.lock | 2++
Mcrates/secrets/Cargo.toml | 2++
Mcrates/secrets/src/error.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/lib.rs | 2++
Mcrates/secrets/src/provider.rs | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/src/wrapping.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/tests/package_boundary.rs | 4+++-
Acrates/secrets/tests/provider_contract.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 750 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4972,8 +4972,10 @@ dependencies = [ name = "radroots_secrets" version = "0.1.0-alpha" dependencies = [ + "futures-executor", "serde", "serde_json", + "zeroize", ] [[package]] diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml @@ -28,8 +28,10 @@ serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ], optional = true } +zeroize = { workspace = true } [dev-dependencies] +futures-executor = { workspace = true } serde_json = { workspace = true, features = ["std"] } [lints] diff --git a/crates/secrets/src/error.rs b/crates/secrets/src/error.rs @@ -1,5 +1,6 @@ //! Normalized secret-operation errors. +use crate::id::BackendKind; use core::fmt; /// Why a [`crate::SecretId`] failed validation. @@ -22,6 +23,28 @@ pub enum SecretIdError { }, } +/// A security property requested by a host but unsupported by a provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PolicyRequirement { + /// The secret must remain device-local. + DeviceLocal, + /// The provider must require user presence. + UserPresence, + /// The provider must use hardware-backed protection. + HardwareBacked, +} + +/// A normalized provider operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Operation { + /// Wrap plaintext key material. + Wrap, + /// Unwrap protected key material. + Unwrap, +} + /// A normalized, secret-safe package failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -30,6 +53,46 @@ pub enum Error { InvalidSecretId(SecretIdError), /// Key versions start at one; zero is never a valid version. InvalidKeyVersion, + /// Secret material was empty or exceeded the bounded input limit. + InvalidSecretLength { + /// Observed byte length. + actual_bytes: usize, + /// Maximum accepted byte length. + max_bytes: usize, + }, + /// Wrapped material was empty or exceeded the bounded input limit. + InvalidWrappedLength { + /// Observed byte length. + actual_bytes: usize, + /// Maximum accepted byte length. + max_bytes: usize, + }, + /// No explicitly selected provider was available. + BackendUnavailable { + /// Requested adapter family. + backend: BackendKind, + }, + /// A provider cannot satisfy a required security property. + PolicyUnsupported { + /// Provider that rejected the policy. + backend: BackendKind, + /// Unsupported property. + requirement: PolicyRequirement, + }, + /// A reference was sent to the wrong provider family. + BackendMismatch { + /// Provider selected by the host. + provider: BackendKind, + /// Provider recorded by the reference. + reference: BackendKind, + }, + /// A provider operation failed without exposing its native diagnostic. + BackendFailure { + /// Provider that failed. + backend: BackendKind, + /// Normalized operation that failed. + operation: Operation, + }, } impl fmt::Display for SecretIdError { @@ -56,6 +119,41 @@ impl fmt::Display for Error { match self { Self::InvalidSecretId(reason) => reason.fmt(formatter), Self::InvalidKeyVersion => formatter.write_str("secret key version must be non-zero"), + Self::InvalidSecretLength { + actual_bytes, + max_bytes, + } => write!( + formatter, + "secret material length is invalid: {actual_bytes} bytes; maximum is {max_bytes}" + ), + Self::InvalidWrappedLength { + actual_bytes, + max_bytes, + } => write!( + formatter, + "wrapped material length is invalid: {actual_bytes} bytes; maximum is {max_bytes}" + ), + Self::BackendUnavailable { backend } => { + write!(formatter, "secret backend {backend:?} is unavailable") + } + Self::PolicyUnsupported { + backend, + requirement, + } => write!( + formatter, + "secret backend {backend:?} does not satisfy {requirement:?}" + ), + Self::BackendMismatch { + provider, + reference, + } => write!( + formatter, + "secret reference backend {reference:?} does not match provider {provider:?}" + ), + Self::BackendFailure { backend, operation } => write!( + formatter, + "secret backend {backend:?} failed during {operation:?}" + ), } } } diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs @@ -18,3 +18,5 @@ pub mod wrapping; pub use error::Error; pub use id::{SecretId, SecretRef}; +pub use provider::SecretProvider; +pub use wrapping::KeyWrapping; diff --git a/crates/secrets/src/provider.rs b/crates/secrets/src/provider.rs @@ -1 +1,227 @@ //! Secret-provider contracts and capability selection. + +use crate::error::{Error, PolicyRequirement}; +use crate::id::BackendKind; +use crate::wrapping::KeyWrapping; + +/// Secret residency required by a host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResidencyPolicy { + /// The provider may use its normal host-selected residency. + Any, + /// The provider must keep material local to the current device. + DeviceLocal, +} + +/// User-presence behavior required by a host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum UserPresencePolicy { + /// User presence is not required for the operation. + NotRequired, + /// The provider must require user presence. + Required, +} + +/// Hardware-backed behavior requested by a host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum HardwarePolicy { + /// Hardware-backed protection is not required. + Any, + /// Prefer hardware-backed protection when available. + PreferHardwareBacked, + /// Hardware-backed protection is mandatory. + RequireHardwareBacked, +} + +/// Provider residency support. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResidencySupport { + /// Volatile process-local storage. + Volatile, + /// Persistent storage associated with the host user profile. + UserProfile, + /// Persistent storage restricted to the current device. + DeviceLocal, +} + +/// Whether a provider supports an optional security property. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilitySupport { + /// The property is unavailable. + Unavailable, + /// The property is supported. + Supported, +} + +/// Explicit host security requirements for provider selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AccessPolicy { + residency: ResidencyPolicy, + user_presence: UserPresencePolicy, + hardware: HardwarePolicy, +} + +impl AccessPolicy { + /// Creates an explicit access policy. + #[must_use] + pub const fn new( + residency: ResidencyPolicy, + user_presence: UserPresencePolicy, + hardware: HardwarePolicy, + ) -> Self { + Self { + residency, + user_presence, + hardware, + } + } + + /// Returns a policy suitable for an explicitly selected local adapter. + #[must_use] + pub const fn standard() -> Self { + Self::new( + ResidencyPolicy::Any, + UserPresencePolicy::NotRequired, + HardwarePolicy::Any, + ) + } +} + +/// Security properties reported by a provider without performing access. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SecretCapabilities { + available: bool, + residency: ResidencySupport, + user_presence: CapabilitySupport, + hardware_backed: CapabilitySupport, +} + +impl SecretCapabilities { + /// Reports an unavailable provider without probing or mutating it. + #[must_use] + pub const fn unavailable() -> Self { + Self { + available: false, + residency: ResidencySupport::Volatile, + user_presence: CapabilitySupport::Unavailable, + hardware_backed: CapabilitySupport::Unavailable, + } + } + + /// Reports the static security properties of an available provider. + #[must_use] + pub const fn available( + residency: ResidencySupport, + user_presence: CapabilitySupport, + hardware_backed: CapabilitySupport, + ) -> Self { + Self { + available: true, + residency, + user_presence, + hardware_backed, + } + } + + /// Returns whether the provider is available for explicit selection. + #[must_use] + pub const fn is_available(self) -> bool { + self.available + } + + /// Returns the strongest residency guarantee reported by the provider. + #[must_use] + pub const fn residency(self) -> ResidencySupport { + self.residency + } + + /// Returns user-presence support. + #[must_use] + pub const fn user_presence(self) -> CapabilitySupport { + self.user_presence + } + + /// Returns hardware-backed protection support. + #[must_use] + pub const fn hardware_backed(self) -> CapabilitySupport { + self.hardware_backed + } + + fn validate(self, backend: BackendKind, policy: AccessPolicy) -> Result<(), Error> { + if !self.available { + return Err(Error::BackendUnavailable { backend }); + } + if matches!(policy.residency, ResidencyPolicy::DeviceLocal) + && !matches!(self.residency, ResidencySupport::DeviceLocal) + { + return Err(Error::PolicyUnsupported { + backend, + requirement: PolicyRequirement::DeviceLocal, + }); + } + if matches!(policy.user_presence, UserPresencePolicy::Required) + && !matches!(self.user_presence, CapabilitySupport::Supported) + { + return Err(Error::PolicyUnsupported { + backend, + requirement: PolicyRequirement::UserPresence, + }); + } + if matches!(policy.hardware, HardwarePolicy::RequireHardwareBacked) + && !matches!(self.hardware_backed, CapabilitySupport::Supported) + { + return Err(Error::PolicyUnsupported { + backend, + requirement: PolicyRequirement::HardwareBacked, + }); + } + Ok(()) + } +} + +/// A wrapping provider selected and owned by the host. +pub trait SecretProvider: KeyWrapping + Send + Sync { + /// Returns the adapter family implemented by this provider. + fn backend_kind(&self) -> BackendKind; + + /// Reports capabilities without accessing secret storage. + fn capabilities(&self) -> SecretCapabilities; +} + +/// Exact provider selection with no implicit fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionPolicy { + backend: BackendKind, + access: AccessPolicy, +} + +impl SelectionPolicy { + /// Selects one backend family and its mandatory security properties. + #[must_use] + pub const fn new(backend: BackendKind, access: AccessPolicy) -> Self { + Self { backend, access } + } + + /// Resolves the exact provider without probing a fallback backend. + pub fn select<'a>( + self, + candidates: &'a [&'a dyn SecretProvider], + ) -> Result<&'a dyn SecretProvider, Error> { + let provider = candidates + .iter() + .copied() + .find(|candidate| candidate.backend_kind() == self.backend) + .ok_or(Error::BackendUnavailable { + backend: self.backend, + })?; + provider + .capabilities() + .validate(self.backend, self.access)?; + Ok(provider) + } +} diff --git a/crates/secrets/src/wrapping.rs b/crates/secrets/src/wrapping.rs @@ -1 +1,166 @@ //! Data-key wrapping contracts. + +use crate::SecretRef; +use crate::error::Error; +use alloc::boxed::Box; +use alloc::vec::Vec; +use core::fmt; +use core::future::Future; +use core::pin::Pin; +use zeroize::Zeroize; + +/// Maximum plaintext accepted by the generic wrapping boundary. +pub const SECRET_MATERIAL_MAX_BYTES: usize = 64 * 1024; +/// Maximum protected value accepted by the generic wrapping boundary. +pub const WRAPPED_SECRET_MAX_BYTES: usize = 128 * 1024; + +/// A sendable provider future that does not prescribe an executor. +pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; + +/// Opaque, single-owner plaintext material that zeroizes on drop. +/// +/// This type never implements `Clone` or `Serialize`, and its diagnostics are +/// always redacted. Callers must opt in to the narrow [`Self::expose_secret`] +/// scope when invoking cryptographic code. +pub struct SecretMaterial(Vec<u8>); + +impl SecretMaterial { + /// Copies caller-supplied material into a zeroizing owner. + pub fn from_slice(bytes: &[u8]) -> Result<Self, Error> { + if bytes.is_empty() || bytes.len() > SECRET_MATERIAL_MAX_BYTES { + return Err(Error::InvalidSecretLength { + actual_bytes: bytes.len(), + max_bytes: SECRET_MATERIAL_MAX_BYTES, + }); + } + Ok(Self(bytes.to_vec())) + } + + /// Exposes plaintext only for the lifetime of an explicit closure call. + pub fn expose_secret<T>(&self, use_secret: impl FnOnce(&[u8]) -> T) -> T { + use_secret(self.0.as_slice()) + } + + /// Returns the plaintext length without exposing its contents. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether the value is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for SecretMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretMaterial(<redacted>)") + } +} + +impl Drop for SecretMaterial { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// Opaque provider-wrapped material safe for persistence but not diagnostics. +#[derive(Clone, PartialEq, Eq)] +pub struct WrappedSecret(Vec<u8>); + +impl WrappedSecret { + /// Validates and owns provider-wrapped material. + pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self, Error> { + let bytes = bytes.into(); + if bytes.is_empty() || bytes.len() > WRAPPED_SECRET_MAX_BYTES { + return Err(Error::InvalidWrappedLength { + actual_bytes: bytes.len(), + max_bytes: WRAPPED_SECRET_MAX_BYTES, + }); + } + Ok(Self(bytes)) + } + + /// Returns the wrapped representation for envelope persistence. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl fmt::Debug for WrappedSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("WrappedSecret(<redacted>)") + } +} + +/// Borrowed input for one key-wrapping operation. +#[derive(Debug, Clone, Copy)] +pub struct WrapRequest<'a> { + reference: &'a SecretRef, + plaintext: &'a SecretMaterial, +} + +impl<'a> WrapRequest<'a> { + /// Creates an explicit wrapping request. + #[must_use] + pub const fn new(reference: &'a SecretRef, plaintext: &'a SecretMaterial) -> Self { + Self { + reference, + plaintext, + } + } + + /// Returns the provider capability reference. + #[must_use] + pub const fn reference(&self) -> &'a SecretRef { + self.reference + } + + /// Returns the single-owner plaintext wrapper. + #[must_use] + pub const fn plaintext(&self) -> &'a SecretMaterial { + self.plaintext + } +} + +/// Borrowed input for one key-unwrapping operation. +#[derive(Debug, Clone, Copy)] +pub struct UnwrapRequest<'a> { + reference: &'a SecretRef, + wrapped: &'a WrappedSecret, +} + +impl<'a> UnwrapRequest<'a> { + /// Creates an explicit unwrapping request. + #[must_use] + pub const fn new(reference: &'a SecretRef, wrapped: &'a WrappedSecret) -> Self { + Self { reference, wrapped } + } + + /// Returns the provider capability reference. + #[must_use] + pub const fn reference(&self) -> &'a SecretRef { + self.reference + } + + /// Returns the provider-wrapped value. + #[must_use] + pub const fn wrapped(&self) -> &'a WrappedSecret { + self.wrapped + } +} + +/// Executor-neutral, dyn-compatible data-key wrapping. +pub trait KeyWrapping: Send + Sync { + /// Wraps explicit caller-owned plaintext for the selected reference. + fn wrap<'a>(&'a self, request: WrapRequest<'a>) -> BoxFuture<'a, Result<WrappedSecret, Error>>; + + /// Unwraps provider-owned protected material into a zeroizing owner. + fn unwrap<'a>( + &'a self, + request: UnwrapRequest<'a>, + ) -> BoxFuture<'a, Result<SecretMaterial, Error>>; +} diff --git a/crates/secrets/tests/package_boundary.rs b/crates/secrets/tests/package_boundary.rs @@ -50,7 +50,9 @@ fn crate_root_contains_only_the_approved_module_skeleton() { .collect::<BTreeSet<_>>(), BTreeSet::from([ "pub use error::Error;", - "pub use id::{SecretId, SecretRef};" + "pub use id::{SecretId, SecretRef};", + "pub use provider::SecretProvider;", + "pub use wrapping::KeyWrapping;" ]) ); } diff --git a/crates/secrets/tests/provider_contract.rs b/crates/secrets/tests/provider_contract.rs @@ -0,0 +1,252 @@ +use futures_executor::block_on; +use radroots_secrets::error::{Operation, PolicyRequirement}; +use radroots_secrets::id::{BackendKind, KeyVersion}; +use radroots_secrets::provider::{ + AccessPolicy, CapabilitySupport, HardwarePolicy, ResidencyPolicy, ResidencySupport, + SecretCapabilities, SelectionPolicy, UserPresencePolicy, +}; +use radroots_secrets::wrapping::{ + BoxFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, +}; +use radroots_secrets::{Error, KeyWrapping, SecretId, SecretProvider, SecretRef}; + +struct MockProvider { + backend: BackendKind, + capabilities: SecretCapabilities, + fail: bool, +} + +impl KeyWrapping for MockProvider { + fn wrap<'a>(&'a self, request: WrapRequest<'a>) -> BoxFuture<'a, Result<WrappedSecret, Error>> { + Box::pin(async move { + self.validate_reference(request.reference())?; + if self.fail { + return Err(Error::BackendFailure { + backend: self.backend, + operation: Operation::Wrap, + }); + } + let encoded = request.plaintext().expose_secret(|plaintext| { + plaintext.iter().map(|byte| byte ^ 0xA5).collect::<Vec<_>>() + }); + WrappedSecret::from_bytes(encoded) + }) + } + + fn unwrap<'a>( + &'a self, + request: UnwrapRequest<'a>, + ) -> BoxFuture<'a, Result<SecretMaterial, Error>> { + Box::pin(async move { + self.validate_reference(request.reference())?; + if self.fail { + return Err(Error::BackendFailure { + backend: self.backend, + operation: Operation::Unwrap, + }); + } + let decoded = request + .wrapped() + .as_bytes() + .iter() + .map(|byte| byte ^ 0xA5) + .collect::<Vec<_>>(); + SecretMaterial::from_slice(decoded.as_slice()) + }) + } +} + +impl SecretProvider for MockProvider { + fn backend_kind(&self) -> BackendKind { + self.backend + } + + fn capabilities(&self) -> SecretCapabilities { + self.capabilities + } +} + +impl MockProvider { + fn validate_reference(&self, reference: &SecretRef) -> Result<(), Error> { + if reference.backend() != self.backend { + return Err(Error::BackendMismatch { + provider: self.backend, + reference: reference.backend(), + }); + } + Ok(()) + } +} + +fn provider(backend: BackendKind, capabilities: SecretCapabilities) -> MockProvider { + MockProvider { + backend, + capabilities, + fail: false, + } +} + +fn reference(backend: BackendKind) -> SecretRef { + SecretRef::new( + SecretId::parse("test-wrapping-key").expect("valid id"), + backend, + KeyVersion::new(1).expect("valid version"), + ) +} + +#[test] +fn provider_traits_are_dyn_compatible_and_round_trip_opaque_material() { + fn accept_dyn(_: &dyn SecretProvider) {} + + let provider = provider( + BackendKind::Memory, + SecretCapabilities::available( + ResidencySupport::Volatile, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ), + ); + accept_dyn(&provider); + + let reference = reference(BackendKind::Memory); + let plaintext = SecretMaterial::from_slice(b"caller-owned-data-key").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, b"caller-owned-data-key")); + + assert_eq!(format!("{plaintext:?}"), "SecretMaterial(<redacted>)"); + assert_eq!(format!("{wrapped:?}"), "WrappedSecret(<redacted>)"); +} + +#[test] +fn exact_selection_never_falls_back_to_another_backend() { + let memory = provider( + BackendKind::Memory, + SecretCapabilities::available( + ResidencySupport::Volatile, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ), + ); + let file = provider( + BackendKind::File, + SecretCapabilities::available( + ResidencySupport::DeviceLocal, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ), + ); + let candidates: [&dyn SecretProvider; 2] = [&memory, &file]; + + let selected = SelectionPolicy::new(BackendKind::File, AccessPolicy::standard()) + .select(&candidates) + .expect("file selected"); + assert_eq!(selected.backend_kind(), BackendKind::File); + + assert!(matches!( + SelectionPolicy::new(BackendKind::Keyring, AccessPolicy::standard()).select(&candidates), + Err(Error::BackendUnavailable { + backend: BackendKind::Keyring + }) + )); +} + +#[test] +fn selection_enforces_device_user_presence_and_hardware_policy() { + let keyring = provider( + BackendKind::Keyring, + SecretCapabilities::available( + ResidencySupport::UserProfile, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ), + ); + let candidates: [&dyn SecretProvider; 1] = [&keyring]; + + let cases = [ + ( + AccessPolicy::new( + ResidencyPolicy::DeviceLocal, + UserPresencePolicy::NotRequired, + HardwarePolicy::Any, + ), + PolicyRequirement::DeviceLocal, + ), + ( + AccessPolicy::new( + ResidencyPolicy::Any, + UserPresencePolicy::Required, + HardwarePolicy::Any, + ), + PolicyRequirement::UserPresence, + ), + ( + AccessPolicy::new( + ResidencyPolicy::Any, + UserPresencePolicy::NotRequired, + HardwarePolicy::RequireHardwareBacked, + ), + PolicyRequirement::HardwareBacked, + ), + ]; + + for (access, expected) in cases { + assert_eq!( + SelectionPolicy::new(BackendKind::Keyring, access) + .select(&candidates) + .map(SecretProvider::backend_kind), + Err(Error::PolicyUnsupported { + backend: BackendKind::Keyring, + requirement: expected, + }) + ); + } +} + +#[test] +fn provider_errors_are_normalized_and_secret_safe() { + let provider = MockProvider { + backend: BackendKind::External, + capabilities: SecretCapabilities::available( + ResidencySupport::DeviceLocal, + CapabilitySupport::Supported, + CapabilitySupport::Supported, + ), + fail: true, + }; + let reference = reference(BackendKind::External); + let plaintext = SecretMaterial::from_slice(b"must-not-appear").expect("material"); + let error = block_on(provider.wrap(WrapRequest::new(&reference, &plaintext))) + .expect_err("backend failure"); + assert_eq!( + error, + Error::BackendFailure { + backend: BackendKind::External, + operation: Operation::Wrap, + } + ); + assert!(!error.to_string().contains("must-not-appear")); + assert!(!format!("{error:?}").contains("must-not-appear")); +} + +#[test] +fn reference_backend_mismatch_fails_before_wrapping() { + let provider = provider( + BackendKind::Memory, + SecretCapabilities::available( + ResidencySupport::Volatile, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ), + ); + let reference = reference(BackendKind::File); + let plaintext = SecretMaterial::from_slice(b"data-key").expect("material"); + assert_eq!( + block_on(provider.wrap(WrapRequest::new(&reference, &plaintext))), + Err(Error::BackendMismatch { + provider: BackendKind::Memory, + reference: BackendKind::File, + }) + ); +}