app

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

commit 3643f9fadb7737ee379be0f07b80c7900155c754
parent e0207936c5894343236f10a29640bdcf82352d7e
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 19:24:06 +0000

app: isolate remote signer vault namespace purge

Diffstat:
Mcrates/android/src/remote_signer.rs | 44++++++++++++++++++++++++++++++++++++++------
Mcrates/android/src/security.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/android/src/vault.rs | 23+++++++++++++++++++----
Mcrates/apple/security/src/security.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/apple/security/src/vault.rs | 22++++++++++++++++++----
Mcrates/desktop/src/remote_signer.rs | 44++++++++++++++++++++++++++++++++++++++------
Mcrates/ios/src/remote_signer.rs | 44++++++++++++++++++++++++++++++++++++++------
Mcrates/remote-signer/src/custody.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/remote-signer/src/lib.rs | 2++
Mnative/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mnative/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt | 14++++++++++++++
Mnative/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt | 23+++++++++++++++++++++++
Mnative/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt | 18++++++++++++++++++
Mnative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift | 17+++++++++++++++++
Mnative/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift | 35+++++++++++++++++++++++++++++++++--
15 files changed, 479 insertions(+), 36 deletions(-)

diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -5,9 +5,10 @@ use radroots_app_core::{ RadrootsRemoteSignerPreview, SetupActionState, }; use radroots_app_remote_signer::{ - RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, - RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session, + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerController, + RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession, + RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState, + radroots_app_remote_signer_clear_pending_session, radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview, radroots_app_remote_signer_purge_all_custody_state, radroots_app_remote_signer_reconcile_startup, @@ -35,6 +36,7 @@ impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks { REMOTE_SIGNER_LABEL, load_client_secret, remove_client_secret, + purge_client_secret_namespace, ) } @@ -206,7 +208,11 @@ pub(crate) fn cancel_pending_connection() -> Result<(), String> { pub(crate) fn purge_all_custody_state() -> Result<(), String> { let store_path = sessions_path()?; - radroots_app_remote_signer_purge_all_custody_state(store_path.as_path(), remove_client_secret) + radroots_app_remote_signer_purge_all_custody_state( + store_path.as_path(), + remove_client_secret, + purge_client_secret_namespace, + ) } fn activate_remote_session( @@ -263,6 +269,13 @@ fn sessions_path() -> Result<PathBuf, String> { } fn client_secret_vault() -> RadrootsAndroidKeystoreVault { + RadrootsAndroidKeystoreVault::new_with_namespace( + ANDROID_NOSTR_SERVICE, + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, + ) +} + +fn legacy_client_secret_vault() -> RadrootsAndroidKeystoreVault { RadrootsAndroidKeystoreVault::new(ANDROID_NOSTR_SERVICE) } @@ -277,10 +290,20 @@ fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result< 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())?; - client_secret_vault() + if let Some(secret) = client_secret_vault() .load_secret_hex(&account_id) .map_err(|source| source.to_string())? - .ok_or_else(|| "remote signer session secret is missing".to_owned()) + { + return Ok(secret); + } + + let secret = legacy_client_secret_vault() + .load_secret_hex(&account_id) + .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); + Ok(secret) } fn remove_client_secret(client_account_id: &str) -> Result<(), String> { @@ -288,5 +311,14 @@ fn remove_client_secret(client_account_id: &str) -> Result<(), String> { .map_err(|_| "invalid remote signer client account id".to_owned())?; client_secret_vault() .remove_secret(&account_id) + .map_err(|source| source.to_string())?; + legacy_client_secret_vault() + .remove_secret(&account_id) + .map_err(|source| source.to_string()) +} + +fn purge_client_secret_namespace() -> Result<(), String> { + client_secret_vault() + .purge_namespace() .map_err(|source| source.to_string()) } diff --git a/crates/android/src/security.rs b/crates/android/src/security.rs @@ -255,6 +255,53 @@ pub(crate) fn remove_secret( } #[cfg(target_os = "android")] +pub(crate) fn remove_secret_namespace( + service: &str, + namespace: &str, +) -> Result<(), RadrootsNostrAccountsError> { + let java_vm = android_java_vm()?; + let mut env = java_vm.attach_current_thread().map_err(jni_error)?; + let bridge_class = bridge_class(&mut env)?; + let service = java_string_arg(&mut env, service)?; + let namespace = java_string_arg(&mut env, namespace)?; + + let status = env + .call_static_method( + &bridge_class, + "deleteSecretNamespace", + "(Ljava/lang/String;Ljava/lang/String;)I", + &[JValue::Object(&service), JValue::Object(&namespace)], + ) + .and_then(|value| value.i()) + .map_err(jni_error)?; + + match AndroidSecretStatus::from_raw(status)? { + AndroidSecretStatus::Success | AndroidSecretStatus::NotFound => Ok(()), + AndroidSecretStatus::InvalidInput => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge rejected the namespace delete request", + )), + AndroidSecretStatus::Error => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android keystore namespace delete failed", + )), + } +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn remove_secret_namespace( + service: &str, + namespace: &str, +) -> Result<(), RadrootsNostrAccountsError> { + let _ = (service, namespace); + Err(RadrootsNostrAccountsError::Vault( + "android keystore storage is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> { let java_vm = android_java_vm()?; let mut env = java_vm.attach_current_thread().map_err(jni_error)?; diff --git a/crates/android/src/vault.rs b/crates/android/src/vault.rs @@ -1,4 +1,6 @@ -use crate::security::{ANDROID_NOSTR_NAMESPACE, load_secret, remove_secret, store_secret}; +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 zeroize::Zeroizing; @@ -6,18 +8,31 @@ use zeroize::Zeroizing; #[derive(Debug, Clone)] pub(crate) struct RadrootsAndroidKeystoreVault { service_name: String, + namespace: String, } impl RadrootsAndroidKeystoreVault { pub(crate) fn new(service_name: impl Into<String>) -> Self { + Self::new_with_namespace(service_name, ANDROID_NOSTR_NAMESPACE) + } + + pub(crate) fn new_with_namespace( + service_name: impl Into<String>, + namespace: impl Into<String>, + ) -> Self { Self { service_name: service_name.into(), + namespace: namespace.into(), } } fn account_name(account_id: &RadrootsIdentityId) -> &str { account_id.as_str() } + + pub(crate) fn purge_namespace(&self) -> Result<(), RadrootsNostrAccountsError> { + remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + } } impl RadrootsNostrSecretVault for RadrootsAndroidKeystoreVault { @@ -29,7 +44,7 @@ impl RadrootsNostrSecretVault for RadrootsAndroidKeystoreVault { let secret_key_hex = Zeroizing::new(secret_key_hex.to_owned()); store_secret( self.service_name.as_str(), - ANDROID_NOSTR_NAMESPACE, + self.namespace.as_str(), Self::account_name(account_id), secret_key_hex.as_bytes(), true, @@ -44,7 +59,7 @@ impl RadrootsNostrSecretVault for RadrootsAndroidKeystoreVault { ) -> Result<Option<String>, RadrootsNostrAccountsError> { let Some(secret) = load_secret( self.service_name.as_str(), - ANDROID_NOSTR_NAMESPACE, + self.namespace.as_str(), Self::account_name(account_id), )? else { @@ -66,7 +81,7 @@ impl RadrootsNostrSecretVault for RadrootsAndroidKeystoreVault { ) -> Result<(), RadrootsNostrAccountsError> { remove_secret( self.service_name.as_str(), - ANDROID_NOSTR_NAMESPACE, + self.namespace.as_str(), Self::account_name(account_id), ) } diff --git a/crates/apple/security/src/security.rs b/crates/apple/security/src/security.rs @@ -65,6 +65,12 @@ unsafe extern "C" { error_out: *mut *mut c_char, ) -> i32; + fn radroots_apple_secret_store_delete_namespace( + service_prefix: *const c_char, + namespace: *const c_char, + error_out: *mut *mut c_char, + ) -> i32; + fn radroots_apple_user_presence_verify( reason: *const c_char, error_out: *mut *mut c_char, @@ -324,6 +330,46 @@ pub fn remove_secret( } } +pub fn remove_secret_namespace( + service: &str, + namespace: &str, +) -> Result<(), RadrootsNostrAccountsError> { + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + let service = c_string(service)?; + let namespace = c_string(namespace)?; + let mut ffi_error = FfiErrorString::new(); + let status = unsafe { + // SAFETY: all pointers are backed by live CString values for the duration + // of the call. + radroots_apple_secret_store_delete_namespace( + service.as_ptr(), + namespace.as_ptr(), + ffi_error.as_mut_ptr(), + ) + }; + return match AppleSecretStatus::from_raw(status)? { + AppleSecretStatus::Success | AppleSecretStatus::NotFound => Ok(()), + AppleSecretStatus::InvalidInput => Err(vault_error( + ffi_error, + "apple security ffi rejected the namespace delete request", + )), + AppleSecretStatus::Error => Err(vault_error( + ffi_error, + "apple keychain namespace delete failed", + )), + }; + } + + #[cfg(not(any(target_os = "ios", target_os = "macos")))] + { + let _ = (service, namespace); + Err(RadrootsNostrAccountsError::Vault( + "apple keychain storage is only available on ios and macos".to_owned(), + )) + } +} + pub fn verify_user_presence(reason: &str) -> Result<(), RadrootsNostrAccountsError> { #[cfg(any(target_os = "ios", target_os = "macos"))] { diff --git a/crates/apple/security/src/vault.rs b/crates/apple/security/src/vault.rs @@ -1,5 +1,6 @@ use crate::security::{ - APPLE_NOSTR_NAMESPACE, AppleSecretAccessPolicy, load_secret, remove_secret, store_secret, + APPLE_NOSTR_NAMESPACE, AppleSecretAccessPolicy, load_secret, remove_secret, + remove_secret_namespace, store_secret, }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrSecretVault}; @@ -8,18 +9,31 @@ use zeroize::Zeroizing; #[derive(Debug, Clone)] pub struct RadrootsAppleKeychainVault { service_name: String, + namespace: String, } impl RadrootsAppleKeychainVault { pub fn new(service_name: impl Into<String>) -> Self { + Self::new_with_namespace(service_name, APPLE_NOSTR_NAMESPACE) + } + + pub fn new_with_namespace( + service_name: impl Into<String>, + namespace: impl Into<String>, + ) -> Self { Self { service_name: service_name.into(), + namespace: namespace.into(), } } fn account_name(account_id: &RadrootsIdentityId) -> &str { account_id.as_str() } + + pub fn purge_namespace(&self) -> Result<(), RadrootsNostrAccountsError> { + remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + } } impl RadrootsNostrSecretVault for RadrootsAppleKeychainVault { @@ -31,7 +45,7 @@ impl RadrootsNostrSecretVault for RadrootsAppleKeychainVault { let secret_key_hex = Zeroizing::new(secret_key_hex.to_owned()); store_secret( self.service_name.as_str(), - APPLE_NOSTR_NAMESPACE, + self.namespace.as_str(), Self::account_name(account_id), secret_key_hex.as_bytes(), AppleSecretAccessPolicy::SECURE_LOCAL_SECRET, @@ -44,7 +58,7 @@ impl RadrootsNostrSecretVault for RadrootsAppleKeychainVault { ) -> Result<Option<String>, RadrootsNostrAccountsError> { let Some(secret) = load_secret( self.service_name.as_str(), - APPLE_NOSTR_NAMESPACE, + self.namespace.as_str(), Self::account_name(account_id), )? else { @@ -66,7 +80,7 @@ impl RadrootsNostrSecretVault for RadrootsAppleKeychainVault { ) -> Result<(), RadrootsNostrAccountsError> { remove_secret( self.service_name.as_str(), - APPLE_NOSTR_NAMESPACE, + self.namespace.as_str(), Self::account_name(account_id), ) } diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -5,9 +5,10 @@ use radroots_app_core::{ RadrootsRemoteSignerPreview, SetupActionState, }; use radroots_app_remote_signer::{ - RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, - RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session, + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerController, + RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession, + RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState, + radroots_app_remote_signer_clear_pending_session, radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview, radroots_app_remote_signer_purge_all_custody_state, radroots_app_remote_signer_reconcile_startup, @@ -34,6 +35,7 @@ impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks { REMOTE_SIGNER_LABEL, load_client_secret, remove_client_secret, + purge_client_secret_namespace, ) } @@ -205,7 +207,11 @@ pub(crate) fn cancel_pending_connection() -> Result<(), String> { pub(crate) fn purge_all_custody_state() -> Result<(), String> { let store_path = sessions_path()?; - radroots_app_remote_signer_purge_all_custody_state(store_path.as_path(), remove_client_secret) + radroots_app_remote_signer_purge_all_custody_state( + store_path.as_path(), + remove_client_secret, + purge_client_secret_namespace, + ) } fn activate_remote_session( @@ -263,6 +269,13 @@ fn sessions_path() -> Result<PathBuf, String> { } fn client_secret_vault() -> RadrootsAppleKeychainVault { + RadrootsAppleKeychainVault::new_with_namespace( + APPLE_NOSTR_SERVICE, + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, + ) +} + +fn legacy_client_secret_vault() -> RadrootsAppleKeychainVault { RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE) } @@ -277,10 +290,20 @@ fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result< 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())?; - client_secret_vault() + if let Some(secret) = client_secret_vault() .load_secret_hex(&account_id) .map_err(|source| source.to_string())? - .ok_or_else(|| "remote signer session secret is missing".to_owned()) + { + return Ok(secret); + } + + let secret = legacy_client_secret_vault() + .load_secret_hex(&account_id) + .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); + Ok(secret) } fn remove_client_secret(client_account_id: &str) -> Result<(), String> { @@ -288,6 +311,15 @@ fn remove_client_secret(client_account_id: &str) -> Result<(), String> { .map_err(|_| "invalid remote signer client account id".to_owned())?; client_secret_vault() .remove_secret(&account_id) + .map_err(|source| source.to_string())?; + legacy_client_secret_vault() + .remove_secret(&account_id) + .map_err(|source| source.to_string()) +} + +fn purge_client_secret_namespace() -> Result<(), String> { + client_secret_vault() + .purge_namespace() .map_err(|source| source.to_string()) } diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs @@ -5,9 +5,10 @@ use radroots_app_core::{ RadrootsRemoteSignerPreview, SetupActionState, }; use radroots_app_remote_signer::{ - RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, - RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session, + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerController, + RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession, + RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState, + radroots_app_remote_signer_clear_pending_session, radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview, radroots_app_remote_signer_purge_all_custody_state, radroots_app_remote_signer_reconcile_startup, @@ -35,6 +36,7 @@ impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks { REMOTE_SIGNER_LABEL, load_client_secret, remove_client_secret, + purge_client_secret_namespace, ) } @@ -206,7 +208,11 @@ pub(crate) fn cancel_pending_connection() -> Result<(), String> { pub(crate) fn purge_all_custody_state() -> Result<(), String> { let store_path = sessions_path()?; - radroots_app_remote_signer_purge_all_custody_state(store_path.as_path(), remove_client_secret) + radroots_app_remote_signer_purge_all_custody_state( + store_path.as_path(), + remove_client_secret, + purge_client_secret_namespace, + ) } fn activate_remote_session( @@ -264,6 +270,13 @@ fn sessions_path() -> Result<PathBuf, String> { } fn client_secret_vault() -> RadrootsAppleKeychainVault { + RadrootsAppleKeychainVault::new_with_namespace( + APPLE_NOSTR_SERVICE, + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, + ) +} + +fn legacy_client_secret_vault() -> RadrootsAppleKeychainVault { RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE) } @@ -278,10 +291,20 @@ fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result< 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())?; - client_secret_vault() + if let Some(secret) = client_secret_vault() .load_secret_hex(&account_id) .map_err(|source| source.to_string())? - .ok_or_else(|| "remote signer session secret is missing".to_owned()) + { + return Ok(secret); + } + + let secret = legacy_client_secret_vault() + .load_secret_hex(&account_id) + .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); + Ok(secret) } fn remove_client_secret(client_account_id: &str) -> Result<(), String> { @@ -289,5 +312,14 @@ fn remove_client_secret(client_account_id: &str) -> Result<(), String> { .map_err(|_| "invalid remote signer client account id".to_owned())?; client_secret_vault() .remove_secret(&account_id) + .map_err(|source| source.to_string())?; + legacy_client_secret_vault() + .remove_secret(&account_id) + .map_err(|source| source.to_string()) +} + +fn purge_client_secret_namespace() -> Result<(), String> { + client_secret_vault() + .purge_namespace() .map_err(|source| source.to_string()) } diff --git a/crates/remote-signer/src/custody.rs b/crates/remote-signer/src/custody.rs @@ -72,6 +72,7 @@ pub fn radroots_app_remote_signer_reconcile_startup( remote_signer_label: &str, load_client_secret: impl Fn(&str) -> Result<String, String>, remove_client_secret: impl Fn(&str) -> Result<(), String>, + purge_client_secret_namespace: impl Fn() -> Result<(), String>, ) -> Result<(), String> { let load = load_sessions_with_recovery(path)?; let mut state = load.state; @@ -85,6 +86,7 @@ pub fn radroots_app_remote_signer_reconcile_startup( .collect::<HashSet<_>>(); if load.recovered_from_corruption { + purge_client_secret_namespace()?; for account in remote_signer_public_only_accounts(manager, &accounts, remote_signer_label)? { manager @@ -129,11 +131,13 @@ pub fn radroots_app_remote_signer_reconcile_startup( pub fn radroots_app_remote_signer_purge_all_custody_state( path: &Path, remove_client_secret: impl Fn(&str) -> Result<(), String>, + purge_client_secret_namespace: impl Fn() -> Result<(), String>, ) -> Result<(), String> { let load = load_sessions_with_recovery(path)?; for record in &load.state.sessions { remove_client_secret(record.client_account_id())?; } + purge_client_secret_namespace()?; remove_sessions_file_if_present(path)?; Ok(()) } @@ -240,6 +244,20 @@ mod tests { } } + fn secret_namespace_purger( + vault: RadrootsNostrSecretVaultMemory, + client_account_ids: Vec<String>, + ) -> impl Fn() -> Result<(), String> { + move || { + for client_account_id in &client_account_ids { + vault + .remove_secret(&fixture_account_id(client_account_id)) + .map_err(|source| source.to_string())?; + } + Ok(()) + } + } + fn write_pending_state(path: &Path) -> RadrootsAppRemoteSignerSessionRecord { let record = RadrootsAppRemoteSignerSessionRecord::pending( fixture_public(&FIXTURE_ALICE), @@ -355,6 +373,7 @@ mod tests { REMOTE_SIGNER_LABEL, secret_loader(RadrootsNostrSecretVaultMemory::new()), secret_remover(RadrootsNostrSecretVaultMemory::new()), + secret_namespace_purger(RadrootsNostrSecretVaultMemory::new(), Vec::new()), ) .expect("reconcile"); @@ -392,6 +411,13 @@ mod tests { radroots_app_remote_signer_purge_all_custody_state( path.as_path(), secret_remover(vault.clone()), + secret_namespace_purger( + vault.clone(), + vec![ + pending.client_account_id().to_owned(), + active.client_account_id().to_owned(), + ], + ), ) .expect("purge"); @@ -409,4 +435,46 @@ mod tests { .is_none() ); } + + #[test] + fn reconcile_startup_purges_namespace_after_store_quarantine() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + std::fs::write(path.as_path(), "{invalid").expect("write invalid"); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let public = fixture_public(&FIXTURE_CAROL); + manager + .upsert_public_identity(public, Some(REMOTE_SIGNER_LABEL.to_owned()), true) + .expect("upsert"); + + let vault = RadrootsNostrSecretVaultMemory::new(); + secret_store_secret(&vault, FIXTURE_ALICE.id, "pending"); + secret_store_secret(&vault, FIXTURE_BOB.id, "active"); + + radroots_app_remote_signer_reconcile_startup( + &manager, + path.as_path(), + REMOTE_SIGNER_LABEL, + secret_loader(vault.clone()), + secret_remover(vault.clone()), + secret_namespace_purger( + vault.clone(), + vec![FIXTURE_ALICE.id.to_owned(), FIXTURE_BOB.id.to_owned()], + ), + ) + .expect("reconcile after quarantine"); + + assert!( + vault + .load_secret_hex(&fixture_account_id(FIXTURE_ALICE.id)) + .expect("pending removed by namespace purge") + .is_none() + ); + assert!( + vault + .load_secret_hex(&fixture_account_id(FIXTURE_BOB.id)) + .expect("active removed by namespace purge") + .is_none() + ); + } } diff --git a/crates/remote-signer/src/lib.rs b/crates/remote-signer/src/lib.rs @@ -7,6 +7,8 @@ mod input; mod protocol; mod session; +pub const RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE: &str = "remote-signer"; + pub use controller::{RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks}; pub use custody::{ radroots_app_remote_signer_clear_pending_session, diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt @@ -47,10 +47,21 @@ class RadRootsAndroidKeystoreSecretStore( ): ByteArray? { validateIdentifiers(servicePrefix, namespace, name) val target = RadRootsAndroidStoragePaths.secretFile(context, servicePrefix, namespace, name) - if (!target.exists()) { + val legacyTarget = RadRootsAndroidStoragePaths.legacySecretFile( + context.noBackupFilesDir, + servicePrefix, + namespace, + name, + ) + val source = when { + target.exists() -> target + legacyTarget.exists() -> legacyTarget + else -> null + } + if (source == null) { return null } - val secretBlob = readSecretFile(target) + val secretBlob = readSecretFile(source) val (iv, ciphertext) = decodeSecretBlob(secretBlob) val cipher = Cipher.getInstance(cipherTransformation) cipher.init( @@ -77,13 +88,36 @@ class RadRootsAndroidKeystoreSecretStore( name: String, ) { validateIdentifiers(servicePrefix, namespace, name) - val target = RadRootsAndroidStoragePaths.secretFile(context, servicePrefix, namespace, name) - if (!target.exists()) { - return - } - if (!target.delete()) { - throw RadRootsAndroidSecurityError.StorageFailure("failed to delete encrypted secret file") + val current = RadRootsAndroidStoragePaths.secretFile(context, servicePrefix, namespace, name) + val legacy = RadRootsAndroidStoragePaths.legacySecretFile( + context.noBackupFilesDir, + servicePrefix, + namespace, + name, + ) + deleteSecretFileIfPresent(current) + deleteSecretFileIfPresent(legacy) + } + + fun deleteNamespace( + servicePrefix: String, + namespace: String, + ) { + validateNamespace(servicePrefix, namespace) + val secretsDir = RadRootsAndroidStoragePaths.secretsDir(context) + val prefix = RadRootsAndroidStoragePaths.namespaceFilePrefix(servicePrefix, namespace) + val children = secretsDir.listFiles().orEmpty() + for (child in children) { + if (!child.isFile || !child.name.startsWith(prefix) || !child.name.endsWith(".bin")) { + continue + } + if (!child.delete()) { + throw RadRootsAndroidSecurityError.StorageFailure( + "failed to delete encrypted secret namespace file", + ) + } } + deleteKey(masterKeyAlias(servicePrefix, namespace)) } fun resolveNostrStorageRoot(): File = RadRootsAndroidStoragePaths.nostrRoot(context) @@ -100,6 +134,15 @@ class RadRootsAndroidKeystoreSecretStore( } } + private fun validateNamespace(servicePrefix: String, namespace: String) { + if (servicePrefix.isBlank()) { + throw RadRootsAndroidSecurityError.InvalidInput("service prefix must not be blank") + } + if (namespace.isBlank()) { + throw RadRootsAndroidSecurityError.InvalidInput("namespace must not be blank") + } + } + private fun requireSupportedPolicy(policy: RadRootsAndroidSecretAccessPolicy) { if (!policy.deviceLocalOnly) { throw RadRootsAndroidSecurityError.InvalidInput( @@ -295,6 +338,15 @@ class RadRootsAndroidKeystoreSecretStore( } } + private fun deleteSecretFileIfPresent(target: File) { + if (!target.exists()) { + return + } + if (!target.delete()) { + throw RadRootsAndroidSecurityError.StorageFailure("failed to delete encrypted secret file") + } + } + private fun keystoreFailure(cause: Throwable): RadRootsAndroidSecurityError.KeystoreFailure { return when (cause) { is RadRootsAndroidSecurityError.KeystoreFailure -> cause diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt @@ -96,6 +96,20 @@ object RadRootsAndroidSecurityBridge { } @JvmStatic + fun deleteSecretNamespace( + servicePrefix: String, + namespace: String, + ): Int { + return try { + secretStore().deleteNamespace(servicePrefix, namespace) + clearError() + STATUS_SUCCESS + } catch (cause: Throwable) { + captureError(cause) + } + } + + @JvmStatic fun resolveNostrStorageRoot(): String? { return try { val path = secretStore().resolveNostrStorageRoot().absolutePath diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt @@ -46,8 +46,31 @@ object RadRootsAndroidStoragePaths { servicePrefix: String, namespace: String, name: String, + ): File = File( + secretsDir(baseDir), + "${secretNamespaceId(servicePrefix, namespace)}.${secretFileId(servicePrefix, namespace, name)}.bin", + ) + + fun legacySecretFile( + baseDir: File, + servicePrefix: String, + namespace: String, + name: String, ): File = File(secretsDir(baseDir), "${secretFileId(servicePrefix, namespace, name)}.bin") + fun secretNamespaceId(servicePrefix: String, namespace: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val encoded = buildString { + append(servicePrefix) + append('\u0000') + append(namespace) + }.toByteArray(Charsets.UTF_8) + return digest.digest(encoded).joinToString("") { "%02x".format(it) } + } + + fun namespaceFilePrefix(servicePrefix: String, namespace: String): String = + "${secretNamespaceId(servicePrefix, namespace)}." + fun secretFileId(servicePrefix: String, namespace: String, name: String): String { val digest = MessageDigest.getInstance("SHA-256") val encoded = buildString { diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt @@ -50,6 +50,24 @@ class RadRootsAndroidSecurityTests { } @Test + fun secretFileNamesCarryNamespacePrefix() { + val baseDir = File("/data/user/0/org.radroots.app.android/no_backup") + val path = RadRootsAndroidStoragePaths.secretFile( + baseDir = baseDir, + servicePrefix = "org.radroots.app.nostr", + namespace = "remote-signer", + name = "client-1", + ) + + assertTrue(path.name.endsWith(".bin")) + assertTrue( + path.name.startsWith( + "${RadRootsAndroidStoragePaths.secretNamespaceId("org.radroots.app.nostr", "remote-signer")}.", + ), + ) + } + + @Test fun strongBoxIsRequestedOnlyWhenSupported() { val policy = RadRootsAndroidSecretAccessPolicy.SECURE_LOCAL_SECRET diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift @@ -54,6 +54,16 @@ public final class RadRootsAppleKeychainSecretStore: @unchecked Sendable { } } + public func deleteNamespace(_ namespace: String) throws { + guard !namespace.isEmpty else { + throw RadRootsAppleSecurityError.invalidRequest("secret namespace cannot be empty") + } + let status = SecItemDelete(namespaceQuery(namespace) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw Self.mapSecurityStatus(status, defaultMessage: "keychain namespace delete failed") + } + } + func baseQuery(for key: RadRootsAppleSecretKey) -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, @@ -62,6 +72,13 @@ public final class RadRootsAppleKeychainSecretStore: @unchecked Sendable { ] } + func namespaceQuery(_ namespace: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(servicePrefix).\(namespace)" + ] + } + func writeQuery( for key: RadRootsAppleSecretKey, value: Data, diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift b/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift @@ -114,6 +114,23 @@ public func radroots_apple_secret_store_delete( } } +@_cdecl("radroots_apple_secret_store_delete_namespace") +public func radroots_apple_secret_store_delete_namespace( + _ servicePrefix: UnsafePointer<CChar>?, + _ namespace: UnsafePointer<CChar>?, + _ errorOut: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? +) -> Int32 { + do { + let store = try makeStore(servicePrefix: servicePrefix) + let namespace = try makeNamespace(namespace) + try store.deleteNamespace(namespace) + return RadRootsAppleFFIStatus.success.rawValue + } catch { + setError(error, into: errorOut) + return statusForError(error) + } +} + @_cdecl("radroots_apple_user_presence_verify") public func radroots_apple_user_presence_verify( _ reason: UnsafePointer<CChar>?, @@ -170,15 +187,29 @@ private func makeKey( namespace: UnsafePointer<CChar>?, name: UnsafePointer<CChar>? ) throws -> RadRootsAppleSecretKey { - guard let namespace, let name else { + let namespaceValue = try makeNamespace(namespace) + guard let name else { throw RadRootsAppleSecurityError.invalidRequest("secret namespace and name are required") } return try RadRootsAppleSecretKey( - namespace: String(cString: namespace), + namespace: namespaceValue, name: String(cString: name) ) } +private func makeNamespace( + _ namespace: UnsafePointer<CChar>? +) throws -> String { + guard let namespace else { + throw RadRootsAppleSecurityError.invalidRequest("secret namespace is required") + } + let value = String(cString: namespace) + guard !value.isEmpty else { + throw RadRootsAppleSecurityError.invalidRequest("secret namespace cannot be empty") + } + return value +} + private func makePolicy( accessibilityRaw: Int32, deviceLocalOnlyRaw: Int32,