field_lib

Cross-platform Rust runtime for Radroots iOS and Android apps
git clone https://radroots.dev/git/field_lib.git
Log | Files | Refs | README | LICENSE

commit da4071afe64866e67f90a0041ec030ce16bc3241
parent cabb6376c9b77ad313df5e3b08e25f655c85ff3d
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 02:10:03 -0700

core: narrow ios key custody boundary

Diffstat:
MCargo.lock | 3+++
Mcrates/field_core/src/runtime/key_management.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/field_core/src/runtime/trade_listing.rs | 6++++++
Mcrates/field_core/tests/key_management.rs | 55++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/field_core/tests/no_nostr_runtime.rs | 7++++++-
5 files changed, 127 insertions(+), 58 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1497,8 +1497,11 @@ dependencies = [ name = "radroots_events" version = "0.1.0-alpha.2" dependencies = [ + "hex", "radroots_core", "serde", + "serde_json", + "sha2", ] [[package]] diff --git a/crates/field_core/src/runtime/key_management.rs b/crates/field_core/src/runtime/key_management.rs @@ -22,6 +22,48 @@ pub struct NostrIdentitySnapshot { pub identities: Vec<NostrIdentityRecord>, } +#[cfg(feature = "nostr-client")] +fn account_record( + net: &radroots_net_core::Net, + account_id: &RadrootsIdentityId, +) -> Result<NostrIdentityRecord, RadrootsAppError> { + let selected_identity_id = net + .accounts + .default_account_id() + .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; + let account = net + .accounts + .list_accounts() + .map_err(|e| RadrootsAppError::Msg(format!("{e}")))? + .into_iter() + .find(|account| &account.account_id == account_id) + .ok_or_else(|| RadrootsAppError::Msg(format!("identity not found: {account_id}")))?; + let is_selected = selected_identity_id + .as_ref() + .map(|selected| selected == &account.account_id) + .unwrap_or(false); + + Ok(NostrIdentityRecord { + id: account.account_id.to_string(), + public_key_hex: account.public_identity.public_key_hex, + public_key_npub: account.public_identity.public_key_npub, + label: account.label, + is_selected, + }) +} + +#[cfg(feature = "nostr-client")] +fn invalidate_nostr_runtime(net: &mut radroots_net_core::Net) { + net.set_nostr_signer(None); + net.nostr = None; +} + +#[cfg(feature = "nostr-client")] +fn identity_from_secret(secret_key: &str) -> Result<RadrootsIdentity, RadrootsAppError> { + RadrootsIdentity::from_secret_key_str(secret_key) + .map_err(|e| RadrootsAppError::Msg(format!("{e}"))) +} + #[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { @@ -172,7 +214,7 @@ impl RadrootsRuntime { &self, label: Option<String>, make_selected: bool, - ) -> Result<String, RadrootsAppError> { + ) -> Result<NostrIdentityRecord, RadrootsAppError> { #[cfg(feature = "nostr-client")] { let mut guard = match self.net.lock() { @@ -183,8 +225,8 @@ impl RadrootsRuntime { .accounts .generate_identity(label, make_selected) .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - guard.nostr = None; - return Ok(account_id.to_string()); + invalidate_nostr_runtime(&mut guard); + return account_record(&guard, &account_id); } #[cfg(not(feature = "nostr-client"))] { @@ -198,21 +240,20 @@ impl RadrootsRuntime { secret_key: String, label: Option<String>, make_selected: bool, - ) -> Result<String, RadrootsAppError> { + ) -> Result<NostrIdentityRecord, RadrootsAppError> { #[cfg(feature = "nostr-client")] { let mut guard = match self.net.lock() { Ok(guard) => guard, Err(err) => return Err(RadrootsAppError::Msg(format!("{err}"))), }; - let identity = RadrootsIdentity::from_secret_key_str(secret_key.as_str()) - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; + let identity = identity_from_secret(secret_key.as_str())?; let account_id = guard .accounts .upsert_identity(&identity, label, make_selected) .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - guard.nostr = None; - return Ok(account_id.to_string()); + invalidate_nostr_runtime(&mut guard); + return account_record(&guard, &account_id); } #[cfg(not(feature = "nostr-client"))] { @@ -221,12 +262,21 @@ impl RadrootsRuntime { } } + pub fn nostr_identity_restore_host_secret( + &self, + secret_key: String, + label: Option<String>, + make_selected: bool, + ) -> Result<NostrIdentityRecord, RadrootsAppError> { + self.nostr_identity_import_secret(secret_key, label, make_selected) + } + pub fn nostr_identity_import_from_path( &self, path: String, label: Option<String>, make_selected: bool, - ) -> Result<String, RadrootsAppError> { + ) -> Result<NostrIdentityRecord, RadrootsAppError> { #[cfg(feature = "nostr-client")] { let mut guard = match self.net.lock() { @@ -237,8 +287,8 @@ impl RadrootsRuntime { .accounts .migrate_legacy_identity_file(PathBuf::from(path), label, make_selected) .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - guard.nostr = None; - return Ok(account_id.to_string()); + invalidate_nostr_runtime(&mut guard); + return account_record(&guard, &account_id); } #[cfg(not(feature = "nostr-client"))] { @@ -247,33 +297,6 @@ impl RadrootsRuntime { } } - pub fn nostr_identity_export_selected_secret_hex( - &self, - ) -> Result<Option<String>, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::Msg(format!("{err}"))), - }; - let Some(selected_id) = guard - .accounts - .default_account_id() - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))? - else { - return Ok(None); - }; - return guard - .accounts - .export_secret_hex(&selected_id) - .map_err(|e| RadrootsAppError::Msg(format!("{e}"))); - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::Msg("nostr disabled".into())) - } - } - pub fn nostr_identity_select(&self, identity_id: String) -> Result<(), RadrootsAppError> { #[cfg(feature = "nostr-client")] { @@ -287,7 +310,7 @@ impl RadrootsRuntime { .accounts .set_default_account(&account_id) .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - guard.nostr = None; + invalidate_nostr_runtime(&mut guard); Ok(()) } #[cfg(not(feature = "nostr-client"))] @@ -310,7 +333,7 @@ impl RadrootsRuntime { .accounts .remove_account(&account_id) .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - guard.nostr = None; + invalidate_nostr_runtime(&mut guard); Ok(()) } #[cfg(not(feature = "nostr-client"))] @@ -320,7 +343,7 @@ impl RadrootsRuntime { } } - pub fn nostr_identity_reset_all(&self) -> Result<(), RadrootsAppError> { + pub fn nostr_identity_clear_runtime_state(&self) -> Result<(), RadrootsAppError> { #[cfg(feature = "nostr-client")] { let mut guard = match self.net.lock() { @@ -341,7 +364,10 @@ impl RadrootsRuntime { .accounts .clear_default_account() .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - guard.nostr = None; + invalidate_nostr_runtime(&mut guard); + if let Ok(mut rx_guard) = self.post_events_rx.lock() { + *rx_guard = None; + } Ok(()) } #[cfg(not(feature = "nostr-client"))] @@ -349,4 +375,8 @@ impl RadrootsRuntime { Err(RadrootsAppError::Msg("nostr disabled".into())) } } + + pub fn nostr_identity_reset_all(&self) -> Result<(), RadrootsAppError> { + self.nostr_identity_clear_runtime_state() + } } diff --git a/crates/field_core/src/runtime/trade_listing.rs b/crates/field_core/src/runtime/trade_listing.rs @@ -9,6 +9,7 @@ use radroots_core::{ use radroots_events::{ RadrootsNostrEvent, farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, kinds::KIND_LISTING, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, @@ -258,6 +259,10 @@ fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, Radr draft.bin_id.clone().unwrap_or_else(|| "bin-1".to_string()), "bin_id", )?; + let listing_id = RadrootsDTag::parse(listing_id) + .map_err(|error| RadrootsAppError::Msg(format!("invalid listing_id: {error}")))?; + let bin_id = RadrootsInventoryBinId::parse(bin_id) + .map_err(|error| RadrootsAppError::Msg(format!("invalid bin_id: {error}")))?; let amount = parse_decimal(&draft.bin_display_amount, "bin_display_amount")?; let unit = parse_unit(&draft.bin_display_unit)?; let canonical_unit = unit.canonical_unit(); @@ -268,6 +273,7 @@ fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, Radr Ok(RadrootsListing { d_tag: listing_id, + published_at: None, farm: RadrootsFarmRef { pubkey: farm_pubkey, d_tag: farm_d_tag, diff --git a/crates/field_core/tests/key_management.rs b/crates/field_core/tests/key_management.rs @@ -6,10 +6,10 @@ use radroots_field_core::RadrootsRuntime; fn identity_reset_all_removes_selected_and_unselected_identities() { let runtime = RadrootsRuntime::new().expect("runtime"); - let selected_id = runtime + let selected = runtime .nostr_identity_generate(Some("selected".to_string()), true) .expect("selected identity"); - let other_id = runtime + let other = runtime .nostr_identity_generate(Some("other".to_string()), false) .expect("other identity"); @@ -17,15 +17,11 @@ fn identity_reset_all_removes_selected_and_unselected_identities() { assert!(snapshot.has_selected_signing_identity); assert_eq!( snapshot.selected_identity_id.as_deref(), - Some(selected_id.as_str()) + Some(selected.id.as_str()) ); + assert!(selected.is_selected); + assert!(!other.is_selected); assert_eq!(snapshot.identities.len(), 2); - assert!( - runtime - .nostr_identity_export_selected_secret_hex() - .expect("export") - .is_some() - ); runtime.nostr_identity_reset_all().expect("reset all"); @@ -35,12 +31,41 @@ fn identity_reset_all_removes_selected_and_unselected_identities() { assert_eq!(snapshot.selected_npub, None); assert!(snapshot.identities.is_empty()); assert!(runtime.nostr_identity_list().expect("list").is_empty()); + + assert!(runtime.nostr_identity_remove(other.id).is_err()); +} + +#[test] +fn host_secret_restore_recreates_runtime_signing_identity_after_lock() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let host_identity = radroots_identity::RadrootsIdentity::generate(); + let secret_key = host_identity.secret_key_hex(); + + let restored = runtime + .nostr_identity_restore_host_secret( + secret_key.clone(), + Some("local custody".to_string()), + true, + ) + .expect("restore host secret"); + assert_eq!(restored.public_key_hex, host_identity.public_key_hex()); + assert!(restored.is_selected); + assert!(runtime.nostr_identity_has_selected_signing_identity()); + + runtime + .nostr_identity_clear_runtime_state() + .expect("clear runtime state"); + let locked = runtime.nostr_identity_snapshot().expect("locked snapshot"); + assert!(!locked.has_selected_signing_identity); + assert!(locked.identities.is_empty()); + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + + let restored_again = runtime + .nostr_identity_restore_host_secret(secret_key, Some("local custody".to_string()), true) + .expect("restore host secret again"); assert_eq!( - runtime - .nostr_identity_export_selected_secret_hex() - .expect("export after reset"), - None + restored_again.public_key_hex, + host_identity.public_key_hex() ); - - assert!(runtime.nostr_identity_remove(other_id).is_err()); + assert!(runtime.nostr_identity_has_selected_signing_identity()); } diff --git a/crates/field_core/tests/no_nostr_runtime.rs b/crates/field_core/tests/no_nostr_runtime.rs @@ -40,14 +40,19 @@ fn key_management_disabled_paths_are_exercised() { Some("alpha".to_string()), true, )); + expect_disabled(runtime.nostr_identity_restore_host_secret( + "deadbeef".to_string(), + Some("alpha".to_string()), + true, + )); expect_disabled(runtime.nostr_identity_import_from_path( "/tmp/nostr.json".to_string(), Some("alpha".to_string()), true, )); - expect_disabled(runtime.nostr_identity_export_selected_secret_hex()); expect_disabled(runtime.nostr_identity_select("account-1".to_string())); expect_disabled(runtime.nostr_identity_remove("account-1".to_string())); + expect_disabled(runtime.nostr_identity_clear_runtime_state()); expect_disabled(runtime.nostr_identity_reset_all()); }