app

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

commit 1ba84c50a23ce2fef1c269ce8d2beec87242dd4a
parent bbc5307554943e7f1f1e03fed5a625cf329d0bbd
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 17:39:13 +0000

android: add encrypted local secret key flows

- add encrypted secret key backup and import as the primary local android account path
- keep plaintext nsec import and reveal behind the explicit advanced raw secret action
- route android user-presence export polling through encrypted backup and raw reveal states
- extend android tests for encrypted and raw local secret import and export behavior

Diffstat:
Mcrates/android/Cargo.toml | 3+++
Mcrates/android/src/lib.rs | 255++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 236 insertions(+), 22 deletions(-)

diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml @@ -29,3 +29,6 @@ jni.workspace = true ndk-context.workspace = true wgpu = { workspace = true, features = ["vulkan", "gles", "wgsl"] } winit.workspace = true + +[dev-dependencies] +radroots-app-test-support = { path = "../test-support" } diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -13,7 +13,7 @@ use radroots_app_core::{ RadrootsLocationCountryCenterLookupResult, RadrootsLocationCountryListResult, RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, RadrootsOfflineGeocoderState, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult, - SetupActionState, + RadrootsSecretImportMode, RadrootsSecretImportRequest, SetupActionState, }; #[cfg(any(target_os = "android", test))] use radroots_identity::RadrootsIdentity; @@ -25,6 +25,8 @@ use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use radroots_nostr_accounts::prelude::RadrootsNostrSelectedAccountStatus; #[cfg(any(target_os = "android", test))] use std::path::Path; +#[cfg(any(target_os = "android", test))] +use std::sync::Mutex; #[cfg(target_os = "android")] use winit::platform::android::activity::AndroidApp; #[cfg(any(target_os = "android", test))] @@ -52,6 +54,17 @@ struct AndroidBackend { } #[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +enum PendingSecretKeyExport { + EncryptedBackup { password: Zeroizing<String> }, + RawReveal, +} + +#[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +static PENDING_SECRET_KEY_EXPORT: Mutex<Option<PendingSecretKeyExport>> = Mutex::new(None); + +#[cfg(any(target_os = "android", test))] impl RadrootsAppBackend for AndroidBackend { fn load_identity_state(&self) -> Result<IdentityGateState, String> { #[cfg(target_os = "android")] @@ -261,16 +274,19 @@ impl RadrootsAppBackend for AndroidBackend { } } - fn request_import_action(&self, secret_key: &str) -> Result<Option<IdentityGateState>, String> { + fn request_import_action( + &self, + request: &RadrootsSecretImportRequest, + ) -> Result<Option<IdentityGateState>, String> { #[cfg(target_os = "android")] { let manager = Self::accounts_manager()?; - return Self::import_local_identity(&manager, secret_key).map(Some); + return Self::import_local_identity(&manager, request).map(Some); } #[cfg(not(target_os = "android"))] { - let _ = secret_key; + let _ = request; Ok(None) } } @@ -309,6 +325,12 @@ impl RadrootsAppBackend for AndroidBackend { pending: secret_key_export_pending, }, HomeActionState { + kind: HomeActionKind::RevealRawSecretKey, + label: "Reveal Raw Secret Key".to_owned(), + enabled: !secret_key_export_pending, + pending: secret_key_export_pending, + }, + HomeActionState { kind: HomeActionKind::RemoveLocalKey, label: "Remove Key From This Device".to_owned(), enabled: true, @@ -333,8 +355,9 @@ impl RadrootsAppBackend for AndroidBackend { #[cfg(target_os = "android")] { return match action { - HomeActionKind::BackupSecretKey => { - Self::begin_secret_key_export().map(|()| HomeActionResult::None) + HomeActionKind::BackupSecretKey => Ok(HomeActionResult::None), + HomeActionKind::RevealRawSecretKey => { + Self::begin_raw_secret_key_reveal().map(|()| HomeActionResult::None) } HomeActionKind::RemoveLocalKey => { let manager = Self::accounts_manager()?; @@ -358,6 +381,20 @@ impl RadrootsAppBackend for AndroidBackend { } } + fn request_secret_key_backup_action(&self, password: &str) -> Result<HomeActionResult, String> { + #[cfg(target_os = "android")] + { + return Self::begin_encrypted_secret_key_backup(password) + .map(|()| HomeActionResult::None); + } + + #[cfg(not(target_os = "android"))] + { + let _ = password; + Ok(HomeActionResult::None) + } + } + fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> { #[cfg(target_os = "android")] { @@ -472,7 +509,33 @@ impl AndroidBackend { Self::identity_state_from_manager(manager) } - fn export_selected_local_secret_key( + fn export_selected_local_encrypted_secret_key( + manager: &RadrootsNostrAccountsManager, + password: &str, + ) -> 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())?; + identity + .encrypt_secret_key_ncryptsec(password) + .map_err(|source| source.to_string()) + } + + fn export_selected_local_raw_secret_key( manager: &RadrootsNostrAccountsManager, ) -> Result<String, String> { let Some(account_id) = manager @@ -497,10 +560,24 @@ impl AndroidBackend { fn import_local_identity( manager: &RadrootsNostrAccountsManager, - secret_key: &str, + request: &RadrootsSecretImportRequest, ) -> Result<IdentityGateState, String> { - let identity = RadrootsIdentity::from_secret_key_str(secret_key) - .map_err(|_| "invalid secret key".to_owned())?; + let identity = match request.mode { + RadrootsSecretImportMode::EncryptedSecretKey => { + let Some(password) = request.password.as_deref() else { + return Err("password is required to import an encrypted secret key".to_owned()); + }; + RadrootsIdentity::from_encrypted_secret_key_str( + request.secret_text.as_str(), + password, + ) + .map_err(|_| "invalid encrypted secret key or password".to_owned())? + } + RadrootsSecretImportMode::RawSecretKey => { + RadrootsIdentity::from_secret_key_str(request.secret_text.as_str()) + .map_err(|_| "invalid raw secret key".to_owned())? + } + }; manager .upsert_identity(&identity, None, true) @@ -510,13 +587,50 @@ impl AndroidBackend { } #[cfg(target_os = "android")] - fn begin_secret_key_export() -> Result<(), String> { - security::begin_user_presence_verification("reveal the current secret key") - .map_err(|source| source.to_string()) + fn begin_encrypted_secret_key_backup(password: &str) -> Result<(), String> { + *PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to store pending encrypted secret key backup".to_owned())? = + Some(PendingSecretKeyExport::EncryptedBackup { + password: Zeroizing::new(password.to_owned()), + }); + if let Err(source) = + security::begin_user_presence_verification("back up the current secret key") + { + *PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to clear pending encrypted secret key backup".to_owned())? = + None; + return Err(source.to_string()); + } + Ok(()) + } + + #[cfg(not(target_os = "android"))] + fn begin_encrypted_secret_key_backup(password: &str) -> Result<(), String> { + let _ = password; + Ok(()) + } + + #[cfg(target_os = "android")] + fn begin_raw_secret_key_reveal() -> Result<(), String> { + *PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to store pending raw secret key reveal".to_owned())? = + Some(PendingSecretKeyExport::RawReveal); + if let Err(source) = + security::begin_user_presence_verification("reveal the current secret key") + { + *PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to clear pending raw secret key reveal".to_owned())? = None; + return Err(source.to_string()); + } + Ok(()) } #[cfg(not(target_os = "android"))] - fn begin_secret_key_export() -> Result<(), String> { + fn begin_raw_secret_key_reveal() -> Result<(), String> { Ok(()) } @@ -537,10 +651,33 @@ impl AndroidBackend { { Some(security::AndroidUserPresenceVerificationResult::Verified) => { let manager = Self::accounts_manager()?; - Self::export_selected_local_secret_key(&manager) - .map(|nsec| Some(HomeActionResult::RevealSecretKey { nsec })) + let pending_export = PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to take pending secret key export".to_owned())? + .take(); + match pending_export { + Some(PendingSecretKeyExport::EncryptedBackup { password }) => { + Self::export_selected_local_encrypted_secret_key( + &manager, + password.as_str(), + ) + .map(|ncryptsec| { + Some(HomeActionResult::RevealEncryptedSecretKey { ncryptsec }) + }) + } + Some(PendingSecretKeyExport::RawReveal) => { + Self::export_selected_local_raw_secret_key(&manager) + .map(|nsec| Some(HomeActionResult::RevealRawSecretKey { nsec })) + } + None => Err("missing pending secret key export request".to_owned()), + } + } + Some(security::AndroidUserPresenceVerificationResult::Failed(message)) => { + *PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to clear pending secret key export".to_owned())? = None; + Err(message) } - Some(security::AndroidUserPresenceVerificationResult::Failed(message)) => Err(message), None => Ok(None), } } @@ -642,6 +779,9 @@ pub extern "C" fn android_main(android_app: AndroidApp) { #[cfg(test)] mod tests { use super::*; + use radroots_app_test_support::{ + FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD, fixture_identity_ncryptsec, + }; #[test] fn android_backend_reports_android_disabled_state_off_target() { @@ -765,7 +905,7 @@ mod tests { } #[test] - fn export_selected_local_secret_key_returns_nsec() { + fn export_selected_local_raw_secret_key_returns_nsec() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let identity = RadrootsIdentity::generate(); @@ -774,19 +914,51 @@ mod tests { .expect("store identity"); let nsec = - AndroidBackend::export_selected_local_secret_key(&manager).expect("export secret"); + AndroidBackend::export_selected_local_raw_secret_key(&manager).expect("export secret"); assert_eq!(nsec, identity.nsec()); assert!(nsec.starts_with("nsec1")); } #[test] - fn import_local_identity_imports_nsec_and_selects_account() { + fn export_selected_local_encrypted_secret_key_returns_ncryptsec() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let fixture_identity = + RadrootsIdentity::from_secret_key_str(FIXTURE_ALICE.secret_key_hex).expect("fixture"); + + manager + .upsert_identity(&fixture_identity, Some("primary".into()), true) + .expect("store identity"); + + let ncryptsec = AndroidBackend::export_selected_local_encrypted_secret_key( + &manager, + FIXTURE_BACKUP_PASSWORD, + ) + .expect("export encrypted secret"); + + let restored = RadrootsIdentity::from_encrypted_secret_key_str( + ncryptsec.as_str(), + FIXTURE_BACKUP_PASSWORD, + ) + .expect("restore encrypted secret"); + + assert_eq!(restored.secret_key_hex(), FIXTURE_ALICE.secret_key_hex); + } + + #[test] + fn import_local_identity_imports_raw_secret_key_and_selects_account() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let identity = RadrootsIdentity::generate(); - let state = AndroidBackend::import_local_identity(&manager, identity.nsec().as_str()) - .expect("import"); + let state = AndroidBackend::import_local_identity( + &manager, + &RadrootsSecretImportRequest { + mode: RadrootsSecretImportMode::RawSecretKey, + secret_text: identity.nsec(), + password: None, + }, + ) + .expect("import"); assert_eq!( state, @@ -808,6 +980,45 @@ mod tests { } #[test] + fn import_local_identity_imports_encrypted_secret_key_and_selects_account() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let encrypted_secret_key = + fixture_identity_ncryptsec(&FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD) + .expect("fixture encrypted secret key"); + let fixture_identity = + RadrootsIdentity::from_secret_key_str(FIXTURE_ALICE.secret_key_hex).expect("fixture"); + let fixture_account_id = fixture_identity.id(); + + let state = AndroidBackend::import_local_identity( + &manager, + &RadrootsSecretImportRequest { + mode: RadrootsSecretImportMode::EncryptedSecretKey, + secret_text: encrypted_secret_key, + password: Some(FIXTURE_BACKUP_PASSWORD.to_owned()), + }, + ) + .expect("import"); + + assert_eq!( + state, + IdentityGateState::Ready { + account_id: fixture_account_id.to_string(), + } + ); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(fixture_account_id.clone()) + ); + assert_eq!(manager.list_accounts().expect("list").len(), 1); + assert_eq!( + manager + .export_secret_hex(&fixture_account_id) + .expect("export secret"), + Some(FIXTURE_ALICE.secret_key_hex.to_owned()) + ); + } + + #[test] fn remove_accounts_file_if_present_deletes_existing_file() { let unique = format!( "radroots-android-reset-{}-{}.json",