app

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

commit 54ea1910c3912cad5a072be440948a1d43092d93
parent e5a0f2f70a62605d01c730c27afa8f7be0725911
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 18:11:13 +0000

desktop: add recovery-key backup for local identities

- add the desktop 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 desktop identity flows
- add desktop coverage for selected local recovery-key export and the required transient secret dependency

Diffstat:
MCargo.lock | 1+
Mcrates/desktop/Cargo.toml | 1+
Mcrates/desktop/src/main.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 58 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2817,6 +2817,7 @@ dependencies = [ "radroots-identity", "radroots-nostr-accounts", "wgpu", + "zeroize", ] [[package]] diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -21,6 +21,7 @@ egui.workspace = true image.workspace = true radroots-app-core = { path = "../core" } radroots-nostr-accounts.workspace = true +zeroize.workspace = true [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] wgpu = { workspace = true, features = ["metal", "wgsl"] } diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -11,12 +11,16 @@ use radroots_app_core::{ RadrootsAppBackend, SetupActionState, }; #[cfg(target_os = "macos")] +use radroots_identity::RadrootsIdentity; +#[cfg(target_os = "macos")] use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrSelectedAccountStatus, }; #[cfg(target_os = "macos")] use std::path::{Path, PathBuf}; use std::sync::Arc; +#[cfg(target_os = "macos")] +use zeroize::Zeroizing; const RADROOTS_DESKTOP_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/radroots-logo.ico"); @@ -139,6 +143,30 @@ impl DesktopBackend { } #[cfg(target_os = "macos")] + 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()) + } + + #[cfg(target_os = "macos")] fn remove_all_local_identities( manager: &RadrootsNostrAccountsManager, ) -> Result<IdentityGateState, String> { @@ -245,6 +273,12 @@ impl RadrootsAppBackend for DesktopBackend { { return 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, @@ -270,7 +304,10 @@ impl RadrootsAppBackend for DesktopBackend { { let manager = Self::accounts_manager()?; return 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), HomeActionKind::ResetDevice => { @@ -320,6 +357,7 @@ fn main() -> eframe::Result<()> { mod tests { use super::DesktopBackend; use radroots_app_apple_security::RadrootsAppleKeychainVault; + use radroots_identity::RadrootsIdentity; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::RadrootsNostrSecretVault; use std::path::PathBuf; @@ -391,6 +429,23 @@ mod tests { } #[test] + fn export_selected_local_recovery_key_returns_nsec() { + let manager = + radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager::new_in_memory(); + let identity = RadrootsIdentity::generate(); + + manager + .upsert_identity(&identity, Some("primary".into()), true) + .expect("store identity"); + + let nsec = + DesktopBackend::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-desktop-reset-{}-{}.json",