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