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:
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());
}