myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 4929ba2227974491d7944720592aa0d3d1452b8e
parent 632759153917a5e4bb29aa8275b0308b7b9e0f61
Author: triesap <tyson@radroots.org>
Date:   Wed,  1 Apr 2026 23:27:50 +0000

custody: use signerless clients for external command

- replace the external_command relay client fallback with a signerless client
- keep signing and nip04 or nip44 delegation on the helper-backed identity path
- add transport and discovery runtime coverage for external_command identities
- verify the new signerless path with targeted myc checks and tests

Diffstat:
Msrc/app/runtime.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/custody.rs | 14+++++++++++++-
2 files changed, 114 insertions(+), 1 deletion(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -1237,6 +1237,9 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { #[cfg(test)] mod tests { + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; @@ -1254,6 +1257,7 @@ mod tests { use crate::config::{ MycConfig, MycIdentityBackend, MycRuntimeAuditBackend, MycSignerStateBackend, }; + use crate::discovery::MycDiscoveryContext; use crate::error::MycError; use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus}; @@ -1264,6 +1268,26 @@ mod tests { .expect("write identity"); } + fn write_external_command_helper( + path: &std::path::Path, + secret_key: &str, + ) -> RadrootsIdentity { + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + let identity_json = + serde_json::to_string(&identity.to_public()).expect("serialize public identity"); + let script = format!( + "#!/bin/sh\nrequest=\"$(cat)\"\ncase \"$request\" in\n *'\"operation\":\"describe\"'*) printf '%s' '{{\"identity\":{identity_json}}}' ;;\n *) printf '%s' '{{\"error\":\"unsupported operation\"}}' ;;\nesac\n" + ); + fs::write(path, script).expect("write helper"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("set permissions"); + } + identity + } + #[test] fn bootstrap_creates_runtime_directories() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1493,6 +1517,83 @@ mod tests { assert_eq!(runtime.snapshot().transport.connect_timeout_secs, 15); } + #[tokio::test] + async fn bootstrap_prepares_signerless_transport_for_external_command_backend() { + let temp = tempfile::tempdir().expect("tempdir"); + let helper_path = temp.path().join("signer-helper.sh"); + let helper_identity = write_external_command_helper( + &helper_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_backend = MycIdentityBackend::ExternalCommand; + config.paths.signer_identity_path = helper_path; + config.paths.user_identity_path = temp.path().join("user.json"); + config.transport.enabled = true; + config.transport.connect_timeout_secs = 15; + config.transport.relays = vec!["wss://relay.example.com".to_owned()]; + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(config).expect("runtime"); + + assert!(runtime.transport().is_some()); + assert!( + !runtime + .transport() + .expect("transport") + .client() + .has_signer() + .await + ); + assert_eq!( + runtime.signer_identity().public_key_hex(), + helper_identity.public_key_hex() + ); + } + + #[tokio::test] + async fn discovery_context_uses_signerless_client_for_external_command_app_identity() { + let temp = tempfile::tempdir().expect("tempdir"); + let helper_path = temp.path().join("discovery-helper.sh"); + let helper_identity = write_external_command_helper( + &helper_path, + "6666666666666666666666666666666666666666666666666666666666666666", + ); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.discovery.enabled = true; + config.discovery.domain = Some("signer.example.com".to_owned()); + config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()]; + config.discovery.publish_relays = vec!["wss://relay.example.com".to_owned()]; + config.discovery.nostrconnect_url_template = + Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned()); + config.discovery.app_identity_backend = Some(MycIdentityBackend::ExternalCommand); + config.discovery.app_identity_path = Some(helper_path); + write_test_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(config).expect("runtime"); + let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); + + assert!(!context.app_identity().nostr_client().has_signer().await); + assert_eq!( + context.app_identity().public_key_hex(), + helper_identity.public_key_hex() + ); + } + #[test] fn bootstrap_supports_sqlite_signer_state_backend() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/custody.rs b/src/custody.rs @@ -389,7 +389,7 @@ impl MycExternalCommandIdentityOperations { impl MycIdentityOperations for MycExternalCommandIdentityOperations { fn nostr_client(&self) -> RadrootsNostrClient { - RadrootsNostrClient::from_identity_owned(RadrootsIdentity::generate()) + RadrootsNostrClient::new_signerless() } fn nostr_client_owned(&self) -> RadrootsNostrClient { @@ -1761,4 +1761,16 @@ mod tests { assert!(operations.contains(&MycExternalCommandOperation::Nip04Encrypt)); assert!(operations.contains(&MycExternalCommandOperation::Nip44Encrypt)); } + + #[tokio::test] + async fn external_command_provider_uses_signerless_relay_client() { + let (provider, _executor) = external_command_provider( + "signer", + "1111111111111111111111111111111111111111111111111111111111111111", + ); + let active = provider.load_active_identity().expect("active identity"); + + assert!(!active.nostr_client().has_signer().await); + assert!(!active.nostr_client_owned().has_signer().await); + } }