app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit d7430cb2aa4d02ee0f76ff7e5f4fb453d9188f2d
parent 4c58112725b2ac13a34a6bacf82859a96581d9e5
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 17:57:16 +0000

app: adopt shared host-vault boundary

- switch the apple and android adapters to the shared radroots-secret-vault contract with explicit host policy mapping
- move desktop, ios, and android remote signer session secrets to canonical slot-based vault access
- enable memory-vault features where app-hosted test surfaces still rely on in-memory nostr accounts helpers
- verify the new boundary with ios compile coverage plus remote-signer, android, and desktop test runs

Diffstat:
MCargo.lock | 12++++++++++--
MCargo.toml | 1+
Mcrates/android/Cargo.toml | 3++-
Mcrates/android/src/remote_signer.rs | 30+++++++++++++++++-------------
Mcrates/android/src/vault.rs | 178++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/apple/security/Cargo.toml | 2+-
Mcrates/apple/security/src/security.rs | 1+
Mcrates/apple/security/src/vault.rs | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mcrates/desktop/Cargo.toml | 2+-
Mcrates/desktop/src/main.rs | 15++++++++-------
Mcrates/desktop/src/remote_signer.rs | 33+++++++++++++++++++--------------
Mcrates/ios/Cargo.toml | 2+-
Mcrates/ios/src/remote_signer.rs | 30+++++++++++++++++-------------
Mcrates/remote-signer/Cargo.toml | 2+-
Mcrates/remote-signer/src/custody.rs | 47++++++++++++++++++++++++++++++++++-------------
15 files changed, 354 insertions(+), 182 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3087,6 +3087,7 @@ dependencies = [ "radroots-geocoder", "radroots-identity", "radroots-nostr-accounts", + "radroots-secret-vault", "wgpu", "winit", "zeroize", @@ -3096,8 +3097,8 @@ dependencies = [ name = "radroots-app-apple-security" version = "0.1.0" dependencies = [ - "radroots-identity", "radroots-nostr-accounts", + "radroots-secret-vault", "zeroize", ] @@ -3255,10 +3256,10 @@ dependencies = [ name = "radroots-nostr-accounts" version = "0.1.0-alpha.1" dependencies = [ - "keyring", "radroots-identity", "radroots-nostr-signer", "radroots-runtime", + "radroots-secret-vault", "serde", "serde_json", "thiserror 1.0.69", @@ -3310,6 +3311,13 @@ dependencies = [ ] [[package]] +name = "radroots-secret-vault" +version = "0.1.0-alpha.1" +dependencies = [ + "keyring", +] + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -39,6 +39,7 @@ radroots-identity = { path = "../lib/crates/identity", default-features = false, radroots-nostr = { path = "../lib/crates/nostr", default-features = false, features = ["std", "client"] } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "file-store", "os-keyring"] } radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } +radroots-secret-vault = { path = "../lib/crates/secret-vault", default-features = false, features = ["std"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["rt", "sync", "time"] } diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml @@ -21,7 +21,8 @@ radroots-app-core = { path = "../core" } radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-identity.workspace = true -radroots-nostr-accounts.workspace = true +radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } +radroots-secret-vault.workspace = true zeroize.workspace = true [target.'cfg(target_os = "android")'.dependencies] diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -17,7 +17,8 @@ use radroots_app_remote_signer::{ }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, RadrootsSecretVault, + account_secret_slot, }; use std::path::{Path, PathBuf}; @@ -421,41 +422,44 @@ fn legacy_client_secret_vault() -> RadrootsAndroidKeystoreVault { RadrootsAndroidKeystoreVault::new(ANDROID_NOSTR_SERVICE) } -fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { +fn client_secret_slot(client_account_id: &str) -> Result<String, String> { let account_id = RadrootsIdentityId::try_from(client_account_id) .map_err(|_| "invalid remote signer client account id".to_owned())?; + Ok(account_secret_slot(&account_id)) +} + +fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { + let slot = client_secret_slot(client_account_id)?; client_secret_vault() - .store_secret_hex(&account_id, secret_key_hex) + .store_secret(slot.as_str(), secret_key_hex) .map_err(|source| source.to_string()) } fn load_client_secret(client_account_id: &str) -> Result<String, String> { - let account_id = RadrootsIdentityId::try_from(client_account_id) - .map_err(|_| "invalid remote signer client account id".to_owned())?; + let slot = client_secret_slot(client_account_id)?; if let Some(secret) = client_secret_vault() - .load_secret_hex(&account_id) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? { return Ok(secret); } let secret = legacy_client_secret_vault() - .load_secret_hex(&account_id) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? .ok_or_else(|| "remote signer session secret is missing".to_owned())?; - let _ = client_secret_vault().store_secret_hex(&account_id, secret.as_str()); - let _ = legacy_client_secret_vault().remove_secret(&account_id); + let _ = client_secret_vault().store_secret(slot.as_str(), secret.as_str()); + let _ = legacy_client_secret_vault().remove_secret(slot.as_str()); Ok(secret) } fn remove_client_secret(client_account_id: &str) -> Result<(), String> { - let account_id = RadrootsIdentityId::try_from(client_account_id) - .map_err(|_| "invalid remote signer client account id".to_owned())?; + let slot = client_secret_slot(client_account_id)?; client_secret_vault() - .remove_secret(&account_id) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string())?; legacy_client_secret_vault() - .remove_secret(&account_id) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string()) } diff --git a/crates/android/src/vault.rs b/crates/android/src/vault.rs @@ -1,8 +1,11 @@ use crate::security::{ ANDROID_NOSTR_NAMESPACE, load_secret, remove_secret, remove_secret_namespace, store_secret, }; -use radroots_identity::RadrootsIdentityId; -use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrSecretVault}; +use radroots_secret_vault::{ + RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, + RadrootsHostVaultResidency, RadrootsHostVaultUserPresencePolicy, RadrootsSecretVault, + RadrootsSecretVaultAccessError, +}; use zeroize::Zeroizing; #[derive(Debug, Clone)] @@ -12,10 +15,12 @@ pub(crate) struct RadrootsAndroidKeystoreVault { } impl RadrootsAndroidKeystoreVault { + #[must_use] pub(crate) fn new(service_name: impl Into<String>) -> Self { Self::new_with_namespace(service_name, ANDROID_NOSTR_NAMESPACE) } + #[must_use] pub(crate) fn new_with_namespace( service_name: impl Into<String>, namespace: impl Into<String>, @@ -26,81 +31,148 @@ impl RadrootsAndroidKeystoreVault { } } - fn account_name(account_id: &RadrootsIdentityId) -> &str { - account_id.as_str() + #[must_use] + pub(crate) const fn secure_local_policy() -> RadrootsHostVaultPolicy { + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::PreferHardwareBacked, + } } - pub(crate) fn purge_namespace(&self) -> Result<(), RadrootsNostrAccountsError> { - remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + fn capabilities() -> RadrootsHostVaultCapabilities { + #[cfg(target_os = "android")] + { + RadrootsHostVaultCapabilities { + available: true, + supports_device_local_only: true, + supports_user_presence: true, + supports_hardware_backed: true, + } + } + + #[cfg(not(target_os = "android"))] + { + RadrootsHostVaultCapabilities::unavailable() + } + } + + fn validate_policy( + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultAccessError> { + Self::capabilities() + .validate(policy) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) } -} -impl RadrootsNostrSecretVault for RadrootsAndroidKeystoreVault { - fn store_secret_hex( + fn security_flags(policy: RadrootsHostVaultPolicy) -> (bool, bool, bool) { + ( + matches!( + policy.residency, + RadrootsHostVaultResidency::DeviceLocalOnly + ), + matches!( + policy.user_presence, + RadrootsHostVaultUserPresencePolicy::Required + ), + !matches!(policy.hardware, RadrootsHostVaultHardwarePolicy::Any), + ) + } + + pub(crate) fn store_secret_with_policy( &self, - account_id: &RadrootsIdentityId, - secret_key_hex: &str, - ) -> Result<(), RadrootsNostrAccountsError> { - let secret_key_hex = Zeroizing::new(secret_key_hex.to_owned()); + slot: &str, + secret: &str, + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultAccessError> { + Self::validate_policy(policy)?; + let secret = Zeroizing::new(secret.to_owned()); + let (device_local_only, user_presence_required, prefer_strong_box) = + Self::security_flags(policy); store_secret( self.service_name.as_str(), self.namespace.as_str(), - Self::account_name(account_id), - secret_key_hex.as_bytes(), - true, - false, - true, + slot, + secret.as_bytes(), + device_local_only, + user_presence_required, + prefer_strong_box, ) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) } - fn load_secret_hex( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<Option<String>, RadrootsNostrAccountsError> { - let Some(secret) = load_secret( - self.service_name.as_str(), - self.namespace.as_str(), - Self::account_name(account_id), - )? + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub(crate) fn purge_namespace(&self) -> Result<(), RadrootsSecretVaultAccessError> { + remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } +} + +impl RadrootsSecretVault for RadrootsAndroidKeystoreVault { + fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { + self.store_secret_with_policy(slot, secret, Self::secure_local_policy()) + } + + fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { + let Some(secret) = + load_secret(self.service_name.as_str(), self.namespace.as_str(), slot) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))? else { return Ok(None); }; let secret = Zeroizing::new(secret); let secret = std::str::from_utf8(secret.as_slice()).map_err(|source| { - RadrootsNostrAccountsError::Vault(format!( + RadrootsSecretVaultAccessError::Backend(format!( "android keystore secret was not valid utf-8: {source}" )) })?; Ok(Some(secret.to_owned())) } - fn remove_secret( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<(), RadrootsNostrAccountsError> { - remove_secret( - self.service_name.as_str(), - self.namespace.as_str(), - Self::account_name(account_id), - ) + fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { + remove_secret(self.service_name.as_str(), self.namespace.as_str(), slot) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) } } #[cfg(test)] mod tests { use super::*; + use radroots_secret_vault::{ + RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, RadrootsHostVaultResidency, + RadrootsHostVaultUserPresencePolicy, + }; #[test] - fn account_name_uses_account_id_string() { - let account_id = RadrootsIdentityId::parse( - "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", - ) - .expect("account id"); + fn secure_local_policy_prefers_device_local_hardware_backed_storage() { + assert_eq!( + RadrootsAndroidKeystoreVault::secure_local_policy(), + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::PreferHardwareBacked, + } + ); + } + #[test] + fn security_flags_request_strong_box_for_hardware_backed_policies() { + assert_eq!( + RadrootsAndroidKeystoreVault::security_flags(RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::UserProfile, + user_presence: RadrootsHostVaultUserPresencePolicy::Required, + hardware: RadrootsHostVaultHardwarePolicy::Any, + }), + (false, true, false) + ); assert_eq!( - RadrootsAndroidKeystoreVault::account_name(&account_id), - "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606" + RadrootsAndroidKeystoreVault::security_flags(RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::Required, + hardware: RadrootsHostVaultHardwarePolicy::RequireHardwareBacked, + }), + (true, true, true) ); } @@ -108,24 +180,18 @@ mod tests { #[test] fn vault_operations_report_unavailable_off_android() { let vault = RadrootsAndroidKeystoreVault::new(crate::security::ANDROID_NOSTR_SERVICE); - let account_id = RadrootsIdentityId::parse( - "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", - ) - .expect("account id"); - let load = vault - .load_secret_hex(&account_id) - .expect_err("load off android"); - assert!(load.to_string().starts_with("vault error:")); + let load = vault.load_secret("alice").expect_err("load off android"); + assert!(load.to_string().starts_with("secret vault access error:")); let store = vault - .store_secret_hex(&account_id, "deadbeef") + .store_secret("alice", "deadbeef") .expect_err("store off android"); - assert!(store.to_string().starts_with("vault error:")); + assert!(store.to_string().starts_with("secret vault access error:")); let remove = vault - .remove_secret(&account_id) + .remove_secret("alice") .expect_err("remove off android"); - assert!(remove.to_string().starts_with("vault error:")); + assert!(remove.to_string().starts_with("secret vault access error:")); } } diff --git a/crates/apple/security/Cargo.toml b/crates/apple/security/Cargo.toml @@ -11,8 +11,8 @@ description = "Rad Roots Apple security bridge" publish = false [dependencies] -radroots-identity.workspace = true radroots-nostr-accounts.workspace = true +radroots-secret-vault.workspace = true zeroize.workspace = true [lints.rust] diff --git a/crates/apple/security/src/security.rs b/crates/apple/security/src/security.rs @@ -189,6 +189,7 @@ pub struct AppleSecretAccessPolicy { } impl AppleSecretAccessPolicy { + #[cfg_attr(not(test), allow(dead_code))] pub const SECURE_LOCAL_SECRET: Self = Self { accessibility: AppleSecretAccessibility::WhenUnlocked, device_local_only: true, diff --git a/crates/apple/security/src/vault.rs b/crates/apple/security/src/vault.rs @@ -1,9 +1,12 @@ use crate::security::{ - APPLE_NOSTR_NAMESPACE, AppleSecretAccessPolicy, load_secret, remove_secret, - remove_secret_namespace, store_secret, + APPLE_NOSTR_NAMESPACE, AppleSecretAccessPolicy, AppleSecretAccessibility, load_secret, + remove_secret, remove_secret_namespace, store_secret, +}; +use radroots_secret_vault::{ + RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, + RadrootsHostVaultResidency, RadrootsHostVaultUserPresencePolicy, RadrootsSecretVault, + RadrootsSecretVaultAccessError, }; -use radroots_identity::RadrootsIdentityId; -use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrSecretVault}; use zeroize::Zeroizing; #[derive(Debug, Clone)] @@ -13,10 +16,12 @@ pub struct RadrootsAppleKeychainVault { } impl RadrootsAppleKeychainVault { + #[must_use] pub fn new(service_name: impl Into<String>) -> Self { Self::new_with_namespace(service_name, APPLE_NOSTR_NAMESPACE) } + #[must_use] pub fn new_with_namespace( service_name: impl Into<String>, namespace: impl Into<String>, @@ -27,79 +32,123 @@ impl RadrootsAppleKeychainVault { } } - fn account_name(account_id: &RadrootsIdentityId) -> &str { - account_id.as_str() + #[must_use] + pub const fn secure_local_policy() -> RadrootsHostVaultPolicy { + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::Any, + } } - pub fn purge_namespace(&self) -> Result<(), RadrootsNostrAccountsError> { - remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + fn capabilities() -> RadrootsHostVaultCapabilities { + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + RadrootsHostVaultCapabilities { + available: true, + supports_device_local_only: true, + supports_user_presence: true, + supports_hardware_backed: false, + } + } + + #[cfg(not(any(target_os = "ios", target_os = "macos")))] + { + RadrootsHostVaultCapabilities::unavailable() + } + } + + fn validate_policy( + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultAccessError> { + Self::capabilities() + .validate(policy) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } + + fn access_policy(policy: RadrootsHostVaultPolicy) -> AppleSecretAccessPolicy { + AppleSecretAccessPolicy { + accessibility: AppleSecretAccessibility::WhenUnlocked, + device_local_only: matches!( + policy.residency, + RadrootsHostVaultResidency::DeviceLocalOnly + ), + user_presence_required: matches!( + policy.user_presence, + RadrootsHostVaultUserPresencePolicy::Required + ), + } } -} -impl RadrootsNostrSecretVault for RadrootsAppleKeychainVault { - fn store_secret_hex( + pub fn store_secret_with_policy( &self, - account_id: &RadrootsIdentityId, - secret_key_hex: &str, - ) -> Result<(), RadrootsNostrAccountsError> { - let secret_key_hex = Zeroizing::new(secret_key_hex.to_owned()); + slot: &str, + secret: &str, + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultAccessError> { + Self::validate_policy(policy)?; + let secret = Zeroizing::new(secret.to_owned()); store_secret( self.service_name.as_str(), self.namespace.as_str(), - Self::account_name(account_id), - secret_key_hex.as_bytes(), - AppleSecretAccessPolicy::SECURE_LOCAL_SECRET, + slot, + secret.as_bytes(), + Self::access_policy(policy), ) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) } - fn load_secret_hex( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<Option<String>, RadrootsNostrAccountsError> { - let Some(secret) = load_secret( - self.service_name.as_str(), - self.namespace.as_str(), - Self::account_name(account_id), - )? + pub fn purge_namespace(&self) -> Result<(), RadrootsSecretVaultAccessError> { + remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } +} + +impl RadrootsSecretVault for RadrootsAppleKeychainVault { + fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { + self.store_secret_with_policy(slot, secret, Self::secure_local_policy()) + } + + fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { + let Some(secret) = + load_secret(self.service_name.as_str(), self.namespace.as_str(), slot) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))? else { return Ok(None); }; let secret = Zeroizing::new(secret); let secret = std::str::from_utf8(secret.as_slice()).map_err(|source| { - RadrootsNostrAccountsError::Vault(format!( + RadrootsSecretVaultAccessError::Backend(format!( "apple keychain secret was not valid utf-8: {source}" )) })?; Ok(Some(secret.to_owned())) } - fn remove_secret( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<(), RadrootsNostrAccountsError> { - remove_secret( - self.service_name.as_str(), - self.namespace.as_str(), - Self::account_name(account_id), - ) + fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { + remove_secret(self.service_name.as_str(), self.namespace.as_str(), slot) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) } } #[cfg(test)] mod tests { use super::*; + use radroots_secret_vault::{ + RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, RadrootsHostVaultResidency, + RadrootsHostVaultUserPresencePolicy, + }; #[test] - fn account_name_uses_account_id_string() { - let account_id = RadrootsIdentityId::parse( - "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", - ) - .expect("account id"); - + fn secure_local_policy_is_device_local_without_user_presence() { assert_eq!( - RadrootsAppleKeychainVault::account_name(&account_id), - "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606" + RadrootsAppleKeychainVault::secure_local_policy(), + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::Any, + } ); } @@ -107,24 +156,35 @@ mod tests { #[test] fn vault_operations_report_unavailable_off_apple() { let vault = RadrootsAppleKeychainVault::new(crate::APPLE_NOSTR_SERVICE); - let account_id = RadrootsIdentityId::parse( - "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", - ) - .expect("account id"); - let load = vault - .load_secret_hex(&account_id) - .expect_err("load off apple"); - assert!(load.to_string().starts_with("vault error:")); + let load = vault.load_secret("alice").expect_err("load off apple"); + assert!(load.to_string().starts_with("secret vault access error:")); let store = vault - .store_secret_hex(&account_id, "deadbeef") + .store_secret("alice", "deadbeef") .expect_err("store off apple"); - assert!(store.to_string().starts_with("vault error:")); + assert!(store.to_string().starts_with("secret vault access error:")); - let remove = vault - .remove_secret(&account_id) - .expect_err("remove off apple"); - assert!(remove.to_string().starts_with("vault error:")); + let remove = vault.remove_secret("alice").expect_err("remove off apple"); + assert!(remove.to_string().starts_with("secret vault access error:")); + } + + #[cfg(any(target_os = "ios", target_os = "macos"))] + #[test] + fn hardware_backed_requirement_reports_unsupported() { + let vault = RadrootsAppleKeychainVault::new(crate::APPLE_NOSTR_SERVICE); + let error = vault + .store_secret_with_policy( + "alice", + "deadbeef", + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::Required, + hardware: RadrootsHostVaultHardwarePolicy::RequireHardwareBacked, + }, + ) + .expect_err("apple adapter should reject hardware-backed requirement"); + + assert!(error.to_string().contains("hardware_backed")); } } diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -23,7 +23,7 @@ log.workspace = true radroots-app-core = { path = "../core" } radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true -radroots-nostr-accounts.workspace = true +radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } zeroize.workspace = true [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -963,7 +963,7 @@ mod tests { FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD, fixture_identity_ncryptsec, }; use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; - use radroots_nostr_accounts::prelude::RadrootsNostrSecretVault; + use radroots_nostr_accounts::prelude::{RadrootsSecretVault, account_secret_slot}; use std::path::PathBuf; #[test] @@ -991,24 +991,25 @@ mod tests { "3bf0c63f0f4478a288f6b67f0429dbf7f5119d4fa7218a4c40ef1378f80f7606", ) .expect("account id"); + let slot = account_secret_slot(&account_id); - let _ = vault.remove_secret(&account_id); + let _ = vault.remove_secret(slot.as_str()); vault - .store_secret_hex( - &account_id, + .store_secret( + slot.as_str(), "a0468b0f2f5de9db868fb563b13632eb92ec4697dd4fddbdca0488f1a1b2c3d4", ) .expect("store secret"); assert_eq!( - vault.load_secret_hex(&account_id).expect("load secret"), + vault.load_secret(slot.as_str()).expect("load secret"), Some("a0468b0f2f5de9db868fb563b13632eb92ec4697dd4fddbdca0488f1a1b2c3d4".to_owned()) ); - vault.remove_secret(&account_id).expect("remove secret"); + vault.remove_secret(slot.as_str()).expect("remove secret"); assert_eq!( - vault.load_secret_hex(&account_id).expect("load missing"), + vault.load_secret(slot.as_str()).expect("load missing"), None ); } diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -15,8 +15,10 @@ use radroots_app_remote_signer::{ radroots_app_remote_signer_purge_all_custody_state, radroots_app_remote_signer_reconcile_startup, }; +use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, RadrootsSecretVault, + account_secret_slot, }; use std::path::{Path, PathBuf}; @@ -446,41 +448,44 @@ fn legacy_client_secret_vault() -> RadrootsAppleKeychainVault { RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE) } -fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { - let account_id = radroots_identity::RadrootsIdentityId::try_from(client_account_id) +fn client_secret_slot(client_account_id: &str) -> Result<String, String> { + let account_id = RadrootsIdentityId::try_from(client_account_id) .map_err(|_| "invalid remote signer client account id".to_owned())?; + Ok(account_secret_slot(&account_id)) +} + +fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { + let slot = client_secret_slot(client_account_id)?; client_secret_vault() - .store_secret_hex(&account_id, secret_key_hex) + .store_secret(slot.as_str(), secret_key_hex) .map_err(|source| source.to_string()) } fn load_client_secret(client_account_id: &str) -> Result<String, String> { - let account_id = radroots_identity::RadrootsIdentityId::try_from(client_account_id) - .map_err(|_| "invalid remote signer client account id".to_owned())?; + let slot = client_secret_slot(client_account_id)?; if let Some(secret) = client_secret_vault() - .load_secret_hex(&account_id) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? { return Ok(secret); } let secret = legacy_client_secret_vault() - .load_secret_hex(&account_id) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? .ok_or_else(|| "remote signer session secret is missing".to_owned())?; - let _ = client_secret_vault().store_secret_hex(&account_id, secret.as_str()); - let _ = legacy_client_secret_vault().remove_secret(&account_id); + let _ = client_secret_vault().store_secret(slot.as_str(), secret.as_str()); + let _ = legacy_client_secret_vault().remove_secret(slot.as_str()); Ok(secret) } fn remove_client_secret(client_account_id: &str) -> Result<(), String> { - let account_id = radroots_identity::RadrootsIdentityId::try_from(client_account_id) - .map_err(|_| "invalid remote signer client account id".to_owned())?; + let slot = client_secret_slot(client_account_id)?; client_secret_vault() - .remove_secret(&account_id) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string())?; legacy_client_secret_vault() - .remove_secret(&account_id) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string()) } diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml @@ -22,7 +22,7 @@ radroots-app-core = { path = "../core" } radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-identity.workspace = true -radroots-nostr-accounts.workspace = true +radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } zeroize.workspace = true [target.'cfg(target_os = "ios")'.dependencies] diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs @@ -17,7 +17,8 @@ use radroots_app_remote_signer::{ }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, RadrootsSecretVault, + account_secret_slot, }; use std::path::{Path, PathBuf}; @@ -420,41 +421,44 @@ fn legacy_client_secret_vault() -> RadrootsAppleKeychainVault { RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE) } -fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { +fn client_secret_slot(client_account_id: &str) -> Result<String, String> { let account_id = RadrootsIdentityId::try_from(client_account_id) .map_err(|_| "invalid remote signer client account id".to_owned())?; + Ok(account_secret_slot(&account_id)) +} + +fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { + let slot = client_secret_slot(client_account_id)?; client_secret_vault() - .store_secret_hex(&account_id, secret_key_hex) + .store_secret(slot.as_str(), secret_key_hex) .map_err(|source| source.to_string()) } fn load_client_secret(client_account_id: &str) -> Result<String, String> { - let account_id = RadrootsIdentityId::try_from(client_account_id) - .map_err(|_| "invalid remote signer client account id".to_owned())?; + let slot = client_secret_slot(client_account_id)?; if let Some(secret) = client_secret_vault() - .load_secret_hex(&account_id) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? { return Ok(secret); } let secret = legacy_client_secret_vault() - .load_secret_hex(&account_id) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? .ok_or_else(|| "remote signer session secret is missing".to_owned())?; - let _ = client_secret_vault().store_secret_hex(&account_id, secret.as_str()); - let _ = legacy_client_secret_vault().remove_secret(&account_id); + let _ = client_secret_vault().store_secret(slot.as_str(), secret.as_str()); + let _ = legacy_client_secret_vault().remove_secret(slot.as_str()); Ok(secret) } fn remove_client_secret(client_account_id: &str) -> Result<(), String> { - let account_id = RadrootsIdentityId::try_from(client_account_id) - .map_err(|_| "invalid remote signer client account id".to_owned())?; + let slot = client_secret_slot(client_account_id)?; client_secret_vault() - .remove_secret(&account_id) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string())?; legacy_client_secret_vault() - .remove_secret(&account_id) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string()) } diff --git a/crates/remote-signer/Cargo.toml b/crates/remote-signer/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] nostr = { workspace = true, features = ["nip44"] } radroots-identity.workspace = true -radroots-nostr-accounts.workspace = true +radroots-nostr-accounts = { workspace = true, features = ["memory-vault"] } radroots-nostr.workspace = true radroots-nostr-connect.workspace = true serde.workspace = true diff --git a/crates/remote-signer/src/custody.rs b/crates/remote-signer/src/custody.rs @@ -221,8 +221,8 @@ mod tests { use radroots_app_test_support::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, fixture_identity}; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSecretVaultMemory, - RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountsManager, RadrootsNostrSecretVaultMemory, + RadrootsNostrSelectedAccountStatus, RadrootsSecretVault, account_secret_slot, }; const REMOTE_SIGNER_LABEL: &str = "remote signer"; @@ -242,8 +242,9 @@ mod tests { client_account_id: &str, secret: &str, ) { + let slot = account_secret_slot(&fixture_account_id(client_account_id)); vault - .store_secret_hex(&fixture_account_id(client_account_id), secret) + .store_secret(slot.as_str(), secret) .expect("store secret"); } @@ -251,8 +252,9 @@ mod tests { vault: RadrootsNostrSecretVaultMemory, ) -> impl Fn(&str) -> Result<String, String> { move |client_account_id| { + let slot = account_secret_slot(&fixture_account_id(client_account_id)); vault - .load_secret_hex(&fixture_account_id(client_account_id)) + .load_secret(slot.as_str()) .map_err(|source| source.to_string())? .ok_or_else(|| "missing secret".to_owned()) } @@ -262,8 +264,9 @@ mod tests { vault: RadrootsNostrSecretVaultMemory, ) -> impl Fn(&str) -> Result<(), String> { move |client_account_id| { + let slot = account_secret_slot(&fixture_account_id(client_account_id)); vault - .remove_secret(&fixture_account_id(client_account_id)) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string()) } } @@ -274,8 +277,9 @@ mod tests { ) -> impl Fn() -> Result<(), String> { move || { for client_account_id in &client_account_ids { + let slot = account_secret_slot(&fixture_account_id(client_account_id)); vault - .remove_secret(&fixture_account_id(client_account_id)) + .remove_secret(slot.as_str()) .map_err(|source| source.to_string())?; } Ok(()) @@ -329,7 +333,9 @@ mod tests { ); assert!( vault - .load_secret_hex(&fixture_account_id(record.client_account_id())) + .load_secret( + account_secret_slot(&fixture_account_id(record.client_account_id())).as_str() + ) .expect("load") .is_none() ); @@ -364,7 +370,9 @@ mod tests { ); assert_eq!( vault - .load_secret_hex(&fixture_account_id(record.client_account_id())) + .load_secret( + account_secret_slot(&fixture_account_id(record.client_account_id())).as_str() + ) .expect("load retained secret") .as_deref(), Some("deadbeef") @@ -511,13 +519,17 @@ mod tests { assert!(!path.exists()); assert!( vault - .load_secret_hex(&fixture_account_id(pending.client_account_id())) + .load_secret( + account_secret_slot(&fixture_account_id(pending.client_account_id())).as_str() + ) .expect("pending removed") .is_none() ); assert!( vault - .load_secret_hex(&fixture_account_id(active.client_account_id())) + .load_secret( + account_secret_slot(&fixture_account_id(active.client_account_id())).as_str() + ) .expect("active removed") .is_none() ); @@ -558,13 +570,19 @@ mod tests { assert!( vault - .load_secret_hex(&fixture_account_id(alice_client_account_id.as_str())) + .load_secret( + account_secret_slot(&fixture_account_id(alice_client_account_id.as_str())) + .as_str() + ) .expect("pending removed by namespace purge") .is_none() ); assert!( vault - .load_secret_hex(&fixture_account_id(bob_client_account_id.as_str())) + .load_secret( + account_secret_slot(&fixture_account_id(bob_client_account_id.as_str())) + .as_str() + ) .expect("active removed by namespace purge") .is_none() ); @@ -595,7 +613,10 @@ mod tests { assert!( vault - .load_secret_hex(&fixture_account_id(alice_client_account_id.as_str())) + .load_secret( + account_secret_slot(&fixture_account_id(alice_client_account_id.as_str())) + .as_str() + ) .expect("pending removed by empty-store namespace purge") .is_none() );