commit 19234ef5e7315794e742fba4b79a7993d31cb935 parent d0eb3433728098840f6ede6bb5fe26f2c0e9dc26 Author: triesap <tyson@radroots.org> Date: Sat, 11 Apr 2026 00:40:01 +0000 app: refactor repository structure Diffstat:
98 files changed, 3163 insertions(+), 3076 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -2,13 +2,13 @@ /.trunk/ /assets/geocoder/geonames.db /assets/geocoder/geonames.revision -/crates/web/.trunk/ -/crates/web/dist/ -/native/apple/swift/**/.build/ -/native/apple/swift/**/.swiftpm/ -/native/android/kotlin/**/.gradle/ -/native/android/kotlin/**/build/ -/native/android/kotlin/**/local.properties +/crates/**/.trunk/ +/crates/**/dist/ +/native/**/.build/ +/native/**/.swiftpm/ +/native/**/.gradle/ +/native/**/build/ +/native/**/local.properties /platforms/android/.gradle/ /platforms/android/.tooling/ /platforms/android/app/build/ diff --git a/AGENTS.md b/AGENTS.md @@ -47,9 +47,10 @@ Before making substantial changes: ## 6. Workspace structure - Keep the repository root as the workspace root. -- Keep shared application code under `crates/core`. -- Keep target launchers and bridge crates under `crates/`. -- Keep reusable platform-native libraries under `native/`. +- Keep reusable Rust application logic under `crates/shared/`. +- Keep Rust host-integration adapters under `crates/bridges/`. +- Keep runnable Rust targets under `crates/launchers/`. +- Keep reusable platform-native bridge libraries under `native/bridges/`. - Keep native host projects under `platforms/`. - Add new crates only when they represent a durable architectural boundary. - Keep manifests, paths, and crate boundaries simple and intentional. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Rad Roots is an open-source application. Contributions are welcome, including bu ## Scope -This repository is the standalone Rad Roots application repository. Shared Rust application code is organized under `crates/`. Reusable native libraries are organized under `native/`, and native host projects are organized under `platforms/`. +This repository is the standalone Rad Roots application repository. Reusable Rust application logic is organized under `crates/shared/`, Rust host bridges under `crates/bridges/`, runnable Rust targets under `crates/launchers/`, native bridge implementations under `native/bridges/`, and native host projects under `platforms/`. ## Prerequisites @@ -110,21 +110,21 @@ Check the wasm application: Build the wasm application: ```bash -cd crates/web -../../scripts/with-wasm-toolchain.sh env -u NO_COLOR trunk build +cd crates/launchers/web +../../../scripts/with-wasm-toolchain.sh env -u NO_COLOR trunk build ``` Run the wasm application: ```bash -cd crates/web -../../scripts/with-wasm-toolchain.sh env -u NO_COLOR trunk serve --open +cd crates/launchers/web +../../../scripts/with-wasm-toolchain.sh env -u NO_COLOR trunk serve --open ``` Test the Apple native security package: ```bash -cd native/apple/swift/RadRootsAppleSecurity +cd native/bridges/apple/security/swift/RadRootsAppleSecurity swift test ``` diff --git a/Cargo.lock b/Cargo.lock @@ -3051,9 +3051,8 @@ version = "0.1.0" dependencies = [ "android_logger", "eframe", - "jni 0.21.1", "log", - "ndk-context", + "radroots_app_android_security", "radroots_app_core", "radroots_app_remote_signer", "radroots_app_test_support", @@ -3061,13 +3060,23 @@ dependencies = [ "radroots_identity", "radroots_nostr_accounts", "radroots_runtime_paths", - "radroots_secret_vault", "wgpu", "winit", "zeroize", ] [[package]] +name = "radroots_app_android_security" +version = "0.1.0" +dependencies = [ + "jni 0.21.1", + "ndk-context", + "radroots_nostr_accounts", + "radroots_secret_vault", + "zeroize", +] + +[[package]] name = "radroots_app_apple_security" version = "0.1.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -1,13 +1,14 @@ [workspace] members = [ - "crates/apple/security", - "crates/android", - "crates/core", - "crates/desktop", - "crates/ios", - "crates/remote_signer", - "crates/test_support", - "crates/web", + "crates/shared/core", + "crates/shared/remote_signer", + "crates/shared/test_support", + "crates/bridges/android/security", + "crates/bridges/apple/security", + "crates/launchers/android", + "crates/launchers/desktop", + "crates/launchers/ios", + "crates/launchers/web", ] resolver = "2" @@ -32,7 +33,8 @@ ndk-context = "0.1.1" nostr = { version = "0.44.1", default-features = false, features = ["std"] } nostr-browser-signer = "0.44.1" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] } -radroots_app_apple_security = { path = "crates/apple/security" } +radroots_app_android_security = { path = "crates/bridges/android/security" } +radroots_app_apple_security = { path = "crates/bridges/apple/security" } radroots_geocoder = { path = "../lib/crates/geocoder" } radroots_identity = { path = "../lib/crates/identity", default-features = false, features = ["std", "nip49"] } radroots_nostr = { path = "../lib/crates/nostr", default-features = false, features = ["std", "client"] } diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml @@ -1,37 +0,0 @@ -[package] -name = "radroots_app_android" -authors.workspace = true -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Rad Roots Android launcher" -publish = false - -[lib] -path = "src/lib.rs" -crate-type = ["cdylib", "rlib"] - -[dependencies] -eframe = { workspace = true, features = ["android-game-activity", "glow"] } -log.workspace = true -radroots_app_core = { path = "../core" } -radroots_app_remote_signer = { path = "../remote_signer" } -radroots_geocoder.workspace = true -radroots_identity.workspace = true -radroots_nostr_accounts = { workspace = true, features = ["memory-vault"] } -radroots_runtime_paths.workspace = true -radroots_secret_vault.workspace = true -zeroize.workspace = true - -[target.'cfg(target_os = "android")'.dependencies] -android_logger.workspace = true -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 @@ -1,1230 +0,0 @@ -#[cfg(target_os = "android")] -use android_logger::Config; -#[cfg(target_os = "android")] -use eframe::egui::ViewportBuilder; -#[cfg(any(target_os = "android", test))] -use radroots_app_core::RadrootsAppBackend; -#[cfg(target_os = "android")] -use radroots_app_core::{APP_NAME, RadrootsApp}; -#[cfg(any(target_os = "android", test))] -use radroots_app_core::{ - HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, ImportActionState, - RadrootsAccountCustody, RadrootsAccountSummary, RadrootsLocationCountry, - RadrootsLocationCountryCenterLookupResult, RadrootsLocationCountryListResult, - RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, - RadrootsOfflineGeocoderState, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult, - RadrootsSecretImportMode, RadrootsSecretImportRequest, SetupActionState, -}; -#[cfg(any(target_os = "android", test))] -use radroots_identity::RadrootsIdentity; -#[cfg(test)] -use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; -#[cfg(any(target_os = "android", test))] -use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; -#[cfg(any(target_os = "android", test))] -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))] -use zeroize::Zeroizing; - -#[cfg(any(target_os = "android", test))] -mod country_lookup; -#[cfg(any(target_os = "android", test))] -mod offline_geocoder; -#[cfg(target_os = "android")] -mod remote_signer; -#[cfg(any(target_os = "android", test))] -mod reverse_lookup; -#[cfg(any(target_os = "android", test))] -mod security; -#[cfg(any(target_os = "android", test))] -mod storage; -#[cfg(any(target_os = "android", test))] -mod vault; - -#[cfg(any(target_os = "android", test))] -#[cfg_attr(not(target_os = "android"), allow(dead_code))] -struct AndroidBackend { - country_lookup: country_lookup::AndroidCountryLookup, - offline_geocoder: offline_geocoder::AndroidOfflineGeocoder, - #[cfg(target_os = "android")] - remote_signer: remote_signer::AndroidRemoteSigner, - reverse_lookup: reverse_lookup::AndroidReverseLookup, -} - -#[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")] - { - let manager = Self::accounts_manager()?; - let status = manager - .selected_account_status() - .map_err(|source| source.to_string())?; - return remote_signer::identity_state_from_status(status); - } - - #[cfg(not(target_os = "android"))] - { - Ok(Self::unsupported_identity_state()) - } - } - - fn load_account_roster(&self) -> Result<Vec<RadrootsAccountSummary>, String> { - #[cfg(target_os = "android")] - { - let manager = Self::accounts_manager()?; - return Self::account_roster_from_manager(&manager); - } - - #[cfg(not(target_os = "android"))] - { - Ok(Vec::new()) - } - } - - fn offline_geocoder_state(&self) -> Option<RadrootsOfflineGeocoderState> { - Some(self.offline_geocoder.current_state()) - } - - fn poll_offline_geocoder_state(&self) -> Result<Option<RadrootsOfflineGeocoderState>, String> { - Ok(self.offline_geocoder.take_update()) - } - - fn reverse_location( - &self, - point: RadrootsLocationPoint, - options: Option<RadrootsLocationReverseOptions>, - ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { - #[cfg(target_os = "android")] - { - return offline_geocoder::reverse_location( - &self.offline_geocoder.current_state(), - point, - options, - ); - } - - #[cfg(not(target_os = "android"))] - { - let _ = (point, options); - Err(RadrootsLocationResolverError::Unsupported) - } - } - - fn request_reverse_location_lookup( - &self, - point: RadrootsLocationPoint, - options: Option<RadrootsLocationReverseOptions>, - ) -> Result<(), RadrootsLocationResolverError> { - #[cfg(target_os = "android")] - { - return self.reverse_lookup.begin( - self.offline_geocoder.current_state(), - point, - options, - ); - } - - #[cfg(not(target_os = "android"))] - { - let _ = (point, options); - Err(RadrootsLocationResolverError::Unsupported) - } - } - - fn poll_reverse_location_lookup_result( - &self, - ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { - Ok(self.reverse_lookup.take_update()) - } - - fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { - #[cfg(target_os = "android")] - { - return self - .country_lookup - .begin_list(self.offline_geocoder.current_state()); - } - - #[cfg(not(target_os = "android"))] - { - Err(RadrootsLocationResolverError::Unsupported) - } - } - - fn poll_location_country_list_result( - &self, - ) -> Result<Option<RadrootsLocationCountryListResult>, String> { - Ok(self.country_lookup.take_list_update()) - } - - fn request_location_country_center_lookup( - &self, - country_id: &str, - ) -> Result<(), RadrootsLocationResolverError> { - #[cfg(target_os = "android")] - { - return self - .country_lookup - .begin_center(self.offline_geocoder.current_state(), country_id.to_owned()); - } - - #[cfg(not(target_os = "android"))] - { - let _ = country_id; - Err(RadrootsLocationResolverError::Unsupported) - } - } - - fn poll_location_country_center_lookup_result( - &self, - ) -> Result<Option<RadrootsLocationCountryCenterLookupResult>, String> { - Ok(self.country_lookup.take_center_update()) - } - - fn list_location_countries( - &self, - ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { - #[cfg(target_os = "android")] - { - return offline_geocoder::list_countries(&self.offline_geocoder.current_state()); - } - - #[cfg(not(target_os = "android"))] - { - Err(RadrootsLocationResolverError::Unsupported) - } - } - - fn location_country_center( - &self, - country_id: &str, - ) -> Result<RadrootsLocationPoint, RadrootsLocationResolverError> { - #[cfg(target_os = "android")] - { - return offline_geocoder::country_center( - &self.offline_geocoder.current_state(), - country_id, - ); - } - - #[cfg(not(target_os = "android"))] - { - let _ = country_id; - Err(RadrootsLocationResolverError::Unsupported) - } - } - - fn setup_action_state(&self) -> SetupActionState { - #[cfg(target_os = "android")] - { - return Self::enabled_setup_action_state(); - } - - #[cfg(not(target_os = "android"))] - { - Self::unsupported_setup_action_state() - } - } - - fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> { - #[cfg(target_os = "android")] - { - let manager = Self::accounts_manager()?; - return Self::generate_local_identity(&manager).map(Some); - } - - #[cfg(not(target_os = "android"))] - { - Ok(Some(Self::unsupported_identity_state())) - } - } - - fn home_setup_action_state(&self) -> Option<SetupActionState> { - Some(self.setup_action_state()) - } - - fn request_home_setup_action(&self) -> Result<Option<IdentityGateState>, String> { - self.request_setup_action() - } - - fn import_action_state(&self) -> Option<ImportActionState> { - #[cfg(target_os = "android")] - { - return Some(ImportActionState { - label: "Import Secret Key".to_owned(), - enabled: true, - pending: false, - }); - } - - #[cfg(not(target_os = "android"))] - { - None - } - } - - 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, request).map(Some); - } - - #[cfg(not(target_os = "android"))] - { - let _ = request; - Ok(None) - } - } - - fn request_select_account( - &self, - account_id: &str, - ) -> Result<Option<IdentityGateState>, String> { - #[cfg(target_os = "android")] - { - let manager = Self::accounts_manager()?; - let account_id = radroots_identity::RadrootsIdentityId::try_from(account_id) - .map_err(|_| "invalid account id".to_owned())?; - manager - .select_account(&account_id) - .map_err(|source| source.to_string())?; - return self.load_identity_state().map(Some); - } - - #[cfg(not(target_os = "android"))] - { - let _ = account_id; - Ok(None) - } - } - - fn remote_signer_action_state(&self) -> Option<SetupActionState> { - #[cfg(target_os = "android")] - { - return Some( - self.remote_signer - .action_state() - .unwrap_or_else(|_| SetupActionState { - label: "Connect Remote Signer".to_owned(), - enabled: !self.remote_signer.is_connecting(), - pending: self.remote_signer.is_connecting(), - }), - ); - } - - #[cfg(not(target_os = "android"))] - { - None - } - } - - fn preview_remote_signer_connection( - &self, - input: &str, - ) -> Result<radroots_app_core::RadrootsRemoteSignerPreview, String> { - #[cfg(target_os = "android")] - { - return remote_signer::preview_connection(input); - } - - #[cfg(not(target_os = "android"))] - { - let _ = input; - Err("remote signer onboarding is not available in this build".to_owned()) - } - } - - fn request_remote_signer_connection( - &self, - input: &str, - ) -> Result<Option<IdentityGateState>, String> { - #[cfg(target_os = "android")] - { - self.remote_signer.begin_connect(input)?; - return Ok(None); - } - - #[cfg(not(target_os = "android"))] - { - let _ = input; - Ok(None) - } - } - - fn pending_remote_signer_connection( - &self, - ) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> { - #[cfg(target_os = "android")] - { - return self.remote_signer.pending_connection(); - } - - #[cfg(not(target_os = "android"))] - { - Ok(None) - } - } - - fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> { - #[cfg(target_os = "android")] - { - return remote_signer::cancel_pending_connection(); - } - - #[cfg(not(target_os = "android"))] - { - Ok(()) - } - } - - fn remote_signer_note_action_state(&self) -> Option<SetupActionState> { - #[cfg(target_os = "android")] - { - return Some( - self.remote_signer - .note_action_state() - .unwrap_or(SetupActionState { - label: "Sign Remote Kind 1 Note".to_owned(), - enabled: false, - pending: false, - }), - ); - } - - #[cfg(not(target_os = "android"))] - { - None - } - } - - fn request_remote_signer_note_action(&self, content: &str) -> Result<(), String> { - #[cfg(target_os = "android")] - { - return self.remote_signer.begin_sign_kind1_note_selected(content); - } - - #[cfg(not(target_os = "android"))] - { - let _ = content; - Ok(()) - } - } - - fn poll_remote_signer_note_action_result( - &self, - ) -> Result<Option<radroots_app_core::RadrootsRemoteSignerSignedNote>, String> { - #[cfg(target_os = "android")] - { - return self - .remote_signer - .take_note_update() - .transpose() - .map(|result| result.flatten()); - } - - #[cfg(not(target_os = "android"))] - { - Ok(None) - } - } - - fn home_action_states(&self) -> Vec<HomeActionState> { - #[cfg(target_os = "android")] - { - let secret_key_export_pending = Self::secret_key_export_pending(); - let Ok(manager) = Self::accounts_manager() else { - return Vec::new(); - }; - let Ok(status) = manager - .selected_account_status() - .map_err(|source| source.to_string()) - else { - return Vec::new(); - }; - - return match status { - RadrootsNostrSelectedAccountStatus::NotConfigured => Vec::new(), - RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { - if matches!( - remote_signer::custody_for_account_id(account.account_id.as_str()), - Ok(RadrootsAccountCustody::RemoteSigner) - ) { - vec![HomeActionState { - kind: HomeActionKind::DisconnectSigner, - label: "Disconnect Remote Signer".to_owned(), - enabled: true, - pending: false, - }] - } else { - Vec::new() - } - } - RadrootsNostrSelectedAccountStatus::Ready { .. } => vec![ - HomeActionState { - kind: HomeActionKind::BackupSecretKey, - label: "Back Up Secret Key".to_owned(), - enabled: !secret_key_export_pending, - 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, - pending: false, - }, - HomeActionState { - kind: HomeActionKind::ResetDevice, - label: "Reset This Device".to_owned(), - enabled: true, - pending: false, - }, - ], - }; - } - - #[cfg(not(target_os = "android"))] - { - Vec::new() - } - } - - fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { - #[cfg(target_os = "android")] - { - return match action { - HomeActionKind::BackupSecretKey => Ok(HomeActionResult::None), - HomeActionKind::RevealRawSecretKey => { - Self::begin_raw_secret_key_reveal().map(|()| HomeActionResult::None) - } - HomeActionKind::RemoveLocalKey => { - let manager = Self::accounts_manager()?; - Self::remove_selected_local_identity(&manager) - .map(HomeActionResult::IdentityState) - } - HomeActionKind::ResetDevice => { - let manager = Self::accounts_manager()?; - let accounts_path = storage::accounts_path()?; - Self::reset_local_device_state(&manager, accounts_path.as_path()) - .map(HomeActionResult::IdentityState) - } - HomeActionKind::DisconnectSigner => { - let manager = Self::accounts_manager()?; - remote_signer::disconnect_selected_remote_signer(&manager) - .map(HomeActionResult::IdentityState) - } - }; - } - - #[cfg(not(target_os = "android"))] - { - let _ = action; - Ok(HomeActionResult::None) - } - } - - 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")] - { - return Self::poll_secret_key_export(); - } - - #[cfg(not(target_os = "android"))] - { - Ok(None) - } - } - - fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { - #[cfg(target_os = "android")] - { - return self - .remote_signer - .take_update() - .transpose() - .map(|state| state.flatten()); - } - - #[cfg(not(target_os = "android"))] - { - Ok(None) - } - } -} - -#[cfg(any(target_os = "android", test))] -#[cfg_attr(not(target_os = "android"), allow(dead_code))] -impl AndroidBackend { - fn new() -> Self { - #[cfg(target_os = "android")] - let offline_geocoder = offline_geocoder::AndroidOfflineGeocoder::start(); - - #[cfg(not(target_os = "android"))] - let offline_geocoder = offline_geocoder::AndroidOfflineGeocoder::from_state( - RadrootsOfflineGeocoderState::unavailable( - radroots_app_core::RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, - radroots_app_core::RadrootsOfflineGeocoderPlatform::Android, - "android offline geocoder initialization is only wired on android targets", - ), - ); - - Self { - country_lookup: country_lookup::AndroidCountryLookup::new(), - offline_geocoder, - #[cfg(target_os = "android")] - remote_signer: remote_signer::AndroidRemoteSigner::new(), - reverse_lookup: reverse_lookup::AndroidReverseLookup::new(), - } - } - - #[cfg(target_os = "android")] - fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { - #[cfg(target_os = "android")] - { - return storage::accounts_manager(); - } - } - - #[cfg(test)] - fn unsupported_identity_state() -> IdentityGateState { - IdentityGateState::Unsupported { - reason: ANDROID_SETUP_UNAVAILABLE_REASON.to_owned(), - } - } - - #[cfg(test)] - fn unsupported_setup_action_state() -> SetupActionState { - SetupActionState { - label: "Generate New Key".to_owned(), - enabled: false, - pending: false, - } - } - - fn enabled_setup_action_state() -> SetupActionState { - SetupActionState { - label: "Generate New Key".to_owned(), - enabled: true, - pending: false, - } - } - - fn map_status(status: RadrootsNostrSelectedAccountStatus) -> IdentityGateState { - match status { - RadrootsNostrSelectedAccountStatus::Ready { account } => IdentityGateState::Ready { - account_id: account.account_id.to_string(), - }, - RadrootsNostrSelectedAccountStatus::NotConfigured - | RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => IdentityGateState::Missing, - } - } - - fn identity_state_from_manager( - manager: &RadrootsNostrAccountsManager, - ) -> Result<IdentityGateState, String> { - let status = manager - .selected_account_status() - .map_err(|source| source.to_string())?; - Ok(Self::map_status(status)) - } - - fn account_roster_from_manager( - manager: &RadrootsNostrAccountsManager, - ) -> Result<Vec<RadrootsAccountSummary>, String> { - manager - .list_accounts() - .map_err(|source| source.to_string())? - .into_iter() - .map(|record| { - #[cfg(target_os = "android")] - let custody = remote_signer::custody_for_account_id(record.account_id.as_str())?; - #[cfg(not(target_os = "android"))] - let custody = RadrootsAccountCustody::LocalManaged; - Ok(RadrootsAccountSummary { - account_id: record.account_id.to_string(), - npub: record.public_identity.public_key_npub, - label: record.label, - custody, - }) - }) - .collect() - } - - fn generate_local_identity( - manager: &RadrootsNostrAccountsManager, - ) -> Result<IdentityGateState, String> { - manager - .generate_identity(Some("local".to_owned()), true) - .map_err(|source| source.to_string())?; - Self::identity_state_from_manager(manager) - } - - 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 - .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 import_local_identity( - manager: &RadrootsNostrAccountsManager, - request: &RadrootsSecretImportRequest, - ) -> Result<IdentityGateState, String> { - 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) - .map_err(|source| source.to_string())?; - - Self::identity_state_from_manager(manager) - } - - #[cfg(target_os = "android")] - 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_raw_secret_key_reveal() -> Result<(), String> { - Ok(()) - } - - #[cfg(target_os = "android")] - fn secret_key_export_pending() -> bool { - security::is_user_presence_verification_pending().unwrap_or(false) - } - - #[cfg(not(target_os = "android"))] - fn secret_key_export_pending() -> bool { - false - } - - #[cfg(target_os = "android")] - fn poll_secret_key_export() -> Result<Option<HomeActionResult>, String> { - match security::take_user_presence_verification_result() - .map_err(|source| source.to_string())? - { - Some(security::AndroidUserPresenceVerificationResult::Verified) => { - let manager = Self::accounts_manager()?; - 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) - } - None => Ok(None), - } - } - - #[cfg(not(target_os = "android"))] - fn poll_secret_key_export() -> Result<Option<HomeActionResult>, String> { - Ok(None) - } - - fn remove_selected_local_identity( - manager: &RadrootsNostrAccountsManager, - ) -> Result<IdentityGateState, String> { - let Some(account_id) = manager - .selected_account_id() - .map_err(|source| source.to_string())? - else { - return Ok(IdentityGateState::Missing); - }; - - manager - .remove_account(&account_id) - .map_err(|source| source.to_string())?; - Self::identity_state_from_manager(manager) - } - - fn remove_all_local_identities( - manager: &RadrootsNostrAccountsManager, - ) -> Result<IdentityGateState, String> { - let account_ids = manager - .list_accounts() - .map_err(|source| source.to_string())? - .into_iter() - .map(|record| record.account_id) - .collect::<Vec<_>>(); - - for account_id in account_ids { - manager - .remove_account(&account_id) - .map_err(|source| source.to_string())?; - } - - Self::identity_state_from_manager(manager) - } - - fn remove_accounts_file_if_present(accounts_path: &Path) -> Result<(), String> { - match std::fs::remove_file(accounts_path) { - Ok(()) => Ok(()), - Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(source) => Err(format!("failed to remove android accounts file: {source}")), - } - } - - #[cfg(target_os = "android")] - fn reset_local_device_state( - manager: &RadrootsNostrAccountsManager, - accounts_path: &Path, - ) -> Result<IdentityGateState, String> { - remote_signer::purge_all_custody_state()?; - let state = Self::remove_all_local_identities(manager)?; - Self::remove_accounts_file_if_present(accounts_path)?; - Ok(state) - } -} - -#[cfg(any(target_os = "android", test))] -#[cfg(test)] -const ANDROID_SETUP_UNAVAILABLE_REASON: &str = "Secure onboarding is not yet available on Android."; - -#[cfg(target_os = "android")] -fn native_options(android_app: AndroidApp) -> eframe::NativeOptions { - eframe::NativeOptions { - renderer: eframe::Renderer::Glow, - android_app: Some(android_app), - viewport: ViewportBuilder::default().with_title(APP_NAME), - ..Default::default() - } -} - -#[cfg(target_os = "android")] -fn run_android_app(android_app: AndroidApp) -> Result<(), String> { - android_logger::init_once(Config::default().with_max_level(log::LevelFilter::Info)); - eframe::run_native( - APP_NAME, - native_options(android_app), - Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(AndroidBackend::new()))))), - ) - .map_err(|err| err.to_string()) -} - -#[cfg(target_os = "android")] -#[allow(improper_ctypes_definitions)] -#[allow(unsafe_code)] -#[unsafe(no_mangle)] -pub extern "C" fn android_main(android_app: AndroidApp) { - if let Err(err) = run_android_app(android_app) { - log::error!("android launcher failed: {err}"); - } -} - -#[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() { - assert_eq!( - AndroidBackend::unsupported_identity_state(), - IdentityGateState::Unsupported { - reason: ANDROID_SETUP_UNAVAILABLE_REASON.to_owned(), - } - ); - assert_eq!( - AndroidBackend::unsupported_setup_action_state(), - SetupActionState { - label: "Generate New Key".to_owned(), - enabled: false, - pending: false, - } - ); - } - - #[test] - fn android_backend_enables_setup_action_when_android_keygen_is_wired() { - assert_eq!( - AndroidBackend::enabled_setup_action_state(), - SetupActionState { - label: "Generate New Key".to_owned(), - enabled: true, - pending: false, - } - ); - } - - #[test] - fn android_backend_maps_ready_account_to_ready_state() { - let identity = RadrootsIdentity::generate(); - let account = - RadrootsNostrAccountRecord::new(identity.to_public(), Some("local".into()), 0); - - let state = AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::Ready { - account: account.clone(), - }); - - assert_eq!( - state, - IdentityGateState::Ready { - account_id: account.account_id.to_string(), - } - ); - } - - #[test] - fn android_backend_maps_fresh_and_public_only_accounts_to_missing() { - let public_only_identity = RadrootsIdentity::generate(); - let public_only_account = - RadrootsNostrAccountRecord::new(public_only_identity.to_public(), None, 0); - - assert_eq!( - AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::NotConfigured), - IdentityGateState::Missing - ); - assert_eq!( - AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::PublicOnly { - account: public_only_account, - }), - IdentityGateState::Missing - ); - } - - #[test] - fn fresh_android_manager_starts_in_setup_state() { - let manager = RadrootsNostrAccountsManager::new_in_memory(); - - assert_eq!( - AndroidBackend::identity_state_from_manager(&manager), - Ok(IdentityGateState::Missing) - ); - } - - #[test] - fn local_identity_generation_transitions_android_to_ready() { - let manager = RadrootsNostrAccountsManager::new_in_memory(); - - let state = AndroidBackend::generate_local_identity(&manager).expect("generate identity"); - let IdentityGateState::Ready { account_id } = state else { - panic!("expected ready identity state"); - }; - - assert!(!account_id.is_empty()); - } - - #[test] - fn local_identity_removal_transitions_android_back_to_missing() { - let manager = RadrootsNostrAccountsManager::new_in_memory(); - - AndroidBackend::generate_local_identity(&manager).expect("generate identity"); - let state = AndroidBackend::remove_selected_local_identity(&manager) - .expect("remove selected account"); - - assert_eq!(state, IdentityGateState::Missing); - assert_eq!( - manager.selected_account_id().expect("selected account"), - None - ); - } - - #[test] - fn remove_all_local_identities_clears_every_account() { - let manager = RadrootsNostrAccountsManager::new_in_memory(); - - manager - .generate_identity(Some("first".into()), true) - .expect("generate first"); - manager - .generate_identity(Some("second".into()), false) - .expect("generate second"); - - let state = AndroidBackend::remove_all_local_identities(&manager).expect("reset state"); - - assert_eq!(state, IdentityGateState::Missing); - assert_eq!(manager.list_accounts().expect("list accounts").len(), 0); - assert_eq!(manager.selected_account_id().expect("selected"), None); - } - - #[test] - fn export_selected_local_raw_secret_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 = - AndroidBackend::export_selected_local_raw_secret_key(&manager).expect("export secret"); - - assert_eq!(nsec, identity.nsec()); - assert!(nsec.starts_with("nsec1")); - } - - #[test] - 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, - &RadrootsSecretImportRequest { - mode: RadrootsSecretImportMode::RawSecretKey, - secret_text: identity.nsec(), - password: None, - }, - ) - .expect("import"); - - assert_eq!( - state, - IdentityGateState::Ready { - account_id: identity.id().to_string(), - } - ); - assert_eq!( - manager.selected_account_id().expect("selected"), - Some(identity.id()) - ); - assert_eq!(manager.list_accounts().expect("list").len(), 1); - assert_eq!( - manager - .export_secret_hex(&identity.id()) - .expect("export secret"), - Some(identity.secret_key_hex()) - ); - } - - #[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", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time") - .as_nanos() - ); - let path = std::env::temp_dir().join(unique); - std::fs::write(&path, b"{}").expect("write accounts file"); - - AndroidBackend::remove_accounts_file_if_present(path.as_path()).expect("remove file"); - - assert!(!path.exists()); - } -} diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -1,472 +0,0 @@ -use crate::security::ANDROID_NOSTR_SERVICE; -use crate::storage; -use crate::vault::RadrootsAndroidKeystoreVault; -use radroots_app_core::{ - IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection, - RadrootsRemoteSignerPreview, RadrootsRemoteSignerSignedNote, SetupActionState, -}; -use radroots_app_remote_signer::{ - RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerActionController, - RadrootsAppRemoteSignerActionControllerHooks, RadrootsAppRemoteSignerActionState, - RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, - RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerPendingState, - RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState, - RadrootsAppRemoteSignerSignedEvent, 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, -}; -use radroots_identity::RadrootsIdentityId; -use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, RadrootsSecretVault, - account_secret_slot, -}; -use std::path::{Path, PathBuf}; - -const REMOTE_SIGNER_LABEL: &str = "remote signer"; - -#[derive(Clone, Copy)] -struct AndroidRemoteSignerHooks; - -impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks { - type ReadyState = IdentityGateState; - - fn reconcile_startup_state(&self) -> Result<(), String> { - let manager = crate::storage::accounts_manager()?; - let store_path = sessions_path()?; - radroots_app_remote_signer_reconcile_startup( - &manager, - store_path.as_path(), - REMOTE_SIGNER_LABEL, - load_client_secret, - remove_client_secret, - purge_client_secret_namespace, - ) - } - - fn store_pending_session( - &self, - pending: &RadrootsAppRemoteSignerPendingSession, - ) -> Result<(), String> { - let client_account_id = pending.record.client_account_id().to_owned(); - store_client_secret( - client_account_id.as_str(), - pending.client_secret_key_hex.as_str(), - )?; - let store_path = sessions_path()?; - let mut state = load_sessions(store_path.as_path())?; - if let Err(error) = state.upsert_pending(pending.record.clone()) { - let _ = remove_client_secret(client_account_id.as_str()); - return Err(error.to_string()); - } - if let Err(error) = save_sessions(store_path.as_path(), &state) { - let _ = remove_client_secret(client_account_id.as_str()); - return Err(error); - } - Ok(()) - } - - fn pending_session_record( - &self, - ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { - pending_session_record() - } - - fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> { - load_client_secret(client_account_id) - } - - fn activate_pending_session( - &self, - client_account_id: &str, - approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession, - ) -> Result<Self::ReadyState, String> { - activate_remote_session(client_account_id, approved) - } - - fn clear_pending_session( - &self, - ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { - let store_path = sessions_path()?; - radroots_app_remote_signer_clear_pending_session(store_path.as_path(), remove_client_secret) - } -} - -#[derive(Clone)] -pub(crate) struct AndroidRemoteSigner { - controller: RadrootsAppRemoteSignerController<AndroidRemoteSignerHooks>, - action_controller: RadrootsAppRemoteSignerActionController<AndroidRemoteSignerHooks>, -} - -impl AndroidRemoteSigner { - pub(crate) fn new() -> Self { - Self { - controller: RadrootsAppRemoteSignerController::new(AndroidRemoteSignerHooks), - action_controller: RadrootsAppRemoteSignerActionController::new( - AndroidRemoteSignerHooks, - ), - } - } - - pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> { - self.controller.take_update() - } - - pub(crate) fn is_connecting(&self) -> bool { - self.controller.is_connecting() - } - - pub(crate) fn action_state(&self) -> Result<SetupActionState, String> { - if self.is_connecting() { - return Ok(SetupActionState { - label: "Connecting Remote Signer...".to_owned(), - enabled: false, - pending: true, - }); - } - - if self.pending_connection()?.is_some() { - return Ok(match self.controller.pending_state() { - RadrootsAppRemoteSignerPendingState::TransportFailure { .. } => SetupActionState { - label: "Remote Signer Approval Check Retrying".to_owned(), - enabled: false, - pending: false, - }, - RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { .. } => { - SetupActionState { - label: "Authorize Remote Signer to Continue".to_owned(), - enabled: false, - pending: false, - } - } - RadrootsAppRemoteSignerPendingState::Idle - | RadrootsAppRemoteSignerPendingState::WaitingApproval => SetupActionState { - label: "Remote Signer Waiting for Approval".to_owned(), - enabled: false, - pending: false, - }, - }); - } - - Ok(SetupActionState { - label: "Connect Remote Signer".to_owned(), - enabled: true, - pending: false, - }) - } - - pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> { - self.controller.begin_connect(input) - } - - pub(crate) fn pending_connection( - &self, - ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> { - Ok( - pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection { - signer_npub: record.signer_identity.public_key_npub, - relays: record.relays, - auth_url: match self.controller.pending_state() { - RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { url } => Some(url), - _ => None, - }, - }), - ) - } - - pub(crate) fn note_action_state(&self) -> Result<SetupActionState, String> { - if selected_remote_signer_account()?.is_none() { - return Ok(SetupActionState { - label: "Sign Remote Kind 1 Note".to_owned(), - enabled: false, - pending: false, - }); - } - - Ok(match self.action_controller.state() { - RadrootsAppRemoteSignerActionState::Idle => SetupActionState { - label: "Sign Remote Kind 1 Note".to_owned(), - enabled: true, - pending: false, - }, - RadrootsAppRemoteSignerActionState::Signing => SetupActionState { - label: "Signing Remote Kind 1 Note...".to_owned(), - enabled: false, - pending: true, - }, - RadrootsAppRemoteSignerActionState::AwaitingAuthorization { .. } => SetupActionState { - label: "Authorize Remote Signer to Continue".to_owned(), - enabled: false, - pending: false, - }, - }) - } - - pub(crate) fn begin_sign_kind1_note_selected(&self, content: &str) -> Result<(), String> { - self.action_controller.begin_sign_kind1_note(content) - } - - pub(crate) fn take_note_update( - &self, - ) -> Option<Result<Option<RadrootsRemoteSignerSignedNote>, String>> { - self.action_controller.take_update() - } -} - -pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPreview, String> { - let preview = radroots_app_remote_signer_preview(input).map_err(|error| error.to_string())?; - let requested_permissions = preview.requested_permission_labels(); - Ok(RadrootsRemoteSignerPreview { - source_label: preview.source_label().to_owned(), - signer_npub: preview.signer_identity.public_key_npub, - relays: preview.relays, - requested_permissions, - }) -} - -pub(crate) fn identity_state_from_status( - status: RadrootsNostrSelectedAccountStatus, -) -> Result<IdentityGateState, String> { - match status { - RadrootsNostrSelectedAccountStatus::NotConfigured => Ok(IdentityGateState::Missing), - RadrootsNostrSelectedAccountStatus::Ready { account } => Ok(IdentityGateState::Ready { - account_id: account.account_id.to_string(), - }), - RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { - if active_session_for_account_id(account.account_id.as_str())?.is_some() { - Ok(IdentityGateState::Ready { - account_id: account.account_id.to_string(), - }) - } else { - Ok(IdentityGateState::Missing) - } - } - } -} - -pub(crate) fn custody_for_account_id(account_id: &str) -> Result<RadrootsAccountCustody, String> { - if active_session_for_account_id(account_id)?.is_some() { - Ok(RadrootsAccountCustody::RemoteSigner) - } else { - Ok(RadrootsAccountCustody::LocalManaged) - } -} - -pub(crate) fn disconnect_selected_remote_signer( - manager: &RadrootsNostrAccountsManager, -) -> Result<IdentityGateState, String> { - let store_path = sessions_path()?; - let status = radroots_app_remote_signer_disconnect_selected( - manager, - store_path.as_path(), - remove_client_secret, - )?; - identity_state_from_status(status) -} - -pub(crate) fn cancel_pending_connection() -> Result<(), String> { - let store_path = sessions_path()?; - let _ = radroots_app_remote_signer_clear_pending_session( - store_path.as_path(), - remove_client_secret, - )?; - Ok(()) -} - -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, - purge_client_secret_namespace, - ) -} - -fn activate_remote_session( - client_account_id: &str, - approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession, -) -> Result<IdentityGateState, String> { - let manager = crate::storage::accounts_manager()?; - manager - .upsert_public_identity( - approved.user_identity.clone(), - Some(REMOTE_SIGNER_LABEL.to_owned()), - true, - ) - .map_err(|source| source.to_string())?; - let store_path = sessions_path()?; - let activation_result = (|| -> Result<(), String> { - let mut state = load_sessions(store_path.as_path())?; - state - .activate_session( - client_account_id, - approved.user_identity.clone(), - approved.relays.clone(), - approved.approved_permissions.clone(), - ) - .ok_or_else(|| { - "pending remote signer session disappeared before activation".to_owned() - })?; - save_sessions(store_path.as_path(), &state) - })(); - if let Err(error) = activation_result { - if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) { - return Err(format!( - "{error}. remote signer account rollback needs retry: {rollback_error}" - )); - } - return Err(error); - } - Ok(IdentityGateState::Ready { - account_id: approved.user_identity.id.to_string(), - }) -} - -fn selected_remote_signer_account() -> Result<Option<String>, String> { - let manager = crate::storage::accounts_manager()?; - let Some(account_id) = manager - .selected_account_id() - .map_err(|source| source.to_string())? - else { - return Ok(None); - }; - if active_session_for_account_id(account_id.as_str())?.is_some() { - Ok(Some(account_id.to_string())) - } else { - Ok(None) - } -} - -fn update_active_session_relays(account_id: &str, relays: Vec<String>) -> Result<(), String> { - let store_path = sessions_path()?; - let mut state = load_sessions(store_path.as_path())?; - let Some(mut session) = state.active_session_for_account_id(account_id).cloned() else { - return Err("active remote signer session disappeared before relay update".to_owned()); - }; - if session.relays == relays { - return Ok(()); - } - session.relays = relays; - state.remove_active_session_for_account_id(account_id); - state.sessions.push(session); - save_sessions(store_path.as_path(), &state) -} - -impl RadrootsAppRemoteSignerActionControllerHooks for AndroidRemoteSignerHooks { - type ReadyState = RadrootsRemoteSignerSignedNote; - - fn selected_active_session( - &self, - ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String> { - let Some(account_id) = selected_remote_signer_account()? else { - return Ok(None); - }; - let Some(record) = active_session_for_account_id(account_id.as_str())? else { - return Ok(None); - }; - let secret = load_client_secret(record.client_account_id())?; - Ok(Some((record, secret))) - } - - fn complete_sign_event( - &self, - signed_event: RadrootsAppRemoteSignerSignedEvent, - ) -> Result<Self::ReadyState, String> { - let Some(account_id) = selected_remote_signer_account()? else { - return Err("remote signer account is no longer selected".to_owned()); - }; - update_active_session_relays(account_id.as_str(), signed_event.relays.clone())?; - Ok(RadrootsRemoteSignerSignedNote { - event_id_hex: signed_event.event_id_hex, - }) - } -} - -fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { - let store_path = sessions_path()?; - let state = load_sessions(store_path.as_path())?; - Ok(state.pending_session().cloned()) -} - -fn active_session_for_account_id( - account_id: &str, -) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { - let store_path = sessions_path()?; - let state = load_sessions(store_path.as_path())?; - Ok(state.active_session_for_account_id(account_id).cloned()) -} - -fn load_sessions(path: &Path) -> Result<RadrootsAppRemoteSignerSessionStoreState, String> { - RadrootsAppRemoteSignerSessionStoreState::load(path).map_err(|error| error.to_string()) -} - -fn save_sessions( - path: &Path, - state: &RadrootsAppRemoteSignerSessionStoreState, -) -> Result<(), String> { - state.save(path).map_err(|error| error.to_string()) -} - -fn sessions_path() -> Result<PathBuf, String> { - Ok(storage::app_data_root()? - .join("nostr") - .join("remote-signer-sessions.json")) -} - -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) -} - -fn client_secret_slot(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())?; - Ok(account_secret_slot(&account_id)) -} - -fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { - let slot = client_secret_slot(client_account_id)?; - client_secret_vault() - .store_secret(slot.as_str(), secret_key_hex) - .map_err(|source| source.to_string()) -} - -fn load_client_secret(client_account_id: &str) -> Result<String, String> { - let slot = client_secret_slot(client_account_id)?; - if let Some(secret) = client_secret_vault() - .load_secret(slot.as_str()) - .map_err(|source| source.to_string())? - { - return Ok(secret); - } - - let secret = legacy_client_secret_vault() - .load_secret(slot.as_str()) - .map_err(|source| source.to_string())? - .ok_or_else(|| "remote signer session secret is missing".to_owned())?; - let _ = client_secret_vault().store_secret(slot.as_str(), secret.as_str()); - let _ = legacy_client_secret_vault().remove_secret(slot.as_str()); - Ok(secret) -} - -fn remove_client_secret(client_account_id: &str) -> Result<(), String> { - let slot = client_secret_slot(client_account_id)?; - client_secret_vault() - .remove_secret(slot.as_str()) - .map_err(|source| source.to_string())?; - legacy_client_secret_vault() - .remove_secret(slot.as_str()) - .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 @@ -1,553 +0,0 @@ -#![cfg_attr(not(target_os = "android"), allow(dead_code))] - -use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; -use std::path::PathBuf; - -pub(crate) const ANDROID_NOSTR_SERVICE: &str = "org.radroots.app.nostr"; -pub(crate) const ANDROID_NOSTR_NAMESPACE: &str = "nostr"; - -#[cfg(target_os = "android")] -use jni::objects::{JByteArray, JClass, JObject, JString, JValue}; -#[cfg(target_os = "android")] -use jni::sys::{jboolean, jobject}; -#[cfg(target_os = "android")] -use jni::{JNIEnv, JavaVM}; - -#[cfg(target_os = "android")] -const ANDROID_SECURITY_BRIDGE_CLASS: &str = - "org.radroots.app.android.security.RadRootsAndroidSecurityBridge"; - -#[cfg(target_os = "android")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AndroidSecretStatus { - Success, - NotFound, - InvalidInput, - Error, -} - -#[cfg(target_os = "android")] -impl AndroidSecretStatus { - fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> { - match value { - 0 => Ok(Self::Success), - 1 => Ok(Self::NotFound), - 2 => Ok(Self::InvalidInput), - 3 => Ok(Self::Error), - other => Err(RadrootsNostrAccountsError::Vault(format!( - "unknown android security bridge status {other}" - ))), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum AndroidUserPresenceVerificationResult { - Verified, - Failed(String), -} - -#[cfg(target_os = "android")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AndroidUserPresenceResultStatus { - None, - Success, - Error, -} - -#[cfg(target_os = "android")] -impl AndroidUserPresenceResultStatus { - fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> { - match value { - 0 => Ok(Self::None), - 1 => Ok(Self::Success), - 2 => Ok(Self::Error), - other => Err(RadrootsNostrAccountsError::Vault(format!( - "unknown android user presence status {other}" - ))), - } - } -} - -#[cfg(target_os = "android")] -pub(crate) fn store_secret( - service: &str, - namespace: &str, - name: &str, - value: &[u8], - device_local_only: bool, - user_presence_required: bool, - prefer_strong_box: bool, -) -> 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 name = java_string_arg(&mut env, name)?; - let value = env.byte_array_from_slice(value).map_err(jni_error)?; - let value = JObject::from(value); - - let status = env - .call_static_method( - &bridge_class, - "putSecret", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZZZ)I", - &[ - JValue::Object(&service), - JValue::Object(&namespace), - JValue::Object(&name), - JValue::Object(&value), - JValue::Bool(bool_to_jboolean(device_local_only)), - JValue::Bool(bool_to_jboolean(user_presence_required)), - JValue::Bool(bool_to_jboolean(prefer_strong_box)), - ], - ) - .and_then(|value| value.i()) - .map_err(jni_error)?; - - match AndroidSecretStatus::from_raw(status)? { - AndroidSecretStatus::Success => Ok(()), - AndroidSecretStatus::NotFound => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android security bridge reported not found during store", - )), - AndroidSecretStatus::InvalidInput => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android security bridge rejected the store request", - )), - AndroidSecretStatus::Error => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android keystore store failed", - )), - } -} - -#[cfg(not(target_os = "android"))] -pub(crate) fn store_secret( - service: &str, - namespace: &str, - name: &str, - value: &[u8], - device_local_only: bool, - user_presence_required: bool, - prefer_strong_box: bool, -) -> Result<(), RadrootsNostrAccountsError> { - let _ = ( - service, - namespace, - name, - value, - device_local_only, - user_presence_required, - prefer_strong_box, - ); - Err(RadrootsNostrAccountsError::Vault( - "android keystore storage is only available on android".to_owned(), - )) -} - -#[cfg(target_os = "android")] -pub(crate) fn load_secret( - service: &str, - namespace: &str, - name: &str, -) -> Result<Option<Vec<u8>>, 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 name = java_string_arg(&mut env, name)?; - - let value = env - .call_static_method( - &bridge_class, - "getSecret", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[B", - &[ - JValue::Object(&service), - JValue::Object(&namespace), - JValue::Object(&name), - ], - ) - .and_then(|value| value.l()) - .map_err(jni_error)?; - - if value.is_null() { - let Some(message) = take_last_error_message(&mut env, &bridge_class)? else { - return Ok(None); - }; - return Err(RadrootsNostrAccountsError::Vault(message)); - } - - let value = JByteArray::from(value); - env.convert_byte_array(&value).map(Some).map_err(jni_error) -} - -#[cfg(not(target_os = "android"))] -pub(crate) fn load_secret( - service: &str, - namespace: &str, - name: &str, -) -> Result<Option<Vec<u8>>, RadrootsNostrAccountsError> { - let _ = (service, namespace, name); - Err(RadrootsNostrAccountsError::Vault( - "android keystore storage is only available on android".to_owned(), - )) -} - -#[cfg(target_os = "android")] -pub(crate) fn remove_secret( - service: &str, - namespace: &str, - name: &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 name = java_string_arg(&mut env, name)?; - - let status = env - .call_static_method( - &bridge_class, - "deleteSecret", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", - &[ - JValue::Object(&service), - JValue::Object(&namespace), - JValue::Object(&name), - ], - ) - .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 delete request", - )), - AndroidSecretStatus::Error => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android keystore delete failed", - )), - } -} - -#[cfg(not(target_os = "android"))] -pub(crate) fn remove_secret( - service: &str, - namespace: &str, - name: &str, -) -> Result<(), RadrootsNostrAccountsError> { - let _ = (service, namespace, name); - Err(RadrootsNostrAccountsError::Vault( - "android keystore storage is only available on android".to_owned(), - )) -} - -#[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_radroots_base_root() -> Result<PathBuf, 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 value = env - .call_static_method( - &bridge_class, - "resolveRadrootsBaseRoot", - "()Ljava/lang/String;", - &[], - ) - .and_then(|value| value.l()) - .map_err(jni_error)?; - - if value.is_null() { - return Err(bridge_store_error( - &mut env, - &bridge_class, - "android security bridge returned no storage root", - )); - } - - let value = JString::from(value); - let path: String = env.get_string(&value).map_err(jni_error)?.into(); - Ok(PathBuf::from(path)) -} - -#[cfg(target_os = "android")] -pub(crate) fn begin_user_presence_verification( - reason: &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 reason = java_string_arg(&mut env, reason)?; - - let status = env - .call_static_method( - &bridge_class, - "beginUserPresenceVerification", - "(Ljava/lang/String;)I", - &[JValue::Object(&reason)], - ) - .and_then(|value| value.i()) - .map_err(jni_error)?; - - match AndroidSecretStatus::from_raw(status)? { - AndroidSecretStatus::Success => Ok(()), - AndroidSecretStatus::NotFound => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android security bridge reported no user presence result", - )), - AndroidSecretStatus::InvalidInput => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android security bridge rejected the user presence request", - )), - AndroidSecretStatus::Error => Err(bridge_vault_error( - &mut env, - &bridge_class, - "android user presence verification failed to start", - )), - } -} - -#[cfg(not(target_os = "android"))] -pub(crate) fn begin_user_presence_verification( - reason: &str, -) -> Result<(), RadrootsNostrAccountsError> { - let _ = reason; - Err(RadrootsNostrAccountsError::Vault( - "android user presence verification is only available on android".to_owned(), - )) -} - -#[cfg(target_os = "android")] -pub(crate) fn is_user_presence_verification_pending() -> Result<bool, 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)?; - - env.call_static_method( - &bridge_class, - "isUserPresenceVerificationPending", - "()Z", - &[], - ) - .and_then(|value| value.z()) - .map_err(jni_error) -} - -#[cfg(not(target_os = "android"))] -pub(crate) fn is_user_presence_verification_pending() -> Result<bool, RadrootsNostrAccountsError> { - Err(RadrootsNostrAccountsError::Vault( - "android user presence verification is only available on android".to_owned(), - )) -} - -#[cfg(target_os = "android")] -pub(crate) fn take_user_presence_verification_result() --> Result<Option<AndroidUserPresenceVerificationResult>, 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 status = env - .call_static_method( - &bridge_class, - "takeUserPresenceVerificationResult", - "()I", - &[], - ) - .and_then(|value| value.i()) - .map_err(jni_error)?; - - match AndroidUserPresenceResultStatus::from_raw(status)? { - AndroidUserPresenceResultStatus::None => Ok(None), - AndroidUserPresenceResultStatus::Success => { - Ok(Some(AndroidUserPresenceVerificationResult::Verified)) - } - AndroidUserPresenceResultStatus::Error => { - Ok(Some(AndroidUserPresenceVerificationResult::Failed( - take_last_error_message(&mut env, &bridge_class)? - .unwrap_or_else(|| "android device authentication failed".to_owned()), - ))) - } - } -} - -#[cfg(not(target_os = "android"))] -pub(crate) fn take_user_presence_verification_result() --> Result<Option<AndroidUserPresenceVerificationResult>, RadrootsNostrAccountsError> { - Err(RadrootsNostrAccountsError::Vault( - "android user presence verification is only available on android".to_owned(), - )) -} - -#[cfg(not(target_os = "android"))] -#[allow(dead_code)] -pub(crate) fn resolve_radroots_base_root() -> Result<PathBuf, RadrootsNostrAccountsError> { - Err(RadrootsNostrAccountsError::Store( - "android mobile base storage root is only available on android".to_owned(), - )) -} - -#[cfg(target_os = "android")] -#[allow(unsafe_code)] -fn android_java_vm() -> Result<JavaVM, RadrootsNostrAccountsError> { - let context = ndk_context::android_context(); - // SAFETY: ndk_context is initialized by the Android runtime before this code runs and - // returns a stable JavaVM pointer for the current process. - unsafe { JavaVM::from_raw(context.vm().cast()) }.map_err(jni_error) -} - -#[cfg(target_os = "android")] -#[allow(unsafe_code)] -fn bridge_class<'local>( - env: &mut JNIEnv<'local>, -) -> Result<JClass<'local>, RadrootsNostrAccountsError> { - let context = ndk_context::android_context(); - // SAFETY: ndk_context stores a live process-wide Context jobject for the active Android app. - let context = unsafe { JObject::from_raw(context.context() as jobject) }; - let context = env.new_local_ref(&context).map_err(jni_error)?; - let class_loader = env - .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) - .and_then(|value| value.l()) - .map_err(jni_error)?; - let class_name = env - .new_string(ANDROID_SECURITY_BRIDGE_CLASS) - .map_err(jni_error)?; - let class_name = JObject::from(class_name); - let bridge_class = env - .call_method( - &class_loader, - "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;", - &[JValue::Object(&class_name)], - ) - .and_then(|value| value.l()) - .map_err(jni_error)?; - Ok(JClass::from(bridge_class)) -} - -#[cfg(target_os = "android")] -fn java_string_arg<'local>( - env: &mut JNIEnv<'local>, - value: &str, -) -> Result<JObject<'local>, RadrootsNostrAccountsError> { - env.new_string(value).map(JObject::from).map_err(jni_error) -} - -#[cfg(target_os = "android")] -fn take_last_error_message( - env: &mut JNIEnv<'_>, - bridge_class: &JClass<'_>, -) -> Result<Option<String>, RadrootsNostrAccountsError> { - let value = env - .call_static_method( - bridge_class, - "takeLastErrorMessage", - "()Ljava/lang/String;", - &[], - ) - .and_then(|value| value.l()) - .map_err(jni_error)?; - if value.is_null() { - return Ok(None); - } - let value = JString::from(value); - let value: String = env.get_string(&value).map_err(jni_error)?.into(); - Ok(Some(value)) -} - -#[cfg(target_os = "android")] -fn bridge_vault_error( - env: &mut JNIEnv<'_>, - bridge_class: &JClass<'_>, - fallback: &str, -) -> RadrootsNostrAccountsError { - let message = take_last_error_message(env, bridge_class) - .ok() - .flatten() - .unwrap_or_else(|| fallback.to_owned()); - RadrootsNostrAccountsError::Vault(message) -} - -#[cfg(target_os = "android")] -fn bridge_store_error( - env: &mut JNIEnv<'_>, - bridge_class: &JClass<'_>, - fallback: &str, -) -> RadrootsNostrAccountsError { - let message = take_last_error_message(env, bridge_class) - .ok() - .flatten() - .unwrap_or_else(|| fallback.to_owned()); - RadrootsNostrAccountsError::Store(message) -} - -#[cfg(target_os = "android")] -fn jni_error(error: jni::errors::Error) -> RadrootsNostrAccountsError { - RadrootsNostrAccountsError::Vault(format!("android jni error: {error}")) -} - -#[cfg(target_os = "android")] -fn bool_to_jboolean(value: bool) -> jboolean { - if value { 1 } else { 0 } -} diff --git a/crates/android/src/storage.rs b/crates/android/src/storage.rs @@ -1,109 +0,0 @@ -#[cfg(target_os = "android")] -use crate::security::{ANDROID_NOSTR_SERVICE, resolve_radroots_base_root}; -#[cfg(target_os = "android")] -use crate::vault::RadrootsAndroidKeystoreVault; -use radroots_app_core::mobile_native_app_storage_layout; -#[cfg(target_os = "android")] -use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, -}; -use radroots_runtime_paths::{RadrootsPaths, RadrootsPlatform}; -use std::path::{Path, PathBuf}; -#[cfg(target_os = "android")] -use std::sync::Arc; - -fn app_paths_from_base_root(base_root: &Path) -> Result<RadrootsPaths, String> { - Ok(mobile_native_app_storage_layout(RadrootsPlatform::Android, base_root)?.app_paths) -} - -#[cfg(target_os = "android")] -pub(crate) fn app_data_root() -> Result<PathBuf, String> { - let base_root = resolve_radroots_base_root().map_err(|source| source.to_string())?; - let root = app_data_root_from_base_root(base_root.as_path())?; - ensure_directory_tree(root.as_path())?; - Ok(root) -} - -#[cfg(target_os = "android")] -pub(crate) fn accounts_path() -> Result<PathBuf, String> { - let base_root = resolve_radroots_base_root().map_err(|source| source.to_string())?; - let accounts_path = accounts_path_from_base_root(base_root.as_path())?; - if let Some(parent) = accounts_path.parent() { - ensure_directory_tree(parent)?; - } - Ok(accounts_path) -} - -#[cfg(target_os = "android")] -pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { - let store = Arc::new(RadrootsNostrFileAccountStore::new(accounts_path()?)); - let vault = Arc::new(RadrootsAndroidKeystoreVault::new(ANDROID_NOSTR_SERVICE)); - RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) -} - -pub(crate) fn app_data_root_from_base_root(base_root: &Path) -> Result<PathBuf, String> { - Ok(app_paths_from_base_root(base_root)?.data) -} - -pub(crate) fn accounts_path_from_base_root(base_root: &Path) -> Result<PathBuf, String> { - Ok(app_data_root_from_base_root(base_root)? - .join("nostr") - .join("accounts.json")) -} - -#[cfg(target_os = "android")] -fn ensure_directory_tree(path: &Path) -> Result<(), String> { - std::fs::create_dir_all(path) - .map_err(|source| format!("failed to create android app data directory: {source}"))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accounts_path_uses_android_mobile_native_layout() { - let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); - - assert_eq!( - accounts_path_from_base_root(base_root.as_path()).expect("accounts path"), - PathBuf::from( - "/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app/nostr/accounts.json" - ) - ); - } - - #[test] - fn app_data_root_uses_android_mobile_native_layout() { - let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); - - assert_eq!( - app_data_root_from_base_root(base_root.as_path()).expect("app data root"), - PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app") - ); - } - - #[test] - fn mobile_paths_follow_shared_logical_root_model() { - let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); - let paths = app_paths_from_base_root(base_root.as_path()).expect("mobile paths"); - - assert_eq!( - paths.config, - PathBuf::from( - "/data/user/0/org.radroots.app.android/no_backup/RadRoots/config/apps/app" - ) - ); - assert_eq!( - paths.data, - PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app") - ); - assert_eq!( - paths.secrets, - PathBuf::from( - "/data/user/0/org.radroots.app.android/no_backup/RadRoots/secrets/apps/app" - ) - ); - } -} diff --git a/crates/android/src/vault.rs b/crates/android/src/vault.rs @@ -1,197 +0,0 @@ -use crate::security::{ - ANDROID_NOSTR_NAMESPACE, load_secret, remove_secret, remove_secret_namespace, store_secret, -}; -use radroots_secret_vault::{ - RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, - RadrootsHostVaultResidency, RadrootsHostVaultUserPresencePolicy, RadrootsSecretVault, - RadrootsSecretVaultAccessError, -}; -use zeroize::Zeroizing; - -#[derive(Debug, Clone)] -pub(crate) struct RadrootsAndroidKeystoreVault { - service_name: String, - namespace: String, -} - -impl RadrootsAndroidKeystoreVault { - #[must_use] - pub(crate) fn new(service_name: impl Into<String>) -> Self { - Self::new_with_namespace(service_name, ANDROID_NOSTR_NAMESPACE) - } - - #[must_use] - 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(), - } - } - - #[must_use] - pub(crate) const fn secure_local_policy() -> RadrootsHostVaultPolicy { - RadrootsHostVaultPolicy { - residency: RadrootsHostVaultResidency::DeviceLocalOnly, - user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, - hardware: RadrootsHostVaultHardwarePolicy::PreferHardwareBacked, - } - } - - fn capabilities() -> RadrootsHostVaultCapabilities { - #[cfg(target_os = "android")] - { - RadrootsHostVaultCapabilities { - available: true, - supports_device_local_only: true, - supports_user_presence: true, - supports_hardware_backed: true, - } - } - - #[cfg(not(target_os = "android"))] - { - RadrootsHostVaultCapabilities::unavailable() - } - } - - fn validate_policy( - policy: RadrootsHostVaultPolicy, - ) -> Result<(), RadrootsSecretVaultAccessError> { - Self::capabilities() - .validate(policy) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) - } - - fn security_flags(policy: RadrootsHostVaultPolicy) -> (bool, bool, bool) { - ( - matches!( - policy.residency, - RadrootsHostVaultResidency::DeviceLocalOnly - ), - matches!( - policy.user_presence, - RadrootsHostVaultUserPresencePolicy::Required - ), - !matches!(policy.hardware, RadrootsHostVaultHardwarePolicy::Any), - ) - } - - pub(crate) fn store_secret_with_policy( - &self, - slot: &str, - secret: &str, - policy: RadrootsHostVaultPolicy, - ) -> Result<(), RadrootsSecretVaultAccessError> { - Self::validate_policy(policy)?; - let secret = Zeroizing::new(secret.to_owned()); - let (device_local_only, user_presence_required, prefer_strong_box) = - Self::security_flags(policy); - store_secret( - self.service_name.as_str(), - self.namespace.as_str(), - slot, - secret.as_bytes(), - device_local_only, - user_presence_required, - prefer_strong_box, - ) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) - } - - #[cfg_attr(not(target_os = "android"), allow(dead_code))] - pub(crate) fn purge_namespace(&self) -> Result<(), RadrootsSecretVaultAccessError> { - remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) - } -} - -impl RadrootsSecretVault for RadrootsAndroidKeystoreVault { - fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { - self.store_secret_with_policy(slot, secret, Self::secure_local_policy()) - } - - fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { - let Some(secret) = - load_secret(self.service_name.as_str(), self.namespace.as_str(), slot) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))? - else { - return Ok(None); - }; - - let secret = Zeroizing::new(secret); - let secret = std::str::from_utf8(secret.as_slice()).map_err(|source| { - RadrootsSecretVaultAccessError::Backend(format!( - "android keystore secret was not valid utf-8: {source}" - )) - })?; - Ok(Some(secret.to_owned())) - } - - fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { - remove_secret(self.service_name.as_str(), self.namespace.as_str(), slot) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use radroots_secret_vault::{ - RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, RadrootsHostVaultResidency, - RadrootsHostVaultUserPresencePolicy, - }; - - #[test] - fn secure_local_policy_prefers_device_local_hardware_backed_storage() { - assert_eq!( - RadrootsAndroidKeystoreVault::secure_local_policy(), - RadrootsHostVaultPolicy { - residency: RadrootsHostVaultResidency::DeviceLocalOnly, - user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, - hardware: RadrootsHostVaultHardwarePolicy::PreferHardwareBacked, - } - ); - } - - #[test] - fn security_flags_request_strong_box_for_hardware_backed_policies() { - assert_eq!( - RadrootsAndroidKeystoreVault::security_flags(RadrootsHostVaultPolicy { - residency: RadrootsHostVaultResidency::UserProfile, - user_presence: RadrootsHostVaultUserPresencePolicy::Required, - hardware: RadrootsHostVaultHardwarePolicy::Any, - }), - (false, true, false) - ); - assert_eq!( - RadrootsAndroidKeystoreVault::security_flags(RadrootsHostVaultPolicy { - residency: RadrootsHostVaultResidency::DeviceLocalOnly, - user_presence: RadrootsHostVaultUserPresencePolicy::Required, - hardware: RadrootsHostVaultHardwarePolicy::RequireHardwareBacked, - }), - (true, true, true) - ); - } - - #[cfg(not(target_os = "android"))] - #[test] - fn vault_operations_report_unavailable_off_android() { - let vault = RadrootsAndroidKeystoreVault::new(crate::security::ANDROID_NOSTR_SERVICE); - - let load = vault.load_secret("alice").expect_err("load off android"); - assert!(load.to_string().starts_with("secret vault access error:")); - - let store = vault - .store_secret("alice", "deadbeef") - .expect_err("store off android"); - assert!(store.to_string().starts_with("secret vault access error:")); - - let remove = vault - .remove_secret("alice") - .expect_err("remove off android"); - assert!(remove.to_string().starts_with("secret vault access error:")); - } -} diff --git a/crates/apple/security/build.rs b/crates/apple/security/build.rs @@ -1,141 +0,0 @@ -use std::env; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn main() { - println!("cargo:rerun-if-changed=build.rs"); - - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "macos" && target_os != "ios" { - return; - } - - let package_dir = swift_package_dir(); - println!( - "cargo:rerun-if-changed={}", - package_dir.join("Package.swift").display() - ); - println!( - "cargo:rerun-if-changed={}", - package_dir.join("Sources").display() - ); - println!( - "cargo:rerun-if-changed={}", - package_dir.join("Tests").display() - ); - - let ffi_library = "libRadRootsAppleSecurityFFIDynamic.dylib"; - run_swift_build(package_dir.as_path(), "RadRootsAppleSecurityFFIDynamic"); - - let build_dir = find_library_dir(package_dir.join(".build"), ffi_library) - .expect("swift ffi library dir"); - let swift_runtime_dir = swift_runtime_dir(target_os.as_str()); - println!("cargo:rustc-link-search=native={}", build_dir.display()); - println!( - "cargo:rustc-link-search=native={}", - swift_runtime_dir.display() - ); - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", build_dir.display()); - println!( - "cargo:rustc-link-arg=-Wl,-rpath,{}", - swift_runtime_dir.display() - ); - println!("cargo:rustc-link-lib=dylib=RadRootsAppleSecurityFFIDynamic"); - println!("cargo:rustc-link-lib=framework=CoreFoundation"); - println!("cargo:rustc-link-lib=framework=Foundation"); - println!("cargo:rustc-link-lib=framework=LocalAuthentication"); - println!("cargo:rustc-link-lib=framework=Security"); - println!("cargo:rustc-link-lib=dylib=objc"); -} - -fn swift_package_dir() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir")) - .join("../../../native/apple/swift/RadRootsAppleSecurity") -} - -fn swift_runtime_dir(target_os: &str) -> PathBuf { - let swift_bin = run_stdout(Command::new("xcrun").arg("--toolchain").arg("swift").arg("--find").arg("swift")); - let swift_bin = PathBuf::from(swift_bin.trim()); - let toolchain_dir = swift_bin - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .expect("swift toolchain dir"); - find_swift_runtime_dir(toolchain_dir.join("usr/lib"), target_os).expect("swift runtime dir") -} - -fn find_swift_runtime_dir(root: PathBuf, target_os: &str) -> Option<PathBuf> { - let platform_dir = match target_os { - "macos" => "macosx", - "ios" => "iphoneos", - other => other, - }; - let mut stack = vec![root]; - while let Some(dir) = stack.pop() { - let entries = std::fs::read_dir(&dir).ok()?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - if path.file_name().is_some_and(|name| name == "libswift_Concurrency.dylib") - && path - .components() - .any(|component| component.as_os_str() == platform_dir) - { - return path.parent().map(Path::to_path_buf); - } - } - } - None -} - -fn find_library_dir(root: PathBuf, library_name: &str) -> Option<PathBuf> { - let mut stack = vec![root]; - while let Some(dir) = stack.pop() { - let entries = std::fs::read_dir(&dir).ok()?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - if path.file_name().is_some_and(|name| name == library_name) { - return path.parent().map(Path::to_path_buf); - } - } - } - None -} - -fn run_swift_build(package_dir: &Path, product: &str) { - let output = Command::new("swift") - .arg("build") - .arg("--product") - .arg(product) - .current_dir(package_dir) - .output() - .expect("failed to run swift build"); - - if output.status.success() { - return; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "swift build --product {product} failed in {}:\nstdout:\n{stdout}\nstderr:\n{stderr}", - package_dir.display() - ); -} - -fn run_stdout(command: &mut Command) -> String { - let output = command.output().expect("failed to run command"); - if output.status.success() { - return String::from_utf8(output.stdout).expect("utf-8 stdout"); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - panic!("command failed: {command:?}\nstderr:\n{stderr}"); -} diff --git a/crates/bridges/android/security/Cargo.toml b/crates/bridges/android/security/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "radroots_app_android_security" +authors.workspace = true +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots Android security bridge" +publish = false + +[dependencies] +radroots_nostr_accounts.workspace = true +radroots_secret_vault.workspace = true +zeroize.workspace = true + +[target.'cfg(target_os = "android")'.dependencies] +jni.workspace = true +ndk-context.workspace = true diff --git a/crates/bridges/android/security/src/lib.rs b/crates/bridges/android/security/src/lib.rs @@ -0,0 +1,9 @@ +mod security; +mod vault; + +pub use security::{ + ANDROID_NOSTR_SERVICE, AndroidUserPresenceVerificationResult, + begin_user_presence_verification, is_user_presence_verification_pending, + resolve_radroots_base_root, take_user_presence_verification_result, +}; +pub use vault::RadrootsAndroidKeystoreVault; diff --git a/crates/bridges/android/security/src/security.rs b/crates/bridges/android/security/src/security.rs @@ -0,0 +1,553 @@ +#![cfg_attr(not(target_os = "android"), allow(dead_code))] + +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsError; +use std::path::PathBuf; + +pub const ANDROID_NOSTR_SERVICE: &str = "org.radroots.app.nostr"; +pub(crate) const ANDROID_NOSTR_NAMESPACE: &str = "nostr"; + +#[cfg(target_os = "android")] +use jni::objects::{JByteArray, JClass, JObject, JString, JValue}; +#[cfg(target_os = "android")] +use jni::sys::{jboolean, jobject}; +#[cfg(target_os = "android")] +use jni::{JNIEnv, JavaVM}; + +#[cfg(target_os = "android")] +const ANDROID_SECURITY_BRIDGE_CLASS: &str = + "org.radroots.app.android.security.RadRootsAndroidSecurityBridge"; + +#[cfg(target_os = "android")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AndroidSecretStatus { + Success, + NotFound, + InvalidInput, + Error, +} + +#[cfg(target_os = "android")] +impl AndroidSecretStatus { + fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> { + match value { + 0 => Ok(Self::Success), + 1 => Ok(Self::NotFound), + 2 => Ok(Self::InvalidInput), + 3 => Ok(Self::Error), + other => Err(RadrootsNostrAccountsError::Vault(format!( + "unknown android security bridge status {other}" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AndroidUserPresenceVerificationResult { + Verified, + Failed(String), +} + +#[cfg(target_os = "android")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AndroidUserPresenceResultStatus { + None, + Success, + Error, +} + +#[cfg(target_os = "android")] +impl AndroidUserPresenceResultStatus { + fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Success), + 2 => Ok(Self::Error), + other => Err(RadrootsNostrAccountsError::Vault(format!( + "unknown android user presence status {other}" + ))), + } + } +} + +#[cfg(target_os = "android")] +pub(crate) fn store_secret( + service: &str, + namespace: &str, + name: &str, + value: &[u8], + device_local_only: bool, + user_presence_required: bool, + prefer_strong_box: bool, +) -> 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 name = java_string_arg(&mut env, name)?; + let value = env.byte_array_from_slice(value).map_err(jni_error)?; + let value = JObject::from(value); + + let status = env + .call_static_method( + &bridge_class, + "putSecret", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BZZZ)I", + &[ + JValue::Object(&service), + JValue::Object(&namespace), + JValue::Object(&name), + JValue::Object(&value), + JValue::Bool(bool_to_jboolean(device_local_only)), + JValue::Bool(bool_to_jboolean(user_presence_required)), + JValue::Bool(bool_to_jboolean(prefer_strong_box)), + ], + ) + .and_then(|value| value.i()) + .map_err(jni_error)?; + + match AndroidSecretStatus::from_raw(status)? { + AndroidSecretStatus::Success => Ok(()), + AndroidSecretStatus::NotFound => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge reported not found during store", + )), + AndroidSecretStatus::InvalidInput => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge rejected the store request", + )), + AndroidSecretStatus::Error => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android keystore store failed", + )), + } +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn store_secret( + service: &str, + namespace: &str, + name: &str, + value: &[u8], + device_local_only: bool, + user_presence_required: bool, + prefer_strong_box: bool, +) -> Result<(), RadrootsNostrAccountsError> { + let _ = ( + service, + namespace, + name, + value, + device_local_only, + user_presence_required, + prefer_strong_box, + ); + Err(RadrootsNostrAccountsError::Vault( + "android keystore storage is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +pub(crate) fn load_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<Option<Vec<u8>>, 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 name = java_string_arg(&mut env, name)?; + + let value = env + .call_static_method( + &bridge_class, + "getSecret", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)[B", + &[ + JValue::Object(&service), + JValue::Object(&namespace), + JValue::Object(&name), + ], + ) + .and_then(|value| value.l()) + .map_err(jni_error)?; + + if value.is_null() { + let Some(message) = take_last_error_message(&mut env, &bridge_class)? else { + return Ok(None); + }; + return Err(RadrootsNostrAccountsError::Vault(message)); + } + + let value = JByteArray::from(value); + env.convert_byte_array(&value).map(Some).map_err(jni_error) +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn load_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<Option<Vec<u8>>, RadrootsNostrAccountsError> { + let _ = (service, namespace, name); + Err(RadrootsNostrAccountsError::Vault( + "android keystore storage is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +pub(crate) fn remove_secret( + service: &str, + namespace: &str, + name: &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 name = java_string_arg(&mut env, name)?; + + let status = env + .call_static_method( + &bridge_class, + "deleteSecret", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", + &[ + JValue::Object(&service), + JValue::Object(&namespace), + JValue::Object(&name), + ], + ) + .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 delete request", + )), + AndroidSecretStatus::Error => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android keystore delete failed", + )), + } +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn remove_secret( + service: &str, + namespace: &str, + name: &str, +) -> Result<(), RadrootsNostrAccountsError> { + let _ = (service, namespace, name); + Err(RadrootsNostrAccountsError::Vault( + "android keystore storage is only available on android".to_owned(), + )) +} + +#[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 fn resolve_radroots_base_root() -> Result<PathBuf, 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 value = env + .call_static_method( + &bridge_class, + "resolveRadrootsBaseRoot", + "()Ljava/lang/String;", + &[], + ) + .and_then(|value| value.l()) + .map_err(jni_error)?; + + if value.is_null() { + return Err(bridge_store_error( + &mut env, + &bridge_class, + "android security bridge returned no storage root", + )); + } + + let value = JString::from(value); + let path: String = env.get_string(&value).map_err(jni_error)?.into(); + Ok(PathBuf::from(path)) +} + +#[cfg(target_os = "android")] +pub fn begin_user_presence_verification( + reason: &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 reason = java_string_arg(&mut env, reason)?; + + let status = env + .call_static_method( + &bridge_class, + "beginUserPresenceVerification", + "(Ljava/lang/String;)I", + &[JValue::Object(&reason)], + ) + .and_then(|value| value.i()) + .map_err(jni_error)?; + + match AndroidSecretStatus::from_raw(status)? { + AndroidSecretStatus::Success => Ok(()), + AndroidSecretStatus::NotFound => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge reported no user presence result", + )), + AndroidSecretStatus::InvalidInput => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge rejected the user presence request", + )), + AndroidSecretStatus::Error => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android user presence verification failed to start", + )), + } +} + +#[cfg(not(target_os = "android"))] +pub fn begin_user_presence_verification( + reason: &str, +) -> Result<(), RadrootsNostrAccountsError> { + let _ = reason; + Err(RadrootsNostrAccountsError::Vault( + "android user presence verification is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +pub fn is_user_presence_verification_pending() -> Result<bool, 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)?; + + env.call_static_method( + &bridge_class, + "isUserPresenceVerificationPending", + "()Z", + &[], + ) + .and_then(|value| value.z()) + .map_err(jni_error) +} + +#[cfg(not(target_os = "android"))] +pub fn is_user_presence_verification_pending() -> Result<bool, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Vault( + "android user presence verification is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +pub fn take_user_presence_verification_result() +-> Result<Option<AndroidUserPresenceVerificationResult>, 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 status = env + .call_static_method( + &bridge_class, + "takeUserPresenceVerificationResult", + "()I", + &[], + ) + .and_then(|value| value.i()) + .map_err(jni_error)?; + + match AndroidUserPresenceResultStatus::from_raw(status)? { + AndroidUserPresenceResultStatus::None => Ok(None), + AndroidUserPresenceResultStatus::Success => { + Ok(Some(AndroidUserPresenceVerificationResult::Verified)) + } + AndroidUserPresenceResultStatus::Error => { + Ok(Some(AndroidUserPresenceVerificationResult::Failed( + take_last_error_message(&mut env, &bridge_class)? + .unwrap_or_else(|| "android device authentication failed".to_owned()), + ))) + } + } +} + +#[cfg(not(target_os = "android"))] +pub fn take_user_presence_verification_result() +-> Result<Option<AndroidUserPresenceVerificationResult>, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Vault( + "android user presence verification is only available on android".to_owned(), + )) +} + +#[cfg(not(target_os = "android"))] +#[allow(dead_code)] +pub fn resolve_radroots_base_root() -> Result<PathBuf, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Store( + "android mobile base storage root is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +#[allow(unsafe_code)] +fn android_java_vm() -> Result<JavaVM, RadrootsNostrAccountsError> { + let context = ndk_context::android_context(); + // SAFETY: ndk_context is initialized by the Android runtime before this code runs and + // returns a stable JavaVM pointer for the current process. + unsafe { JavaVM::from_raw(context.vm().cast()) }.map_err(jni_error) +} + +#[cfg(target_os = "android")] +#[allow(unsafe_code)] +fn bridge_class<'local>( + env: &mut JNIEnv<'local>, +) -> Result<JClass<'local>, RadrootsNostrAccountsError> { + let context = ndk_context::android_context(); + // SAFETY: ndk_context stores a live process-wide Context jobject for the active Android app. + let context = unsafe { JObject::from_raw(context.context() as jobject) }; + let context = env.new_local_ref(&context).map_err(jni_error)?; + let class_loader = env + .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) + .and_then(|value| value.l()) + .map_err(jni_error)?; + let class_name = env + .new_string(ANDROID_SECURITY_BRIDGE_CLASS) + .map_err(jni_error)?; + let class_name = JObject::from(class_name); + let bridge_class = env + .call_method( + &class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&class_name)], + ) + .and_then(|value| value.l()) + .map_err(jni_error)?; + Ok(JClass::from(bridge_class)) +} + +#[cfg(target_os = "android")] +fn java_string_arg<'local>( + env: &mut JNIEnv<'local>, + value: &str, +) -> Result<JObject<'local>, RadrootsNostrAccountsError> { + env.new_string(value).map(JObject::from).map_err(jni_error) +} + +#[cfg(target_os = "android")] +fn take_last_error_message( + env: &mut JNIEnv<'_>, + bridge_class: &JClass<'_>, +) -> Result<Option<String>, RadrootsNostrAccountsError> { + let value = env + .call_static_method( + bridge_class, + "takeLastErrorMessage", + "()Ljava/lang/String;", + &[], + ) + .and_then(|value| value.l()) + .map_err(jni_error)?; + if value.is_null() { + return Ok(None); + } + let value = JString::from(value); + let value: String = env.get_string(&value).map_err(jni_error)?.into(); + Ok(Some(value)) +} + +#[cfg(target_os = "android")] +fn bridge_vault_error( + env: &mut JNIEnv<'_>, + bridge_class: &JClass<'_>, + fallback: &str, +) -> RadrootsNostrAccountsError { + let message = take_last_error_message(env, bridge_class) + .ok() + .flatten() + .unwrap_or_else(|| fallback.to_owned()); + RadrootsNostrAccountsError::Vault(message) +} + +#[cfg(target_os = "android")] +fn bridge_store_error( + env: &mut JNIEnv<'_>, + bridge_class: &JClass<'_>, + fallback: &str, +) -> RadrootsNostrAccountsError { + let message = take_last_error_message(env, bridge_class) + .ok() + .flatten() + .unwrap_or_else(|| fallback.to_owned()); + RadrootsNostrAccountsError::Store(message) +} + +#[cfg(target_os = "android")] +fn jni_error(error: jni::errors::Error) -> RadrootsNostrAccountsError { + RadrootsNostrAccountsError::Vault(format!("android jni error: {error}")) +} + +#[cfg(target_os = "android")] +fn bool_to_jboolean(value: bool) -> jboolean { + if value { 1 } else { 0 } +} diff --git a/crates/bridges/android/security/src/vault.rs b/crates/bridges/android/security/src/vault.rs @@ -0,0 +1,197 @@ +use crate::security::{ + ANDROID_NOSTR_NAMESPACE, load_secret, remove_secret, remove_secret_namespace, store_secret, +}; +use radroots_secret_vault::{ + RadrootsHostVaultCapabilities, RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, + RadrootsHostVaultResidency, RadrootsHostVaultUserPresencePolicy, RadrootsSecretVault, + RadrootsSecretVaultAccessError, +}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone)] +pub struct RadrootsAndroidKeystoreVault { + service_name: String, + namespace: String, +} + +impl RadrootsAndroidKeystoreVault { + #[must_use] + pub fn new(service_name: impl Into<String>) -> Self { + Self::new_with_namespace(service_name, ANDROID_NOSTR_NAMESPACE) + } + + #[must_use] + pub fn new_with_namespace( + service_name: impl Into<String>, + namespace: impl Into<String>, + ) -> Self { + Self { + service_name: service_name.into(), + namespace: namespace.into(), + } + } + + #[must_use] + pub const fn secure_local_policy() -> RadrootsHostVaultPolicy { + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::PreferHardwareBacked, + } + } + + fn capabilities() -> RadrootsHostVaultCapabilities { + #[cfg(target_os = "android")] + { + RadrootsHostVaultCapabilities { + available: true, + supports_device_local_only: true, + supports_user_presence: true, + supports_hardware_backed: true, + } + } + + #[cfg(not(target_os = "android"))] + { + RadrootsHostVaultCapabilities::unavailable() + } + } + + fn validate_policy( + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultAccessError> { + Self::capabilities() + .validate(policy) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } + + fn security_flags(policy: RadrootsHostVaultPolicy) -> (bool, bool, bool) { + ( + matches!( + policy.residency, + RadrootsHostVaultResidency::DeviceLocalOnly + ), + matches!( + policy.user_presence, + RadrootsHostVaultUserPresencePolicy::Required + ), + !matches!(policy.hardware, RadrootsHostVaultHardwarePolicy::Any), + ) + } + + pub fn store_secret_with_policy( + &self, + slot: &str, + secret: &str, + policy: RadrootsHostVaultPolicy, + ) -> Result<(), RadrootsSecretVaultAccessError> { + Self::validate_policy(policy)?; + let secret = Zeroizing::new(secret.to_owned()); + let (device_local_only, user_presence_required, prefer_strong_box) = + Self::security_flags(policy); + store_secret( + self.service_name.as_str(), + self.namespace.as_str(), + slot, + secret.as_bytes(), + device_local_only, + user_presence_required, + prefer_strong_box, + ) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } + + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub fn purge_namespace(&self) -> Result<(), RadrootsSecretVaultAccessError> { + remove_secret_namespace(self.service_name.as_str(), self.namespace.as_str()) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } +} + +impl RadrootsSecretVault for RadrootsAndroidKeystoreVault { + fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { + self.store_secret_with_policy(slot, secret, Self::secure_local_policy()) + } + + fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { + let Some(secret) = + load_secret(self.service_name.as_str(), self.namespace.as_str(), slot) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))? + else { + return Ok(None); + }; + + let secret = Zeroizing::new(secret); + let secret = std::str::from_utf8(secret.as_slice()).map_err(|source| { + RadrootsSecretVaultAccessError::Backend(format!( + "android keystore secret was not valid utf-8: {source}" + )) + })?; + Ok(Some(secret.to_owned())) + } + + fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { + remove_secret(self.service_name.as_str(), self.namespace.as_str(), slot) + .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_secret_vault::{ + RadrootsHostVaultHardwarePolicy, RadrootsHostVaultPolicy, RadrootsHostVaultResidency, + RadrootsHostVaultUserPresencePolicy, + }; + + #[test] + fn secure_local_policy_prefers_device_local_hardware_backed_storage() { + assert_eq!( + RadrootsAndroidKeystoreVault::secure_local_policy(), + RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::NotRequired, + hardware: RadrootsHostVaultHardwarePolicy::PreferHardwareBacked, + } + ); + } + + #[test] + fn security_flags_request_strong_box_for_hardware_backed_policies() { + assert_eq!( + RadrootsAndroidKeystoreVault::security_flags(RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::UserProfile, + user_presence: RadrootsHostVaultUserPresencePolicy::Required, + hardware: RadrootsHostVaultHardwarePolicy::Any, + }), + (false, true, false) + ); + assert_eq!( + RadrootsAndroidKeystoreVault::security_flags(RadrootsHostVaultPolicy { + residency: RadrootsHostVaultResidency::DeviceLocalOnly, + user_presence: RadrootsHostVaultUserPresencePolicy::Required, + hardware: RadrootsHostVaultHardwarePolicy::RequireHardwareBacked, + }), + (true, true, true) + ); + } + + #[cfg(not(target_os = "android"))] + #[test] + fn vault_operations_report_unavailable_off_android() { + let vault = RadrootsAndroidKeystoreVault::new(crate::security::ANDROID_NOSTR_SERVICE); + + let load = vault.load_secret("alice").expect_err("load off android"); + assert!(load.to_string().starts_with("secret vault access error:")); + + let store = vault + .store_secret("alice", "deadbeef") + .expect_err("store off android"); + assert!(store.to_string().starts_with("secret vault access error:")); + + let remove = vault + .remove_secret("alice") + .expect_err("remove off android"); + assert!(remove.to_string().starts_with("secret vault access error:")); + } +} diff --git a/crates/apple/security/Cargo.toml b/crates/bridges/apple/security/Cargo.toml diff --git a/crates/bridges/apple/security/build.rs b/crates/bridges/apple/security/build.rs @@ -0,0 +1,164 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "macos" && target_os != "ios" { + return; + } + + let package_dir = swift_package_dir(); + println!( + "cargo:rerun-if-changed={}", + package_dir.join("Package.swift").display() + ); + println!( + "cargo:rerun-if-changed={}", + package_dir.join("Sources").display() + ); + println!( + "cargo:rerun-if-changed={}", + package_dir.join("Tests").display() + ); + + let ffi_library = "libRadRootsAppleSecurityFFIDynamic.dylib"; + run_swift_build(package_dir.as_path(), "RadRootsAppleSecurityFFIDynamic"); + + let build_dir = find_library_dir(package_dir.join(".build"), ffi_library) + .expect("swift ffi library dir"); + let copied_library_dir = target_profile_dir(); + fs::create_dir_all(&copied_library_dir).expect("create target profile dir"); + fs::copy( + build_dir.join(ffi_library), + copied_library_dir.join(ffi_library), + ) + .expect("copy swift ffi library into cargo target dir"); + let swift_runtime_dir = swift_runtime_dir(target_os.as_str()); + println!( + "cargo:rustc-link-search=native={}", + copied_library_dir.display() + ); + println!( + "cargo:rustc-link-search=native={}", + swift_runtime_dir.display() + ); + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + copied_library_dir.display() + ); + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + swift_runtime_dir.display() + ); + println!("cargo:rustc-link-lib=dylib=RadRootsAppleSecurityFFIDynamic"); + println!("cargo:rustc-link-lib=framework=CoreFoundation"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=LocalAuthentication"); + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=dylib=objc"); +} + +fn swift_package_dir() -> PathBuf { + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir")) + .join("../../../../native/bridges/apple/security/swift/RadRootsAppleSecurity") +} + +fn swift_runtime_dir(target_os: &str) -> PathBuf { + let swift_bin = run_stdout(Command::new("xcrun").arg("--toolchain").arg("swift").arg("--find").arg("swift")); + let swift_bin = PathBuf::from(swift_bin.trim()); + let toolchain_dir = swift_bin + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .expect("swift toolchain dir"); + find_swift_runtime_dir(toolchain_dir.join("usr/lib"), target_os).expect("swift runtime dir") +} + +fn find_swift_runtime_dir(root: PathBuf, target_os: &str) -> Option<PathBuf> { + let platform_dir = match target_os { + "macos" => "macosx", + "ios" => "iphoneos", + other => other, + }; + let mut stack = vec![root]; + while let Some(dir) = stack.pop() { + let entries = std::fs::read_dir(&dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.file_name().is_some_and(|name| name == "libswift_Concurrency.dylib") + && path + .components() + .any(|component| component.as_os_str() == platform_dir) + { + return path.parent().map(Path::to_path_buf); + } + } + } + None +} + +fn find_library_dir(root: PathBuf, library_name: &str) -> Option<PathBuf> { + let mut stack = vec![root]; + while let Some(dir) = stack.pop() { + let entries = std::fs::read_dir(&dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.file_name().is_some_and(|name| name == library_name) { + return path.parent().map(Path::to_path_buf); + } + } + } + None +} + +fn target_profile_dir() -> PathBuf { + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + out_dir + .ancestors() + .nth(3) + .unwrap_or_else(|| panic!("unexpected cargo OUT_DIR layout: {}", out_dir.display())) + .to_path_buf() +} + +fn run_swift_build(package_dir: &Path, product: &str) { + let output = Command::new("swift") + .arg("build") + .arg("--product") + .arg(product) + .current_dir(package_dir) + .output() + .expect("failed to run swift build"); + + if output.status.success() { + return; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + panic!( + "swift build --product {product} failed in {}:\nstdout:\n{stdout}\nstderr:\n{stderr}", + package_dir.display() + ); +} + +fn run_stdout(command: &mut Command) -> String { + let output = command.output().expect("failed to run command"); + if output.status.success() { + return String::from_utf8(output.stdout).expect("utf-8 stdout"); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("command failed: {command:?}\nstderr:\n{stderr}"); +} diff --git a/crates/apple/security/src/lib.rs b/crates/bridges/apple/security/src/lib.rs diff --git a/crates/apple/security/src/security.rs b/crates/bridges/apple/security/src/security.rs diff --git a/crates/apple/security/src/vault.rs b/crates/bridges/apple/security/src/vault.rs diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -1,44 +0,0 @@ -[package] -name = "radroots_app_desktop" -authors.workspace = true -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Rad Roots desktop launcher" -publish = false -build = "build.rs" - -[lints] -workspace = true - -[dependencies] -eframe = { workspace = true, features = ["wgpu", "wayland", "x11"] } -egui.workspace = true -image.workspace = true -log.workspace = true -radroots_app_core = { path = "../core" } -radroots_app_remote_signer = { path = "../remote_signer" } -radroots_geocoder.workspace = true -radroots_nostr_accounts = { workspace = true, features = ["memory-vault"] } -radroots_runtime_paths.workspace = true -zeroize.workspace = true - -[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] -wgpu = { workspace = true, features = ["metal", "wgsl"] } - -[target.'cfg(target_os = "macos")'.dependencies] -objc2-foundation = { workspace = true, features = ["NSProcessInfo", "NSString"] } -radroots_app_apple_security.workspace = true -radroots_identity.workspace = true - -[target.'cfg(target_os = "windows")'.dependencies] -wgpu = { workspace = true, features = ["dx12", "wgsl"] } - -[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] -wgpu = { workspace = true, features = ["vulkan", "gles", "wgsl"] } - -[dev-dependencies] -radroots_app_test_support = { path = "../test_support" } diff --git a/crates/desktop/build.rs b/crates/desktop/build.rs @@ -1,192 +0,0 @@ -use std::env; -use std::path::{Path, PathBuf}; -use std::process::Command; - -const GEOCODER_DB_FILENAME: &str = "geonames.db"; -const GEOCODER_REVISION_FILENAME: &str = "geonames.revision"; - -fn main() { - println!("cargo:rerun-if-changed=build.rs"); - sync_optional_geocoder_assets(); - - if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("macos") { - return; - } - - let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); - let package_dir = manifest_dir.join("../../native/apple/swift/RadRootsAppleSecurity"); - let info_plist_path = manifest_dir.join("macos/Info.plist"); - - emit_rerun_paths(&package_dir); - println!("cargo:rerun-if-changed={}", info_plist_path.display()); - - let configuration = if env::var("PROFILE").ok().as_deref() == Some("release") { - "release" - } else { - "debug" - }; - let arch = env::var("CARGO_CFG_TARGET_ARCH").expect("target arch"); - - run_swift_build(&package_dir, configuration, &arch); - let bin_path = swift_bin_path(&package_dir, configuration, &arch); - - let dylib_path = bin_path.join("libRadRootsAppleSecurityFFIDynamic.dylib"); - if !dylib_path.is_file() { - panic!( - "swift package did not produce expected dynamic library at {}", - dylib_path.display() - ); - } - - println!("cargo:rustc-link-search=native={}", bin_path.display()); - println!("cargo:rustc-link-lib=dylib=RadRootsAppleSecurityFFIDynamic"); - println!("cargo:rustc-link-lib=framework=Foundation"); - println!("cargo:rustc-link-lib=framework=Security"); - println!("cargo:rustc-link-lib=framework=LocalAuthentication"); - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", bin_path.display()); - println!( - "cargo:rustc-link-arg-bin=radroots_app_desktop=-Wl,-sectcreate,__TEXT,__info_plist,{}", - info_plist_path.display() - ); -} - -fn sync_optional_geocoder_assets() { - let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); - let source_db_path = manifest_dir.join(format!("../../assets/geocoder/{GEOCODER_DB_FILENAME}")); - let source_revision_path = manifest_dir.join(format!( - "../../assets/geocoder/{GEOCODER_REVISION_FILENAME}" - )); - println!("cargo:rerun-if-changed={}", source_db_path.display()); - println!("cargo:rerun-if-changed={}", source_revision_path.display()); - - let profile_dir = target_profile_dir(); - let target_db_path = profile_dir.join(GEOCODER_DB_FILENAME); - let target_revision_path = profile_dir.join(GEOCODER_REVISION_FILENAME); - - if source_db_path.is_file() { - if !source_revision_path.is_file() { - panic!( - "stamped desktop geocoder revision asset missing at {}", - source_revision_path.display() - ); - } - - std::fs::copy(&source_db_path, &target_db_path).unwrap_or_else(|err| { - panic!( - "failed to copy optional desktop geocoder asset from {} to {}: {err}", - source_db_path.display(), - target_db_path.display() - ) - }); - std::fs::copy(&source_revision_path, &target_revision_path).unwrap_or_else(|err| { - panic!( - "failed to copy optional desktop geocoder revision from {} to {}: {err}", - source_revision_path.display(), - target_revision_path.display() - ) - }); - return; - } - - if target_db_path.exists() { - std::fs::remove_file(&target_db_path).unwrap_or_else(|err| { - panic!( - "failed to remove stale desktop geocoder asset at {}: {err}", - target_db_path.display() - ) - }); - } - if target_revision_path.exists() { - std::fs::remove_file(&target_revision_path).unwrap_or_else(|err| { - panic!( - "failed to remove stale desktop geocoder revision at {}: {err}", - target_revision_path.display() - ) - }); - } -} - -fn target_profile_dir() -> PathBuf { - let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); - out_dir - .ancestors() - .nth(3) - .unwrap_or_else(|| panic!("unexpected cargo OUT_DIR layout: {}", out_dir.display())) - .to_path_buf() -} - -fn emit_rerun_paths(package_dir: &Path) { - println!( - "cargo:rerun-if-changed={}", - package_dir.join("Package.swift").display() - ); - emit_rerun_dir(&package_dir.join("Sources")); -} - -fn emit_rerun_dir(dir: &Path) { - if !dir.is_dir() { - return; - } - - let mut entries = std::fs::read_dir(dir) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) - .map(|entry| entry.unwrap().path()) - .collect::<Vec<_>>(); - entries.sort(); - - for path in entries { - if path.is_dir() { - emit_rerun_dir(&path); - } else { - println!("cargo:rerun-if-changed={}", path.display()); - } - } -} - -fn run_swift_build(package_dir: &Path, configuration: &str, arch: &str) { - let status = Command::new("swift") - .arg("build") - .arg("--package-path") - .arg(package_dir) - .arg("--product") - .arg("RadRootsAppleSecurityFFIDynamic") - .arg("--configuration") - .arg(configuration) - .arg("--arch") - .arg(arch) - .status() - .unwrap_or_else(|err| panic!("failed to run swift build: {err}")); - - if !status.success() { - panic!("swift build failed for RadRootsAppleSecurityFFIDynamic"); - } -} - -fn swift_bin_path(package_dir: &Path, configuration: &str, arch: &str) -> PathBuf { - let output = Command::new("swift") - .arg("build") - .arg("--package-path") - .arg(package_dir) - .arg("--product") - .arg("RadRootsAppleSecurityFFIDynamic") - .arg("--configuration") - .arg(configuration) - .arg("--arch") - .arg(arch) - .arg("--show-bin-path") - .output() - .unwrap_or_else(|err| panic!("failed to resolve swift bin path: {err}")); - - if !output.status.success() { - panic!( - "swift build --show-bin-path failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - PathBuf::from( - String::from_utf8(output.stdout) - .expect("swift bin path utf-8") - .trim(), - ) -} diff --git a/crates/ios/Cargo.toml b/crates/ios/Cargo.toml @@ -1,33 +0,0 @@ -[package] -name = "radroots_app_ios" -authors.workspace = true -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Rad Roots iOS launcher" -publish = false - -[lib] -path = "src/lib.rs" -crate-type = ["staticlib", "rlib"] - -[dependencies] -eframe = { workspace = true, features = ["wgpu"] } -log.workspace = true -radroots_app_apple_security.workspace = true -radroots_app_core = { path = "../core" } -radroots_app_remote_signer = { path = "../remote_signer" } -radroots_geocoder.workspace = true -radroots_identity.workspace = true -radroots_nostr_accounts = { workspace = true, features = ["memory-vault"] } -radroots_runtime_paths.workspace = true -zeroize.workspace = true - -[target.'cfg(target_os = "ios")'.dependencies] -wgpu = { workspace = true, features = ["metal", "wgsl"] } - -[dev-dependencies] -radroots_app_test_support = { path = "../test_support" } diff --git a/crates/launchers/android/Cargo.toml b/crates/launchers/android/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "radroots_app_android" +authors.workspace = true +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots Android launcher" +publish = false + +[lib] +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +eframe = { workspace = true, features = ["android-game-activity", "glow"] } +log.workspace = true +radroots_app_android_security.workspace = true +radroots_app_core = { path = "../../shared/core" } +radroots_app_remote_signer = { path = "../../shared/remote_signer" } +radroots_geocoder.workspace = true +radroots_identity.workspace = true +radroots_nostr_accounts = { workspace = true, features = ["memory-vault"] } +radroots_runtime_paths.workspace = true +zeroize.workspace = true + +[target.'cfg(target_os = "android")'.dependencies] +android_logger.workspace = true +wgpu = { workspace = true, features = ["vulkan", "gles", "wgsl"] } +winit.workspace = true + +[dev-dependencies] +radroots_app_test_support = { path = "../../shared/test_support" } diff --git a/crates/android/src/country_lookup.rs b/crates/launchers/android/src/country_lookup.rs diff --git a/crates/launchers/android/src/lib.rs b/crates/launchers/android/src/lib.rs @@ -0,0 +1,1228 @@ +#[cfg(target_os = "android")] +use android_logger::Config; +#[cfg(target_os = "android")] +use eframe::egui::ViewportBuilder; +#[cfg(target_os = "android")] +use radroots_app_android_security as android_security; +#[cfg(any(target_os = "android", test))] +use radroots_app_core::RadrootsAppBackend; +#[cfg(target_os = "android")] +use radroots_app_core::{APP_NAME, RadrootsApp}; +#[cfg(any(target_os = "android", test))] +use radroots_app_core::{ + HomeActionKind, HomeActionResult, HomeActionState, IdentityGateState, ImportActionState, + RadrootsAccountCustody, RadrootsAccountSummary, RadrootsLocationCountry, + RadrootsLocationCountryCenterLookupResult, RadrootsLocationCountryListResult, + RadrootsLocationPoint, RadrootsLocationResolverError, RadrootsLocationReverseOptions, + RadrootsOfflineGeocoderState, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult, + RadrootsSecretImportMode, RadrootsSecretImportRequest, SetupActionState, +}; +#[cfg(any(target_os = "android", test))] +use radroots_identity::RadrootsIdentity; +#[cfg(test)] +use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; +#[cfg(any(target_os = "android", test))] +use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; +#[cfg(any(target_os = "android", test))] +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))] +use zeroize::Zeroizing; + +#[cfg(any(target_os = "android", test))] +mod country_lookup; +#[cfg(any(target_os = "android", test))] +mod offline_geocoder; +#[cfg(target_os = "android")] +mod remote_signer; +#[cfg(any(target_os = "android", test))] +mod reverse_lookup; +#[cfg(any(target_os = "android", test))] +mod storage; + +#[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +struct AndroidBackend { + country_lookup: country_lookup::AndroidCountryLookup, + offline_geocoder: offline_geocoder::AndroidOfflineGeocoder, + #[cfg(target_os = "android")] + remote_signer: remote_signer::AndroidRemoteSigner, + reverse_lookup: reverse_lookup::AndroidReverseLookup, +} + +#[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")] + { + let manager = Self::accounts_manager()?; + let status = manager + .selected_account_status() + .map_err(|source| source.to_string())?; + return remote_signer::identity_state_from_status(status); + } + + #[cfg(not(target_os = "android"))] + { + Ok(Self::unsupported_identity_state()) + } + } + + fn load_account_roster(&self) -> Result<Vec<RadrootsAccountSummary>, String> { + #[cfg(target_os = "android")] + { + let manager = Self::accounts_manager()?; + return Self::account_roster_from_manager(&manager); + } + + #[cfg(not(target_os = "android"))] + { + Ok(Vec::new()) + } + } + + fn offline_geocoder_state(&self) -> Option<RadrootsOfflineGeocoderState> { + Some(self.offline_geocoder.current_state()) + } + + fn poll_offline_geocoder_state(&self) -> Result<Option<RadrootsOfflineGeocoderState>, String> { + Ok(self.offline_geocoder.take_update()) + } + + fn reverse_location( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return offline_geocoder::reverse_location( + &self.offline_geocoder.current_state(), + point, + options, + ); + } + + #[cfg(not(target_os = "android"))] + { + let _ = (point, options); + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn request_reverse_location_lookup( + &self, + point: RadrootsLocationPoint, + options: Option<RadrootsLocationReverseOptions>, + ) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return self.reverse_lookup.begin( + self.offline_geocoder.current_state(), + point, + options, + ); + } + + #[cfg(not(target_os = "android"))] + { + let _ = (point, options); + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_reverse_location_lookup_result( + &self, + ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> { + Ok(self.reverse_lookup.take_update()) + } + + fn request_location_country_list(&self) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return self + .country_lookup + .begin_list(self.offline_geocoder.current_state()); + } + + #[cfg(not(target_os = "android"))] + { + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_location_country_list_result( + &self, + ) -> Result<Option<RadrootsLocationCountryListResult>, String> { + Ok(self.country_lookup.take_list_update()) + } + + fn request_location_country_center_lookup( + &self, + country_id: &str, + ) -> Result<(), RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return self + .country_lookup + .begin_center(self.offline_geocoder.current_state(), country_id.to_owned()); + } + + #[cfg(not(target_os = "android"))] + { + let _ = country_id; + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn poll_location_country_center_lookup_result( + &self, + ) -> Result<Option<RadrootsLocationCountryCenterLookupResult>, String> { + Ok(self.country_lookup.take_center_update()) + } + + fn list_location_countries( + &self, + ) -> Result<Vec<RadrootsLocationCountry>, RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return offline_geocoder::list_countries(&self.offline_geocoder.current_state()); + } + + #[cfg(not(target_os = "android"))] + { + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn location_country_center( + &self, + country_id: &str, + ) -> Result<RadrootsLocationPoint, RadrootsLocationResolverError> { + #[cfg(target_os = "android")] + { + return offline_geocoder::country_center( + &self.offline_geocoder.current_state(), + country_id, + ); + } + + #[cfg(not(target_os = "android"))] + { + let _ = country_id; + Err(RadrootsLocationResolverError::Unsupported) + } + } + + fn setup_action_state(&self) -> SetupActionState { + #[cfg(target_os = "android")] + { + return Self::enabled_setup_action_state(); + } + + #[cfg(not(target_os = "android"))] + { + Self::unsupported_setup_action_state() + } + } + + fn request_setup_action(&self) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "android")] + { + let manager = Self::accounts_manager()?; + return Self::generate_local_identity(&manager).map(Some); + } + + #[cfg(not(target_os = "android"))] + { + Ok(Some(Self::unsupported_identity_state())) + } + } + + fn home_setup_action_state(&self) -> Option<SetupActionState> { + Some(self.setup_action_state()) + } + + fn request_home_setup_action(&self) -> Result<Option<IdentityGateState>, String> { + self.request_setup_action() + } + + fn import_action_state(&self) -> Option<ImportActionState> { + #[cfg(target_os = "android")] + { + return Some(ImportActionState { + label: "Import Secret Key".to_owned(), + enabled: true, + pending: false, + }); + } + + #[cfg(not(target_os = "android"))] + { + None + } + } + + 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, request).map(Some); + } + + #[cfg(not(target_os = "android"))] + { + let _ = request; + Ok(None) + } + } + + fn request_select_account( + &self, + account_id: &str, + ) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "android")] + { + let manager = Self::accounts_manager()?; + let account_id = radroots_identity::RadrootsIdentityId::try_from(account_id) + .map_err(|_| "invalid account id".to_owned())?; + manager + .select_account(&account_id) + .map_err(|source| source.to_string())?; + return self.load_identity_state().map(Some); + } + + #[cfg(not(target_os = "android"))] + { + let _ = account_id; + Ok(None) + } + } + + fn remote_signer_action_state(&self) -> Option<SetupActionState> { + #[cfg(target_os = "android")] + { + return Some( + self.remote_signer + .action_state() + .unwrap_or_else(|_| SetupActionState { + label: "Connect Remote Signer".to_owned(), + enabled: !self.remote_signer.is_connecting(), + pending: self.remote_signer.is_connecting(), + }), + ); + } + + #[cfg(not(target_os = "android"))] + { + None + } + } + + fn preview_remote_signer_connection( + &self, + input: &str, + ) -> Result<radroots_app_core::RadrootsRemoteSignerPreview, String> { + #[cfg(target_os = "android")] + { + return remote_signer::preview_connection(input); + } + + #[cfg(not(target_os = "android"))] + { + let _ = input; + Err("remote signer onboarding is not available in this build".to_owned()) + } + } + + fn request_remote_signer_connection( + &self, + input: &str, + ) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "android")] + { + self.remote_signer.begin_connect(input)?; + return Ok(None); + } + + #[cfg(not(target_os = "android"))] + { + let _ = input; + Ok(None) + } + } + + fn pending_remote_signer_connection( + &self, + ) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> { + #[cfg(target_os = "android")] + { + return self.remote_signer.pending_connection(); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } + + fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> { + #[cfg(target_os = "android")] + { + return remote_signer::cancel_pending_connection(); + } + + #[cfg(not(target_os = "android"))] + { + Ok(()) + } + } + + fn remote_signer_note_action_state(&self) -> Option<SetupActionState> { + #[cfg(target_os = "android")] + { + return Some( + self.remote_signer + .note_action_state() + .unwrap_or(SetupActionState { + label: "Sign Remote Kind 1 Note".to_owned(), + enabled: false, + pending: false, + }), + ); + } + + #[cfg(not(target_os = "android"))] + { + None + } + } + + fn request_remote_signer_note_action(&self, content: &str) -> Result<(), String> { + #[cfg(target_os = "android")] + { + return self.remote_signer.begin_sign_kind1_note_selected(content); + } + + #[cfg(not(target_os = "android"))] + { + let _ = content; + Ok(()) + } + } + + fn poll_remote_signer_note_action_result( + &self, + ) -> Result<Option<radroots_app_core::RadrootsRemoteSignerSignedNote>, String> { + #[cfg(target_os = "android")] + { + return self + .remote_signer + .take_note_update() + .transpose() + .map(|result| result.flatten()); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } + + fn home_action_states(&self) -> Vec<HomeActionState> { + #[cfg(target_os = "android")] + { + let secret_key_export_pending = Self::secret_key_export_pending(); + let Ok(manager) = Self::accounts_manager() else { + return Vec::new(); + }; + let Ok(status) = manager + .selected_account_status() + .map_err(|source| source.to_string()) + else { + return Vec::new(); + }; + + return match status { + RadrootsNostrSelectedAccountStatus::NotConfigured => Vec::new(), + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + if matches!( + remote_signer::custody_for_account_id(account.account_id.as_str()), + Ok(RadrootsAccountCustody::RemoteSigner) + ) { + vec![HomeActionState { + kind: HomeActionKind::DisconnectSigner, + label: "Disconnect Remote Signer".to_owned(), + enabled: true, + pending: false, + }] + } else { + Vec::new() + } + } + RadrootsNostrSelectedAccountStatus::Ready { .. } => vec![ + HomeActionState { + kind: HomeActionKind::BackupSecretKey, + label: "Back Up Secret Key".to_owned(), + enabled: !secret_key_export_pending, + 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, + pending: false, + }, + HomeActionState { + kind: HomeActionKind::ResetDevice, + label: "Reset This Device".to_owned(), + enabled: true, + pending: false, + }, + ], + }; + } + + #[cfg(not(target_os = "android"))] + { + Vec::new() + } + } + + fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { + #[cfg(target_os = "android")] + { + return match action { + HomeActionKind::BackupSecretKey => Ok(HomeActionResult::None), + HomeActionKind::RevealRawSecretKey => { + Self::begin_raw_secret_key_reveal().map(|()| HomeActionResult::None) + } + HomeActionKind::RemoveLocalKey => { + let manager = Self::accounts_manager()?; + Self::remove_selected_local_identity(&manager) + .map(HomeActionResult::IdentityState) + } + HomeActionKind::ResetDevice => { + let manager = Self::accounts_manager()?; + let accounts_path = storage::accounts_path()?; + Self::reset_local_device_state(&manager, accounts_path.as_path()) + .map(HomeActionResult::IdentityState) + } + HomeActionKind::DisconnectSigner => { + let manager = Self::accounts_manager()?; + remote_signer::disconnect_selected_remote_signer(&manager) + .map(HomeActionResult::IdentityState) + } + }; + } + + #[cfg(not(target_os = "android"))] + { + let _ = action; + Ok(HomeActionResult::None) + } + } + + 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")] + { + return Self::poll_secret_key_export(); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } + + fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "android")] + { + return self + .remote_signer + .take_update() + .transpose() + .map(|state| state.flatten()); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } +} + +#[cfg(any(target_os = "android", test))] +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +impl AndroidBackend { + fn new() -> Self { + #[cfg(target_os = "android")] + let offline_geocoder = offline_geocoder::AndroidOfflineGeocoder::start(); + + #[cfg(not(target_os = "android"))] + let offline_geocoder = offline_geocoder::AndroidOfflineGeocoder::from_state( + RadrootsOfflineGeocoderState::unavailable( + radroots_app_core::RadrootsOfflineGeocoderUnavailableKind::MissingBuildAsset, + radroots_app_core::RadrootsOfflineGeocoderPlatform::Android, + "android offline geocoder initialization is only wired on android targets", + ), + ); + + Self { + country_lookup: country_lookup::AndroidCountryLookup::new(), + offline_geocoder, + #[cfg(target_os = "android")] + remote_signer: remote_signer::AndroidRemoteSigner::new(), + reverse_lookup: reverse_lookup::AndroidReverseLookup::new(), + } + } + + #[cfg(target_os = "android")] + fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { + #[cfg(target_os = "android")] + { + return storage::accounts_manager(); + } + } + + #[cfg(test)] + fn unsupported_identity_state() -> IdentityGateState { + IdentityGateState::Unsupported { + reason: ANDROID_SETUP_UNAVAILABLE_REASON.to_owned(), + } + } + + #[cfg(test)] + fn unsupported_setup_action_state() -> SetupActionState { + SetupActionState { + label: "Generate New Key".to_owned(), + enabled: false, + pending: false, + } + } + + fn enabled_setup_action_state() -> SetupActionState { + SetupActionState { + label: "Generate New Key".to_owned(), + enabled: true, + pending: false, + } + } + + fn map_status(status: RadrootsNostrSelectedAccountStatus) -> IdentityGateState { + match status { + RadrootsNostrSelectedAccountStatus::Ready { account } => IdentityGateState::Ready { + account_id: account.account_id.to_string(), + }, + RadrootsNostrSelectedAccountStatus::NotConfigured + | RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => IdentityGateState::Missing, + } + } + + fn identity_state_from_manager( + manager: &RadrootsNostrAccountsManager, + ) -> Result<IdentityGateState, String> { + let status = manager + .selected_account_status() + .map_err(|source| source.to_string())?; + Ok(Self::map_status(status)) + } + + fn account_roster_from_manager( + manager: &RadrootsNostrAccountsManager, + ) -> Result<Vec<RadrootsAccountSummary>, String> { + manager + .list_accounts() + .map_err(|source| source.to_string())? + .into_iter() + .map(|record| { + #[cfg(target_os = "android")] + let custody = remote_signer::custody_for_account_id(record.account_id.as_str())?; + #[cfg(not(target_os = "android"))] + let custody = RadrootsAccountCustody::LocalManaged; + Ok(RadrootsAccountSummary { + account_id: record.account_id.to_string(), + npub: record.public_identity.public_key_npub, + label: record.label, + custody, + }) + }) + .collect() + } + + fn generate_local_identity( + manager: &RadrootsNostrAccountsManager, + ) -> Result<IdentityGateState, String> { + manager + .generate_identity(Some("local".to_owned()), true) + .map_err(|source| source.to_string())?; + Self::identity_state_from_manager(manager) + } + + 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 + .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 import_local_identity( + manager: &RadrootsNostrAccountsManager, + request: &RadrootsSecretImportRequest, + ) -> Result<IdentityGateState, String> { + 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) + .map_err(|source| source.to_string())?; + + Self::identity_state_from_manager(manager) + } + + #[cfg(target_os = "android")] + 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) = + android_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) = + android_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_raw_secret_key_reveal() -> Result<(), String> { + Ok(()) + } + + #[cfg(target_os = "android")] + fn secret_key_export_pending() -> bool { + android_security::is_user_presence_verification_pending().unwrap_or(false) + } + + #[cfg(not(target_os = "android"))] + fn secret_key_export_pending() -> bool { + false + } + + #[cfg(target_os = "android")] + fn poll_secret_key_export() -> Result<Option<HomeActionResult>, String> { + match android_security::take_user_presence_verification_result() + .map_err(|source| source.to_string())? + { + Some(android_security::AndroidUserPresenceVerificationResult::Verified) => { + let manager = Self::accounts_manager()?; + 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(android_security::AndroidUserPresenceVerificationResult::Failed(message)) => { + *PENDING_SECRET_KEY_EXPORT + .lock() + .map_err(|_| "failed to clear pending secret key export".to_owned())? = None; + Err(message) + } + None => Ok(None), + } + } + + #[cfg(not(target_os = "android"))] + fn poll_secret_key_export() -> Result<Option<HomeActionResult>, String> { + Ok(None) + } + + fn remove_selected_local_identity( + manager: &RadrootsNostrAccountsManager, + ) -> Result<IdentityGateState, String> { + let Some(account_id) = manager + .selected_account_id() + .map_err(|source| source.to_string())? + else { + return Ok(IdentityGateState::Missing); + }; + + manager + .remove_account(&account_id) + .map_err(|source| source.to_string())?; + Self::identity_state_from_manager(manager) + } + + fn remove_all_local_identities( + manager: &RadrootsNostrAccountsManager, + ) -> Result<IdentityGateState, String> { + let account_ids = manager + .list_accounts() + .map_err(|source| source.to_string())? + .into_iter() + .map(|record| record.account_id) + .collect::<Vec<_>>(); + + for account_id in account_ids { + manager + .remove_account(&account_id) + .map_err(|source| source.to_string())?; + } + + Self::identity_state_from_manager(manager) + } + + fn remove_accounts_file_if_present(accounts_path: &Path) -> Result<(), String> { + match std::fs::remove_file(accounts_path) { + Ok(()) => Ok(()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(format!("failed to remove android accounts file: {source}")), + } + } + + #[cfg(target_os = "android")] + fn reset_local_device_state( + manager: &RadrootsNostrAccountsManager, + accounts_path: &Path, + ) -> Result<IdentityGateState, String> { + remote_signer::purge_all_custody_state()?; + let state = Self::remove_all_local_identities(manager)?; + Self::remove_accounts_file_if_present(accounts_path)?; + Ok(state) + } +} + +#[cfg(any(target_os = "android", test))] +#[cfg(test)] +const ANDROID_SETUP_UNAVAILABLE_REASON: &str = "Secure onboarding is not yet available on Android."; + +#[cfg(target_os = "android")] +fn native_options(android_app: AndroidApp) -> eframe::NativeOptions { + eframe::NativeOptions { + renderer: eframe::Renderer::Glow, + android_app: Some(android_app), + viewport: ViewportBuilder::default().with_title(APP_NAME), + ..Default::default() + } +} + +#[cfg(target_os = "android")] +fn run_android_app(android_app: AndroidApp) -> Result<(), String> { + android_logger::init_once(Config::default().with_max_level(log::LevelFilter::Info)); + eframe::run_native( + APP_NAME, + native_options(android_app), + Box::new(|_cc| Ok(Box::new(RadrootsApp::new(Box::new(AndroidBackend::new()))))), + ) + .map_err(|err| err.to_string()) +} + +#[cfg(target_os = "android")] +#[allow(improper_ctypes_definitions)] +#[allow(unsafe_code)] +#[unsafe(no_mangle)] +pub extern "C" fn android_main(android_app: AndroidApp) { + if let Err(err) = run_android_app(android_app) { + log::error!("android launcher failed: {err}"); + } +} + +#[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() { + assert_eq!( + AndroidBackend::unsupported_identity_state(), + IdentityGateState::Unsupported { + reason: ANDROID_SETUP_UNAVAILABLE_REASON.to_owned(), + } + ); + assert_eq!( + AndroidBackend::unsupported_setup_action_state(), + SetupActionState { + label: "Generate New Key".to_owned(), + enabled: false, + pending: false, + } + ); + } + + #[test] + fn android_backend_enables_setup_action_when_android_keygen_is_wired() { + assert_eq!( + AndroidBackend::enabled_setup_action_state(), + SetupActionState { + label: "Generate New Key".to_owned(), + enabled: true, + pending: false, + } + ); + } + + #[test] + fn android_backend_maps_ready_account_to_ready_state() { + let identity = RadrootsIdentity::generate(); + let account = + RadrootsNostrAccountRecord::new(identity.to_public(), Some("local".into()), 0); + + let state = AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::Ready { + account: account.clone(), + }); + + assert_eq!( + state, + IdentityGateState::Ready { + account_id: account.account_id.to_string(), + } + ); + } + + #[test] + fn android_backend_maps_fresh_and_public_only_accounts_to_missing() { + let public_only_identity = RadrootsIdentity::generate(); + let public_only_account = + RadrootsNostrAccountRecord::new(public_only_identity.to_public(), None, 0); + + assert_eq!( + AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::NotConfigured), + IdentityGateState::Missing + ); + assert_eq!( + AndroidBackend::map_status(RadrootsNostrSelectedAccountStatus::PublicOnly { + account: public_only_account, + }), + IdentityGateState::Missing + ); + } + + #[test] + fn fresh_android_manager_starts_in_setup_state() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + + assert_eq!( + AndroidBackend::identity_state_from_manager(&manager), + Ok(IdentityGateState::Missing) + ); + } + + #[test] + fn local_identity_generation_transitions_android_to_ready() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + + let state = AndroidBackend::generate_local_identity(&manager).expect("generate identity"); + let IdentityGateState::Ready { account_id } = state else { + panic!("expected ready identity state"); + }; + + assert!(!account_id.is_empty()); + } + + #[test] + fn local_identity_removal_transitions_android_back_to_missing() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + + AndroidBackend::generate_local_identity(&manager).expect("generate identity"); + let state = AndroidBackend::remove_selected_local_identity(&manager) + .expect("remove selected account"); + + assert_eq!(state, IdentityGateState::Missing); + assert_eq!( + manager.selected_account_id().expect("selected account"), + None + ); + } + + #[test] + fn remove_all_local_identities_clears_every_account() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + + manager + .generate_identity(Some("first".into()), true) + .expect("generate first"); + manager + .generate_identity(Some("second".into()), false) + .expect("generate second"); + + let state = AndroidBackend::remove_all_local_identities(&manager).expect("reset state"); + + assert_eq!(state, IdentityGateState::Missing); + assert_eq!(manager.list_accounts().expect("list accounts").len(), 0); + assert_eq!(manager.selected_account_id().expect("selected"), None); + } + + #[test] + fn export_selected_local_raw_secret_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 = + AndroidBackend::export_selected_local_raw_secret_key(&manager).expect("export secret"); + + assert_eq!(nsec, identity.nsec()); + assert!(nsec.starts_with("nsec1")); + } + + #[test] + 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, + &RadrootsSecretImportRequest { + mode: RadrootsSecretImportMode::RawSecretKey, + secret_text: identity.nsec(), + password: None, + }, + ) + .expect("import"); + + assert_eq!( + state, + IdentityGateState::Ready { + account_id: identity.id().to_string(), + } + ); + assert_eq!( + manager.selected_account_id().expect("selected"), + Some(identity.id()) + ); + assert_eq!(manager.list_accounts().expect("list").len(), 1); + assert_eq!( + manager + .export_secret_hex(&identity.id()) + .expect("export secret"), + Some(identity.secret_key_hex()) + ); + } + + #[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", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + std::fs::write(&path, b"{}").expect("write accounts file"); + + AndroidBackend::remove_accounts_file_if_present(path.as_path()).expect("remove file"); + + assert!(!path.exists()); + } +} diff --git a/crates/android/src/offline_geocoder.rs b/crates/launchers/android/src/offline_geocoder.rs diff --git a/crates/launchers/android/src/remote_signer.rs b/crates/launchers/android/src/remote_signer.rs @@ -0,0 +1,471 @@ +use crate::storage; +use radroots_app_android_security::{ANDROID_NOSTR_SERVICE, RadrootsAndroidKeystoreVault}; +use radroots_app_core::{ + IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection, + RadrootsRemoteSignerPreview, RadrootsRemoteSignerSignedNote, SetupActionState, +}; +use radroots_app_remote_signer::{ + RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerActionController, + RadrootsAppRemoteSignerActionControllerHooks, RadrootsAppRemoteSignerActionState, + RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, + RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerPendingState, + RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState, + RadrootsAppRemoteSignerSignedEvent, 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, +}; +use radroots_identity::RadrootsIdentityId; +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, RadrootsSecretVault, + account_secret_slot, +}; +use std::path::{Path, PathBuf}; + +const REMOTE_SIGNER_LABEL: &str = "remote signer"; + +#[derive(Clone, Copy)] +struct AndroidRemoteSignerHooks; + +impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks { + type ReadyState = IdentityGateState; + + fn reconcile_startup_state(&self) -> Result<(), String> { + let manager = crate::storage::accounts_manager()?; + let store_path = sessions_path()?; + radroots_app_remote_signer_reconcile_startup( + &manager, + store_path.as_path(), + REMOTE_SIGNER_LABEL, + load_client_secret, + remove_client_secret, + purge_client_secret_namespace, + ) + } + + fn store_pending_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<(), String> { + let client_account_id = pending.record.client_account_id().to_owned(); + store_client_secret( + client_account_id.as_str(), + pending.client_secret_key_hex.as_str(), + )?; + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + if let Err(error) = state.upsert_pending(pending.record.clone()) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error.to_string()); + } + if let Err(error) = save_sessions(store_path.as_path(), &state) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error); + } + Ok(()) + } + + fn pending_session_record( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + pending_session_record() + } + + fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> { + load_client_secret(client_account_id) + } + + fn activate_pending_session( + &self, + client_account_id: &str, + approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession, + ) -> Result<Self::ReadyState, String> { + activate_remote_session(client_account_id, approved) + } + + fn clear_pending_session( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + let store_path = sessions_path()?; + radroots_app_remote_signer_clear_pending_session(store_path.as_path(), remove_client_secret) + } +} + +#[derive(Clone)] +pub(crate) struct AndroidRemoteSigner { + controller: RadrootsAppRemoteSignerController<AndroidRemoteSignerHooks>, + action_controller: RadrootsAppRemoteSignerActionController<AndroidRemoteSignerHooks>, +} + +impl AndroidRemoteSigner { + pub(crate) fn new() -> Self { + Self { + controller: RadrootsAppRemoteSignerController::new(AndroidRemoteSignerHooks), + action_controller: RadrootsAppRemoteSignerActionController::new( + AndroidRemoteSignerHooks, + ), + } + } + + pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> { + self.controller.take_update() + } + + pub(crate) fn is_connecting(&self) -> bool { + self.controller.is_connecting() + } + + pub(crate) fn action_state(&self) -> Result<SetupActionState, String> { + if self.is_connecting() { + return Ok(SetupActionState { + label: "Connecting Remote Signer...".to_owned(), + enabled: false, + pending: true, + }); + } + + if self.pending_connection()?.is_some() { + return Ok(match self.controller.pending_state() { + RadrootsAppRemoteSignerPendingState::TransportFailure { .. } => SetupActionState { + label: "Remote Signer Approval Check Retrying".to_owned(), + enabled: false, + pending: false, + }, + RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { .. } => { + SetupActionState { + label: "Authorize Remote Signer to Continue".to_owned(), + enabled: false, + pending: false, + } + } + RadrootsAppRemoteSignerPendingState::Idle + | RadrootsAppRemoteSignerPendingState::WaitingApproval => SetupActionState { + label: "Remote Signer Waiting for Approval".to_owned(), + enabled: false, + pending: false, + }, + }); + } + + Ok(SetupActionState { + label: "Connect Remote Signer".to_owned(), + enabled: true, + pending: false, + }) + } + + pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> { + self.controller.begin_connect(input) + } + + pub(crate) fn pending_connection( + &self, + ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> { + Ok( + pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection { + signer_npub: record.signer_identity.public_key_npub, + relays: record.relays, + auth_url: match self.controller.pending_state() { + RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { url } => Some(url), + _ => None, + }, + }), + ) + } + + pub(crate) fn note_action_state(&self) -> Result<SetupActionState, String> { + if selected_remote_signer_account()?.is_none() { + return Ok(SetupActionState { + label: "Sign Remote Kind 1 Note".to_owned(), + enabled: false, + pending: false, + }); + } + + Ok(match self.action_controller.state() { + RadrootsAppRemoteSignerActionState::Idle => SetupActionState { + label: "Sign Remote Kind 1 Note".to_owned(), + enabled: true, + pending: false, + }, + RadrootsAppRemoteSignerActionState::Signing => SetupActionState { + label: "Signing Remote Kind 1 Note...".to_owned(), + enabled: false, + pending: true, + }, + RadrootsAppRemoteSignerActionState::AwaitingAuthorization { .. } => SetupActionState { + label: "Authorize Remote Signer to Continue".to_owned(), + enabled: false, + pending: false, + }, + }) + } + + pub(crate) fn begin_sign_kind1_note_selected(&self, content: &str) -> Result<(), String> { + self.action_controller.begin_sign_kind1_note(content) + } + + pub(crate) fn take_note_update( + &self, + ) -> Option<Result<Option<RadrootsRemoteSignerSignedNote>, String>> { + self.action_controller.take_update() + } +} + +pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPreview, String> { + let preview = radroots_app_remote_signer_preview(input).map_err(|error| error.to_string())?; + let requested_permissions = preview.requested_permission_labels(); + Ok(RadrootsRemoteSignerPreview { + source_label: preview.source_label().to_owned(), + signer_npub: preview.signer_identity.public_key_npub, + relays: preview.relays, + requested_permissions, + }) +} + +pub(crate) fn identity_state_from_status( + status: RadrootsNostrSelectedAccountStatus, +) -> Result<IdentityGateState, String> { + match status { + RadrootsNostrSelectedAccountStatus::NotConfigured => Ok(IdentityGateState::Missing), + RadrootsNostrSelectedAccountStatus::Ready { account } => Ok(IdentityGateState::Ready { + account_id: account.account_id.to_string(), + }), + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + if active_session_for_account_id(account.account_id.as_str())?.is_some() { + Ok(IdentityGateState::Ready { + account_id: account.account_id.to_string(), + }) + } else { + Ok(IdentityGateState::Missing) + } + } + } +} + +pub(crate) fn custody_for_account_id(account_id: &str) -> Result<RadrootsAccountCustody, String> { + if active_session_for_account_id(account_id)?.is_some() { + Ok(RadrootsAccountCustody::RemoteSigner) + } else { + Ok(RadrootsAccountCustody::LocalManaged) + } +} + +pub(crate) fn disconnect_selected_remote_signer( + manager: &RadrootsNostrAccountsManager, +) -> Result<IdentityGateState, String> { + let store_path = sessions_path()?; + let status = radroots_app_remote_signer_disconnect_selected( + manager, + store_path.as_path(), + remove_client_secret, + )?; + identity_state_from_status(status) +} + +pub(crate) fn cancel_pending_connection() -> Result<(), String> { + let store_path = sessions_path()?; + let _ = radroots_app_remote_signer_clear_pending_session( + store_path.as_path(), + remove_client_secret, + )?; + Ok(()) +} + +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, + purge_client_secret_namespace, + ) +} + +fn activate_remote_session( + client_account_id: &str, + approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession, +) -> Result<IdentityGateState, String> { + let manager = crate::storage::accounts_manager()?; + manager + .upsert_public_identity( + approved.user_identity.clone(), + Some(REMOTE_SIGNER_LABEL.to_owned()), + true, + ) + .map_err(|source| source.to_string())?; + let store_path = sessions_path()?; + let activation_result = (|| -> Result<(), String> { + let mut state = load_sessions(store_path.as_path())?; + state + .activate_session( + client_account_id, + approved.user_identity.clone(), + approved.relays.clone(), + approved.approved_permissions.clone(), + ) + .ok_or_else(|| { + "pending remote signer session disappeared before activation".to_owned() + })?; + save_sessions(store_path.as_path(), &state) + })(); + if let Err(error) = activation_result { + if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) { + return Err(format!( + "{error}. remote signer account rollback needs retry: {rollback_error}" + )); + } + return Err(error); + } + Ok(IdentityGateState::Ready { + account_id: approved.user_identity.id.to_string(), + }) +} + +fn selected_remote_signer_account() -> Result<Option<String>, String> { + let manager = crate::storage::accounts_manager()?; + let Some(account_id) = manager + .selected_account_id() + .map_err(|source| source.to_string())? + else { + return Ok(None); + }; + if active_session_for_account_id(account_id.as_str())?.is_some() { + Ok(Some(account_id.to_string())) + } else { + Ok(None) + } +} + +fn update_active_session_relays(account_id: &str, relays: Vec<String>) -> Result<(), String> { + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + let Some(mut session) = state.active_session_for_account_id(account_id).cloned() else { + return Err("active remote signer session disappeared before relay update".to_owned()); + }; + if session.relays == relays { + return Ok(()); + } + session.relays = relays; + state.remove_active_session_for_account_id(account_id); + state.sessions.push(session); + save_sessions(store_path.as_path(), &state) +} + +impl RadrootsAppRemoteSignerActionControllerHooks for AndroidRemoteSignerHooks { + type ReadyState = RadrootsRemoteSignerSignedNote; + + fn selected_active_session( + &self, + ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String> { + let Some(account_id) = selected_remote_signer_account()? else { + return Ok(None); + }; + let Some(record) = active_session_for_account_id(account_id.as_str())? else { + return Ok(None); + }; + let secret = load_client_secret(record.client_account_id())?; + Ok(Some((record, secret))) + } + + fn complete_sign_event( + &self, + signed_event: RadrootsAppRemoteSignerSignedEvent, + ) -> Result<Self::ReadyState, String> { + let Some(account_id) = selected_remote_signer_account()? else { + return Err("remote signer account is no longer selected".to_owned()); + }; + update_active_session_relays(account_id.as_str(), signed_event.relays.clone())?; + Ok(RadrootsRemoteSignerSignedNote { + event_id_hex: signed_event.event_id_hex, + }) + } +} + +fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + let store_path = sessions_path()?; + let state = load_sessions(store_path.as_path())?; + Ok(state.pending_session().cloned()) +} + +fn active_session_for_account_id( + account_id: &str, +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + let store_path = sessions_path()?; + let state = load_sessions(store_path.as_path())?; + Ok(state.active_session_for_account_id(account_id).cloned()) +} + +fn load_sessions(path: &Path) -> Result<RadrootsAppRemoteSignerSessionStoreState, String> { + RadrootsAppRemoteSignerSessionStoreState::load(path).map_err(|error| error.to_string()) +} + +fn save_sessions( + path: &Path, + state: &RadrootsAppRemoteSignerSessionStoreState, +) -> Result<(), String> { + state.save(path).map_err(|error| error.to_string()) +} + +fn sessions_path() -> Result<PathBuf, String> { + Ok(storage::app_data_root()? + .join("nostr") + .join("remote-signer-sessions.json")) +} + +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) +} + +fn client_secret_slot(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())?; + Ok(account_secret_slot(&account_id)) +} + +fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { + let slot = client_secret_slot(client_account_id)?; + client_secret_vault() + .store_secret(slot.as_str(), secret_key_hex) + .map_err(|source| source.to_string()) +} + +fn load_client_secret(client_account_id: &str) -> Result<String, String> { + let slot = client_secret_slot(client_account_id)?; + if let Some(secret) = client_secret_vault() + .load_secret(slot.as_str()) + .map_err(|source| source.to_string())? + { + return Ok(secret); + } + + let secret = legacy_client_secret_vault() + .load_secret(slot.as_str()) + .map_err(|source| source.to_string())? + .ok_or_else(|| "remote signer session secret is missing".to_owned())?; + let _ = client_secret_vault().store_secret(slot.as_str(), secret.as_str()); + let _ = legacy_client_secret_vault().remove_secret(slot.as_str()); + Ok(secret) +} + +fn remove_client_secret(client_account_id: &str) -> Result<(), String> { + let slot = client_secret_slot(client_account_id)?; + client_secret_vault() + .remove_secret(slot.as_str()) + .map_err(|source| source.to_string())?; + legacy_client_secret_vault() + .remove_secret(slot.as_str()) + .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/reverse_lookup.rs b/crates/launchers/android/src/reverse_lookup.rs diff --git a/crates/launchers/android/src/storage.rs b/crates/launchers/android/src/storage.rs @@ -0,0 +1,109 @@ +use radroots_app_core::mobile_native_app_storage_layout; +#[cfg(target_os = "android")] +use radroots_app_android_security::{ + ANDROID_NOSTR_SERVICE, RadrootsAndroidKeystoreVault, resolve_radroots_base_root, +}; +#[cfg(target_os = "android")] +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, +}; +use radroots_runtime_paths::{RadrootsPaths, RadrootsPlatform}; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "android")] +use std::sync::Arc; + +fn app_paths_from_base_root(base_root: &Path) -> Result<RadrootsPaths, String> { + Ok(mobile_native_app_storage_layout(RadrootsPlatform::Android, base_root)?.app_paths) +} + +#[cfg(target_os = "android")] +pub(crate) fn app_data_root() -> Result<PathBuf, String> { + let base_root = resolve_radroots_base_root().map_err(|source| source.to_string())?; + let root = app_data_root_from_base_root(base_root.as_path())?; + ensure_directory_tree(root.as_path())?; + Ok(root) +} + +#[cfg(target_os = "android")] +pub(crate) fn accounts_path() -> Result<PathBuf, String> { + let base_root = resolve_radroots_base_root().map_err(|source| source.to_string())?; + let accounts_path = accounts_path_from_base_root(base_root.as_path())?; + if let Some(parent) = accounts_path.parent() { + ensure_directory_tree(parent)?; + } + Ok(accounts_path) +} + +#[cfg(target_os = "android")] +pub(crate) fn accounts_manager() -> Result<RadrootsNostrAccountsManager, String> { + let store = Arc::new(RadrootsNostrFileAccountStore::new(accounts_path()?)); + let vault = Arc::new(RadrootsAndroidKeystoreVault::new(ANDROID_NOSTR_SERVICE)); + RadrootsNostrAccountsManager::new(store, vault).map_err(|source| source.to_string()) +} + +pub(crate) fn app_data_root_from_base_root(base_root: &Path) -> Result<PathBuf, String> { + Ok(app_paths_from_base_root(base_root)?.data) +} + +pub(crate) fn accounts_path_from_base_root(base_root: &Path) -> Result<PathBuf, String> { + Ok(app_data_root_from_base_root(base_root)? + .join("nostr") + .join("accounts.json")) +} + +#[cfg(target_os = "android")] +fn ensure_directory_tree(path: &Path) -> Result<(), String> { + std::fs::create_dir_all(path) + .map_err(|source| format!("failed to create android app data directory: {source}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accounts_path_uses_android_mobile_native_layout() { + let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); + + assert_eq!( + accounts_path_from_base_root(base_root.as_path()).expect("accounts path"), + PathBuf::from( + "/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app/nostr/accounts.json" + ) + ); + } + + #[test] + fn app_data_root_uses_android_mobile_native_layout() { + let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); + + assert_eq!( + app_data_root_from_base_root(base_root.as_path()).expect("app data root"), + PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app") + ); + } + + #[test] + fn mobile_paths_follow_shared_logical_root_model() { + let base_root = PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots"); + let paths = app_paths_from_base_root(base_root.as_path()).expect("mobile paths"); + + assert_eq!( + paths.config, + PathBuf::from( + "/data/user/0/org.radroots.app.android/no_backup/RadRoots/config/apps/app" + ) + ); + assert_eq!( + paths.data, + PathBuf::from("/data/user/0/org.radroots.app.android/no_backup/RadRoots/data/apps/app") + ); + assert_eq!( + paths.secrets, + PathBuf::from( + "/data/user/0/org.radroots.app.android/no_backup/RadRoots/secrets/apps/app" + ) + ); + } +} diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "radroots_app_desktop" +authors.workspace = true +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots desktop launcher" +publish = false +build = "build.rs" + +[lints] +workspace = true + +[dependencies] +eframe = { workspace = true, features = ["wgpu", "wayland", "x11"] } +egui.workspace = true +image.workspace = true +log.workspace = true +radroots_app_core = { path = "../../shared/core" } +radroots_app_remote_signer = { path = "../../shared/remote_signer" } +radroots_geocoder.workspace = true +radroots_nostr_accounts = { workspace = true, features = ["memory-vault"] } +radroots_runtime_paths.workspace = true +zeroize.workspace = true + +[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] +wgpu = { workspace = true, features = ["metal", "wgsl"] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-foundation = { workspace = true, features = ["NSProcessInfo", "NSString"] } +radroots_app_apple_security.workspace = true +radroots_identity.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +wgpu = { workspace = true, features = ["dx12", "wgsl"] } + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +wgpu = { workspace = true, features = ["vulkan", "gles", "wgsl"] } + +[dev-dependencies] +radroots_app_test_support = { path = "../../shared/test_support" } diff --git a/crates/desktop/assets/icons/radroots-logo.ico b/crates/launchers/desktop/assets/icons/radroots-logo.ico Binary files differ. diff --git a/crates/launchers/desktop/build.rs b/crates/launchers/desktop/build.rs @@ -0,0 +1,214 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const GEOCODER_DB_FILENAME: &str = "geonames.db"; +const GEOCODER_REVISION_FILENAME: &str = "geonames.revision"; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + sync_optional_geocoder_assets(); + + if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() != Some("macos") { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); + let package_dir = + manifest_dir.join("../../../native/bridges/apple/security/swift/RadRootsAppleSecurity"); + let info_plist_path = manifest_dir.join("macos/Info.plist"); + + emit_rerun_paths(&package_dir); + println!("cargo:rerun-if-changed={}", info_plist_path.display()); + + let configuration = if env::var("PROFILE").ok().as_deref() == Some("release") { + "release" + } else { + "debug" + }; + let arch = env::var("CARGO_CFG_TARGET_ARCH").expect("target arch"); + + run_swift_build(&package_dir, configuration, &arch); + let bin_path = swift_bin_path(&package_dir, configuration, &arch); + + let dylib_path = bin_path.join("libRadRootsAppleSecurityFFIDynamic.dylib"); + if !dylib_path.is_file() { + panic!( + "swift package did not produce expected dynamic library at {}", + dylib_path.display() + ); + } + + let copied_library_dir = target_profile_dir(); + fs::copy( + &dylib_path, + copied_library_dir.join("libRadRootsAppleSecurityFFIDynamic.dylib"), + ) + .unwrap_or_else(|err| { + panic!( + "failed to copy swift ffi library from {} into {}: {err}", + dylib_path.display(), + copied_library_dir.display() + ) + }); + + println!( + "cargo:rustc-link-search=native={}", + copied_library_dir.display() + ); + println!("cargo:rustc-link-lib=dylib=RadRootsAppleSecurityFFIDynamic"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=framework=LocalAuthentication"); + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + copied_library_dir.display() + ); + println!( + "cargo:rustc-link-arg-bin=radroots_app_desktop=-Wl,-sectcreate,__TEXT,__info_plist,{}", + info_plist_path.display() + ); +} + +fn sync_optional_geocoder_assets() { + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); + let source_db_path = + manifest_dir.join(format!("../../../assets/geocoder/{GEOCODER_DB_FILENAME}")); + let source_revision_path = manifest_dir.join(format!( + "../../../assets/geocoder/{GEOCODER_REVISION_FILENAME}" + )); + println!("cargo:rerun-if-changed={}", source_db_path.display()); + println!("cargo:rerun-if-changed={}", source_revision_path.display()); + + let profile_dir = target_profile_dir(); + let target_db_path = profile_dir.join(GEOCODER_DB_FILENAME); + let target_revision_path = profile_dir.join(GEOCODER_REVISION_FILENAME); + + if source_db_path.is_file() { + if !source_revision_path.is_file() { + panic!( + "stamped desktop geocoder revision asset missing at {}", + source_revision_path.display() + ); + } + + std::fs::copy(&source_db_path, &target_db_path).unwrap_or_else(|err| { + panic!( + "failed to copy optional desktop geocoder asset from {} to {}: {err}", + source_db_path.display(), + target_db_path.display() + ) + }); + std::fs::copy(&source_revision_path, &target_revision_path).unwrap_or_else(|err| { + panic!( + "failed to copy optional desktop geocoder revision from {} to {}: {err}", + source_revision_path.display(), + target_revision_path.display() + ) + }); + return; + } + + if target_db_path.exists() { + std::fs::remove_file(&target_db_path).unwrap_or_else(|err| { + panic!( + "failed to remove stale desktop geocoder asset at {}: {err}", + target_db_path.display() + ) + }); + } + if target_revision_path.exists() { + std::fs::remove_file(&target_revision_path).unwrap_or_else(|err| { + panic!( + "failed to remove stale desktop geocoder revision at {}: {err}", + target_revision_path.display() + ) + }); + } +} + +fn target_profile_dir() -> PathBuf { + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + out_dir + .ancestors() + .nth(3) + .unwrap_or_else(|| panic!("unexpected cargo OUT_DIR layout: {}", out_dir.display())) + .to_path_buf() +} + +fn emit_rerun_paths(package_dir: &Path) { + println!( + "cargo:rerun-if-changed={}", + package_dir.join("Package.swift").display() + ); + emit_rerun_dir(&package_dir.join("Sources")); +} + +fn emit_rerun_dir(dir: &Path) { + if !dir.is_dir() { + return; + } + + let mut entries = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .map(|entry| entry.unwrap().path()) + .collect::<Vec<_>>(); + entries.sort(); + + for path in entries { + if path.is_dir() { + emit_rerun_dir(&path); + } else { + println!("cargo:rerun-if-changed={}", path.display()); + } + } +} + +fn run_swift_build(package_dir: &Path, configuration: &str, arch: &str) { + let status = Command::new("swift") + .arg("build") + .arg("--package-path") + .arg(package_dir) + .arg("--product") + .arg("RadRootsAppleSecurityFFIDynamic") + .arg("--configuration") + .arg(configuration) + .arg("--arch") + .arg(arch) + .status() + .unwrap_or_else(|err| panic!("failed to run swift build: {err}")); + + if !status.success() { + panic!("swift build failed for RadRootsAppleSecurityFFIDynamic"); + } +} + +fn swift_bin_path(package_dir: &Path, configuration: &str, arch: &str) -> PathBuf { + let output = Command::new("swift") + .arg("build") + .arg("--package-path") + .arg(package_dir) + .arg("--product") + .arg("RadRootsAppleSecurityFFIDynamic") + .arg("--configuration") + .arg(configuration) + .arg("--arch") + .arg(arch) + .arg("--show-bin-path") + .output() + .unwrap_or_else(|err| panic!("failed to resolve swift bin path: {err}")); + + if !output.status.success() { + panic!( + "swift build --show-bin-path failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + PathBuf::from( + String::from_utf8(output.stdout) + .expect("swift bin path utf-8") + .trim(), + ) +} diff --git a/crates/desktop/macos/Info.plist b/crates/launchers/desktop/macos/Info.plist diff --git a/crates/desktop/src/country_lookup.rs b/crates/launchers/desktop/src/country_lookup.rs diff --git a/crates/desktop/src/main.rs b/crates/launchers/desktop/src/main.rs diff --git a/crates/desktop/src/offline_geocoder.rs b/crates/launchers/desktop/src/offline_geocoder.rs diff --git a/crates/desktop/src/remote_signer.rs b/crates/launchers/desktop/src/remote_signer.rs diff --git a/crates/desktop/src/reverse_lookup.rs b/crates/launchers/desktop/src/reverse_lookup.rs diff --git a/crates/launchers/ios/Cargo.toml b/crates/launchers/ios/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "radroots_app_ios" +authors.workspace = true +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots iOS launcher" +publish = false + +[lib] +path = "src/lib.rs" +crate-type = ["staticlib", "rlib"] + +[dependencies] +eframe = { workspace = true, features = ["wgpu"] } +log.workspace = true +radroots_app_apple_security.workspace = true +radroots_app_core = { path = "../../shared/core" } +radroots_app_remote_signer = { path = "../../shared/remote_signer" } +radroots_geocoder.workspace = true +radroots_identity.workspace = true +radroots_nostr_accounts = { workspace = true, features = ["memory-vault"] } +radroots_runtime_paths.workspace = true +zeroize.workspace = true + +[target.'cfg(target_os = "ios")'.dependencies] +wgpu = { workspace = true, features = ["metal", "wgsl"] } + +[dev-dependencies] +radroots_app_test_support = { path = "../../shared/test_support" } diff --git a/crates/ios/src/country_lookup.rs b/crates/launchers/ios/src/country_lookup.rs diff --git a/crates/ios/src/lib.rs b/crates/launchers/ios/src/lib.rs diff --git a/crates/ios/src/offline_geocoder.rs b/crates/launchers/ios/src/offline_geocoder.rs diff --git a/crates/ios/src/remote_signer.rs b/crates/launchers/ios/src/remote_signer.rs diff --git a/crates/ios/src/reverse_lookup.rs b/crates/launchers/ios/src/reverse_lookup.rs diff --git a/crates/ios/src/storage.rs b/crates/launchers/ios/src/storage.rs diff --git a/crates/launchers/web/Cargo.toml b/crates/launchers/web/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "radroots_app_web" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Rad Roots web launcher" +publish = false + +[lib] +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +eframe = { workspace = true, features = ["wgpu"] } +js-sys = "0.3.91" +log.workspace = true +radroots_app_core = { path = "../../shared/core" } +radroots_geocoder.workspace = true +wasm-bindgen-futures.workspace = true +web-sys = { workspace = true, features = ["Document", "Element", "HtmlCanvasElement", "Response", "Window"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +nostr.workspace = true +nostr-browser-signer.workspace = true +wgpu = { workspace = true, features = ["std", "wgsl", "webgpu", "fragile-send-sync-non-atomic-wasm"] } + +[lints] +workspace = true diff --git a/crates/web/Trunk.toml b/crates/launchers/web/Trunk.toml diff --git a/crates/web/index.html b/crates/launchers/web/index.html diff --git a/crates/web/src/lib.rs b/crates/launchers/web/src/lib.rs diff --git a/crates/web/src/main.rs b/crates/launchers/web/src/main.rs diff --git a/crates/core/Cargo.toml b/crates/shared/core/Cargo.toml diff --git a/crates/core/src/account_roster.rs b/crates/shared/core/src/account_roster.rs diff --git a/crates/core/src/home_location_tools/country_lookup.rs b/crates/shared/core/src/home_location_tools/country_lookup.rs diff --git a/crates/core/src/home_location_tools/mod.rs b/crates/shared/core/src/home_location_tools/mod.rs diff --git a/crates/core/src/home_location_tools/reverse_lookup.rs b/crates/shared/core/src/home_location_tools/reverse_lookup.rs diff --git a/crates/core/src/lib.rs b/crates/shared/core/src/lib.rs diff --git a/crates/core/src/location_resolver.rs b/crates/shared/core/src/location_resolver.rs diff --git a/crates/core/src/offline_geocoder.rs b/crates/shared/core/src/offline_geocoder.rs diff --git a/crates/core/src/remote_signer.rs b/crates/shared/core/src/remote_signer.rs diff --git a/crates/core/src/secret_keys.rs b/crates/shared/core/src/secret_keys.rs diff --git a/crates/core/src/storage_paths.rs b/crates/shared/core/src/storage_paths.rs diff --git a/crates/remote_signer/Cargo.toml b/crates/shared/remote_signer/Cargo.toml diff --git a/crates/remote_signer/src/action.rs b/crates/shared/remote_signer/src/action.rs diff --git a/crates/remote_signer/src/controller.rs b/crates/shared/remote_signer/src/controller.rs diff --git a/crates/remote_signer/src/custody.rs b/crates/shared/remote_signer/src/custody.rs diff --git a/crates/remote_signer/src/error.rs b/crates/shared/remote_signer/src/error.rs diff --git a/crates/remote_signer/src/input.rs b/crates/shared/remote_signer/src/input.rs diff --git a/crates/remote_signer/src/lib.rs b/crates/shared/remote_signer/src/lib.rs diff --git a/crates/remote_signer/src/protocol.rs b/crates/shared/remote_signer/src/protocol.rs diff --git a/crates/remote_signer/src/session.rs b/crates/shared/remote_signer/src/session.rs diff --git a/crates/test_support/Cargo.toml b/crates/shared/test_support/Cargo.toml diff --git a/crates/test_support/src/lib.rs b/crates/shared/test_support/src/lib.rs diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml @@ -1,32 +0,0 @@ -[package] -name = "radroots_app_web" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Rad Roots web launcher" -publish = false - -[lib] -path = "src/lib.rs" -crate-type = ["cdylib", "rlib"] - -[dependencies] -eframe = { workspace = true, features = ["wgpu"] } -js-sys = "0.3.91" -log.workspace = true -radroots_app_core = { path = "../core" } -radroots_geocoder.workspace = true -wasm-bindgen-futures.workspace = true -web-sys = { workspace = true, features = ["Document", "Element", "HtmlCanvasElement", "Response", "Window"] } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -nostr.workspace = true -nostr-browser-signer.workspace = true -wgpu = { workspace = true, features = ["std", "wgsl", "webgpu", "fragile-send-sync-non-atomic-wasm"] } - -[lints] -workspace = true diff --git a/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/build.gradle.kts diff --git a/native/android/kotlin/RadRootsAndroidSecurity/consumer-rules.pro b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/consumer-rules.pro diff --git a/native/android/kotlin/RadRootsAndroidSecurity/settings.gradle.kts b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/settings.gradle.kts diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/AndroidManifest.xml b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/AndroidManifest.xml diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeySecurityLevel.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeySecurityLevel.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidKeystoreSecretStore.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecretAccessPolicy.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecretAccessPolicy.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidStoragePaths.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt b/native/bridges/android/security/kotlin/RadRootsAndroidSecurity/src/test/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityTests.kt diff --git a/native/apple/swift/RadRootsAppleSecurity/Package.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Package.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleKeychainSecretStore.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretAccessPolicy.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretAccessPolicy.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretKey.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecretKey.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecurityError.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleSecurityError.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurity/RadRootsAppleUserPresence.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Sources/RadRootsAppleSecurityFFI/RadRootsAppleSecurityFFI.swift diff --git a/native/apple/swift/RadRootsAppleSecurity/Tests/RadRootsAppleSecurityTests/RadRootsAppleSecurityTests.swift b/native/bridges/apple/security/swift/RadRootsAppleSecurity/Tests/RadRootsAppleSecurityTests/RadRootsAppleSecurityTests.swift diff --git a/platforms/android/app/build.gradle.kts b/platforms/android/app/build.gradle.kts @@ -9,8 +9,10 @@ val rustInputs = files( "../../../Cargo.toml", "../../../Cargo.lock", rustBuildScript, - fileTree("../../../crates/core"), - fileTree("../../../crates/android"), + fileTree("../../../crates/shared/core"), + fileTree("../../../crates/shared/remote_signer"), + fileTree("../../../crates/bridges/android/security"), + fileTree("../../../crates/launchers/android"), ) android { diff --git a/platforms/android/settings.gradle.kts b/platforms/android/settings.gradle.kts @@ -19,4 +19,4 @@ rootProject.name = "RadRootsAndroid" include(":app") include(":radrootsAndroidSecurity") -project(":radrootsAndroidSecurity").projectDir = file("../../native/android/kotlin/RadRootsAndroidSecurity") +project(":radrootsAndroidSecurity").projectDir = file("../../native/bridges/android/security/kotlin/RadRootsAndroidSecurity") diff --git a/platforms/ios/project.yml b/platforms/ios/project.yml @@ -19,7 +19,7 @@ configs: packages: RadRootsAppleSecurity: - path: ../../native/apple/swift/RadRootsAppleSecurity + path: ../../native/bridges/apple/security/swift/RadRootsAppleSecurity group: Native/Apple/Swift targetTemplates: @@ -53,10 +53,14 @@ targetTemplates: inputFiles: - $(SRCROOT)/../../Cargo.toml - $(SRCROOT)/../../Cargo.lock - - $(SRCROOT)/../../crates/core/Cargo.toml - - $(SRCROOT)/../../crates/core/src/lib.rs - - $(SRCROOT)/../../crates/ios/Cargo.toml - - $(SRCROOT)/../../crates/ios/src/lib.rs + - $(SRCROOT)/../../crates/shared/core/Cargo.toml + - $(SRCROOT)/../../crates/shared/core/src/lib.rs + - $(SRCROOT)/../../crates/shared/remote_signer/Cargo.toml + - $(SRCROOT)/../../crates/shared/remote_signer/src/lib.rs + - $(SRCROOT)/../../crates/bridges/apple/security/Cargo.toml + - $(SRCROOT)/../../crates/bridges/apple/security/src/lib.rs + - $(SRCROOT)/../../crates/launchers/ios/Cargo.toml + - $(SRCROOT)/../../crates/launchers/ios/src/lib.rs - $(SRCROOT)/Scripts/build_rust_ios.sh outputFiles: - $(SRCROOT)/../../target/$(RUST_TARGET_TRIPLE)/$(RUST_CARGO_PROFILE)/libradroots_app_ios.a