app

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

commit 9e35760e5941576cacd8951a4ad2b75f7a6c416b
parent 54ea1910c3912cad5a072be440948a1d43092d93
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 18:20:34 +0000

ios: add recovery-key backup for local identities

- add the ios home action to back up the selected local recovery key
- export the selected local secret through the existing accounts manager and convert it to nsec
- reveal the recovery key through the shared backup action result without changing other ios identity flows
- add ios coverage for selected local recovery-key export

Diffstat:
Mcrates/ios/src/lib.rs | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 51 insertions(+), 1 deletion(-)

diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs @@ -10,11 +10,15 @@ use radroots_app_core::{ SetupActionState, }; #[cfg(any(target_os = "ios", test))] +use radroots_identity::RadrootsIdentity; +#[cfg(any(target_os = "ios", test))] use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, }; #[cfg(any(target_os = "ios", test))] use std::path::Path; +#[cfg(any(target_os = "ios", test))] +use zeroize::Zeroizing; #[cfg(any(target_os = "ios", test))] mod storage; @@ -74,6 +78,29 @@ impl IosBackend { Self::identity_state_from_manager(manager) } + fn export_selected_local_recovery_key( + manager: &RadrootsNostrAccountsManager, + ) -> Result<String, String> { + let Some(account_id) = manager + .selected_account_id() + .map_err(|source| source.to_string())? + else { + return Err("no selected local identity is available to back up".to_owned()); + }; + + let Some(secret_key_hex) = manager + .export_secret_hex(&account_id) + .map_err(|source| source.to_string())? + else { + return Err("selected local identity does not have an exportable secret".to_owned()); + }; + + let secret_key_hex = Zeroizing::new(secret_key_hex); + let identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str()) + .map_err(|source| source.to_string())?; + Ok(identity.nsec()) + } + fn remove_all_local_identities( manager: &RadrootsNostrAccountsManager, ) -> Result<IdentityGateState, String> { @@ -135,6 +162,12 @@ impl RadrootsAppBackend for IosBackend { fn home_action_states(&self) -> Vec<HomeActionState> { vec![ HomeActionState { + kind: HomeActionKind::BackupRecoveryKey, + label: "Back Up Recovery Key".to_owned(), + enabled: true, + pending: false, + }, + HomeActionState { kind: HomeActionKind::RemoveLocalKey, label: "Remove Key From This Device".to_owned(), enabled: true, @@ -152,7 +185,8 @@ impl RadrootsAppBackend for IosBackend { fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { let manager = Self::accounts_manager()?; match action { - HomeActionKind::BackupRecoveryKey => Ok(HomeActionResult::None), + HomeActionKind::BackupRecoveryKey => Self::export_selected_local_recovery_key(&manager) + .map(|nsec| HomeActionResult::RevealRecoveryKey { nsec }), HomeActionKind::RemoveLocalKey => { Self::remove_selected_local_identity(&manager).map(HomeActionResult::IdentityState) } @@ -278,6 +312,22 @@ mod tests { } #[test] + fn export_selected_local_recovery_key_returns_nsec() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let identity = RadrootsIdentity::generate(); + + manager + .upsert_identity(&identity, Some("primary".into()), true) + .expect("store identity"); + + let nsec = + IosBackend::export_selected_local_recovery_key(&manager).expect("export recovery"); + + assert_eq!(nsec, identity.nsec()); + assert!(nsec.starts_with("nsec1")); + } + + #[test] fn remove_accounts_file_if_present_deletes_existing_file() { let unique = format!( "radroots-ios-reset-{}-{}.json",