lib

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

commit 61baa10c9ad6abb858c0f83f48c525cc967eeec4
parent fae39d7c7bd860858ef9115a215f9458c54d32fc
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 17:16:47 +0000

secret-vault: add canonical backend taxonomy

Diffstat:
MCargo.lock | 4++++
MCargo.toml | 2++
Mcontract/coverage/policy.toml | 1+
Mcontract/release/publish-set.toml | 2++
Acrates/secret-vault/Cargo.toml | 20++++++++++++++++++++
Acrates/secret-vault/README.md | 3+++
Acrates/secret-vault/src/backend.rs | 32++++++++++++++++++++++++++++++++
Acrates/secret-vault/src/error.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/secret-vault/src/lib.rs | 34++++++++++++++++++++++++++++++++++
Acrates/secret-vault/src/policy.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/secret-vault/src/selection.rs | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 647 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2599,6 +2599,10 @@ dependencies = [ ] [[package]] +name = "radroots-secret-vault" +version = "0.1.0-alpha.1" + +[[package]] name = "radroots-simplex-agent-proto" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/nostr-ndb", "crates/nostr-runtime", "crates/runtime", + "crates/secret-vault", "crates/simplex-agent-proto", "crates/simplex-agent-runtime", "crates/simplex-agent-store", @@ -85,6 +86,7 @@ radroots-replica-db-wasm = { path = "crates/replica-db-wasm", version = "0.1.0-a radroots-replica-sync-wasm = { path = "crates/replica-sync-wasm", version = "0.1.0-alpha.1" } radroots-trade = { path = "crates/trade", version = "0.1.0-alpha.1", default-features = false } radroots-types = { path = "crates/types", version = "0.1.0-alpha.1", default-features = false } +radroots-secret-vault = { path = "crates/secret-vault", version = "0.1.0-alpha.1", default-features = false } anyhow = { version = "1" } base64 = { version = "0.22" } diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml @@ -28,6 +28,7 @@ crates = [ "radroots-nostr-ndb", "radroots-nostr-runtime", "radroots-runtime", + "radroots-secret-vault", "radroots-simplex-chat-proto", "radroots-simplex-smp-proto", "radroots-sql-core", diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml @@ -8,6 +8,7 @@ crates = [ "radroots-events", "radroots-log", "radroots-runtime", + "radroots-secret-vault", "radroots-simplex-chat-proto", "radroots-simplex-smp-proto", "radroots-identity", @@ -44,6 +45,7 @@ crates = [ "radroots-log", "radroots-events", "radroots-runtime", + "radroots-secret-vault", "radroots-simplex-chat-proto", "radroots-simplex-smp-proto", "radroots-events-codec", diff --git a/crates/secret-vault/Cargo.toml b/crates/secret-vault/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "radroots-secret-vault" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "canonical secret backend taxonomy and fail-closed selection policy for radroots runtimes" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-secret-vault" +readme = "README.md" + +[features] +default = [] +std = [] + +[dependencies] diff --git a/crates/secret-vault/README.md b/crates/secret-vault/README.md @@ -0,0 +1,3 @@ +# radroots-secret-vault + +Canonical secret backend taxonomy and fail-closed backend-selection policy for Rad Roots runtimes. diff --git a/crates/secret-vault/src/backend.rs b/crates/secret-vault/src/backend.rs @@ -0,0 +1,32 @@ +use crate::policy::RadrootsHostVaultPolicy; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsSecretBackendKind { + HostVault, + EncryptedFile, + ExternalCommand, + Memory, + PlaintextFile, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsSecretBackend { + HostVault(RadrootsHostVaultPolicy), + EncryptedFile, + ExternalCommand, + Memory, + PlaintextFile, +} + +impl RadrootsSecretBackend { + #[must_use] + pub const fn kind(self) -> RadrootsSecretBackendKind { + match self { + Self::HostVault(_) => RadrootsSecretBackendKind::HostVault, + Self::EncryptedFile => RadrootsSecretBackendKind::EncryptedFile, + Self::ExternalCommand => RadrootsSecretBackendKind::ExternalCommand, + Self::Memory => RadrootsSecretBackendKind::Memory, + Self::PlaintextFile => RadrootsSecretBackendKind::PlaintextFile, + } + } +} diff --git a/crates/secret-vault/src/error.rs b/crates/secret-vault/src/error.rs @@ -0,0 +1,76 @@ +use crate::backend::RadrootsSecretBackendKind; +use core::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsHostVaultRequirement { + DeviceLocalOnly, + UserPresence, + HardwareBacked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsSecretVaultError { + BackendUnavailable { + backend: RadrootsSecretBackendKind, + }, + FallbackDisallowed { + primary: RadrootsSecretBackendKind, + fallback: RadrootsSecretBackendKind, + }, + FallbackUnavailable { + primary: RadrootsSecretBackendKind, + fallback: RadrootsSecretBackendKind, + }, + HostVaultPolicyUnsupported { + requirement: RadrootsHostVaultRequirement, + }, +} + +impl fmt::Display for RadrootsHostVaultRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::DeviceLocalOnly => "device_local_only", + Self::UserPresence => "user_presence", + Self::HardwareBacked => "hardware_backed", + }; + f.write_str(value) + } +} + +impl fmt::Display for RadrootsSecretVaultError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BackendUnavailable { backend } => { + write!(f, "secret backend {backend} is unavailable") + } + Self::FallbackDisallowed { primary, fallback } => write!( + f, + "secret backend {primary} may not silently downgrade to {fallback}" + ), + Self::FallbackUnavailable { primary, fallback } => write!( + f, + "secret backend {primary} fallback {fallback} is unavailable" + ), + Self::HostVaultPolicyUnsupported { requirement } => write!( + f, + "host vault does not satisfy the required {requirement} policy" + ), + } + } +} + +impl fmt::Display for RadrootsSecretBackendKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::HostVault => "host_vault", + Self::EncryptedFile => "encrypted_file", + Self::ExternalCommand => "external_command", + Self::Memory => "memory", + Self::PlaintextFile => "plaintext_file", + }; + f.write_str(value) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsSecretVaultError {} diff --git a/crates/secret-vault/src/lib.rs b/crates/secret-vault/src/lib.rs @@ -0,0 +1,34 @@ +#![forbid(unsafe_code)] +#![no_std] + +#[cfg(any(feature = "std", test))] +extern crate std; + +pub mod backend; +pub mod error; +pub mod policy; +pub mod selection; + +pub mod prelude { + pub use crate::backend::{RadrootsSecretBackend, RadrootsSecretBackendKind}; + pub use crate::error::{RadrootsHostVaultRequirement, RadrootsSecretVaultError}; + pub use crate::policy::{ + RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, + RadrootsHostVaultResidency, RadrootsHostVaultUserPresencePolicy, + }; + pub use crate::selection::{ + RadrootsResolvedSecretBackend, RadrootsSecretBackendAvailability, + RadrootsSecretBackendSelection, + }; +} + +pub use backend::{RadrootsSecretBackend, RadrootsSecretBackendKind}; +pub use error::{RadrootsHostVaultRequirement, RadrootsSecretVaultError}; +pub use policy::{ + RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, + RadrootsHostVaultResidency, RadrootsHostVaultUserPresencePolicy, +}; +pub use selection::{ + RadrootsResolvedSecretBackend, RadrootsSecretBackendAvailability, + RadrootsSecretBackendSelection, +}; diff --git a/crates/secret-vault/src/policy.rs b/crates/secret-vault/src/policy.rs @@ -0,0 +1,130 @@ +use crate::error::{RadrootsHostVaultRequirement, RadrootsSecretVaultError}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsHostVaultResidency { + UserProfile, + DeviceLocalOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsHostVaultUserPresencePolicy { + NotRequired, + Required, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadrootsHostVaultHardwarePolicy { + Any, + PreferHardwareBacked, + RequireHardwareBacked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RadrootsHostVaultPolicy { + pub residency: RadrootsHostVaultResidency, + pub user_presence: RadrootsHostVaultUserPresencePolicy, + pub hardware: RadrootsHostVaultHardwarePolicy, +} + +impl RadrootsHostVaultPolicy { + #[must_use] + pub const fn desktop() -> Self { + Self { + residency: RadrootsHostVaultResidency::UserProfile, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::Any, + } + } + + #[must_use] + pub const fn device_local() -> Self { + Self { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::Any, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RadrootsHostVaultCapabilities { + pub available: bool, + pub supports_device_local_only: bool, + pub supports_user_presence: bool, + pub supports_hardware_backed: bool, +} + +impl RadrootsHostVaultCapabilities { + #[must_use] + pub const fn unavailable() -> Self { + Self { + available: false, + supports_device_local_only: false, + supports_user_presence: false, + supports_hardware_backed: false, + } + } + + #[must_use] + pub const fn desktop_keyring() -> Self { + Self { + available: true, + supports_device_local_only: false, + supports_user_presence: false, + supports_hardware_backed: false, + } + } + + #[must_use] + pub const fn secure_device() -> Self { + Self { + available: true, + supports_device_local_only: true, + supports_user_presence: true, + supports_hardware_backed: true, + } + } + + pub const fn validate( + self, + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultError> { + if !self.available { + return Err(RadrootsSecretVaultError::BackendUnavailable { + backend: crate::backend::RadrootsSecretBackendKind::HostVault, + }); + } + + if matches!( + policy.residency, + RadrootsHostVaultResidency::DeviceLocalOnly + ) && !self.supports_device_local_only + { + return Err(RadrootsSecretVaultError::HostVaultPolicyUnsupported { + requirement: RadrootsHostVaultRequirement::DeviceLocalOnly, + }); + } + + if matches!( + policy.user_presence, + RadrootsHostVaultUserPresencePolicy::Required + ) && !self.supports_user_presence + { + return Err(RadrootsSecretVaultError::HostVaultPolicyUnsupported { + requirement: RadrootsHostVaultRequirement::UserPresence, + }); + } + + if matches!( + policy.hardware, + RadrootsHostVaultHardwarePolicy::RequireHardwareBacked + ) && !self.supports_hardware_backed + { + return Err(RadrootsSecretVaultError::HostVaultPolicyUnsupported { + requirement: RadrootsHostVaultRequirement::HardwareBacked, + }); + } + + Ok(()) + } +} diff --git a/crates/secret-vault/src/selection.rs b/crates/secret-vault/src/selection.rs @@ -0,0 +1,343 @@ +use crate::backend::{RadrootsSecretBackend, RadrootsSecretBackendKind}; +use crate::error::RadrootsSecretVaultError; +use crate::policy::RadrootsHostVaultCapabilities; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RadrootsSecretBackendSelection { + pub primary: RadrootsSecretBackend, + pub fallback: Option<RadrootsSecretBackend>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RadrootsSecretBackendAvailability { + pub host_vault: RadrootsHostVaultCapabilities, + pub encrypted_file: bool, + pub external_command: bool, + pub memory: bool, + pub plaintext_file: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RadrootsResolvedSecretBackend { + pub backend: RadrootsSecretBackend, + pub used_fallback: bool, +} + +impl RadrootsSecretBackendSelection { + pub fn resolve( + self, + availability: RadrootsSecretBackendAvailability, + ) -> Result<RadrootsResolvedSecretBackend, RadrootsSecretVaultError> { + if availability.supports(self.primary).is_ok() { + return Ok(RadrootsResolvedSecretBackend { + backend: self.primary, + used_fallback: false, + }); + } + + if let RadrootsSecretBackend::HostVault(policy) = self.primary { + if availability.host_vault.available { + availability.host_vault.validate(policy)?; + } + } + + match self.fallback { + Some(fallback) => { + if !self.primary.allows_fallback_to(fallback.kind()) { + return Err(RadrootsSecretVaultError::FallbackDisallowed { + primary: self.primary.kind(), + fallback: fallback.kind(), + }); + } + + availability.supports(fallback).map_err(|_| { + RadrootsSecretVaultError::FallbackUnavailable { + primary: self.primary.kind(), + fallback: fallback.kind(), + } + })?; + + Ok(RadrootsResolvedSecretBackend { + backend: fallback, + used_fallback: true, + }) + } + None => Err(RadrootsSecretVaultError::BackendUnavailable { + backend: self.primary.kind(), + }), + } + } +} + +impl RadrootsSecretBackendAvailability { + fn supports(self, backend: RadrootsSecretBackend) -> Result<(), RadrootsSecretVaultError> { + match backend { + RadrootsSecretBackend::HostVault(policy) => self.host_vault.validate(policy), + RadrootsSecretBackend::EncryptedFile if self.encrypted_file => Ok(()), + RadrootsSecretBackend::ExternalCommand if self.external_command => Ok(()), + RadrootsSecretBackend::Memory if self.memory => Ok(()), + RadrootsSecretBackend::PlaintextFile if self.plaintext_file => Ok(()), + _ => Err(RadrootsSecretVaultError::BackendUnavailable { + backend: backend.kind(), + }), + } + } +} + +impl RadrootsSecretBackend { + const fn allows_fallback_to(self, fallback: RadrootsSecretBackendKind) -> bool { + matches!( + (self.kind(), fallback), + ( + RadrootsSecretBackendKind::HostVault, + RadrootsSecretBackendKind::EncryptedFile + ) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::RadrootsHostVaultRequirement; + use crate::policy::{ + RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, RadrootsHostVaultResidency, + RadrootsHostVaultUserPresencePolicy, + }; + + #[test] + fn host_vault_is_selected_when_available() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()), + fallback: Some(RadrootsSecretBackend::EncryptedFile), + }; + + let resolved = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::desktop_keyring(), + encrypted_file: true, + external_command: false, + memory: false, + plaintext_file: false, + }) + .expect("host vault resolves"); + + assert_eq!( + resolved, + RadrootsResolvedSecretBackend { + backend: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()), + used_fallback: false, + } + ); + } + + #[test] + fn host_vault_may_explicitly_fallback_to_encrypted_file() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()), + fallback: Some(RadrootsSecretBackend::EncryptedFile), + }; + + let resolved = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: true, + external_command: false, + memory: false, + plaintext_file: false, + }) + .expect("encrypted file fallback resolves"); + + assert_eq!( + resolved, + RadrootsResolvedSecretBackend { + backend: RadrootsSecretBackend::EncryptedFile, + used_fallback: true, + } + ); + } + + #[test] + fn host_vault_without_explicit_fallback_fails_closed() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()), + fallback: None, + }; + + let err = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: true, + external_command: false, + memory: false, + plaintext_file: false, + }) + .expect_err("missing fallback must fail"); + + assert_eq!( + err, + RadrootsSecretVaultError::BackendUnavailable { + backend: RadrootsSecretBackendKind::HostVault, + } + ); + } + + #[test] + fn unsupported_host_vault_policy_fails_before_any_downgrade() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::Required, + hardware: RadrootsHostVaultHardwarePolicy::RequireHardwareBacked, + }), + fallback: Some(RadrootsSecretBackend::EncryptedFile), + }; + + let err = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::desktop_keyring(), + encrypted_file: true, + external_command: false, + memory: false, + plaintext_file: false, + }) + .expect_err("unsupported host policy must fail"); + + assert_eq!( + err, + RadrootsSecretVaultError::HostVaultPolicyUnsupported { + requirement: RadrootsHostVaultRequirement::DeviceLocalOnly, + } + ); + } + + #[test] + fn encrypted_file_may_not_downgrade_to_plaintext_file() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::EncryptedFile, + fallback: Some(RadrootsSecretBackend::PlaintextFile), + }; + + let err = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: false, + external_command: false, + memory: false, + plaintext_file: true, + }) + .expect_err("plaintext downgrade must fail"); + + assert_eq!( + err, + RadrootsSecretVaultError::FallbackDisallowed { + primary: RadrootsSecretBackendKind::EncryptedFile, + fallback: RadrootsSecretBackendKind::PlaintextFile, + } + ); + } + + #[test] + fn external_command_may_not_downgrade_to_encrypted_file() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::ExternalCommand, + fallback: Some(RadrootsSecretBackend::EncryptedFile), + }; + + let err = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: true, + external_command: false, + memory: false, + plaintext_file: false, + }) + .expect_err("external command downgrade must fail"); + + assert_eq!( + err, + RadrootsSecretVaultError::FallbackDisallowed { + primary: RadrootsSecretBackendKind::ExternalCommand, + fallback: RadrootsSecretBackendKind::EncryptedFile, + } + ); + } + + #[test] + fn explicit_plaintext_file_selection_stays_explicit() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::PlaintextFile, + fallback: None, + }; + + let resolved = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: false, + external_command: false, + memory: false, + plaintext_file: true, + }) + .expect("explicit plaintext file selection resolves"); + + assert_eq!( + resolved, + RadrootsResolvedSecretBackend { + backend: RadrootsSecretBackend::PlaintextFile, + used_fallback: false, + } + ); + } + + #[test] + fn memory_backend_must_be_selected_explicitly() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::Memory, + fallback: None, + }; + + let resolved = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: false, + external_command: false, + memory: true, + plaintext_file: false, + }) + .expect("memory backend resolves"); + + assert_eq!( + resolved, + RadrootsResolvedSecretBackend { + backend: RadrootsSecretBackend::Memory, + used_fallback: false, + } + ); + } + + #[test] + fn unavailable_explicit_fallback_reports_fallback_unavailable() { + let selection = RadrootsSecretBackendSelection { + primary: RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()), + fallback: Some(RadrootsSecretBackend::EncryptedFile), + }; + + let err = selection + .resolve(RadrootsSecretBackendAvailability { + host_vault: RadrootsHostVaultCapabilities::unavailable(), + encrypted_file: false, + external_command: false, + memory: false, + plaintext_file: false, + }) + .expect_err("unavailable fallback must fail"); + + assert_eq!( + err, + RadrootsSecretVaultError::FallbackUnavailable { + primary: RadrootsSecretBackendKind::HostVault, + fallback: RadrootsSecretBackendKind::EncryptedFile, + } + ); + } +}