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:
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",