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:
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()
);