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 }