commit 71d0660e2a07d4b742519452956399a3553561f3
parent 7468327d2fb286871a1f3c4fe3bf84b42aa4f2de
Author: triesap <tyson@radroots.org>
Date: Fri, 27 Mar 2026 15:25:51 +0000
custody: add external command backend
Diffstat:
5 files changed, 818 insertions(+), 26 deletions(-)
diff --git a/.env.example b/.env.example
@@ -7,6 +7,7 @@ MYC_PATHS_STATE_DIR=/var/lib/myc
MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem
# filesystem: identity file path
# managed_account: account store file path
+# external_command: signer helper executable path
MYC_PATHS_SIGNER_IDENTITY_PATH=/etc/myc/identities/signer-identity.json
MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=
# os_keyring and managed_account both require a non-empty keyring service name
@@ -15,6 +16,7 @@ MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH=
MYC_PATHS_USER_IDENTITY_BACKEND=filesystem
# filesystem: identity file path
# managed_account: account store file path
+# external_command: signer helper executable path
MYC_PATHS_USER_IDENTITY_PATH=/etc/myc/identities/user-identity.json
MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID=
MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.user
@@ -38,6 +40,7 @@ MYC_DISCOVERY_HANDLER_IDENTIFIER=myc
MYC_DISCOVERY_APP_IDENTITY_BACKEND=
# filesystem: identity file path
# managed_account: account store file path
+# external_command: signer helper executable path
MYC_DISCOVERY_APP_IDENTITY_PATH=/etc/myc/identities/app-identity.json
MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID=
MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.discovery
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -10,8 +10,8 @@ use crate::audit::{
};
use crate::audit_sqlite::MycSqliteOperationAuditStore;
use crate::config::{
- MycAuditConfig, MycConfig, MycIdentitySourceSpec, MycPersistenceConfig, MycRuntimeAuditBackend,
- MycSignerStateBackend, MycTransportDeliveryPolicy,
+ MycAuditConfig, MycConfig, MycIdentityBackend, MycIdentitySourceSpec, MycPersistenceConfig,
+ MycRuntimeAuditBackend, MycSignerStateBackend, MycTransportDeliveryPolicy,
};
use crate::custody::{MycActiveIdentity, MycIdentityProvider};
use crate::discovery::MycDiscoveryContext;
@@ -97,7 +97,10 @@ pub struct MycRuntime {
}
fn startup_identity_path(source: &MycIdentitySourceSpec) -> Option<PathBuf> {
- source.path.clone()
+ match source.backend {
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => source.path.clone(),
+ MycIdentityBackend::OsKeyring | MycIdentityBackend::ExternalCommand => None,
+ }
}
fn format_startup_identity_path(path: Option<&Path>) -> String {
@@ -1617,6 +1620,13 @@ mod tests {
startup_identity_path(&config.paths.user_identity_source()),
Some(PathBuf::from("/tmp/user-accounts.json"))
);
+
+ config.paths.user_identity_backend = MycIdentityBackend::ExternalCommand;
+ config.paths.user_identity_path = PathBuf::from("/usr/local/libexec/myc-user-helper");
+ assert_eq!(
+ startup_identity_path(&config.paths.user_identity_source()),
+ None
+ );
}
#[tokio::test]
diff --git a/src/config.rs b/src/config.rs
@@ -136,6 +136,7 @@ pub enum MycIdentityBackend {
Filesystem,
OsKeyring,
ManagedAccount,
+ ExternalCommand,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -371,6 +372,7 @@ impl MycIdentityBackend {
Self::Filesystem => "filesystem",
Self::OsKeyring => "os_keyring",
Self::ManagedAccount => "managed_account",
+ Self::ExternalCommand => "external_command",
}
}
}
@@ -398,23 +400,27 @@ impl MycPathsConfig {
MycIdentitySourceSpec {
backend: self.signer_identity_backend,
path: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => {
- Some(self.signer_identity_path.clone())
- }
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => Some(self.signer_identity_path.clone()),
MycIdentityBackend::OsKeyring => None,
},
keyring_account_id: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring => self.signer_identity_keyring_account_id.clone(),
},
keyring_service_name: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => {
Some(self.signer_identity_keyring_service_name.clone())
}
},
profile_path: match self.signer_identity_backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring => self.signer_identity_profile_path.clone(),
},
}
@@ -424,23 +430,27 @@ impl MycPathsConfig {
MycIdentitySourceSpec {
backend: self.user_identity_backend,
path: match self.user_identity_backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => {
- Some(self.user_identity_path.clone())
- }
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => Some(self.user_identity_path.clone()),
MycIdentityBackend::OsKeyring => None,
},
keyring_account_id: match self.user_identity_backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring => self.user_identity_keyring_account_id.clone(),
},
keyring_service_name: match self.user_identity_backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => {
Some(self.user_identity_keyring_service_name.clone())
}
},
profile_path: match self.user_identity_backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring => self.user_identity_profile_path.clone(),
},
}
@@ -1369,10 +1379,13 @@ fn parse_identity_backend_env(
"filesystem" => Ok(MycIdentityBackend::Filesystem),
"os_keyring" => Ok(MycIdentityBackend::OsKeyring),
"managed_account" => Ok(MycIdentityBackend::ManagedAccount),
+ "external_command" => Ok(MycIdentityBackend::ExternalCommand),
_ => Err(config_parse_error(
path,
line_number,
- format!("{key} must be `filesystem`, `os_keyring`, or `managed_account`"),
+ format!(
+ "{key} must be `filesystem`, `os_keyring`, `managed_account`, or `external_command`"
+ ),
)),
}
}
@@ -1563,6 +1576,33 @@ fn validate_identity_source_config(
)));
}
}
+ MycIdentityBackend::ExternalCommand => {
+ let Some(path) = source.path.as_ref() else {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.path must be set when backend is `external_command`"
+ )));
+ };
+ if path.as_os_str().is_empty() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.path must not be empty when backend is `external_command`"
+ )));
+ }
+ if source.keyring_account_id.is_some() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.keyring_account_id must not be set when backend is `external_command`"
+ )));
+ }
+ if source.keyring_service_name.is_some() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.keyring_service_name must not be set when backend is `external_command`"
+ )));
+ }
+ if source.profile_path.is_some() {
+ return Err(MycError::InvalidConfig(format!(
+ "{label}.profile_path must not be set when backend is `external_command`"
+ )));
+ }
+ }
MycIdentityBackend::OsKeyring => {
let Some(account_id) = source.keyring_account_id.as_deref() else {
return Err(MycError::InvalidConfig(format!(
@@ -1655,23 +1695,27 @@ impl MycDiscoveryConfig {
Some(MycIdentitySourceSpec {
backend,
path: match backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => {
- self.app_identity_path.clone()
- }
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => self.app_identity_path.clone(),
MycIdentityBackend::OsKeyring => None,
},
keyring_account_id: match backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring => self.app_identity_keyring_account_id.clone(),
},
keyring_service_name: match backend {
- MycIdentityBackend::Filesystem => None,
+ MycIdentityBackend::Filesystem | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => {
self.app_identity_keyring_service_name.clone()
}
},
profile_path: match backend {
- MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None,
+ MycIdentityBackend::Filesystem
+ | MycIdentityBackend::ManagedAccount
+ | MycIdentityBackend::ExternalCommand => None,
MycIdentityBackend::OsKeyring => self.app_identity_profile_path.clone(),
},
})
@@ -2403,6 +2447,49 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
}
#[test]
+ fn parse_and_validate_external_command_identity_backends() {
+ let config = MycConfig::from_env_str(
+ r#"
+MYC_PATHS_SIGNER_IDENTITY_BACKEND=external_command
+MYC_PATHS_SIGNER_IDENTITY_PATH=/usr/local/libexec/myc-signer-helper
+MYC_PATHS_USER_IDENTITY_BACKEND=external_command
+MYC_PATHS_USER_IDENTITY_PATH=/usr/local/libexec/myc-user-helper
+MYC_DISCOVERY_ENABLED=true
+MYC_DISCOVERY_DOMAIN=myc.example.com
+MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.example.com
+MYC_DISCOVERY_APP_IDENTITY_BACKEND=external_command
+MYC_DISCOVERY_APP_IDENTITY_PATH=/usr/local/libexec/myc-discovery-helper
+ "#,
+ )
+ .expect("config");
+
+ assert_eq!(
+ config.paths.signer_identity_backend,
+ MycIdentityBackend::ExternalCommand
+ );
+ assert_eq!(
+ config.paths.signer_identity_source().path,
+ Some(PathBuf::from("/usr/local/libexec/myc-signer-helper"))
+ );
+ assert_eq!(
+ config.paths.user_identity_backend,
+ MycIdentityBackend::ExternalCommand
+ );
+ assert_eq!(
+ config.discovery.app_identity_backend,
+ Some(MycIdentityBackend::ExternalCommand)
+ );
+ assert_eq!(
+ config
+ .discovery
+ .app_identity_source()
+ .expect("app identity source")
+ .path,
+ Some(PathBuf::from("/usr/local/libexec/myc-discovery-helper"))
+ );
+ }
+
+ #[test]
fn example_env_parses_and_validates() {
let example =
fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example"))
diff --git a/src/custody.rs b/src/custody.rs
@@ -1,5 +1,6 @@
use std::fs;
use std::path::PathBuf;
+use std::process::{Command, Stdio};
use std::sync::Arc;
use nostr::nips::nip44::Version;
@@ -13,7 +14,7 @@ use radroots_nostr_accounts::prelude::{
RadrootsNostrSecretVault, RadrootsNostrSecretVaultOsKeyring,
RadrootsNostrSelectedAccountStatus,
};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use crate::config::{MycIdentityBackend, MycIdentitySourceSpec};
use crate::error::MycError;
@@ -105,6 +106,89 @@ enum MycIdentityProviderBackend {
service_name: String,
manager: RadrootsNostrAccountsManager,
},
+ ExternalCommand {
+ command_path: PathBuf,
+ executor: Arc<dyn MycExternalCommandExecutor>,
+ },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+enum MycExternalCommandOperation {
+ Describe,
+ SignEvent,
+ Nip04Encrypt,
+ Nip04Decrypt,
+ Nip44Encrypt,
+ Nip44Decrypt,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct MycExternalCommandRequest {
+ version: u8,
+ operation: MycExternalCommandOperation,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ unsigned_event: Option<nostr::UnsignedEvent>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ public_key_hex: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ content: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct MycExternalCommandResponse {
+ #[serde(default)]
+ identity: Option<RadrootsIdentityPublic>,
+ #[serde(default)]
+ event: Option<nostr::Event>,
+ #[serde(default)]
+ content: Option<String>,
+ #[serde(default)]
+ error: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+struct MycExternalCommandOutput {
+ success: bool,
+ status: Option<i32>,
+ stdout: Vec<u8>,
+ stderr: Vec<u8>,
+}
+
+trait MycExternalCommandExecutor: Send + Sync {
+ fn execute(
+ &self,
+ command_path: &PathBuf,
+ request_json: &[u8],
+ ) -> Result<MycExternalCommandOutput, std::io::Error>;
+}
+
+#[derive(Debug, Default)]
+struct MycProcessCommandExecutor;
+
+impl MycExternalCommandExecutor for MycProcessCommandExecutor {
+ fn execute(
+ &self,
+ command_path: &PathBuf,
+ request_json: &[u8],
+ ) -> Result<MycExternalCommandOutput, std::io::Error> {
+ let mut child = Command::new(command_path)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()?;
+ if let Some(mut stdin) = child.stdin.take() {
+ use std::io::Write;
+ stdin.write_all(request_json)?;
+ }
+ let output = child.wait_with_output()?;
+ Ok(MycExternalCommandOutput {
+ success: output.status.success(),
+ status: output.status.code(),
+ stdout: output.stdout,
+ stderr: output.stderr,
+ })
+ }
}
trait MycIdentityOperations: Send + Sync {
@@ -229,6 +313,202 @@ impl MycIdentityOperations for MycLoadedIdentityOperations {
}
}
+struct MycExternalCommandIdentityOperations {
+ role: String,
+ command_path: PathBuf,
+ public_identity: RadrootsIdentityPublic,
+ public_key: RadrootsNostrPublicKey,
+ executor: Arc<dyn MycExternalCommandExecutor>,
+}
+
+impl MycExternalCommandIdentityOperations {
+ fn new(
+ role: String,
+ command_path: PathBuf,
+ public_identity: RadrootsIdentityPublic,
+ public_key: RadrootsNostrPublicKey,
+ executor: Arc<dyn MycExternalCommandExecutor>,
+ ) -> Self {
+ Self {
+ role,
+ command_path,
+ public_identity,
+ public_key,
+ executor,
+ }
+ }
+
+ fn execute(
+ &self,
+ request: &MycExternalCommandRequest,
+ ) -> Result<MycExternalCommandResponse, MycError> {
+ let request_json = serde_json::to_vec(request)?;
+ let output = self
+ .executor
+ .execute(&self.command_path, &request_json)
+ .map_err(|source| MycError::CustodyExternalCommandIo {
+ role: self.role.clone(),
+ path: self.command_path.clone(),
+ source,
+ })?;
+ if !output.success {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
+ return Err(MycError::CustodyExternalCommandFailed {
+ role: self.role.clone(),
+ path: self.command_path.clone(),
+ status: output
+ .status
+ .map(|status| status.to_string())
+ .unwrap_or_else(|| "terminated by signal".to_owned()),
+ stderr: if stderr.is_empty() {
+ "external signer command failed without stderr".to_owned()
+ } else {
+ stderr
+ },
+ });
+ }
+ let response: MycExternalCommandResponse =
+ serde_json::from_slice(&output.stdout).map_err(|source| {
+ MycError::CustodyExternalCommandParse {
+ role: self.role.clone(),
+ path: self.command_path.clone(),
+ source,
+ }
+ })?;
+ if let Some(error) = response.error.as_deref() {
+ return Err(MycError::CustodyExternalCommandFailed {
+ role: self.role.clone(),
+ path: self.command_path.clone(),
+ status: "0".to_owned(),
+ stderr: error.to_owned(),
+ });
+ }
+ Ok(response)
+ }
+}
+
+impl MycIdentityOperations for MycExternalCommandIdentityOperations {
+ fn nostr_client(&self) -> RadrootsNostrClient {
+ RadrootsNostrClient::from_identity_owned(RadrootsIdentity::generate())
+ }
+
+ fn nostr_client_owned(&self) -> RadrootsNostrClient {
+ self.nostr_client()
+ }
+
+ fn sign_event_builder(
+ &self,
+ builder: RadrootsNostrEventBuilder,
+ operation: &str,
+ ) -> Result<RadrootsNostrEvent, MycError> {
+ let unsigned_event = builder.build(self.public_key);
+ self.sign_unsigned_event(unsigned_event, operation)
+ }
+
+ fn sign_unsigned_event(
+ &self,
+ unsigned_event: nostr::UnsignedEvent,
+ operation: &str,
+ ) -> Result<nostr::Event, MycError> {
+ let response = self.execute(&MycExternalCommandRequest {
+ version: 1,
+ operation: MycExternalCommandOperation::SignEvent,
+ unsigned_event: Some(unsigned_event),
+ public_key_hex: None,
+ content: None,
+ })?;
+ let event = response.event.ok_or_else(|| {
+ MycError::InvalidOperation(format!(
+ "external signer command did not return a signed event for {operation}"
+ ))
+ })?;
+ if event.pubkey != self.public_key {
+ return Err(MycError::InvalidOperation(format!(
+ "external signer command returned a signed {operation} event for `{}` instead of `{}`",
+ event.pubkey.to_hex(),
+ self.public_identity.public_key_hex
+ )));
+ }
+ Ok(event)
+ }
+
+ fn nip04_encrypt(
+ &self,
+ public_key: &RadrootsNostrPublicKey,
+ plaintext: String,
+ ) -> Result<String, MycError> {
+ let response = self.execute(&MycExternalCommandRequest {
+ version: 1,
+ operation: MycExternalCommandOperation::Nip04Encrypt,
+ unsigned_event: None,
+ public_key_hex: Some(public_key.to_hex()),
+ content: Some(plaintext),
+ })?;
+ response.content.ok_or_else(|| {
+ MycError::InvalidOperation(
+ "external signer command did not return NIP-04 ciphertext".to_owned(),
+ )
+ })
+ }
+
+ fn nip04_decrypt(
+ &self,
+ public_key: &RadrootsNostrPublicKey,
+ ciphertext: &str,
+ ) -> Result<String, MycError> {
+ let response = self.execute(&MycExternalCommandRequest {
+ version: 1,
+ operation: MycExternalCommandOperation::Nip04Decrypt,
+ unsigned_event: None,
+ public_key_hex: Some(public_key.to_hex()),
+ content: Some(ciphertext.to_owned()),
+ })?;
+ response.content.ok_or_else(|| {
+ MycError::InvalidOperation(
+ "external signer command did not return NIP-04 cleartext".to_owned(),
+ )
+ })
+ }
+
+ fn nip44_encrypt(
+ &self,
+ public_key: &RadrootsNostrPublicKey,
+ plaintext: String,
+ ) -> Result<String, MycError> {
+ let response = self.execute(&MycExternalCommandRequest {
+ version: 1,
+ operation: MycExternalCommandOperation::Nip44Encrypt,
+ unsigned_event: None,
+ public_key_hex: Some(public_key.to_hex()),
+ content: Some(plaintext),
+ })?;
+ response.content.ok_or_else(|| {
+ MycError::InvalidOperation(
+ "external signer command did not return NIP-44 ciphertext".to_owned(),
+ )
+ })
+ }
+
+ fn nip44_decrypt(
+ &self,
+ public_key: &RadrootsNostrPublicKey,
+ ciphertext: &str,
+ ) -> Result<String, MycError> {
+ let response = self.execute(&MycExternalCommandRequest {
+ version: 1,
+ operation: MycExternalCommandOperation::Nip44Decrypt,
+ unsigned_event: None,
+ public_key_hex: Some(public_key.to_hex()),
+ content: Some(ciphertext.to_owned()),
+ })?;
+ response.content.ok_or_else(|| {
+ MycError::InvalidOperation(
+ "external signer command did not return NIP-44 cleartext".to_owned(),
+ )
+ })
+ }
+}
+
impl MycIdentityProvider {
pub fn from_source(
role: impl Into<String>,
@@ -277,6 +557,17 @@ impl MycIdentityProvider {
})?;
Self::managed_account_provider(role.as_str(), account_store_path, service_name)?
}
+ MycIdentityBackend::ExternalCommand => {
+ let command_path = source.path.clone().ok_or_else(|| {
+ MycError::InvalidConfig(format!(
+ "{role} identity external_command backend requires a path"
+ ))
+ })?;
+ MycIdentityProviderBackend::ExternalCommand {
+ command_path,
+ executor: Arc::new(MycProcessCommandExecutor),
+ }
+ }
};
Ok(Self {
@@ -368,11 +659,38 @@ impl MycIdentityProvider {
path: account_store_path.clone(),
}),
},
+ MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
+ Err(MycError::InvalidOperation(format!(
+ "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process",
+ self.role,
+ command_path.display()
+ )))
+ }
}
}
pub fn load_active_identity(&self) -> Result<MycActiveIdentity, MycError> {
- self.load_identity().map(MycActiveIdentity::new)
+ match &self.backend {
+ MycIdentityProviderBackend::ExternalCommand {
+ command_path,
+ executor,
+ } => {
+ let (public_identity, public_key) =
+ self.load_external_command_identity(command_path, executor.as_ref())?;
+ Ok(MycActiveIdentity::from_operations(
+ public_identity.clone(),
+ public_key,
+ Arc::new(MycExternalCommandIdentityOperations::new(
+ self.role.clone(),
+ command_path.clone(),
+ public_identity,
+ public_key,
+ executor.clone(),
+ )),
+ ))
+ }
+ _ => self.load_identity().map(MycActiveIdentity::new),
+ }
}
pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput {
@@ -499,7 +817,77 @@ impl MycIdentityProvider {
}
fn load_identity_public(&self) -> Result<RadrootsIdentityPublic, MycError> {
- self.load_identity().map(|identity| identity.to_public())
+ match &self.backend {
+ MycIdentityProviderBackend::ExternalCommand {
+ command_path,
+ executor,
+ } => self
+ .load_external_command_identity(command_path, executor.as_ref())
+ .map(|(identity, _)| identity),
+ _ => self.load_identity().map(|identity| identity.to_public()),
+ }
+ }
+
+ fn load_external_command_identity(
+ &self,
+ command_path: &PathBuf,
+ executor: &dyn MycExternalCommandExecutor,
+ ) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> {
+ let request_json = serde_json::to_vec(&MycExternalCommandRequest {
+ version: 1,
+ operation: MycExternalCommandOperation::Describe,
+ unsigned_event: None,
+ public_key_hex: None,
+ content: None,
+ })?;
+ let output = executor
+ .execute(command_path, &request_json)
+ .map_err(|source| MycError::CustodyExternalCommandIo {
+ role: self.role.clone(),
+ path: command_path.clone(),
+ source,
+ })?;
+ if !output.success {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
+ return Err(MycError::CustodyExternalCommandFailed {
+ role: self.role.clone(),
+ path: command_path.clone(),
+ status: output
+ .status
+ .map(|status| status.to_string())
+ .unwrap_or_else(|| "terminated by signal".to_owned()),
+ stderr: if stderr.is_empty() {
+ "external signer command failed without stderr".to_owned()
+ } else {
+ stderr
+ },
+ });
+ }
+ let response: MycExternalCommandResponse =
+ serde_json::from_slice(&output.stdout).map_err(|source| {
+ MycError::CustodyExternalCommandParse {
+ role: self.role.clone(),
+ path: command_path.clone(),
+ source,
+ }
+ })?;
+ if let Some(error) = response.error {
+ return Err(MycError::CustodyExternalCommandFailed {
+ role: self.role.clone(),
+ path: command_path.clone(),
+ status: "0".to_owned(),
+ stderr: error,
+ });
+ }
+ let identity =
+ response
+ .identity
+ .ok_or_else(|| MycError::CustodyExternalCommandInvalidIdentity {
+ role: self.role.clone(),
+ path: command_path.clone(),
+ message: "missing `identity` in describe response".to_owned(),
+ })?;
+ validate_external_command_public_identity(&self.role, command_path, identity)
}
fn status_with_public_identity(
@@ -787,10 +1175,22 @@ impl MycActiveIdentity {
pub fn new(identity: RadrootsIdentity) -> Self {
let public_identity = identity.to_public();
let public_key = identity.public_key();
+ Self::from_operations(
+ public_identity,
+ public_key,
+ Arc::new(MycLoadedIdentityOperations::new(identity)),
+ )
+ }
+
+ fn from_operations(
+ public_identity: RadrootsIdentityPublic,
+ public_key: RadrootsNostrPublicKey,
+ operations: Arc<dyn MycIdentityOperations>,
+ ) -> Self {
Self {
public_identity,
public_key,
- operations: Arc::new(MycLoadedIdentityOperations::new(identity)),
+ operations,
}
}
@@ -874,6 +1274,36 @@ impl MycActiveIdentity {
}
}
+fn validate_external_command_public_identity(
+ role: &str,
+ command_path: &PathBuf,
+ identity: RadrootsIdentityPublic,
+) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> {
+ let public_key =
+ RadrootsNostrPublicKey::parse(identity.public_key_hex.as_str()).map_err(|error| {
+ MycError::CustodyExternalCommandInvalidIdentity {
+ role: role.to_owned(),
+ path: command_path.clone(),
+ message: format!(
+ "invalid public_key_hex `{}`: {error}",
+ identity.public_key_hex
+ ),
+ }
+ })?;
+ let expected_id = RadrootsIdentityId::from(public_key);
+ if identity.id != expected_id {
+ return Err(MycError::CustodyExternalCommandInvalidIdentity {
+ role: role.to_owned(),
+ path: command_path.clone(),
+ message: format!(
+ "identity id `{}` does not match public_key_hex `{}`",
+ identity.id, identity.public_key_hex
+ ),
+ });
+ }
+ Ok((identity, public_key))
+}
+
impl MycIdentityStatusOutput {
pub fn with_inherited_from(mut self, inherited_from: impl Into<String>) -> Self {
self.inherited_from = Some(inherited_from.into());
@@ -884,6 +1314,7 @@ impl MycIdentityStatusOutput {
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
+ use std::sync::Mutex;
use radroots_identity::RadrootsIdentity;
use radroots_nostr_accounts::prelude::{
@@ -910,6 +1341,136 @@ mod tests {
}
}
+ #[derive(Debug)]
+ struct FakeExternalCommandExecutor {
+ identity: RadrootsIdentity,
+ requests: Mutex<Vec<MycExternalCommandRequest>>,
+ }
+
+ impl FakeExternalCommandExecutor {
+ fn new(secret_key: &str) -> Arc<Self> {
+ Arc::new(Self {
+ identity: RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"),
+ requests: Mutex::new(Vec::new()),
+ })
+ }
+ }
+
+ impl MycExternalCommandExecutor for FakeExternalCommandExecutor {
+ fn execute(
+ &self,
+ _command_path: &PathBuf,
+ request_json: &[u8],
+ ) -> Result<MycExternalCommandOutput, std::io::Error> {
+ let request: MycExternalCommandRequest =
+ serde_json::from_slice(request_json).expect("request");
+ self.requests
+ .lock()
+ .expect("requests lock")
+ .push(request.clone());
+ let response = match request.operation {
+ MycExternalCommandOperation::Describe => MycExternalCommandResponse {
+ identity: Some(self.identity.to_public()),
+ event: None,
+ content: None,
+ error: None,
+ },
+ MycExternalCommandOperation::SignEvent => {
+ let unsigned_event = request.unsigned_event.expect("unsigned event");
+ let event = unsigned_event
+ .sign_with_keys(self.identity.keys())
+ .expect("sign event");
+ MycExternalCommandResponse {
+ identity: None,
+ event: Some(event),
+ content: None,
+ error: None,
+ }
+ }
+ MycExternalCommandOperation::Nip04Encrypt => {
+ let public_key = RadrootsNostrPublicKey::parse(
+ request.public_key_hex.as_deref().expect("public key hex"),
+ )
+ .expect("public key");
+ let ciphertext = nip04::encrypt(
+ self.identity.keys().secret_key(),
+ &public_key,
+ request.content.expect("plaintext"),
+ )
+ .expect("encrypt");
+ MycExternalCommandResponse {
+ identity: None,
+ event: None,
+ content: Some(ciphertext),
+ error: None,
+ }
+ }
+ MycExternalCommandOperation::Nip04Decrypt => {
+ let public_key = RadrootsNostrPublicKey::parse(
+ request.public_key_hex.as_deref().expect("public key hex"),
+ )
+ .expect("public key");
+ let plaintext = nip04::decrypt(
+ self.identity.keys().secret_key(),
+ &public_key,
+ request.content.as_deref().expect("ciphertext"),
+ )
+ .expect("decrypt");
+ MycExternalCommandResponse {
+ identity: None,
+ event: None,
+ content: Some(plaintext),
+ error: None,
+ }
+ }
+ MycExternalCommandOperation::Nip44Encrypt => {
+ let public_key = RadrootsNostrPublicKey::parse(
+ request.public_key_hex.as_deref().expect("public key hex"),
+ )
+ .expect("public key");
+ let ciphertext = nip44::encrypt(
+ self.identity.keys().secret_key(),
+ &public_key,
+ request.content.expect("plaintext"),
+ Version::V2,
+ )
+ .expect("encrypt");
+ MycExternalCommandResponse {
+ identity: None,
+ event: None,
+ content: Some(ciphertext),
+ error: None,
+ }
+ }
+ MycExternalCommandOperation::Nip44Decrypt => {
+ let public_key = RadrootsNostrPublicKey::parse(
+ request.public_key_hex.as_deref().expect("public key hex"),
+ )
+ .expect("public key");
+ let plaintext = nip44::decrypt(
+ self.identity.keys().secret_key(),
+ &public_key,
+ request.content.as_deref().expect("ciphertext"),
+ )
+ .expect("decrypt");
+ MycExternalCommandResponse {
+ identity: None,
+ event: None,
+ content: Some(plaintext),
+ error: None,
+ }
+ }
+ };
+
+ Ok(MycExternalCommandOutput {
+ success: true,
+ status: Some(0),
+ stdout: serde_json::to_vec(&response).expect("response"),
+ stderr: Vec::new(),
+ })
+ }
+ }
+
fn managed_account_provider(
role: &str,
service_name: &str,
@@ -940,6 +1501,31 @@ mod tests {
)
}
+ fn external_command_provider(
+ role: &str,
+ secret_key: &str,
+ ) -> (MycIdentityProvider, Arc<FakeExternalCommandExecutor>) {
+ let executor = FakeExternalCommandExecutor::new(secret_key);
+ let command_path = PathBuf::from(format!("/tmp/{role}-identity-helper"));
+ (
+ MycIdentityProvider {
+ role: role.to_owned(),
+ source: MycIdentitySourceSpec {
+ backend: MycIdentityBackend::ExternalCommand,
+ path: Some(command_path.clone()),
+ keyring_account_id: None,
+ keyring_service_name: None,
+ profile_path: None,
+ },
+ backend: MycIdentityProviderBackend::ExternalCommand {
+ command_path,
+ executor: executor.clone(),
+ },
+ },
+ executor,
+ )
+ }
+
#[test]
fn filesystem_provider_loads_identity() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1102,4 +1688,77 @@ mod tests {
Some(MycManagedAccountSelectionState::PublicOnly)
);
}
+
+ #[test]
+ fn external_command_provider_loads_identity_and_executes_signing_operations() {
+ let (provider, executor) = external_command_provider(
+ "signer",
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ let active = provider.load_active_identity().expect("active identity");
+ let expected_identity = RadrootsIdentity::from_secret_key_str(
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ )
+ .expect("identity");
+ assert_eq!(active.id(), expected_identity.id());
+ assert_eq!(active.public_key_hex(), expected_identity.public_key_hex());
+
+ let peer_identity = RadrootsIdentity::from_secret_key_str(
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ )
+ .expect("peer identity");
+ let signed_event = active
+ .sign_event_builder(
+ RadrootsNostrEventBuilder::text_note("hello from external command"),
+ "test event",
+ )
+ .expect("signed event");
+ assert_eq!(signed_event.pubkey, expected_identity.public_key());
+
+ let nip04_ciphertext = active
+ .nip04_encrypt(&peer_identity.public_key(), "hello nip04")
+ .expect("nip04 encrypt");
+ assert_eq!(
+ nip04::decrypt(
+ peer_identity.keys().secret_key(),
+ &expected_identity.public_key(),
+ &nip04_ciphertext,
+ )
+ .expect("decrypt with peer"),
+ "hello nip04"
+ );
+
+ let nip44_ciphertext = active
+ .nip44_encrypt(&peer_identity.public_key(), "hello nip44")
+ .expect("nip44 encrypt");
+ assert_eq!(
+ nip44::decrypt(
+ peer_identity.keys().secret_key(),
+ &expected_identity.public_key(),
+ &nip44_ciphertext,
+ )
+ .expect("decrypt with peer"),
+ "hello nip44"
+ );
+
+ let status = provider.probe_status();
+ assert!(status.resolved);
+ assert_eq!(
+ status.path,
+ Some(PathBuf::from("/tmp/signer-identity-helper"))
+ );
+ assert_eq!(status.identity_id, Some(expected_identity.id().to_string()));
+
+ let operations = executor
+ .requests
+ .lock()
+ .expect("requests lock")
+ .iter()
+ .map(|request| request.operation)
+ .collect::<Vec<_>>();
+ assert!(operations.contains(&MycExternalCommandOperation::Describe));
+ assert!(operations.contains(&MycExternalCommandOperation::SignEvent));
+ assert!(operations.contains(&MycExternalCommandOperation::Nip04Encrypt));
+ assert!(operations.contains(&MycExternalCommandOperation::Nip44Encrypt));
+ }
}
diff --git a/src/error.rs b/src/error.rs
@@ -183,6 +183,39 @@ pub enum MycError {
service_name: String,
account_id: String,
},
+ #[error("external custody command io error for {role} identity at {path}: {source}")]
+ CustodyExternalCommandIo {
+ role: String,
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error(
+ "external custody command for {role} identity at {path} failed with status {status}: {stderr}"
+ )]
+ CustodyExternalCommandFailed {
+ role: String,
+ path: PathBuf,
+ status: String,
+ stderr: String,
+ },
+ #[error(
+ "external custody command response parse error for {role} identity at {path}: {source}"
+ )]
+ CustodyExternalCommandParse {
+ role: String,
+ path: PathBuf,
+ #[source]
+ source: serde_json::Error,
+ },
+ #[error(
+ "external custody command returned invalid public identity for {role} at {path}: {message}"
+ )]
+ CustodyExternalCommandInvalidIdentity {
+ role: String,
+ path: PathBuf,
+ message: String,
+ },
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]