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:
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,
+ })
+ );
+}