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:
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]