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 2fa09506dc46e3f67859616292a823b00ec1f77f
parent da4071afe64866e67f90a0041ec030ce16bc3241
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 15:12:19 -0700

core: make nostr host custody explicit

- replace ambiguous secret import APIs with host-custody verbs
- add host-custody public identity validation records
- lock and reset transient runtime signing state explicitly
- cover restore, invalid secret, lock, and disabled paths

Diffstat:
Mcrates/field_core/src/runtime/key_management.rs | 110++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/field_core/tests/key_management.rs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/field_core/tests/no_nostr_runtime.rs | 18++++--------------
3 files changed, 124 insertions(+), 84 deletions(-)

diff --git a/crates/field_core/src/runtime/key_management.rs b/crates/field_core/src/runtime/key_management.rs @@ -2,8 +2,6 @@ use super::RadrootsRuntime; use crate::RadrootsAppError; #[cfg(feature = "nostr-client")] use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; -#[cfg(feature = "nostr-client")] -use std::path::PathBuf; #[derive(uniffi::Record, Debug, Clone)] pub struct NostrIdentityRecord { @@ -22,6 +20,13 @@ pub struct NostrIdentitySnapshot { pub identities: Vec<NostrIdentityRecord>, } +#[derive(uniffi::Record, Debug, Clone)] +pub struct NostrHostCustodyIdentity { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, +} + #[cfg(feature = "nostr-client")] fn account_record( net: &radroots_net_core::Net, @@ -64,6 +69,34 @@ fn identity_from_secret(secret_key: &str) -> Result<RadrootsIdentity, RadrootsAp .map_err(|e| RadrootsAppError::Msg(format!("{e}"))) } +#[cfg(feature = "nostr-client")] +fn host_custody_identity_from_secret( + secret_key: &str, +) -> Result<(RadrootsIdentity, NostrHostCustodyIdentity), RadrootsAppError> { + let identity = identity_from_secret(secret_key)?; + let record = NostrHostCustodyIdentity { + id: identity.id().to_string(), + public_key_hex: identity.public_key_hex(), + public_key_npub: identity.public_key_npub(), + }; + Ok((identity, record)) +} + +#[cfg(feature = "nostr-client")] +fn restore_host_custody_identity( + net: &mut radroots_net_core::Net, + identity: &RadrootsIdentity, + label: Option<String>, + make_selected: bool, +) -> Result<NostrIdentityRecord, RadrootsAppError> { + let account_id = net + .accounts + .upsert_identity(identity, label, make_selected) + .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; + invalidate_nostr_runtime(net); + account_record(net, &account_id) +} + #[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { @@ -210,89 +243,40 @@ impl RadrootsRuntime { } } - pub fn nostr_identity_generate( - &self, - label: Option<String>, - make_selected: bool, - ) -> 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 account_id = guard - .accounts - .generate_identity(label, make_selected) - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - invalidate_nostr_runtime(&mut guard); - return account_record(&guard, &account_id); - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = (label, make_selected); - Err(RadrootsAppError::Msg("nostr disabled".into())) - } - } - - pub fn nostr_identity_import_secret( + pub fn nostr_identity_validate_host_custody_secret( &self, secret_key: String, - label: Option<String>, - make_selected: bool, - ) -> Result<NostrIdentityRecord, RadrootsAppError> { + ) -> Result<NostrHostCustodyIdentity, 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 = 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}")))?; - invalidate_nostr_runtime(&mut guard); - return account_record(&guard, &account_id); + return host_custody_identity_from_secret(secret_key.as_str()) + .map(|(_, identity)| identity); } #[cfg(not(feature = "nostr-client"))] { - let _ = (secret_key, label, make_selected); + let _ = secret_key; Err(RadrootsAppError::Msg("nostr disabled".into())) } } - pub fn nostr_identity_restore_host_secret( + pub fn nostr_identity_restore_host_custody_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<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 account_id = guard - .accounts - .migrate_legacy_identity_file(PathBuf::from(path), label, make_selected) - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - invalidate_nostr_runtime(&mut guard); - return account_record(&guard, &account_id); + let (identity, _) = host_custody_identity_from_secret(secret_key.as_str())?; + return restore_host_custody_identity(&mut guard, &identity, label, make_selected); } #[cfg(not(feature = "nostr-client"))] { - let _ = (path, label, make_selected); + let _ = (secret_key, label, make_selected); Err(RadrootsAppError::Msg("nostr disabled".into())) } } @@ -343,7 +327,7 @@ impl RadrootsRuntime { } } - pub fn nostr_identity_clear_runtime_state(&self) -> Result<(), RadrootsAppError> { + pub fn nostr_identity_lock_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { #[cfg(feature = "nostr-client")] { let mut guard = match self.net.lock() { @@ -376,7 +360,7 @@ impl RadrootsRuntime { } } - pub fn nostr_identity_reset_all(&self) -> Result<(), RadrootsAppError> { - self.nostr_identity_clear_runtime_state() + pub fn nostr_identity_reset_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.nostr_identity_lock_host_custody_runtime() } } diff --git a/crates/field_core/tests/key_management.rs b/crates/field_core/tests/key_management.rs @@ -5,12 +5,22 @@ use radroots_field_core::RadrootsRuntime; #[test] fn identity_reset_all_removes_selected_and_unselected_identities() { let runtime = RadrootsRuntime::new().expect("runtime"); + let selected_identity = radroots_identity::RadrootsIdentity::generate(); + let other_identity = radroots_identity::RadrootsIdentity::generate(); let selected = runtime - .nostr_identity_generate(Some("selected".to_string()), true) + .nostr_identity_restore_host_custody_secret( + selected_identity.secret_key_hex(), + Some("selected".to_string()), + true, + ) .expect("selected identity"); let other = runtime - .nostr_identity_generate(Some("other".to_string()), false) + .nostr_identity_restore_host_custody_secret( + other_identity.secret_key_hex(), + Some("other".to_string()), + false, + ) .expect("other identity"); let snapshot = runtime.nostr_identity_snapshot().expect("snapshot"); @@ -23,7 +33,9 @@ fn identity_reset_all_removes_selected_and_unselected_identities() { assert!(!other.is_selected); assert_eq!(snapshot.identities.len(), 2); - runtime.nostr_identity_reset_all().expect("reset all"); + runtime + .nostr_identity_reset_host_custody_runtime() + .expect("reset all"); let snapshot = runtime.nostr_identity_snapshot().expect("reset snapshot"); assert!(!snapshot.has_selected_signing_identity); @@ -36,13 +48,35 @@ fn identity_reset_all_removes_selected_and_unselected_identities() { } #[test] -fn host_secret_restore_recreates_runtime_signing_identity_after_lock() { +fn host_custody_secret_validation_derives_public_identity_without_runtime_mutation() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let host_identity = radroots_identity::RadrootsIdentity::generate(); + + let validated = runtime + .nostr_identity_validate_host_custody_secret(host_identity.secret_key_hex()) + .expect("validate host custody secret"); + + assert_eq!(validated.id, host_identity.id().to_string()); + assert_eq!(validated.public_key_hex, host_identity.public_key_hex()); + assert_eq!(validated.public_key_npub, host_identity.public_key_npub()); + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + assert!( + runtime + .nostr_identity_snapshot() + .expect("snapshot") + .identities + .is_empty() + ); +} + +#[test] +fn host_custody_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( + .nostr_identity_restore_host_custody_secret( secret_key.clone(), Some("local custody".to_string()), true, @@ -53,7 +87,7 @@ fn host_secret_restore_recreates_runtime_signing_identity_after_lock() { assert!(runtime.nostr_identity_has_selected_signing_identity()); runtime - .nostr_identity_clear_runtime_state() + .nostr_identity_lock_host_custody_runtime() .expect("clear runtime state"); let locked = runtime.nostr_identity_snapshot().expect("locked snapshot"); assert!(!locked.has_selected_signing_identity); @@ -61,7 +95,11 @@ fn host_secret_restore_recreates_runtime_signing_identity_after_lock() { 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) + .nostr_identity_restore_host_custody_secret( + secret_key, + Some("local custody".to_string()), + true, + ) .expect("restore host secret again"); assert_eq!( restored_again.public_key_hex, @@ -69,3 +107,31 @@ fn host_secret_restore_recreates_runtime_signing_identity_after_lock() { ); assert!(runtime.nostr_identity_has_selected_signing_identity()); } + +#[test] +fn invalid_host_custody_secret_is_rejected_before_runtime_mutation() { + let runtime = RadrootsRuntime::new().expect("runtime"); + + assert!( + runtime + .nostr_identity_validate_host_custody_secret("not-a-secret".to_string()) + .is_err() + ); + assert!( + runtime + .nostr_identity_restore_host_custody_secret( + "not-a-secret".to_string(), + Some("bad".to_string()), + true, + ) + .is_err() + ); + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + assert!( + runtime + .nostr_identity_snapshot() + .expect("snapshot") + .identities + .is_empty() + ); +} diff --git a/crates/field_core/tests/no_nostr_runtime.rs b/crates/field_core/tests/no_nostr_runtime.rs @@ -34,26 +34,16 @@ fn key_management_disabled_paths_are_exercised() { expect_disabled(runtime.nostr_identity_list()); expect_disabled(runtime.nostr_identity_list_ids()); expect_disabled(runtime.nostr_identity_snapshot()); - expect_disabled(runtime.nostr_identity_generate(Some("alpha".to_string()), true)); - expect_disabled(runtime.nostr_identity_import_secret( + expect_disabled(runtime.nostr_identity_validate_host_custody_secret("deadbeef".to_string())); + expect_disabled(runtime.nostr_identity_restore_host_custody_secret( "deadbeef".to_string(), 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_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()); + expect_disabled(runtime.nostr_identity_lock_host_custody_runtime()); + expect_disabled(runtime.nostr_identity_reset_host_custody_runtime()); } #[test]