myc

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

custody.rs (86356B)


      1 use std::fs;
      2 use std::path::Path;
      3 use std::path::PathBuf;
      4 use std::process::{Command, Stdio};
      5 use std::sync::Arc;
      6 use std::time::{Duration, Instant};
      7 
      8 use nostr::nips::nip44::Version;
      9 use nostr::nips::{nip04, nip44};
     10 use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic};
     11 use radroots_nostr::prelude::{
     12     RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrPublicKey,
     13 };
     14 use radroots_nostr_accounts::prelude::{
     15     RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsManager,
     16 };
     17 use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring};
     18 use serde::{Deserialize, Serialize};
     19 use zeroize::Zeroizing;
     20 
     21 use crate::config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec};
     22 use crate::error::MycError;
     23 use crate::identity_files::{
     24     load_encrypted_identity, load_identity_profile, rotate_encrypted_identity,
     25     store_encrypted_identity, store_identity_profile,
     26 };
     27 
     28 #[derive(Clone)]
     29 pub struct MycActiveIdentity {
     30     public_identity: RadrootsIdentityPublic,
     31     public_key: RadrootsNostrPublicKey,
     32     operations: Arc<dyn MycIdentityOperations>,
     33 }
     34 
     35 fn store_plaintext_identity(
     36     path: impl AsRef<Path>,
     37     identity: &RadrootsIdentity,
     38 ) -> Result<(), MycError> {
     39     identity.save_json(path).map_err(MycError::from)
     40 }
     41 
     42 fn store_secret_text(path: impl AsRef<Path>, value: &str) -> Result<(), MycError> {
     43     let path = path.as_ref();
     44     if let Some(parent) = path.parent()
     45         && !parent.as_os_str().is_empty()
     46     {
     47         fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
     48             path: parent.to_path_buf(),
     49             source,
     50         })?;
     51     }
     52 
     53     fs::write(path, value).map_err(|source| MycError::PersistenceIo {
     54         path: path.to_path_buf(),
     55         source,
     56     })?;
     57     set_secret_permissions(path)?;
     58     Ok(())
     59 }
     60 
     61 fn set_secret_permissions(path: &Path) -> Result<(), MycError> {
     62     #[cfg(unix)]
     63     {
     64         use std::os::unix::fs::PermissionsExt;
     65 
     66         let permissions = std::fs::Permissions::from_mode(0o600);
     67         fs::set_permissions(path, permissions).map_err(|source| MycError::PersistenceIo {
     68             path: path.to_path_buf(),
     69             source,
     70         })?;
     71     }
     72     Ok(())
     73 }
     74 
     75 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     76 #[serde(rename_all = "snake_case")]
     77 pub enum MycManagedAccountSelectionState {
     78     NotConfigured,
     79     PublicOnly,
     80     Ready,
     81 }
     82 
     83 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     84 pub struct MycIdentityStatusOutput {
     85     pub backend: MycIdentityBackend,
     86     #[serde(default, skip_serializing_if = "Option::is_none")]
     87     pub path: Option<PathBuf>,
     88     #[serde(default, skip_serializing_if = "Option::is_none")]
     89     pub keyring_account_id: Option<String>,
     90     #[serde(default, skip_serializing_if = "Option::is_none")]
     91     pub keyring_service_name: Option<String>,
     92     #[serde(default, skip_serializing_if = "Option::is_none")]
     93     pub profile_path: Option<PathBuf>,
     94     #[serde(default, skip_serializing_if = "Option::is_none")]
     95     pub inherited_from: Option<String>,
     96     pub resolved: bool,
     97     #[serde(default, skip_serializing_if = "Option::is_none")]
     98     pub selected_account_id: Option<String>,
     99     #[serde(default, skip_serializing_if = "Option::is_none")]
    100     pub selected_account_label: Option<String>,
    101     #[serde(default, skip_serializing_if = "Option::is_none")]
    102     pub selected_account_state: Option<MycManagedAccountSelectionState>,
    103     pub default_shared_secret_backend: MycIdentityBackend,
    104     #[serde(default, skip_serializing_if = "Vec::is_empty")]
    105     pub allowed_shared_secret_backends: Vec<MycIdentityBackend>,
    106     #[serde(default, skip_serializing_if = "Vec::is_empty")]
    107     pub runtime_specific_custody_modes: Vec<String>,
    108     #[serde(default, skip_serializing_if = "Option::is_none")]
    109     pub host_vault_policy: Option<String>,
    110     #[serde(default, skip_serializing_if = "Option::is_none")]
    111     pub identity_id: Option<String>,
    112     #[serde(default, skip_serializing_if = "Option::is_none")]
    113     pub public_key_hex: Option<String>,
    114     #[serde(default, skip_serializing_if = "Option::is_none")]
    115     pub error: Option<String>,
    116 }
    117 
    118 #[derive(Debug, Clone, Serialize)]
    119 pub struct MycManagedAccountsOutput {
    120     pub role: String,
    121     pub backend: MycIdentityBackend,
    122     pub account_store_path: PathBuf,
    123     pub keyring_service_name: String,
    124     #[serde(default, skip_serializing_if = "Option::is_none")]
    125     pub selected_account_id: Option<String>,
    126     pub selected_account_state: MycManagedAccountSelectionState,
    127     pub accounts: Vec<RadrootsNostrAccountRecord>,
    128 }
    129 
    130 #[derive(Debug, Clone, Serialize)]
    131 pub struct MycManagedAccountMutationOutput {
    132     pub role: String,
    133     pub action: String,
    134     #[serde(default, skip_serializing_if = "Option::is_none")]
    135     pub account_id: Option<String>,
    136     pub state: MycManagedAccountsOutput,
    137 }
    138 
    139 #[derive(Debug, Clone, Serialize)]
    140 pub struct MycCustodyExportOutput {
    141     pub role: String,
    142     pub backend: MycIdentityBackend,
    143     pub format: String,
    144     pub out: PathBuf,
    145     pub identity_id: String,
    146     pub public_key_hex: String,
    147 }
    148 
    149 #[derive(Debug, Clone, Serialize)]
    150 pub struct MycCustodyImportOutput {
    151     pub role: String,
    152     pub backend: MycIdentityBackend,
    153     pub format: String,
    154     pub account_id: String,
    155     pub status: MycIdentityStatusOutput,
    156 }
    157 
    158 #[derive(Debug, Clone, Serialize)]
    159 pub struct MycCustodyRotateOutput {
    160     pub role: String,
    161     pub backend: MycIdentityBackend,
    162     pub action: String,
    163     pub status: MycIdentityStatusOutput,
    164 }
    165 
    166 const MYC_CUSTODY_FORMAT_NIP49: &str = "nip49";
    167 
    168 #[derive(Clone)]
    169 pub struct MycIdentityProvider {
    170     role: String,
    171     source: MycIdentitySourceSpec,
    172     backend: MycIdentityProviderBackend,
    173 }
    174 
    175 #[derive(Clone)]
    176 enum MycIdentityProviderBackend {
    177     EncryptedFile {
    178         path: PathBuf,
    179     },
    180     PlaintextFile {
    181         path: PathBuf,
    182     },
    183     HostVault {
    184         account_id: RadrootsIdentityId,
    185         service_name: String,
    186         profile_path: Option<PathBuf>,
    187         vault: Arc<dyn RadrootsSecretVault>,
    188     },
    189     ManagedAccount {
    190         account_store_path: PathBuf,
    191         service_name: String,
    192         manager: RadrootsNostrAccountsManager,
    193     },
    194     ExternalCommand {
    195         command_path: PathBuf,
    196         timeout: Duration,
    197         executor: Arc<dyn MycExternalCommandExecutor>,
    198     },
    199 }
    200 
    201 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    202 #[serde(rename_all = "snake_case")]
    203 enum MycExternalCommandOperation {
    204     Describe,
    205     SignEvent,
    206     Nip04Encrypt,
    207     Nip04Decrypt,
    208     Nip44Encrypt,
    209     Nip44Decrypt,
    210 }
    211 
    212 #[derive(Debug, Clone, Serialize, Deserialize)]
    213 struct MycExternalCommandRequest {
    214     version: u8,
    215     operation: MycExternalCommandOperation,
    216     #[serde(default, skip_serializing_if = "Option::is_none")]
    217     unsigned_event: Option<nostr::UnsignedEvent>,
    218     #[serde(default, skip_serializing_if = "Option::is_none")]
    219     public_key_hex: Option<String>,
    220     #[serde(default, skip_serializing_if = "Option::is_none")]
    221     content: Option<String>,
    222 }
    223 
    224 #[derive(Debug, Clone, Serialize, Deserialize)]
    225 struct MycExternalCommandResponse {
    226     #[serde(default)]
    227     identity: Option<RadrootsIdentityPublic>,
    228     #[serde(default)]
    229     event: Option<nostr::Event>,
    230     #[serde(default)]
    231     content: Option<String>,
    232     #[serde(default)]
    233     error: Option<String>,
    234 }
    235 
    236 #[derive(Debug, Clone)]
    237 struct MycExternalCommandOutput {
    238     success: bool,
    239     status: Option<i32>,
    240     stdout: Vec<u8>,
    241     stderr: Vec<u8>,
    242 }
    243 
    244 #[derive(Debug)]
    245 enum MycExternalCommandExecuteError {
    246     Io(std::io::Error),
    247     TimedOut,
    248 }
    249 
    250 trait MycExternalCommandExecutor: Send + Sync {
    251     fn execute(
    252         &self,
    253         command_path: &PathBuf,
    254         request_json: &[u8],
    255         timeout: Duration,
    256     ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError>;
    257 }
    258 
    259 #[derive(Debug, Default)]
    260 struct MycProcessCommandExecutor;
    261 
    262 impl MycExternalCommandExecutor for MycProcessCommandExecutor {
    263     fn execute(
    264         &self,
    265         command_path: &PathBuf,
    266         request_json: &[u8],
    267         timeout: Duration,
    268     ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
    269         let mut child = Command::new(command_path)
    270             .stdin(Stdio::piped())
    271             .stdout(Stdio::piped())
    272             .stderr(Stdio::piped())
    273             .spawn()
    274             .map_err(MycExternalCommandExecuteError::Io)?;
    275         if let Some(mut stdin) = child.stdin.take() {
    276             use std::io::Write;
    277             stdin
    278                 .write_all(request_json)
    279                 .map_err(MycExternalCommandExecuteError::Io)?;
    280         }
    281         let deadline = Instant::now() + timeout;
    282         loop {
    283             match child
    284                 .try_wait()
    285                 .map_err(MycExternalCommandExecuteError::Io)?
    286             {
    287                 Some(_) => break,
    288                 None if Instant::now() >= deadline => {
    289                     let _ = child.kill();
    290                     let _ = child.wait();
    291                     return Err(MycExternalCommandExecuteError::TimedOut);
    292                 }
    293                 None => std::thread::sleep(Duration::from_millis(10)),
    294             }
    295         }
    296         let output = child
    297             .wait_with_output()
    298             .map_err(MycExternalCommandExecuteError::Io)?;
    299         Ok(MycExternalCommandOutput {
    300             success: output.status.success(),
    301             status: output.status.code(),
    302             stdout: output.stdout,
    303             stderr: output.stderr,
    304         })
    305     }
    306 }
    307 
    308 trait MycIdentityOperations: Send + Sync {
    309     fn nostr_client(&self) -> RadrootsNostrClient;
    310     fn nostr_client_owned(&self) -> RadrootsNostrClient;
    311     fn sign_event_builder(
    312         &self,
    313         builder: RadrootsNostrEventBuilder,
    314         operation: &str,
    315     ) -> Result<RadrootsNostrEvent, MycError>;
    316     fn sign_unsigned_event(
    317         &self,
    318         unsigned_event: nostr::UnsignedEvent,
    319         operation: &str,
    320     ) -> Result<nostr::Event, MycError>;
    321     fn nip04_encrypt(
    322         &self,
    323         public_key: &RadrootsNostrPublicKey,
    324         plaintext: String,
    325     ) -> Result<String, MycError>;
    326     fn nip04_decrypt(
    327         &self,
    328         public_key: &RadrootsNostrPublicKey,
    329         ciphertext: &str,
    330     ) -> Result<String, MycError>;
    331     fn nip44_encrypt(
    332         &self,
    333         public_key: &RadrootsNostrPublicKey,
    334         plaintext: String,
    335     ) -> Result<String, MycError>;
    336     fn nip44_decrypt(
    337         &self,
    338         public_key: &RadrootsNostrPublicKey,
    339         ciphertext: &str,
    340     ) -> Result<String, MycError>;
    341 }
    342 
    343 struct MycLoadedIdentityOperations {
    344     identity: Arc<RadrootsIdentity>,
    345 }
    346 
    347 impl MycLoadedIdentityOperations {
    348     fn new(identity: RadrootsIdentity) -> Self {
    349         Self {
    350             identity: Arc::new(identity),
    351         }
    352     }
    353 }
    354 
    355 impl MycIdentityOperations for MycLoadedIdentityOperations {
    356     fn nostr_client(&self) -> RadrootsNostrClient {
    357         RadrootsNostrClient::from_identity(self.identity.as_ref())
    358     }
    359 
    360     fn nostr_client_owned(&self) -> RadrootsNostrClient {
    361         RadrootsNostrClient::from_identity_owned((*self.identity).clone())
    362     }
    363 
    364     fn sign_event_builder(
    365         &self,
    366         builder: RadrootsNostrEventBuilder,
    367         operation: &str,
    368     ) -> Result<RadrootsNostrEvent, MycError> {
    369         builder
    370             .sign_with_keys(self.identity.keys())
    371             .map_err(|error| {
    372                 MycError::InvalidOperation(format!("failed to sign {operation} event: {error}"))
    373             })
    374     }
    375 
    376     fn sign_unsigned_event(
    377         &self,
    378         unsigned_event: nostr::UnsignedEvent,
    379         operation: &str,
    380     ) -> Result<nostr::Event, MycError> {
    381         unsigned_event
    382             .sign_with_keys(self.identity.keys())
    383             .map_err(|error| {
    384                 MycError::InvalidOperation(format!("failed to sign {operation}: {error}"))
    385             })
    386     }
    387 
    388     fn nip04_encrypt(
    389         &self,
    390         public_key: &RadrootsNostrPublicKey,
    391         plaintext: String,
    392     ) -> Result<String, MycError> {
    393         nip04::encrypt(self.identity.keys().secret_key(), public_key, plaintext)
    394             .map_err(|error| MycError::Nip46Encrypt(error.to_string()))
    395     }
    396 
    397     fn nip04_decrypt(
    398         &self,
    399         public_key: &RadrootsNostrPublicKey,
    400         ciphertext: &str,
    401     ) -> Result<String, MycError> {
    402         nip04::decrypt(self.identity.keys().secret_key(), public_key, ciphertext)
    403             .map_err(|error| MycError::Nip46Decrypt(error.to_string()))
    404     }
    405 
    406     fn nip44_encrypt(
    407         &self,
    408         public_key: &RadrootsNostrPublicKey,
    409         plaintext: String,
    410     ) -> Result<String, MycError> {
    411         nip44::encrypt(
    412             self.identity.keys().secret_key(),
    413             public_key,
    414             plaintext,
    415             Version::V2,
    416         )
    417         .map_err(|error| MycError::Nip46Encrypt(error.to_string()))
    418     }
    419 
    420     fn nip44_decrypt(
    421         &self,
    422         public_key: &RadrootsNostrPublicKey,
    423         ciphertext: &str,
    424     ) -> Result<String, MycError> {
    425         nip44::decrypt(self.identity.keys().secret_key(), public_key, ciphertext)
    426             .map_err(|error| MycError::Nip46Decrypt(error.to_string()))
    427     }
    428 }
    429 
    430 struct MycExternalCommandIdentityOperations {
    431     role: String,
    432     command_path: PathBuf,
    433     timeout: Duration,
    434     public_identity: RadrootsIdentityPublic,
    435     public_key: RadrootsNostrPublicKey,
    436     executor: Arc<dyn MycExternalCommandExecutor>,
    437 }
    438 
    439 impl MycExternalCommandIdentityOperations {
    440     fn new(
    441         role: String,
    442         command_path: PathBuf,
    443         timeout: Duration,
    444         public_identity: RadrootsIdentityPublic,
    445         public_key: RadrootsNostrPublicKey,
    446         executor: Arc<dyn MycExternalCommandExecutor>,
    447     ) -> Self {
    448         Self {
    449             role,
    450             command_path,
    451             timeout,
    452             public_identity,
    453             public_key,
    454             executor,
    455         }
    456     }
    457 
    458     fn execute(
    459         &self,
    460         request: &MycExternalCommandRequest,
    461     ) -> Result<MycExternalCommandResponse, MycError> {
    462         let request_json = serde_json::to_vec(request)?;
    463         let output = self
    464             .executor
    465             .execute(&self.command_path, &request_json, self.timeout)
    466             .map_err(|error| match error {
    467                 MycExternalCommandExecuteError::Io(source) => MycError::CustodyExternalCommandIo {
    468                     role: self.role.clone(),
    469                     path: self.command_path.clone(),
    470                     source,
    471                 },
    472                 MycExternalCommandExecuteError::TimedOut => {
    473                     MycError::CustodyExternalCommandTimedOut {
    474                         role: self.role.clone(),
    475                         path: self.command_path.clone(),
    476                         timeout_secs: self.timeout.as_secs(),
    477                     }
    478                 }
    479             })?;
    480         if !output.success {
    481             let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
    482             return Err(MycError::CustodyExternalCommandFailed {
    483                 role: self.role.clone(),
    484                 path: self.command_path.clone(),
    485                 status: output
    486                     .status
    487                     .map(|status| status.to_string())
    488                     .unwrap_or_else(|| "terminated by signal".to_owned()),
    489                 stderr: if stderr.is_empty() {
    490                     "external signer command failed without stderr".to_owned()
    491                 } else {
    492                     stderr
    493                 },
    494             });
    495         }
    496         let response: MycExternalCommandResponse =
    497             serde_json::from_slice(&output.stdout).map_err(|source| {
    498                 MycError::CustodyExternalCommandParse {
    499                     role: self.role.clone(),
    500                     path: self.command_path.clone(),
    501                     source,
    502                 }
    503             })?;
    504         if let Some(error) = response.error.as_deref() {
    505             return Err(MycError::CustodyExternalCommandFailed {
    506                 role: self.role.clone(),
    507                 path: self.command_path.clone(),
    508                 status: "0".to_owned(),
    509                 stderr: error.to_owned(),
    510             });
    511         }
    512         Ok(response)
    513     }
    514 }
    515 
    516 impl MycIdentityOperations for MycExternalCommandIdentityOperations {
    517     fn nostr_client(&self) -> RadrootsNostrClient {
    518         RadrootsNostrClient::new_signerless()
    519     }
    520 
    521     fn nostr_client_owned(&self) -> RadrootsNostrClient {
    522         self.nostr_client()
    523     }
    524 
    525     fn sign_event_builder(
    526         &self,
    527         builder: RadrootsNostrEventBuilder,
    528         operation: &str,
    529     ) -> Result<RadrootsNostrEvent, MycError> {
    530         let unsigned_event = builder.build(self.public_key);
    531         self.sign_unsigned_event(unsigned_event, operation)
    532     }
    533 
    534     fn sign_unsigned_event(
    535         &self,
    536         unsigned_event: nostr::UnsignedEvent,
    537         operation: &str,
    538     ) -> Result<nostr::Event, MycError> {
    539         let response = self.execute(&MycExternalCommandRequest {
    540             version: 1,
    541             operation: MycExternalCommandOperation::SignEvent,
    542             unsigned_event: Some(unsigned_event),
    543             public_key_hex: None,
    544             content: None,
    545         })?;
    546         let event = response.event.ok_or_else(|| {
    547             MycError::InvalidOperation(format!(
    548                 "external signer command did not return a signed event for {operation}"
    549             ))
    550         })?;
    551         if event.pubkey != self.public_key {
    552             return Err(MycError::InvalidOperation(format!(
    553                 "external signer command returned a signed {operation} event for `{}` instead of `{}`",
    554                 event.pubkey.to_hex(),
    555                 self.public_identity.public_key_hex
    556             )));
    557         }
    558         Ok(event)
    559     }
    560 
    561     fn nip04_encrypt(
    562         &self,
    563         public_key: &RadrootsNostrPublicKey,
    564         plaintext: String,
    565     ) -> Result<String, MycError> {
    566         let response = self.execute(&MycExternalCommandRequest {
    567             version: 1,
    568             operation: MycExternalCommandOperation::Nip04Encrypt,
    569             unsigned_event: None,
    570             public_key_hex: Some(public_key.to_hex()),
    571             content: Some(plaintext),
    572         })?;
    573         response.content.ok_or_else(|| {
    574             MycError::InvalidOperation(
    575                 "external signer command did not return NIP-04 ciphertext".to_owned(),
    576             )
    577         })
    578     }
    579 
    580     fn nip04_decrypt(
    581         &self,
    582         public_key: &RadrootsNostrPublicKey,
    583         ciphertext: &str,
    584     ) -> Result<String, MycError> {
    585         let response = self.execute(&MycExternalCommandRequest {
    586             version: 1,
    587             operation: MycExternalCommandOperation::Nip04Decrypt,
    588             unsigned_event: None,
    589             public_key_hex: Some(public_key.to_hex()),
    590             content: Some(ciphertext.to_owned()),
    591         })?;
    592         response.content.ok_or_else(|| {
    593             MycError::InvalidOperation(
    594                 "external signer command did not return NIP-04 cleartext".to_owned(),
    595             )
    596         })
    597     }
    598 
    599     fn nip44_encrypt(
    600         &self,
    601         public_key: &RadrootsNostrPublicKey,
    602         plaintext: String,
    603     ) -> Result<String, MycError> {
    604         let response = self.execute(&MycExternalCommandRequest {
    605             version: 1,
    606             operation: MycExternalCommandOperation::Nip44Encrypt,
    607             unsigned_event: None,
    608             public_key_hex: Some(public_key.to_hex()),
    609             content: Some(plaintext),
    610         })?;
    611         response.content.ok_or_else(|| {
    612             MycError::InvalidOperation(
    613                 "external signer command did not return NIP-44 ciphertext".to_owned(),
    614             )
    615         })
    616     }
    617 
    618     fn nip44_decrypt(
    619         &self,
    620         public_key: &RadrootsNostrPublicKey,
    621         ciphertext: &str,
    622     ) -> Result<String, MycError> {
    623         let response = self.execute(&MycExternalCommandRequest {
    624             version: 1,
    625             operation: MycExternalCommandOperation::Nip44Decrypt,
    626             unsigned_event: None,
    627             public_key_hex: Some(public_key.to_hex()),
    628             content: Some(ciphertext.to_owned()),
    629         })?;
    630         response.content.ok_or_else(|| {
    631             MycError::InvalidOperation(
    632                 "external signer command did not return NIP-44 cleartext".to_owned(),
    633             )
    634         })
    635     }
    636 }
    637 
    638 impl MycIdentityProvider {
    639     pub fn from_source(
    640         role: impl Into<String>,
    641         source: MycIdentitySourceSpec,
    642         external_command_timeout: Duration,
    643     ) -> Result<Self, MycError> {
    644         let role = role.into();
    645         let backend = match source.backend {
    646             MycIdentityBackend::EncryptedFile => {
    647                 let path = source.path.clone().ok_or_else(|| {
    648                     MycError::InvalidConfig(format!(
    649                         "{role} identity encrypted_file backend requires a path"
    650                     ))
    651                 })?;
    652                 MycIdentityProviderBackend::EncryptedFile { path }
    653             }
    654             MycIdentityBackend::PlaintextFile => {
    655                 let path = source.path.clone().ok_or_else(|| {
    656                     MycError::InvalidConfig(format!(
    657                         "{role} identity plaintext_file backend requires a path"
    658                     ))
    659                 })?;
    660                 MycIdentityProviderBackend::PlaintextFile { path }
    661             }
    662             MycIdentityBackend::HostVault => {
    663                 let account_id = RadrootsIdentityId::parse(
    664                     source.keyring_account_id.as_deref().ok_or_else(|| {
    665                         MycError::InvalidConfig(format!(
    666                             "{role} identity host_vault backend requires keyring_account_id"
    667                         ))
    668                     })?,
    669                 )
    670                 .map_err(|_| {
    671                     MycError::InvalidConfig(format!(
    672                         "{role} identity host_vault backend requires a valid keyring_account_id"
    673                     ))
    674                 })?;
    675                 let service_name = source.keyring_service_name.clone().ok_or_else(|| {
    676                     MycError::InvalidConfig(format!(
    677                         "{role} identity host_vault backend requires keyring_service_name"
    678                     ))
    679                 })?;
    680                 Self::vault_provider(role.as_str(), &source, account_id, service_name)?
    681             }
    682             MycIdentityBackend::ManagedAccount => {
    683                 let account_store_path = source.path.clone().ok_or_else(|| {
    684                     MycError::InvalidConfig(format!(
    685                         "{role} identity managed_account backend requires a path"
    686                     ))
    687                 })?;
    688                 let service_name = source.keyring_service_name.clone().ok_or_else(|| {
    689                     MycError::InvalidConfig(format!(
    690                         "{role} identity managed_account backend requires keyring_service_name"
    691                     ))
    692                 })?;
    693                 Self::managed_account_provider(role.as_str(), account_store_path, service_name)?
    694             }
    695             MycIdentityBackend::ExternalCommand => {
    696                 let command_path = source.path.clone().ok_or_else(|| {
    697                     MycError::InvalidConfig(format!(
    698                         "{role} identity external_command backend requires a path"
    699                     ))
    700                 })?;
    701                 MycIdentityProviderBackend::ExternalCommand {
    702                     command_path,
    703                     timeout: external_command_timeout,
    704                     executor: Arc::new(MycProcessCommandExecutor),
    705                 }
    706             }
    707         };
    708 
    709         Ok(Self {
    710             role,
    711             source,
    712             backend,
    713         })
    714     }
    715 
    716     pub fn load_identity(&self) -> Result<RadrootsIdentity, MycError> {
    717         match &self.backend {
    718             MycIdentityProviderBackend::EncryptedFile { path } => {
    719                 Ok(load_encrypted_identity(path)?)
    720             }
    721             MycIdentityProviderBackend::PlaintextFile { path } => {
    722                 RadrootsIdentity::load_from_path_auto(path).map_err(Into::into)
    723             }
    724             MycIdentityProviderBackend::HostVault {
    725                 account_id,
    726                 service_name,
    727                 profile_path,
    728                 vault,
    729             } => {
    730                 let secret_key_hex = vault
    731                     .load_secret(account_id.as_str())
    732                     .map_err(|source| MycError::CustodyVault {
    733                         role: self.role.clone(),
    734                         source: source.into(),
    735                     })?
    736                     .ok_or_else(|| MycError::CustodySecretNotFound {
    737                         role: self.role.clone(),
    738                         service_name: service_name.clone(),
    739                         account_id: account_id.to_string(),
    740                     })?;
    741                 let mut identity = RadrootsIdentity::from_secret_key_str(secret_key_hex.as_str())?;
    742                 if identity.id() != *account_id {
    743                     return Err(MycError::CustodySecretIdentityMismatch {
    744                         role: self.role.clone(),
    745                         service_name: service_name.clone(),
    746                         account_id: account_id.to_string(),
    747                         resolved_identity_id: identity.id().to_string(),
    748                     });
    749                 }
    750                 if let Some(profile_path) = profile_path {
    751                     let profile_identity = load_identity_profile(profile_path)?;
    752                     if profile_identity.id != *account_id {
    753                         return Err(MycError::CustodyProfileIdentityMismatch {
    754                             role: self.role.clone(),
    755                             path: profile_path.clone(),
    756                             account_id: account_id.to_string(),
    757                             profile_identity_id: profile_identity.id.to_string(),
    758                         });
    759                     }
    760                     if let Some(profile) = profile_identity.profile {
    761                         identity.set_profile(profile);
    762                     }
    763                 }
    764                 Ok(identity)
    765             }
    766             MycIdentityProviderBackend::ManagedAccount {
    767                 account_store_path,
    768                 service_name,
    769                 manager,
    770             } => match manager.default_account_status().map_err(|source| {
    771                 MycError::CustodyManager {
    772                     role: self.role.clone(),
    773                     source,
    774                 }
    775             })? {
    776                 RadrootsNostrAccountStatus::NotConfigured => {
    777                     Err(MycError::CustodyManagedAccountNotConfigured {
    778                         role: self.role.clone(),
    779                         path: account_store_path.clone(),
    780                     })
    781                 }
    782                 RadrootsNostrAccountStatus::PublicOnly { account } => {
    783                     Err(MycError::CustodyManagedAccountPublicOnly {
    784                         role: self.role.clone(),
    785                         path: account_store_path.clone(),
    786                         service_name: service_name.clone(),
    787                         account_id: account.account_id.to_string(),
    788                     })
    789                 }
    790                 RadrootsNostrAccountStatus::Ready { .. } => manager
    791                     .default_signing_identity()
    792                     .map_err(|source| MycError::CustodyManager {
    793                         role: self.role.clone(),
    794                         source,
    795                     })?
    796                     .ok_or_else(|| MycError::CustodyManagedAccountNotConfigured {
    797                         role: self.role.clone(),
    798                         path: account_store_path.clone(),
    799                     }),
    800             },
    801             MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
    802                 Err(MycError::InvalidOperation(format!(
    803                     "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process",
    804                     self.role,
    805                     command_path.display()
    806                 )))
    807             }
    808         }
    809     }
    810 
    811     pub fn load_active_identity(&self) -> Result<MycActiveIdentity, MycError> {
    812         match &self.backend {
    813             MycIdentityProviderBackend::ExternalCommand {
    814                 command_path,
    815                 timeout,
    816                 executor,
    817             } => {
    818                 let (public_identity, public_key) =
    819                     self.load_external_command_identity(command_path, *timeout, executor.as_ref())?;
    820                 Ok(MycActiveIdentity::from_operations(
    821                     public_identity.clone(),
    822                     public_key,
    823                     Arc::new(MycExternalCommandIdentityOperations::new(
    824                         self.role.clone(),
    825                         command_path.clone(),
    826                         *timeout,
    827                         public_identity,
    828                         public_key,
    829                         executor.clone(),
    830                     )),
    831                 ))
    832             }
    833             _ => self.load_identity().map(MycActiveIdentity::new),
    834         }
    835     }
    836 
    837     pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput {
    838         match &self.backend {
    839             MycIdentityProviderBackend::ManagedAccount { .. } => {
    840                 self.managed_account_status(Ok(()), self.selected_managed_account_record_result())
    841             }
    842             _ => self.status_with_public_identity(identity.public_identity()),
    843         }
    844     }
    845 
    846     pub fn probe_status(&self) -> MycIdentityStatusOutput {
    847         match &self.backend {
    848             MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status(
    849                 self.load_identity_public().as_ref().map(|_| ()),
    850                 self.selected_managed_account_record_result(),
    851             ),
    852             _ => match self.load_identity_public() {
    853                 Ok(identity) => self.status_with_public_identity(&identity),
    854                 Err(error) => self.status_with_error(&error),
    855             },
    856         }
    857     }
    858 
    859     pub fn source(&self) -> &MycIdentitySourceSpec {
    860         &self.source
    861     }
    862 
    863     pub fn status_output(&self) -> MycIdentityStatusOutput {
    864         self.probe_status()
    865     }
    866 
    867     pub fn export_nip49(
    868         &self,
    869         out: impl AsRef<std::path::Path>,
    870         password: &str,
    871     ) -> Result<MycCustodyExportOutput, MycError> {
    872         self.ensure_secret_materialized_operation("export NIP-49 secrets")?;
    873         let out = out.as_ref();
    874         let identity = self.load_identity()?;
    875         let payload = identity.encrypt_secret_key_ncryptsec(password)?;
    876         store_secret_text(out, payload.as_str())?;
    877         Ok(MycCustodyExportOutput {
    878             role: self.role.clone(),
    879             backend: self.source.backend,
    880             format: MYC_CUSTODY_FORMAT_NIP49.to_owned(),
    881             out: out.to_path_buf(),
    882             identity_id: identity.id().to_string(),
    883             public_key_hex: identity.public_key_hex(),
    884         })
    885     }
    886 
    887     pub fn import_nip49(
    888         &self,
    889         path: impl AsRef<std::path::Path>,
    890         password: &str,
    891         label: Option<String>,
    892     ) -> Result<MycCustodyImportOutput, MycError> {
    893         self.ensure_secret_materialized_operation("import NIP-49 secrets")?;
    894         let identity = load_identity_from_nip49_file(path.as_ref(), password)?;
    895         let account_id = identity.id().to_string();
    896         match &self.backend {
    897             MycIdentityProviderBackend::ManagedAccount { manager, .. } => {
    898                 manager
    899                     .upsert_identity(&identity, label, true)
    900                     .map_err(|source| MycError::CustodyManager {
    901                         role: self.role.clone(),
    902                         source,
    903                     })?;
    904             }
    905             _ => {
    906                 if let Some(label) = label {
    907                     return Err(MycError::InvalidOperation(format!(
    908                         "{} identity backend `{}` does not support --label for `import-nip49` (got `{label}`)",
    909                         self.role,
    910                         self.source.backend.as_str(),
    911                     )));
    912                 }
    913                 self.store_identity(&identity)?;
    914             }
    915         }
    916         Ok(MycCustodyImportOutput {
    917             role: self.role.clone(),
    918             backend: self.source.backend,
    919             format: MYC_CUSTODY_FORMAT_NIP49.to_owned(),
    920             account_id,
    921             status: self.probe_status(),
    922         })
    923     }
    924 
    925     pub fn rotate_secret_storage(&self) -> Result<MycCustodyRotateOutput, MycError> {
    926         match &self.backend {
    927             MycIdentityProviderBackend::EncryptedFile { path } => {
    928                 rotate_encrypted_identity(path)?;
    929             }
    930             MycIdentityProviderBackend::PlaintextFile { .. } => {
    931                 return Err(MycError::InvalidOperation(format!(
    932                     "{} identity backend `plaintext_file` does not support `custody rotate`; migrate to `encrypted_file`, `host_vault`, or `managed_account` first",
    933                     self.role
    934                 )));
    935             }
    936             MycIdentityProviderBackend::HostVault { .. } => {
    937                 return Err(MycError::InvalidOperation(format!(
    938                     "{} identity backend `host_vault` does not define an in-process `custody rotate` action; rotate or re-provision the secret through the host vault itself",
    939                     self.role
    940                 )));
    941             }
    942             MycIdentityProviderBackend::ManagedAccount { .. } => {
    943                 return Err(MycError::InvalidOperation(format!(
    944                     "{} identity backend `managed_account` does not define an in-process `custody rotate` action; rotate the selected account through the configured host vault policy",
    945                     self.role
    946                 )));
    947             }
    948             MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
    949                 return Err(MycError::InvalidOperation(format!(
    950                     "{} identity backend `external_command` at {} does not materialize secret-bearing identities in-process and cannot rotate local storage",
    951                     self.role,
    952                     command_path.display(),
    953                 )));
    954             }
    955         }
    956 
    957         Ok(MycCustodyRotateOutput {
    958             role: self.role.clone(),
    959             backend: self.source.backend,
    960             action: "rotate".to_owned(),
    961             status: self.probe_status(),
    962         })
    963     }
    964 
    965     pub fn list_managed_accounts(&self) -> Result<MycManagedAccountsOutput, MycError> {
    966         self.managed_accounts_output()
    967     }
    968 
    969     pub fn generate_managed_account(
    970         &self,
    971         label: Option<String>,
    972         make_selected: bool,
    973     ) -> Result<MycManagedAccountMutationOutput, MycError> {
    974         let account_id = {
    975             let manager = self.managed_accounts_manager()?;
    976             manager
    977                 .generate_identity(label, make_selected)
    978                 .map_err(|source| MycError::CustodyManager {
    979                     role: self.role.clone(),
    980                     source,
    981                 })?
    982         };
    983         Ok(MycManagedAccountMutationOutput {
    984             role: self.role.clone(),
    985             action: "generate".to_owned(),
    986             account_id: Some(account_id.to_string()),
    987             state: self.managed_accounts_output()?,
    988         })
    989     }
    990 
    991     pub fn import_managed_account_file(
    992         &self,
    993         path: impl AsRef<std::path::Path>,
    994         label: Option<String>,
    995         make_selected: bool,
    996     ) -> Result<MycManagedAccountMutationOutput, MycError> {
    997         let account_id = {
    998             let manager = self.managed_accounts_manager()?;
    999             manager
   1000                 .migrate_legacy_identity_file(path, label, make_selected)
   1001                 .map_err(|source| MycError::CustodyManager {
   1002                     role: self.role.clone(),
   1003                     source,
   1004                 })?
   1005         };
   1006         Ok(MycManagedAccountMutationOutput {
   1007             role: self.role.clone(),
   1008             action: "import_file".to_owned(),
   1009             account_id: Some(account_id.to_string()),
   1010             state: self.managed_accounts_output()?,
   1011         })
   1012     }
   1013 
   1014     pub fn select_managed_account(
   1015         &self,
   1016         account_id: &str,
   1017     ) -> Result<MycManagedAccountMutationOutput, MycError> {
   1018         let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| {
   1019             MycError::InvalidOperation(format!("invalid managed account id `{account_id}`"))
   1020         })?;
   1021         {
   1022             let manager = self.managed_accounts_manager()?;
   1023             manager.set_default_account(&account_id).map_err(|source| {
   1024                 MycError::CustodyManager {
   1025                     role: self.role.clone(),
   1026                     source,
   1027                 }
   1028             })?;
   1029         }
   1030         Ok(MycManagedAccountMutationOutput {
   1031             role: self.role.clone(),
   1032             action: "select".to_owned(),
   1033             account_id: Some(account_id.to_string()),
   1034             state: self.managed_accounts_output()?,
   1035         })
   1036     }
   1037 
   1038     pub fn remove_managed_account(
   1039         &self,
   1040         account_id: &str,
   1041     ) -> Result<MycManagedAccountMutationOutput, MycError> {
   1042         let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| {
   1043             MycError::InvalidOperation(format!("invalid managed account id `{account_id}`"))
   1044         })?;
   1045         {
   1046             let manager = self.managed_accounts_manager()?;
   1047             manager
   1048                 .remove_account(&account_id)
   1049                 .map_err(|source| MycError::CustodyManager {
   1050                     role: self.role.clone(),
   1051                     source,
   1052                 })?;
   1053         }
   1054         Ok(MycManagedAccountMutationOutput {
   1055             role: self.role.clone(),
   1056             action: "remove".to_owned(),
   1057             account_id: Some(account_id.to_string()),
   1058             state: self.managed_accounts_output()?,
   1059         })
   1060     }
   1061 
   1062     fn store_identity(&self, identity: &RadrootsIdentity) -> Result<(), MycError> {
   1063         match &self.backend {
   1064             MycIdentityProviderBackend::EncryptedFile { path } => {
   1065                 Ok(store_encrypted_identity(path, identity)?)
   1066             }
   1067             MycIdentityProviderBackend::PlaintextFile { path } => {
   1068                 store_plaintext_identity(path, identity)
   1069             }
   1070             MycIdentityProviderBackend::HostVault {
   1071                 account_id,
   1072                 service_name,
   1073                 profile_path,
   1074                 vault,
   1075             } => {
   1076                 let identity_id = identity.id();
   1077                 if identity_id != *account_id {
   1078                     return Err(MycError::CustodySecretIdentityMismatch {
   1079                         role: self.role.clone(),
   1080                         service_name: service_name.clone(),
   1081                         account_id: account_id.to_string(),
   1082                         resolved_identity_id: identity_id.to_string(),
   1083                     });
   1084                 }
   1085                 let secret_key_hex = Zeroizing::new(identity.secret_key_hex());
   1086                 vault
   1087                     .store_secret(account_id.as_str(), secret_key_hex.as_str())
   1088                     .map_err(|source| MycError::CustodyVault {
   1089                         role: self.role.clone(),
   1090                         source: source.into(),
   1091                     })?;
   1092                 if let Some(profile_path) = profile_path {
   1093                     store_identity_profile(profile_path, identity)?;
   1094                 }
   1095                 Ok(())
   1096             }
   1097             MycIdentityProviderBackend::ManagedAccount { .. } => {
   1098                 Err(MycError::InvalidOperation(format!(
   1099                     "{} identity backend `managed_account` requires account-store lifecycle helpers instead of direct identity writes",
   1100                     self.role
   1101                 )))
   1102             }
   1103             MycIdentityProviderBackend::ExternalCommand { command_path, .. } => {
   1104                 Err(MycError::InvalidOperation(format!(
   1105                     "{} identity backend `external_command` at {} does not support direct secret writes",
   1106                     self.role,
   1107                     command_path.display(),
   1108                 )))
   1109             }
   1110         }
   1111     }
   1112 
   1113     fn ensure_secret_materialized_operation(&self, operation: &str) -> Result<(), MycError> {
   1114         if let MycIdentityProviderBackend::ExternalCommand { command_path, .. } = &self.backend {
   1115             return Err(MycError::InvalidOperation(format!(
   1116                 "{} identity backend `external_command` at {} does not support `{operation}` because secret material never enters the myc process",
   1117                 self.role,
   1118                 command_path.display(),
   1119             )));
   1120         }
   1121         Ok(())
   1122     }
   1123 
   1124     fn load_identity_public(&self) -> Result<RadrootsIdentityPublic, MycError> {
   1125         match &self.backend {
   1126             MycIdentityProviderBackend::ExternalCommand {
   1127                 command_path,
   1128                 timeout,
   1129                 executor,
   1130             } => self
   1131                 .load_external_command_identity(command_path, *timeout, executor.as_ref())
   1132                 .map(|(identity, _)| identity),
   1133             _ => self.load_identity().map(|identity| identity.to_public()),
   1134         }
   1135     }
   1136 
   1137     fn load_external_command_identity(
   1138         &self,
   1139         command_path: &PathBuf,
   1140         timeout: Duration,
   1141         executor: &dyn MycExternalCommandExecutor,
   1142     ) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> {
   1143         let request_json = serde_json::to_vec(&MycExternalCommandRequest {
   1144             version: 1,
   1145             operation: MycExternalCommandOperation::Describe,
   1146             unsigned_event: None,
   1147             public_key_hex: None,
   1148             content: None,
   1149         })?;
   1150         let output = executor
   1151             .execute(command_path, &request_json, timeout)
   1152             .map_err(|error| match error {
   1153                 MycExternalCommandExecuteError::Io(source) => MycError::CustodyExternalCommandIo {
   1154                     role: self.role.clone(),
   1155                     path: command_path.clone(),
   1156                     source,
   1157                 },
   1158                 MycExternalCommandExecuteError::TimedOut => {
   1159                     MycError::CustodyExternalCommandTimedOut {
   1160                         role: self.role.clone(),
   1161                         path: command_path.clone(),
   1162                         timeout_secs: timeout.as_secs(),
   1163                     }
   1164                 }
   1165             })?;
   1166         if !output.success {
   1167             let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
   1168             return Err(MycError::CustodyExternalCommandFailed {
   1169                 role: self.role.clone(),
   1170                 path: command_path.clone(),
   1171                 status: output
   1172                     .status
   1173                     .map(|status| status.to_string())
   1174                     .unwrap_or_else(|| "terminated by signal".to_owned()),
   1175                 stderr: if stderr.is_empty() {
   1176                     "external signer command failed without stderr".to_owned()
   1177                 } else {
   1178                     stderr
   1179                 },
   1180             });
   1181         }
   1182         let response: MycExternalCommandResponse =
   1183             serde_json::from_slice(&output.stdout).map_err(|source| {
   1184                 MycError::CustodyExternalCommandParse {
   1185                     role: self.role.clone(),
   1186                     path: command_path.clone(),
   1187                     source,
   1188                 }
   1189             })?;
   1190         if let Some(error) = response.error {
   1191             return Err(MycError::CustodyExternalCommandFailed {
   1192                 role: self.role.clone(),
   1193                 path: command_path.clone(),
   1194                 status: "0".to_owned(),
   1195                 stderr: error,
   1196             });
   1197         }
   1198         let identity =
   1199             response
   1200                 .identity
   1201                 .ok_or_else(|| MycError::CustodyExternalCommandInvalidIdentity {
   1202                     role: self.role.clone(),
   1203                     path: command_path.clone(),
   1204                     message: "missing `identity` in describe response".to_owned(),
   1205                 })?;
   1206         validate_external_command_public_identity(&self.role, command_path, identity)
   1207     }
   1208 
   1209     fn status_with_public_identity(
   1210         &self,
   1211         identity: &RadrootsIdentityPublic,
   1212     ) -> MycIdentityStatusOutput {
   1213         MycIdentityStatusOutput {
   1214             backend: self.source.backend,
   1215             path: self.source.path.clone(),
   1216             keyring_account_id: self.source.keyring_account_id.clone(),
   1217             keyring_service_name: self.source.keyring_service_name.clone(),
   1218             profile_path: self.source.profile_path.clone(),
   1219             inherited_from: None,
   1220             resolved: true,
   1221             selected_account_id: None,
   1222             selected_account_label: None,
   1223             selected_account_state: None,
   1224             default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
   1225             allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
   1226             runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
   1227             host_vault_policy: MycConfig::host_vault_policy(),
   1228             identity_id: Some(identity.id.to_string()),
   1229             public_key_hex: Some(identity.public_key_hex.clone()),
   1230             error: None,
   1231         }
   1232     }
   1233 
   1234     fn status_with_error(&self, error: &MycError) -> MycIdentityStatusOutput {
   1235         MycIdentityStatusOutput {
   1236             backend: self.source.backend,
   1237             path: self.source.path.clone(),
   1238             keyring_account_id: self.source.keyring_account_id.clone(),
   1239             keyring_service_name: self.source.keyring_service_name.clone(),
   1240             profile_path: self.source.profile_path.clone(),
   1241             inherited_from: None,
   1242             resolved: false,
   1243             selected_account_id: None,
   1244             selected_account_label: None,
   1245             selected_account_state: None,
   1246             default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
   1247             allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
   1248             runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
   1249             host_vault_policy: MycConfig::host_vault_policy(),
   1250             identity_id: None,
   1251             public_key_hex: None,
   1252             error: Some(error.to_string()),
   1253         }
   1254     }
   1255 
   1256     fn selected_managed_account_record_result(
   1257         &self,
   1258     ) -> Result<Option<RadrootsNostrAccountRecord>, MycError> {
   1259         let manager = self.managed_accounts_manager()?;
   1260         manager
   1261             .default_account()
   1262             .map_err(|source| MycError::CustodyManager {
   1263                 role: self.role.clone(),
   1264                 source,
   1265             })
   1266     }
   1267 
   1268     fn managed_account_status(
   1269         &self,
   1270         identity_result: Result<(), &MycError>,
   1271         account_result: Result<Option<RadrootsNostrAccountRecord>, MycError>,
   1272     ) -> MycIdentityStatusOutput {
   1273         let MycIdentityProviderBackend::ManagedAccount {
   1274             account_store_path,
   1275             service_name,
   1276             manager,
   1277         } = &self.backend
   1278         else {
   1279             return match self.load_identity_public() {
   1280                 Ok(identity) => self.status_with_public_identity(&identity),
   1281                 Err(error) => self.status_with_error(&error),
   1282             };
   1283         };
   1284 
   1285         let (selected_account_id, selected_account_label, identity_id, public_key_hex) =
   1286             match account_result {
   1287                 Ok(Some(account)) => (
   1288                     Some(account.account_id.to_string()),
   1289                     account.label.clone(),
   1290                     Some(account.account_id.to_string()),
   1291                     Some(account.public_identity.public_key_hex),
   1292                 ),
   1293                 Ok(None) => (None, None, None, None),
   1294                 Err(error) => {
   1295                     return MycIdentityStatusOutput {
   1296                         backend: self.source.backend,
   1297                         path: Some(account_store_path.clone()),
   1298                         keyring_account_id: None,
   1299                         keyring_service_name: Some(service_name.clone()),
   1300                         profile_path: None,
   1301                         inherited_from: None,
   1302                         resolved: false,
   1303                         selected_account_id: None,
   1304                         selected_account_label: None,
   1305                         selected_account_state: None,
   1306                         default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
   1307                         allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
   1308                         runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
   1309                         host_vault_policy: MycConfig::host_vault_policy(),
   1310                         identity_id: None,
   1311                         public_key_hex: None,
   1312                         error: Some(error.to_string()),
   1313                     };
   1314                 }
   1315             };
   1316 
   1317         let (resolved, selected_account_state, error) = match manager
   1318             .default_account_status()
   1319             .map_err(|source| MycError::CustodyManager {
   1320                 role: self.role.clone(),
   1321                 source,
   1322             }) {
   1323             Ok(RadrootsNostrAccountStatus::NotConfigured) => (
   1324                 false,
   1325                 Some(MycManagedAccountSelectionState::NotConfigured),
   1326                 Some(
   1327                     MycError::CustodyManagedAccountNotConfigured {
   1328                         role: self.role.clone(),
   1329                         path: account_store_path.clone(),
   1330                     }
   1331                     .to_string(),
   1332                 ),
   1333             ),
   1334             Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => (
   1335                 false,
   1336                 Some(MycManagedAccountSelectionState::PublicOnly),
   1337                 Some(
   1338                     MycError::CustodyManagedAccountPublicOnly {
   1339                         role: self.role.clone(),
   1340                         path: account_store_path.clone(),
   1341                         service_name: service_name.clone(),
   1342                         account_id: account.account_id.to_string(),
   1343                     }
   1344                     .to_string(),
   1345                 ),
   1346             ),
   1347             Ok(RadrootsNostrAccountStatus::Ready { .. }) => match identity_result {
   1348                 Ok(_) => (true, Some(MycManagedAccountSelectionState::Ready), None),
   1349                 Err(error) => (
   1350                     false,
   1351                     Some(MycManagedAccountSelectionState::Ready),
   1352                     Some(error.to_string()),
   1353                 ),
   1354             },
   1355             Err(error) => (false, None, Some(error.to_string())),
   1356         };
   1357 
   1358         MycIdentityStatusOutput {
   1359             backend: self.source.backend,
   1360             path: Some(account_store_path.clone()),
   1361             keyring_account_id: None,
   1362             keyring_service_name: Some(service_name.clone()),
   1363             profile_path: None,
   1364             inherited_from: None,
   1365             resolved,
   1366             selected_account_id,
   1367             selected_account_label,
   1368             selected_account_state,
   1369             default_shared_secret_backend: MycConfig::default_shared_secret_backend(),
   1370             allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(),
   1371             runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(),
   1372             host_vault_policy: MycConfig::host_vault_policy(),
   1373             identity_id,
   1374             public_key_hex,
   1375             error,
   1376         }
   1377     }
   1378 
   1379     fn managed_accounts_output(&self) -> Result<MycManagedAccountsOutput, MycError> {
   1380         let MycIdentityProviderBackend::ManagedAccount {
   1381             account_store_path,
   1382             service_name,
   1383             manager,
   1384         } = &self.backend
   1385         else {
   1386             return Err(MycError::InvalidOperation(format!(
   1387                 "{} identity backend `{}` does not support managed account lifecycle commands",
   1388                 self.role,
   1389                 self.source.backend.as_str(),
   1390             )));
   1391         };
   1392 
   1393         let accounts = manager
   1394             .list_accounts()
   1395             .map_err(|source| MycError::CustodyManager {
   1396                 role: self.role.clone(),
   1397                 source,
   1398             })?;
   1399         let selected_account_id = manager
   1400             .default_account_id()
   1401             .map_err(|source| MycError::CustodyManager {
   1402                 role: self.role.clone(),
   1403                 source,
   1404             })?
   1405             .map(|value| value.to_string());
   1406         let selected_account_state =
   1407             match manager
   1408                 .default_account_status()
   1409                 .map_err(|source| MycError::CustodyManager {
   1410                     role: self.role.clone(),
   1411                     source,
   1412                 })? {
   1413                 RadrootsNostrAccountStatus::NotConfigured => {
   1414                     MycManagedAccountSelectionState::NotConfigured
   1415                 }
   1416                 RadrootsNostrAccountStatus::PublicOnly { .. } => {
   1417                     MycManagedAccountSelectionState::PublicOnly
   1418                 }
   1419                 RadrootsNostrAccountStatus::Ready { .. } => MycManagedAccountSelectionState::Ready,
   1420             };
   1421 
   1422         Ok(MycManagedAccountsOutput {
   1423             role: self.role.clone(),
   1424             backend: self.source.backend,
   1425             account_store_path: account_store_path.clone(),
   1426             keyring_service_name: service_name.clone(),
   1427             selected_account_id,
   1428             selected_account_state,
   1429             accounts,
   1430         })
   1431     }
   1432 
   1433     fn managed_accounts_manager(&self) -> Result<&RadrootsNostrAccountsManager, MycError> {
   1434         match &self.backend {
   1435             MycIdentityProviderBackend::ManagedAccount { manager, .. } => Ok(manager),
   1436             _ => Err(MycError::InvalidOperation(format!(
   1437                 "{} identity backend `{}` does not support managed account lifecycle commands",
   1438                 self.role,
   1439                 self.source.backend.as_str(),
   1440             ))),
   1441         }
   1442     }
   1443 
   1444     fn vault_provider(
   1445         role: &str,
   1446         source: &MycIdentitySourceSpec,
   1447         account_id: RadrootsIdentityId,
   1448         service_name: String,
   1449     ) -> Result<MycIdentityProviderBackend, MycError> {
   1450         if service_name.trim().is_empty() {
   1451             return Err(MycError::InvalidConfig(format!(
   1452                 "{role} identity host_vault backend requires a non-empty keyring_service_name"
   1453             )));
   1454         }
   1455         Ok(MycIdentityProviderBackend::HostVault {
   1456             account_id,
   1457             service_name: service_name.clone(),
   1458             profile_path: source.profile_path.clone(),
   1459             vault: Arc::new(RadrootsSecretVaultOsKeyring::new(service_name)),
   1460         })
   1461     }
   1462 
   1463     fn managed_account_provider(
   1464         role: &str,
   1465         account_store_path: PathBuf,
   1466         service_name: String,
   1467     ) -> Result<MycIdentityProviderBackend, MycError> {
   1468         if account_store_path.as_os_str().is_empty() {
   1469             return Err(MycError::InvalidConfig(format!(
   1470                 "{role} identity managed_account backend requires a non-empty path"
   1471             )));
   1472         }
   1473         if service_name.trim().is_empty() {
   1474             return Err(MycError::InvalidConfig(format!(
   1475                 "{role} identity managed_account backend requires a non-empty keyring_service_name"
   1476             )));
   1477         }
   1478         if let Some(parent) = account_store_path.parent()
   1479             && !parent.as_os_str().is_empty()
   1480         {
   1481             fs::create_dir_all(parent).map_err(|source| MycError::CreateDir {
   1482                 path: parent.to_path_buf(),
   1483                 source,
   1484             })?;
   1485         }
   1486         let manager = RadrootsNostrAccountsManager::new_file_backed_with_vault(
   1487             account_store_path.as_path(),
   1488             RadrootsSecretVaultOsKeyring::new(service_name.clone()),
   1489         )
   1490         .map_err(|source| MycError::CustodyManager {
   1491             role: role.to_owned(),
   1492             source,
   1493         })?;
   1494         Ok(MycIdentityProviderBackend::ManagedAccount {
   1495             account_store_path,
   1496             service_name,
   1497             manager,
   1498         })
   1499     }
   1500 }
   1501 
   1502 impl MycActiveIdentity {
   1503     pub fn new(identity: RadrootsIdentity) -> Self {
   1504         let public_identity = identity.to_public();
   1505         let public_key = identity.public_key();
   1506         Self::from_operations(
   1507             public_identity,
   1508             public_key,
   1509             Arc::new(MycLoadedIdentityOperations::new(identity)),
   1510         )
   1511     }
   1512 
   1513     fn from_operations(
   1514         public_identity: RadrootsIdentityPublic,
   1515         public_key: RadrootsNostrPublicKey,
   1516         operations: Arc<dyn MycIdentityOperations>,
   1517     ) -> Self {
   1518         Self {
   1519             public_identity,
   1520             public_key,
   1521             operations,
   1522         }
   1523     }
   1524 
   1525     pub fn id(&self) -> RadrootsIdentityId {
   1526         self.public_identity.id.clone()
   1527     }
   1528 
   1529     pub fn public_key(&self) -> RadrootsNostrPublicKey {
   1530         self.public_key
   1531     }
   1532 
   1533     pub fn public_key_hex(&self) -> String {
   1534         self.public_identity.public_key_hex.clone()
   1535     }
   1536 
   1537     pub fn to_public(&self) -> RadrootsIdentityPublic {
   1538         self.public_identity.clone()
   1539     }
   1540 
   1541     pub fn public_identity(&self) -> &RadrootsIdentityPublic {
   1542         &self.public_identity
   1543     }
   1544 
   1545     pub fn nostr_client(&self) -> RadrootsNostrClient {
   1546         self.operations.nostr_client()
   1547     }
   1548 
   1549     pub fn nostr_client_owned(&self) -> RadrootsNostrClient {
   1550         self.operations.nostr_client_owned()
   1551     }
   1552 
   1553     pub fn sign_event_builder(
   1554         &self,
   1555         builder: RadrootsNostrEventBuilder,
   1556         operation: &str,
   1557     ) -> Result<RadrootsNostrEvent, MycError> {
   1558         self.operations.sign_event_builder(builder, operation)
   1559     }
   1560 
   1561     pub fn sign_unsigned_event(
   1562         &self,
   1563         unsigned_event: nostr::UnsignedEvent,
   1564         operation: &str,
   1565     ) -> Result<nostr::Event, MycError> {
   1566         self.operations
   1567             .sign_unsigned_event(unsigned_event, operation)
   1568     }
   1569 
   1570     pub fn nip04_encrypt(
   1571         &self,
   1572         public_key: &RadrootsNostrPublicKey,
   1573         plaintext: impl Into<String>,
   1574     ) -> Result<String, MycError> {
   1575         self.operations.nip04_encrypt(public_key, plaintext.into())
   1576     }
   1577 
   1578     pub fn nip04_decrypt(
   1579         &self,
   1580         public_key: &RadrootsNostrPublicKey,
   1581         ciphertext: impl AsRef<str>,
   1582     ) -> Result<String, MycError> {
   1583         self.operations
   1584             .nip04_decrypt(public_key, ciphertext.as_ref())
   1585     }
   1586 
   1587     pub fn nip44_encrypt(
   1588         &self,
   1589         public_key: &RadrootsNostrPublicKey,
   1590         plaintext: impl Into<String>,
   1591     ) -> Result<String, MycError> {
   1592         self.operations.nip44_encrypt(public_key, plaintext.into())
   1593     }
   1594 
   1595     pub fn nip44_decrypt(
   1596         &self,
   1597         public_key: &RadrootsNostrPublicKey,
   1598         ciphertext: impl AsRef<str>,
   1599     ) -> Result<String, MycError> {
   1600         self.operations
   1601             .nip44_decrypt(public_key, ciphertext.as_ref())
   1602     }
   1603 }
   1604 
   1605 fn load_identity_from_nip49_file(
   1606     path: &std::path::Path,
   1607     password: &str,
   1608 ) -> Result<RadrootsIdentity, MycError> {
   1609     let encoded = fs::read_to_string(path).map_err(|source| MycError::PersistenceIo {
   1610         path: path.to_path_buf(),
   1611         source,
   1612     })?;
   1613     let payload = encoded.trim();
   1614     if payload.is_empty() {
   1615         return Err(MycError::InvalidOperation(format!(
   1616             "NIP-49 payload at {} was empty",
   1617             path.display()
   1618         )));
   1619     }
   1620     RadrootsIdentity::from_encrypted_secret_key_str(payload, password).map_err(MycError::from)
   1621 }
   1622 
   1623 fn validate_external_command_public_identity(
   1624     role: &str,
   1625     command_path: &PathBuf,
   1626     identity: RadrootsIdentityPublic,
   1627 ) -> Result<(RadrootsIdentityPublic, RadrootsNostrPublicKey), MycError> {
   1628     let public_key =
   1629         RadrootsNostrPublicKey::parse(identity.public_key_hex.as_str()).map_err(|error| {
   1630             MycError::CustodyExternalCommandInvalidIdentity {
   1631                 role: role.to_owned(),
   1632                 path: command_path.clone(),
   1633                 message: format!(
   1634                     "invalid public_key_hex `{}`: {error}",
   1635                     identity.public_key_hex
   1636                 ),
   1637             }
   1638         })?;
   1639     let expected_id = RadrootsIdentityId::from(public_key);
   1640     if identity.id != expected_id {
   1641         return Err(MycError::CustodyExternalCommandInvalidIdentity {
   1642             role: role.to_owned(),
   1643             path: command_path.clone(),
   1644             message: format!(
   1645                 "identity id `{}` does not match public_key_hex `{}`",
   1646                 identity.id, identity.public_key_hex
   1647             ),
   1648         });
   1649     }
   1650     Ok((identity, public_key))
   1651 }
   1652 
   1653 impl MycIdentityStatusOutput {
   1654     pub fn with_inherited_from(mut self, inherited_from: impl Into<String>) -> Self {
   1655         self.inherited_from = Some(inherited_from.into());
   1656         self
   1657     }
   1658 }
   1659 
   1660 #[cfg(test)]
   1661 mod tests {
   1662     use std::fs;
   1663     #[cfg(unix)]
   1664     use std::os::unix::fs::PermissionsExt;
   1665     use std::path::{Path, PathBuf};
   1666     use std::process::Command;
   1667     use std::sync::Mutex;
   1668     use std::time::Instant;
   1669 
   1670     use radroots_identity::RadrootsIdentity;
   1671     use radroots_nostr_accounts::prelude::{
   1672         RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore,
   1673         RadrootsNostrSecretVaultMemory,
   1674     };
   1675     use radroots_secret_vault::RadrootsSecretVault;
   1676 
   1677     use super::*;
   1678 
   1679     fn write_identity(path: &Path, secret_key: &str) {
   1680         let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
   1681         crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
   1682     }
   1683 
   1684     fn fixture_source(path: &Path) -> MycIdentitySourceSpec {
   1685         MycIdentitySourceSpec {
   1686             backend: MycIdentityBackend::EncryptedFile,
   1687             path: Some(path.to_path_buf()),
   1688             keyring_account_id: None,
   1689             keyring_service_name: None,
   1690             profile_path: None,
   1691         }
   1692     }
   1693 
   1694     #[cfg(unix)]
   1695     fn shell_single_quote(value: &str) -> String {
   1696         format!("'{}'", value.replace('\'', "'\"'\"'"))
   1697     }
   1698 
   1699     #[cfg(unix)]
   1700     fn write_timeout_helper(path: &Path, pid_path: &Path) {
   1701         let script = format!(
   1702             "#!/bin/sh\nprintf '%s\\n' \"$$\" > {}\nwhile :; do\n  :\ndone\n",
   1703             shell_single_quote(&pid_path.display().to_string())
   1704         );
   1705         fs::write(path, script).expect("write helper");
   1706         let mut permissions = fs::metadata(path).expect("helper metadata").permissions();
   1707         permissions.set_mode(0o755);
   1708         fs::set_permissions(path, permissions).expect("helper permissions");
   1709     }
   1710 
   1711     #[cfg(unix)]
   1712     fn process_exists(pid: u32) -> bool {
   1713         Command::new("kill")
   1714             .arg("-0")
   1715             .arg(pid.to_string())
   1716             .stdout(Stdio::null())
   1717             .stderr(Stdio::null())
   1718             .status()
   1719             .expect("kill probe")
   1720             .success()
   1721     }
   1722 
   1723     #[derive(Debug)]
   1724     struct FakeExternalCommandExecutor {
   1725         identity: RadrootsIdentity,
   1726         requests: Mutex<Vec<MycExternalCommandRequest>>,
   1727     }
   1728 
   1729     impl FakeExternalCommandExecutor {
   1730         fn new(secret_key: &str) -> Arc<Self> {
   1731             Arc::new(Self {
   1732                 identity: RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"),
   1733                 requests: Mutex::new(Vec::new()),
   1734             })
   1735         }
   1736     }
   1737 
   1738     impl MycExternalCommandExecutor for FakeExternalCommandExecutor {
   1739         fn execute(
   1740             &self,
   1741             _command_path: &PathBuf,
   1742             request_json: &[u8],
   1743             _timeout: Duration,
   1744         ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
   1745             let request: MycExternalCommandRequest =
   1746                 serde_json::from_slice(request_json).expect("request");
   1747             self.requests
   1748                 .lock()
   1749                 .expect("requests lock")
   1750                 .push(request.clone());
   1751             let response = match request.operation {
   1752                 MycExternalCommandOperation::Describe => MycExternalCommandResponse {
   1753                     identity: Some(self.identity.to_public()),
   1754                     event: None,
   1755                     content: None,
   1756                     error: None,
   1757                 },
   1758                 MycExternalCommandOperation::SignEvent => {
   1759                     let unsigned_event = request.unsigned_event.expect("unsigned event");
   1760                     let event = unsigned_event
   1761                         .sign_with_keys(self.identity.keys())
   1762                         .expect("sign event");
   1763                     MycExternalCommandResponse {
   1764                         identity: None,
   1765                         event: Some(event),
   1766                         content: None,
   1767                         error: None,
   1768                     }
   1769                 }
   1770                 MycExternalCommandOperation::Nip04Encrypt => {
   1771                     let public_key = RadrootsNostrPublicKey::parse(
   1772                         request.public_key_hex.as_deref().expect("public key hex"),
   1773                     )
   1774                     .expect("public key");
   1775                     let ciphertext = nip04::encrypt(
   1776                         self.identity.keys().secret_key(),
   1777                         &public_key,
   1778                         request.content.expect("plaintext"),
   1779                     )
   1780                     .expect("encrypt");
   1781                     MycExternalCommandResponse {
   1782                         identity: None,
   1783                         event: None,
   1784                         content: Some(ciphertext),
   1785                         error: None,
   1786                     }
   1787                 }
   1788                 MycExternalCommandOperation::Nip04Decrypt => {
   1789                     let public_key = RadrootsNostrPublicKey::parse(
   1790                         request.public_key_hex.as_deref().expect("public key hex"),
   1791                     )
   1792                     .expect("public key");
   1793                     let plaintext = nip04::decrypt(
   1794                         self.identity.keys().secret_key(),
   1795                         &public_key,
   1796                         request.content.as_deref().expect("ciphertext"),
   1797                     )
   1798                     .expect("decrypt");
   1799                     MycExternalCommandResponse {
   1800                         identity: None,
   1801                         event: None,
   1802                         content: Some(plaintext),
   1803                         error: None,
   1804                     }
   1805                 }
   1806                 MycExternalCommandOperation::Nip44Encrypt => {
   1807                     let public_key = RadrootsNostrPublicKey::parse(
   1808                         request.public_key_hex.as_deref().expect("public key hex"),
   1809                     )
   1810                     .expect("public key");
   1811                     let ciphertext = nip44::encrypt(
   1812                         self.identity.keys().secret_key(),
   1813                         &public_key,
   1814                         request.content.expect("plaintext"),
   1815                         Version::V2,
   1816                     )
   1817                     .expect("encrypt");
   1818                     MycExternalCommandResponse {
   1819                         identity: None,
   1820                         event: None,
   1821                         content: Some(ciphertext),
   1822                         error: None,
   1823                     }
   1824                 }
   1825                 MycExternalCommandOperation::Nip44Decrypt => {
   1826                     let public_key = RadrootsNostrPublicKey::parse(
   1827                         request.public_key_hex.as_deref().expect("public key hex"),
   1828                     )
   1829                     .expect("public key");
   1830                     let plaintext = nip44::decrypt(
   1831                         self.identity.keys().secret_key(),
   1832                         &public_key,
   1833                         request.content.as_deref().expect("ciphertext"),
   1834                     )
   1835                     .expect("decrypt");
   1836                     MycExternalCommandResponse {
   1837                         identity: None,
   1838                         event: None,
   1839                         content: Some(plaintext),
   1840                         error: None,
   1841                     }
   1842                 }
   1843             };
   1844 
   1845             Ok(MycExternalCommandOutput {
   1846                 success: true,
   1847                 status: Some(0),
   1848                 stdout: serde_json::to_vec(&response).expect("response"),
   1849                 stderr: Vec::new(),
   1850             })
   1851         }
   1852     }
   1853 
   1854     fn managed_account_provider(
   1855         role: &str,
   1856         service_name: &str,
   1857     ) -> (MycIdentityProvider, Arc<RadrootsNostrSecretVaultMemory>) {
   1858         let vault = Arc::new(RadrootsNostrSecretVaultMemory::new());
   1859         let manager = RadrootsNostrAccountsManager::new(
   1860             Arc::new(RadrootsNostrMemoryAccountStore::new()),
   1861             vault.clone() as Arc<dyn RadrootsSecretVault>,
   1862         )
   1863         .expect("manager");
   1864         (
   1865             MycIdentityProvider {
   1866                 role: role.to_owned(),
   1867                 source: MycIdentitySourceSpec {
   1868                     backend: MycIdentityBackend::ManagedAccount,
   1869                     path: Some(PathBuf::from(format!("/tmp/{role}-accounts.json"))),
   1870                     keyring_account_id: None,
   1871                     keyring_service_name: Some(service_name.to_owned()),
   1872                     profile_path: None,
   1873                 },
   1874                 backend: MycIdentityProviderBackend::ManagedAccount {
   1875                     account_store_path: PathBuf::from(format!("/tmp/{role}-accounts.json")),
   1876                     service_name: service_name.to_owned(),
   1877                     manager,
   1878                 },
   1879             },
   1880             vault,
   1881         )
   1882     }
   1883 
   1884     fn external_command_provider(
   1885         role: &str,
   1886         secret_key: &str,
   1887     ) -> (MycIdentityProvider, Arc<FakeExternalCommandExecutor>) {
   1888         let executor = FakeExternalCommandExecutor::new(secret_key);
   1889         let command_path = PathBuf::from(format!("/tmp/{role}-identity-helper"));
   1890         (
   1891             MycIdentityProvider {
   1892                 role: role.to_owned(),
   1893                 source: MycIdentitySourceSpec {
   1894                     backend: MycIdentityBackend::ExternalCommand,
   1895                     path: Some(command_path.clone()),
   1896                     keyring_account_id: None,
   1897                     keyring_service_name: None,
   1898                     profile_path: None,
   1899                 },
   1900                 backend: MycIdentityProviderBackend::ExternalCommand {
   1901                     command_path,
   1902                     timeout: Duration::from_secs(10),
   1903                     executor: executor.clone(),
   1904                 },
   1905             },
   1906             executor,
   1907         )
   1908     }
   1909 
   1910     #[test]
   1911     fn encrypted_file_provider_loads_identity() {
   1912         let temp = tempfile::tempdir().expect("tempdir");
   1913         let path = temp.path().join("signer.json");
   1914         write_identity(
   1915             &path,
   1916             "1111111111111111111111111111111111111111111111111111111111111111",
   1917         );
   1918 
   1919         let provider = MycIdentityProvider::from_source(
   1920             "signer",
   1921             fixture_source(&path),
   1922             Duration::from_secs(10),
   1923         )
   1924         .expect("provider");
   1925         let identity = provider.load_identity().expect("identity");
   1926 
   1927         assert_eq!(
   1928             identity.public_key_hex(),
   1929             "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
   1930         );
   1931     }
   1932 
   1933     #[test]
   1934     fn vault_provider_loads_identity_and_merges_profile() {
   1935         let temp = tempfile::tempdir().expect("tempdir");
   1936         let profile_path = temp.path().join("profile.json");
   1937         let identity = RadrootsIdentity::from_secret_key_str(
   1938             "1111111111111111111111111111111111111111111111111111111111111111",
   1939         )
   1940         .expect("identity");
   1941         crate::identity_files::store_identity_profile(&profile_path, &identity)
   1942             .expect("save profile");
   1943 
   1944         let account_id = identity.id();
   1945         let vault = Arc::new(RadrootsNostrSecretVaultMemory::new());
   1946         vault
   1947             .store_secret(account_id.as_str(), identity.secret_key_hex().as_str())
   1948             .expect("store");
   1949 
   1950         let provider = MycIdentityProvider {
   1951             role: "signer".to_owned(),
   1952             source: MycIdentitySourceSpec {
   1953                 backend: MycIdentityBackend::HostVault,
   1954                 path: None,
   1955                 keyring_account_id: Some(account_id.to_string()),
   1956                 keyring_service_name: Some("org.radroots.test".to_owned()),
   1957                 profile_path: Some(profile_path.clone()),
   1958             },
   1959             backend: MycIdentityProviderBackend::HostVault {
   1960                 account_id: account_id.clone(),
   1961                 service_name: "org.radroots.test".to_owned(),
   1962                 profile_path: Some(profile_path),
   1963                 vault,
   1964             },
   1965         };
   1966 
   1967         let loaded = provider.load_identity().expect("loaded");
   1968         assert_eq!(loaded.id(), account_id);
   1969         assert!(provider.probe_status().resolved);
   1970     }
   1971 
   1972     #[test]
   1973     fn vault_provider_reports_missing_secret() {
   1974         let account_id = RadrootsIdentity::from_secret_key_str(
   1975             "3333333333333333333333333333333333333333333333333333333333333333",
   1976         )
   1977         .expect("identity")
   1978         .id();
   1979         let provider = MycIdentityProvider {
   1980             role: "user".to_owned(),
   1981             source: MycIdentitySourceSpec {
   1982                 backend: MycIdentityBackend::HostVault,
   1983                 path: None,
   1984                 keyring_account_id: Some(account_id.to_string()),
   1985                 keyring_service_name: Some("org.radroots.test".to_owned()),
   1986                 profile_path: None,
   1987             },
   1988             backend: MycIdentityProviderBackend::HostVault {
   1989                 account_id: account_id.clone(),
   1990                 service_name: "org.radroots.test".to_owned(),
   1991                 profile_path: None,
   1992                 vault: Arc::new(RadrootsNostrSecretVaultMemory::new()),
   1993             },
   1994         };
   1995 
   1996         let err = provider.load_identity().expect_err("missing secret");
   1997         assert!(matches!(err, MycError::CustodySecretNotFound { .. }));
   1998         assert!(!provider.probe_status().resolved);
   1999     }
   2000 
   2001     #[test]
   2002     fn managed_account_provider_loads_selected_identity() {
   2003         let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer");
   2004         let generated = provider
   2005             .generate_managed_account(Some("primary".to_owned()), true)
   2006             .expect("generate");
   2007 
   2008         let identity = provider.load_identity().expect("identity");
   2009         let identity_id = identity.id().to_string();
   2010         assert_eq!(
   2011             generated.state.selected_account_id.as_deref(),
   2012             Some(identity_id.as_str())
   2013         );
   2014         let status = provider.probe_status();
   2015         assert!(status.resolved);
   2016         assert_eq!(
   2017             status.selected_account_state,
   2018             Some(MycManagedAccountSelectionState::Ready)
   2019         );
   2020     }
   2021 
   2022     #[test]
   2023     fn managed_account_provider_supports_nip49_export_and_import() {
   2024         let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer");
   2025         let generated = provider
   2026             .generate_managed_account(Some("primary".to_owned()), true)
   2027             .expect("generate");
   2028         let selected_account_id = generated
   2029             .state
   2030             .selected_account_id
   2031             .clone()
   2032             .expect("selected account id");
   2033         let temp = tempfile::tempdir().expect("tempdir");
   2034         let export_path = temp.path().join("managed-account.ncryptsec");
   2035 
   2036         let export = provider
   2037             .export_nip49(&export_path, "test password")
   2038             .expect("export nip49");
   2039         assert_eq!(export.format, "nip49");
   2040         assert_eq!(export.identity_id, selected_account_id);
   2041 
   2042         provider
   2043             .remove_managed_account(selected_account_id.as_str())
   2044             .expect("remove account");
   2045         let removed_status = provider.probe_status();
   2046         assert!(!removed_status.resolved);
   2047 
   2048         let imported = provider
   2049             .import_nip49(&export_path, "test password", Some("restored".to_owned()))
   2050             .expect("import nip49");
   2051         assert_eq!(imported.account_id, export.identity_id);
   2052         assert!(imported.status.resolved);
   2053         assert_eq!(
   2054             imported.status.selected_account_label.as_deref(),
   2055             Some("restored")
   2056         );
   2057     }
   2058 
   2059     #[test]
   2060     fn managed_account_provider_reports_not_configured() {
   2061         let (provider, _vault) = managed_account_provider("user", "org.radroots.test.user");
   2062 
   2063         let err = provider
   2064             .load_identity()
   2065             .expect_err("missing selected account");
   2066         assert!(matches!(
   2067             err,
   2068             MycError::CustodyManagedAccountNotConfigured { .. }
   2069         ));
   2070         let status = provider.probe_status();
   2071         assert!(!status.resolved);
   2072         assert_eq!(
   2073             status.selected_account_state,
   2074             Some(MycManagedAccountSelectionState::NotConfigured)
   2075         );
   2076     }
   2077 
   2078     #[test]
   2079     fn managed_account_provider_reports_public_only_selected_account() {
   2080         let (provider, vault) = managed_account_provider("user", "org.radroots.test.user");
   2081         let identity = RadrootsIdentity::from_secret_key_str(
   2082             "3333333333333333333333333333333333333333333333333333333333333333",
   2083         )
   2084         .expect("identity");
   2085         let temp = tempfile::tempdir().expect("tempdir");
   2086         let path = temp.path().join("legacy.json");
   2087         identity.save_json(&path).expect("save");
   2088         let record = provider
   2089             .import_managed_account_file(&path, Some("legacy".to_owned()), true)
   2090             .expect("import");
   2091         let selected_account_id = record
   2092             .state
   2093             .selected_account_id
   2094             .clone()
   2095             .expect("selected account");
   2096         vault
   2097             .remove_secret(
   2098                 RadrootsIdentityId::parse(selected_account_id.as_str())
   2099                     .expect("account id")
   2100                     .as_str(),
   2101             )
   2102             .expect("remove secret");
   2103 
   2104         let err = provider.load_identity().expect_err("public only");
   2105         assert!(matches!(
   2106             err,
   2107             MycError::CustodyManagedAccountPublicOnly { .. }
   2108         ));
   2109         let status = provider.probe_status();
   2110         assert!(!status.resolved);
   2111         assert_eq!(
   2112             status.selected_account_state,
   2113             Some(MycManagedAccountSelectionState::PublicOnly)
   2114         );
   2115     }
   2116 
   2117     #[test]
   2118     fn external_command_provider_loads_identity_and_executes_signing_operations() {
   2119         let (provider, executor) = external_command_provider(
   2120             "signer",
   2121             "1111111111111111111111111111111111111111111111111111111111111111",
   2122         );
   2123         let active = provider.load_active_identity().expect("active identity");
   2124         let expected_identity = RadrootsIdentity::from_secret_key_str(
   2125             "1111111111111111111111111111111111111111111111111111111111111111",
   2126         )
   2127         .expect("identity");
   2128         assert_eq!(active.id(), expected_identity.id());
   2129         assert_eq!(active.public_key_hex(), expected_identity.public_key_hex());
   2130 
   2131         let peer_identity = RadrootsIdentity::from_secret_key_str(
   2132             "2222222222222222222222222222222222222222222222222222222222222222",
   2133         )
   2134         .expect("peer identity");
   2135         let signed_event = active
   2136             .sign_event_builder(
   2137                 RadrootsNostrEventBuilder::text_note("hello from external command"),
   2138                 "test event",
   2139             )
   2140             .expect("signed event");
   2141         assert_eq!(signed_event.pubkey, expected_identity.public_key());
   2142 
   2143         let nip04_ciphertext = active
   2144             .nip04_encrypt(&peer_identity.public_key(), "hello nip04")
   2145             .expect("nip04 encrypt");
   2146         assert_eq!(
   2147             nip04::decrypt(
   2148                 peer_identity.keys().secret_key(),
   2149                 &expected_identity.public_key(),
   2150                 &nip04_ciphertext,
   2151             )
   2152             .expect("decrypt with peer"),
   2153             "hello nip04"
   2154         );
   2155 
   2156         let nip44_ciphertext = active
   2157             .nip44_encrypt(&peer_identity.public_key(), "hello nip44")
   2158             .expect("nip44 encrypt");
   2159         assert_eq!(
   2160             nip44::decrypt(
   2161                 peer_identity.keys().secret_key(),
   2162                 &expected_identity.public_key(),
   2163                 &nip44_ciphertext,
   2164             )
   2165             .expect("decrypt with peer"),
   2166             "hello nip44"
   2167         );
   2168 
   2169         let status = provider.probe_status();
   2170         assert!(status.resolved);
   2171         assert_eq!(
   2172             status.path,
   2173             Some(PathBuf::from("/tmp/signer-identity-helper"))
   2174         );
   2175         assert_eq!(status.identity_id, Some(expected_identity.id().to_string()));
   2176 
   2177         let operations = executor
   2178             .requests
   2179             .lock()
   2180             .expect("requests lock")
   2181             .iter()
   2182             .map(|request| request.operation)
   2183             .collect::<Vec<_>>();
   2184         assert!(operations.contains(&MycExternalCommandOperation::Describe));
   2185         assert!(operations.contains(&MycExternalCommandOperation::SignEvent));
   2186         assert!(operations.contains(&MycExternalCommandOperation::Nip04Encrypt));
   2187         assert!(operations.contains(&MycExternalCommandOperation::Nip44Encrypt));
   2188     }
   2189 
   2190     #[tokio::test]
   2191     async fn external_command_provider_uses_signerless_relay_client() {
   2192         let (provider, _executor) = external_command_provider(
   2193             "signer",
   2194             "1111111111111111111111111111111111111111111111111111111111111111",
   2195         );
   2196         let active = provider.load_active_identity().expect("active identity");
   2197 
   2198         assert!(!active.nostr_client().has_signer().await);
   2199         assert!(!active.nostr_client_owned().has_signer().await);
   2200     }
   2201 
   2202     #[derive(Debug, Default)]
   2203     struct TimeoutExternalCommandExecutor;
   2204 
   2205     impl MycExternalCommandExecutor for TimeoutExternalCommandExecutor {
   2206         fn execute(
   2207             &self,
   2208             _command_path: &PathBuf,
   2209             _request_json: &[u8],
   2210             _timeout: Duration,
   2211         ) -> Result<MycExternalCommandOutput, MycExternalCommandExecuteError> {
   2212             Err(MycExternalCommandExecuteError::TimedOut)
   2213         }
   2214     }
   2215 
   2216     #[test]
   2217     fn external_command_provider_maps_describe_timeout() {
   2218         let provider = MycIdentityProvider {
   2219             role: "signer".to_owned(),
   2220             source: MycIdentitySourceSpec {
   2221                 backend: MycIdentityBackend::ExternalCommand,
   2222                 path: Some(PathBuf::from("/tmp/signer-helper")),
   2223                 keyring_account_id: None,
   2224                 keyring_service_name: None,
   2225                 profile_path: None,
   2226             },
   2227             backend: MycIdentityProviderBackend::ExternalCommand {
   2228                 command_path: PathBuf::from("/tmp/signer-helper"),
   2229                 timeout: Duration::from_secs(7),
   2230                 executor: Arc::new(TimeoutExternalCommandExecutor),
   2231             },
   2232         };
   2233 
   2234         let err = provider.load_active_identity().err().expect("timeout");
   2235         assert!(matches!(
   2236             err,
   2237             MycError::CustodyExternalCommandTimedOut {
   2238                 ref role,
   2239                 ref path,
   2240                 timeout_secs: 7,
   2241             } if role == "signer" && path == &PathBuf::from("/tmp/signer-helper")
   2242         ));
   2243     }
   2244 
   2245     #[test]
   2246     fn external_command_provider_maps_operation_timeout() {
   2247         let identity = RadrootsIdentity::from_secret_key_str(
   2248             "1111111111111111111111111111111111111111111111111111111111111111",
   2249         )
   2250         .expect("identity");
   2251         let public_identity = identity.to_public();
   2252         let public_key = identity.public_key();
   2253         let active = MycActiveIdentity::from_operations(
   2254             public_identity.clone(),
   2255             public_key,
   2256             Arc::new(MycExternalCommandIdentityOperations::new(
   2257                 "signer".to_owned(),
   2258                 PathBuf::from("/tmp/signer-helper"),
   2259                 Duration::from_secs(11),
   2260                 public_identity,
   2261                 public_key,
   2262                 Arc::new(TimeoutExternalCommandExecutor),
   2263             )),
   2264         );
   2265 
   2266         let err = active
   2267             .sign_event_builder(
   2268                 RadrootsNostrEventBuilder::text_note("timeout"),
   2269                 "timeout event",
   2270             )
   2271             .expect_err("timeout");
   2272         assert!(matches!(
   2273             err,
   2274             MycError::CustodyExternalCommandTimedOut {
   2275                 ref role,
   2276                 ref path,
   2277                 timeout_secs: 11,
   2278             } if role == "signer" && path == &PathBuf::from("/tmp/signer-helper")
   2279         ));
   2280     }
   2281 
   2282     #[cfg(unix)]
   2283     #[test]
   2284     fn process_executor_times_out_and_kills_real_helper() {
   2285         let timeout = Duration::from_secs(2);
   2286         let temp = tempfile::tempdir().expect("tempdir");
   2287         let helper_path = temp.path().join("timeout-helper.sh");
   2288         let pid_path = temp.path().join("timeout-helper.pid");
   2289         write_timeout_helper(&helper_path, &pid_path);
   2290 
   2291         let helper_path_for_thread = helper_path.clone();
   2292         let handle = std::thread::spawn(move || {
   2293             let executor = MycProcessCommandExecutor;
   2294             let started_at = Instant::now();
   2295             let err = executor
   2296                 .execute(
   2297                     &helper_path_for_thread,
   2298                     b"{\"operation\":\"describe\"}",
   2299                     timeout,
   2300                 )
   2301                 .expect_err("timeout");
   2302             (started_at.elapsed(), err)
   2303         });
   2304 
   2305         // Give the real helper a little slack to create its pid file under a busy full-test run
   2306         // before we conclude the timeout path never launched it.
   2307         let pid_deadline = Instant::now() + timeout + Duration::from_secs(5);
   2308         let pid = loop {
   2309             match fs::read_to_string(&pid_path) {
   2310                 Ok(value) => break value.trim().parse::<u32>().expect("pid"),
   2311                 Err(error)
   2312                     if error.kind() == std::io::ErrorKind::NotFound
   2313                         && Instant::now() < pid_deadline =>
   2314                 {
   2315                     std::thread::sleep(Duration::from_millis(10));
   2316                 }
   2317                 Err(error) => panic!("helper pid: {error}"),
   2318             }
   2319         };
   2320 
   2321         let (elapsed, err) = handle.join().expect("executor thread");
   2322 
   2323         assert!(matches!(err, MycExternalCommandExecuteError::TimedOut));
   2324         assert!(
   2325             elapsed < timeout + Duration::from_secs(2),
   2326             "timeout path should stay bounded"
   2327         );
   2328         assert!(!process_exists(pid), "helper process should be terminated");
   2329     }
   2330 }