account.rs (30080B)
1 use std::{fmt, path::Path, sync::Arc}; 2 3 use radroots_identity::{ 4 IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile, 5 }; 6 use radroots_nostr_accounts::prelude::{ 7 RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, 8 RadrootsNostrAccountsManager, 9 }; 10 use radroots_protected_store::RadrootsProtectedFileSecretVault; 11 use radroots_secret_vault::{ 12 RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend, 13 RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, RadrootsSecretVault, 14 RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring, 15 }; 16 17 use crate::runtime::RuntimeError; 18 use crate::runtime::config::RuntimeConfig; 19 use crate::view::runtime::{AccountResolutionView, AccountSummaryView}; 20 21 const HOST_VAULT_AVAILABILITY_OVERRIDE_ENV: &str = "RADROOTS_CLI_ACCOUNT_HOST_VAULT_AVAILABLE"; 22 const HOST_VAULT_SERVICE_NAME: &str = "org.radroots.cli.local-account"; 23 const HOST_VAULT_PROBE_SLOT: &str = "__radroots_cli_host_vault_probe__"; 24 pub const SHARED_ACCOUNT_STORE_SOURCE: &str = "shared account store ยท local first"; 25 26 #[derive(Debug, Clone, PartialEq, Eq)] 27 pub enum AccountRuntimeFailure { 28 Unresolved(AccountRuntimeFailureIssue), 29 WatchOnly(AccountRuntimeFailureIssue), 30 Mismatch(AccountRuntimeFailureIssue), 31 } 32 33 #[derive(Debug, Clone, PartialEq, Eq)] 34 pub struct AccountRuntimeFailureIssue { 35 message: String, 36 detail_json: Option<String>, 37 } 38 39 impl AccountRuntimeFailureIssue { 40 fn new(message: impl Into<String>) -> Self { 41 Self { 42 message: message.into(), 43 detail_json: None, 44 } 45 } 46 47 fn with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { 48 Self { 49 message: message.into(), 50 detail_json: Some(detail.to_string()), 51 } 52 } 53 54 pub fn message(&self) -> &str { 55 self.message.as_str() 56 } 57 } 58 59 impl AccountRuntimeFailure { 60 pub fn unresolved(message: impl Into<String>) -> Self { 61 Self::Unresolved(AccountRuntimeFailureIssue::new(message)) 62 } 63 64 pub fn unresolved_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { 65 Self::Unresolved(AccountRuntimeFailureIssue::with_detail(message, detail)) 66 } 67 68 pub fn watch_only(account_id: &radroots_identity::RadrootsIdentityId) -> Self { 69 Self::WatchOnly(AccountRuntimeFailureIssue::new(format!( 70 "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" 71 ))) 72 } 73 74 pub fn watch_only_with_detail( 75 account_id: impl fmt::Display, 76 detail: serde_json::Value, 77 ) -> Self { 78 Self::WatchOnly(AccountRuntimeFailureIssue::with_detail( 79 format!( 80 "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" 81 ), 82 detail, 83 )) 84 } 85 86 pub fn mismatch(message: impl Into<String>) -> Self { 87 Self::Mismatch(AccountRuntimeFailureIssue::new(message)) 88 } 89 90 pub fn mismatch_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { 91 Self::Mismatch(AccountRuntimeFailureIssue::with_detail(message, detail)) 92 } 93 94 pub fn message(&self) -> &str { 95 match self { 96 Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => { 97 issue.message.as_str() 98 } 99 } 100 } 101 102 pub fn detail_json(&self) -> Option<&str> { 103 match self { 104 Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => { 105 issue.detail_json.as_deref() 106 } 107 } 108 } 109 } 110 111 impl fmt::Display for AccountRuntimeFailure { 112 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 113 formatter.write_str(self.message()) 114 } 115 } 116 117 impl std::error::Error for AccountRuntimeFailure {} 118 119 #[derive(Debug, Clone)] 120 pub struct AccountSnapshot { 121 pub accounts: Vec<AccountRecordView>, 122 } 123 124 #[derive(Debug, Clone)] 125 pub struct AccountRecordView { 126 pub record: RadrootsNostrAccountRecord, 127 pub is_default: bool, 128 pub custody: AccountCustody, 129 pub write_capable: bool, 130 } 131 132 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 133 pub enum AccountCustody { 134 SecretBacked, 135 WatchOnly, 136 } 137 138 impl AccountCustody { 139 pub fn as_str(self) -> &'static str { 140 match self { 141 Self::SecretBacked => "secret_backed", 142 Self::WatchOnly => "watch_only", 143 } 144 } 145 146 pub fn signer_label(self) -> &'static str { 147 match self { 148 Self::SecretBacked => "local", 149 Self::WatchOnly => "watch_only", 150 } 151 } 152 } 153 154 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 155 struct AccountRuntimeFacts { 156 custody: AccountCustody, 157 write_capable: bool, 158 } 159 160 #[derive(Debug, Clone)] 161 pub struct AccountSecretBackendStatus { 162 pub state: String, 163 pub active_backend: Option<String>, 164 pub used_fallback: bool, 165 pub reason: Option<String>, 166 } 167 168 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 169 pub enum AccountCreateMode { 170 Created, 171 Migrated, 172 } 173 174 #[derive(Debug, Clone)] 175 pub struct AccountCreateResult { 176 pub mode: AccountCreateMode, 177 pub account: AccountRecordView, 178 } 179 180 #[derive(Debug, Clone)] 181 pub struct AccountClearDefaultResult { 182 pub cleared_account: Option<AccountRecordView>, 183 pub remaining_account_count: usize, 184 } 185 186 #[derive(Debug, Clone)] 187 pub struct AccountRemoveResult { 188 pub removed_account: AccountRecordView, 189 pub default_cleared: bool, 190 pub remaining_account_count: usize, 191 } 192 193 #[derive(Debug, Clone)] 194 pub struct AccountRemovePreview { 195 pub account: AccountRecordView, 196 pub default_would_clear: bool, 197 pub remaining_account_count: usize, 198 } 199 200 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 201 pub enum AccountResolutionSource { 202 InvocationOverride, 203 DefaultAccount, 204 None, 205 } 206 207 impl AccountResolutionSource { 208 pub fn as_str(self) -> &'static str { 209 match self { 210 Self::InvocationOverride => "invocation_override", 211 Self::DefaultAccount => "default_account", 212 Self::None => "none", 213 } 214 } 215 } 216 217 #[derive(Debug, Clone)] 218 pub struct AccountResolution { 219 pub source: AccountResolutionSource, 220 pub resolved_account: Option<AccountRecordView>, 221 pub default_account: Option<AccountRecordView>, 222 } 223 224 #[derive(Debug, Clone)] 225 pub struct AccountSigningIdentity { 226 pub account: AccountRecordView, 227 pub identity: RadrootsIdentity, 228 } 229 230 pub fn create_or_migrate_default_account( 231 config: &RuntimeConfig, 232 ) -> Result<AccountCreateResult, RuntimeError> { 233 let manager = account_manager(config)?; 234 let existing = manager.list_accounts()?; 235 let (mode, created_account_id) = if existing.is_empty() && config.identity.path.exists() { 236 ( 237 AccountCreateMode::Migrated, 238 manager.migrate_legacy_identity_file(&config.identity.path, None, false)?, 239 ) 240 } else { 241 ( 242 AccountCreateMode::Created, 243 manager.generate_identity(None, false)?, 244 ) 245 }; 246 247 let snapshot = snapshot(config)?; 248 let account = snapshot_account( 249 &snapshot, 250 &created_account_id, 251 "created account missing after account create", 252 )?; 253 254 Ok(AccountCreateResult { mode, account }) 255 } 256 257 pub fn import_public_identity( 258 config: &RuntimeConfig, 259 path: &Path, 260 make_default: bool, 261 ) -> Result<AccountRecordView, RuntimeError> { 262 let manager = account_manager(config)?; 263 let public_identity = load_public_identity_for_import(path)?; 264 let imported_account_id = 265 manager.upsert_public_identity(public_identity, None, make_default)?; 266 let snapshot = snapshot_from_manager(&manager)?; 267 snapshot_account( 268 &snapshot, 269 &imported_account_id, 270 "imported account missing after account import", 271 ) 272 } 273 274 pub fn preview_public_identity_import( 275 config: &RuntimeConfig, 276 path: &Path, 277 make_default: bool, 278 ) -> Result<AccountRecordView, RuntimeError> { 279 let public_identity = load_public_identity_for_import(path)?; 280 let manager = account_manager(config)?; 281 let snapshot = snapshot_from_manager(&manager)?; 282 if let Some(existing) = snapshot 283 .accounts 284 .iter() 285 .find(|account| account.record.account_id == public_identity.id) 286 .cloned() 287 { 288 let mut account = existing; 289 if make_default { 290 account.is_default = true; 291 } 292 return Ok(account); 293 } 294 295 Ok(AccountRecordView { 296 record: RadrootsNostrAccountRecord::new(public_identity, None, 0), 297 is_default: make_default, 298 custody: AccountCustody::WatchOnly, 299 write_capable: false, 300 }) 301 } 302 303 pub fn preview_identity_secret_attachment( 304 config: &RuntimeConfig, 305 selector: &str, 306 path: &Path, 307 make_default: bool, 308 ) -> Result<AccountRecordView, RuntimeError> { 309 let manager = account_manager(config)?; 310 let snapshot = snapshot_from_manager(&manager)?; 311 let mut account = resolve_selector_account(&manager, &snapshot, selector)?; 312 let identity = load_secret_identity_for_attachment(path)?; 313 validate_identity_secret_matches_account(&account.record, &identity)?; 314 if make_default { 315 account.is_default = true; 316 } 317 account.custody = AccountCustody::SecretBacked; 318 account.write_capable = true; 319 Ok(account) 320 } 321 322 pub fn attach_identity_secret( 323 config: &RuntimeConfig, 324 selector: &str, 325 path: &Path, 326 make_default: bool, 327 ) -> Result<AccountRecordView, RuntimeError> { 328 let manager = account_manager(config)?; 329 let snapshot = snapshot_from_manager(&manager)?; 330 let account = resolve_selector_account(&manager, &snapshot, selector)?; 331 let identity = load_secret_identity_for_attachment(path)?; 332 validate_identity_secret_matches_account(&account.record, &identity)?; 333 let attached = 334 manager.attach_identity_secret(&account.record.account_id, &identity, make_default)?; 335 let snapshot = snapshot_from_manager(&manager)?; 336 snapshot_account( 337 &snapshot, 338 &attached.account_id, 339 "attached account missing after account attach-secret", 340 ) 341 } 342 343 pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> { 344 let manager = account_manager(config)?; 345 snapshot_from_manager(&manager) 346 } 347 348 pub fn resolve_account(config: &RuntimeConfig) -> Result<Option<AccountRecordView>, RuntimeError> { 349 Ok(resolve_account_resolution(config)?.resolved_account) 350 } 351 352 pub fn resolve_account_resolution( 353 config: &RuntimeConfig, 354 ) -> Result<AccountResolution, RuntimeError> { 355 let manager = account_manager(config)?; 356 let snapshot = snapshot_from_manager(&manager)?; 357 let default_account = snapshot 358 .accounts 359 .iter() 360 .find(|account| account.is_default) 361 .cloned(); 362 if let Some(selector) = config.account.selector.as_deref() { 363 let account = resolve_selector_account(&manager, &snapshot, selector)?; 364 return Ok(AccountResolution { 365 source: AccountResolutionSource::InvocationOverride, 366 resolved_account: Some(account), 367 default_account, 368 }); 369 } 370 371 Ok(AccountResolution { 372 source: if default_account.is_some() { 373 AccountResolutionSource::DefaultAccount 374 } else { 375 AccountResolutionSource::None 376 }, 377 resolved_account: default_account.clone(), 378 default_account, 379 }) 380 } 381 382 pub fn select_account( 383 config: &RuntimeConfig, 384 selector: &str, 385 ) -> Result<AccountRecordView, RuntimeError> { 386 let manager = account_manager(config)?; 387 let snapshot = snapshot_from_manager(&manager)?; 388 let account = resolve_selector_account(&manager, &snapshot, selector)?; 389 390 manager.set_default_account(&account.record.account_id)?; 391 let snapshot = snapshot_from_manager(&manager)?; 392 snapshot 393 .accounts 394 .into_iter() 395 .find(|candidate| candidate.record.account_id == account.record.account_id) 396 .ok_or_else(|| { 397 RuntimeError::Accounts( 398 radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( 399 "default account missing after account use".to_owned(), 400 ), 401 ) 402 }) 403 } 404 405 pub fn resolve_account_selector( 406 config: &RuntimeConfig, 407 selector: &str, 408 ) -> Result<AccountRecordView, RuntimeError> { 409 let manager = account_manager(config)?; 410 let snapshot = snapshot_from_manager(&manager)?; 411 resolve_selector_account(&manager, &snapshot, selector) 412 } 413 414 pub fn clear_default_account( 415 config: &RuntimeConfig, 416 ) -> Result<AccountClearDefaultResult, RuntimeError> { 417 let manager = account_manager(config)?; 418 let snapshot = snapshot_from_manager(&manager)?; 419 let cleared_account = snapshot 420 .accounts 421 .iter() 422 .find(|account| account.is_default) 423 .cloned(); 424 manager.clear_default_account()?; 425 let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); 426 Ok(AccountClearDefaultResult { 427 cleared_account, 428 remaining_account_count, 429 }) 430 } 431 432 pub fn remove_account( 433 config: &RuntimeConfig, 434 selector: &str, 435 ) -> Result<AccountRemoveResult, RuntimeError> { 436 let manager = account_manager(config)?; 437 let snapshot = snapshot_from_manager(&manager)?; 438 let removed_account = resolve_selector_account(&manager, &snapshot, selector)?; 439 let default_cleared = removed_account.is_default; 440 manager.remove_account(&removed_account.record.account_id)?; 441 let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); 442 Ok(AccountRemoveResult { 443 removed_account, 444 default_cleared, 445 remaining_account_count, 446 }) 447 } 448 449 pub fn preview_account_removal( 450 config: &RuntimeConfig, 451 selector: &str, 452 ) -> Result<AccountRemovePreview, RuntimeError> { 453 let manager = account_manager(config)?; 454 let snapshot = snapshot_from_manager(&manager)?; 455 let account = resolve_selector_account(&manager, &snapshot, selector)?; 456 Ok(AccountRemovePreview { 457 default_would_clear: account.is_default, 458 remaining_account_count: snapshot.accounts.len().saturating_sub(1), 459 account, 460 }) 461 } 462 463 pub fn resolved_account_signing_status( 464 config: &RuntimeConfig, 465 ) -> Result<RadrootsNostrAccountStatus, RuntimeError> { 466 let manager = account_manager(config)?; 467 let resolution = resolve_account_resolution(config)?; 468 let Some(account) = resolution.resolved_account else { 469 return Ok(RadrootsNostrAccountStatus::NotConfigured); 470 }; 471 472 Ok( 473 match manager.get_signing_identity(&account.record.account_id)? { 474 Some(_) => RadrootsNostrAccountStatus::Ready { 475 account: account.record.clone(), 476 }, 477 None => RadrootsNostrAccountStatus::PublicOnly { 478 account: account.record.clone(), 479 }, 480 }, 481 ) 482 } 483 484 pub fn resolve_local_signing_identity( 485 config: &RuntimeConfig, 486 ) -> Result<AccountSigningIdentity, RuntimeError> { 487 let manager = account_manager(config)?; 488 let resolution = resolve_account_resolution(config)?; 489 let Some(account) = resolution.resolved_account else { 490 return Err(AccountRuntimeFailure::unresolved(unresolved_account_reason(config)?).into()); 491 }; 492 let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { 493 return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into()); 494 }; 495 Ok(AccountSigningIdentity { account, identity }) 496 } 497 498 pub fn resolve_local_signing_identity_for_account( 499 config: &RuntimeConfig, 500 account_id: &str, 501 ) -> Result<AccountSigningIdentity, RuntimeError> { 502 let manager = account_manager(config)?; 503 let snapshot = snapshot_from_manager(&manager)?; 504 let Some(account) = snapshot 505 .accounts 506 .iter() 507 .find(|account| account.record.account_id.as_str() == account_id) 508 .cloned() 509 else { 510 return Err(AccountRuntimeFailure::unresolved(format!( 511 "farm-bound seller account `{account_id}` is not present in the local account store" 512 )) 513 .into()); 514 }; 515 let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { 516 return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into()); 517 }; 518 Ok(AccountSigningIdentity { account, identity }) 519 } 520 521 pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { 522 AccountSummaryView::from_account_runtime( 523 &account.record, 524 account.custody.signer_label(), 525 account.custody.as_str(), 526 account.write_capable, 527 account.is_default, 528 ) 529 } 530 531 pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView { 532 AccountResolutionView { 533 status: if resolution.resolved_account.is_some() { 534 "resolved" 535 } else { 536 "unresolved" 537 } 538 .to_owned(), 539 source: resolution.source.as_str().to_owned(), 540 resolved_account: resolution 541 .resolved_account 542 .as_ref() 543 .map(account_summary_view), 544 default_account: resolution 545 .default_account 546 .as_ref() 547 .map(account_summary_view), 548 } 549 } 550 551 pub fn empty_account_resolution_view() -> AccountResolutionView { 552 AccountResolutionView { 553 status: "unresolved".to_owned(), 554 source: AccountResolutionSource::None.as_str().to_owned(), 555 resolved_account: None, 556 default_account: None, 557 } 558 } 559 560 pub fn unresolved_account_reason(config: &RuntimeConfig) -> Result<String, RuntimeError> { 561 let snapshot = snapshot(config)?; 562 Ok(if snapshot.accounts.is_empty() { 563 format!( 564 "no local accounts found in {}", 565 config.account.store_path.display() 566 ) 567 } else { 568 format!( 569 "accounts exist in {} but no default account is configured and no invocation override was provided", 570 config.account.store_path.display() 571 ) 572 }) 573 } 574 575 pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStatus { 576 match resolve_secret_backend(config) { 577 Ok(resolved) => AccountSecretBackendStatus { 578 state: "ready".to_owned(), 579 active_backend: Some(resolved.backend.kind().to_string()), 580 used_fallback: resolved.used_fallback, 581 reason: None, 582 }, 583 Err(SecretBackendResolutionError::Unavailable(reason)) => AccountSecretBackendStatus { 584 state: "unavailable".to_owned(), 585 active_backend: None, 586 used_fallback: false, 587 reason: Some(reason), 588 }, 589 Err(SecretBackendResolutionError::Invalid(reason)) => AccountSecretBackendStatus { 590 state: "error".to_owned(), 591 active_backend: None, 592 used_fallback: false, 593 reason: Some(reason), 594 }, 595 } 596 } 597 598 pub fn load_secret_backend_secret( 599 config: &RuntimeConfig, 600 slot: &str, 601 service_name: &str, 602 ) -> Result<Option<String>, RuntimeError> { 603 if slot.trim().is_empty() { 604 return Err(RuntimeError::Config( 605 "secret backend slot must not be empty".to_owned(), 606 )); 607 } 608 let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?; 609 let vault = secret_vault_for_backend(config, resolved.backend, service_name)?; 610 vault.load_secret(slot).map_err(|error| { 611 RuntimeError::Config(format!( 612 "failed to load secret `{slot}` from account secret backend `{}`: {error}", 613 resolved.backend.kind() 614 )) 615 }) 616 } 617 618 fn snapshot_from_manager( 619 manager: &RadrootsNostrAccountsManager, 620 ) -> Result<AccountSnapshot, RuntimeError> { 621 let default_account_id = manager.default_account_id()?.map(|id| id.to_string()); 622 let mut accounts = Vec::new(); 623 for record in manager.list_accounts()? { 624 let is_default = default_account_id 625 .as_deref() 626 .is_some_and(|default| default == record.account_id.as_str()); 627 let runtime = account_runtime_facts(manager, &record)?; 628 accounts.push(AccountRecordView { 629 record, 630 is_default, 631 custody: runtime.custody, 632 write_capable: runtime.write_capable, 633 }); 634 } 635 636 Ok(AccountSnapshot { accounts }) 637 } 638 639 fn snapshot_account( 640 snapshot: &AccountSnapshot, 641 account_id: &radroots_identity::RadrootsIdentityId, 642 missing_message: &str, 643 ) -> Result<AccountRecordView, RuntimeError> { 644 snapshot 645 .accounts 646 .iter() 647 .find(|account| account.record.account_id == *account_id) 648 .cloned() 649 .ok_or_else(|| { 650 RuntimeError::Accounts( 651 radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( 652 missing_message.to_owned(), 653 ), 654 ) 655 }) 656 } 657 658 fn resolve_selector_account( 659 manager: &RadrootsNostrAccountsManager, 660 snapshot: &AccountSnapshot, 661 selector: &str, 662 ) -> Result<AccountRecordView, RuntimeError> { 663 let record = manager 664 .resolve_account_selector(selector) 665 .map_err(|error| selector_runtime_error(selector, error))?; 666 snapshot 667 .accounts 668 .iter() 669 .find(|account| account.record.account_id == record.account_id) 670 .cloned() 671 .ok_or_else(|| { 672 RuntimeError::Accounts(RadrootsNostrAccountsError::InvalidState( 673 "resolved account missing from snapshot".to_owned(), 674 )) 675 }) 676 } 677 678 fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> RuntimeError { 679 let normalized = selector.trim(); 680 match error { 681 RadrootsNostrAccountsError::InvalidAccountSelector(reason) => RuntimeError::Config(reason), 682 RadrootsNostrAccountsError::AccountNotFound(_) => { 683 AccountRuntimeFailure::unresolved(format!( 684 "account selector `{normalized}` did not match any local account" 685 )) 686 .into() 687 } 688 RadrootsNostrAccountsError::AmbiguousAccountSelector(_) => { 689 AccountRuntimeFailure::unresolved(format!( 690 "account selector `{normalized}` matched multiple local accounts; use account id or npub" 691 )) 692 .into() 693 } 694 other => RuntimeError::Accounts(other), 695 } 696 } 697 698 fn account_runtime_facts( 699 manager: &RadrootsNostrAccountsManager, 700 record: &RadrootsNostrAccountRecord, 701 ) -> Result<AccountRuntimeFacts, RuntimeError> { 702 Ok( 703 if manager.get_signing_identity(&record.account_id)?.is_some() { 704 AccountRuntimeFacts { 705 custody: AccountCustody::SecretBacked, 706 write_capable: true, 707 } 708 } else { 709 AccountRuntimeFacts { 710 custody: AccountCustody::WatchOnly, 711 write_capable: false, 712 } 713 }, 714 ) 715 } 716 717 fn format_identity_error(error: IdentityError) -> String { 718 match error { 719 IdentityError::NotFound(path) => format!("path not found: {}", path.display()), 720 other => other.to_string(), 721 } 722 } 723 724 fn load_public_identity_for_import(path: &Path) -> Result<RadrootsIdentityPublic, RuntimeError> { 725 load_identity_profile(path).map_err(|error| { 726 RuntimeError::Config(format!( 727 "failed to import account from {}: {}", 728 path.display(), 729 format_identity_error(error) 730 )) 731 }) 732 } 733 734 fn load_secret_identity_for_attachment(path: &Path) -> Result<RadrootsIdentity, RuntimeError> { 735 RadrootsIdentity::load_from_path_auto(path).map_err(|error| { 736 RuntimeError::Config(format!( 737 "failed to import account secret from {}: {}", 738 path.display(), 739 format_identity_error(error) 740 )) 741 }) 742 } 743 744 fn validate_identity_secret_matches_account( 745 record: &RadrootsNostrAccountRecord, 746 identity: &RadrootsIdentity, 747 ) -> Result<(), RuntimeError> { 748 let secret_public_key_hex = identity.public_key_hex(); 749 if record 750 .public_identity 751 .public_key_hex 752 .eq_ignore_ascii_case(secret_public_key_hex.as_str()) 753 { 754 return Ok(()); 755 } 756 757 Err(AccountRuntimeFailure::mismatch(format!( 758 "account mismatch: resolved account `{}` public key `{}` does not match secret public key `{}`", 759 record.account_id, record.public_identity.public_key_hex, secret_public_key_hex 760 )) 761 .into()) 762 } 763 764 fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { 765 let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed( 766 config.account.store_path.as_path(), 767 config.account.secrets_dir.as_path(), 768 account_secret_backend_selection(config), 769 secret_backend_availability()?, 770 HOST_VAULT_SERVICE_NAME, 771 )?; 772 Ok(manager) 773 } 774 775 fn resolve_secret_backend( 776 config: &RuntimeConfig, 777 ) -> Result<RadrootsResolvedSecretBackend, SecretBackendResolutionError> { 778 let availability = secret_backend_availability().map_err(|error| { 779 SecretBackendResolutionError::Invalid(format!("account secret backend: {error}")) 780 })?; 781 RadrootsNostrAccountsManager::resolve_local_backend( 782 account_secret_backend_selection(config), 783 availability, 784 ) 785 .map_err(|error| match error { 786 RadrootsSecretVaultError::BackendUnavailable { .. } 787 | RadrootsSecretVaultError::FallbackUnavailable { .. } => { 788 SecretBackendResolutionError::Unavailable(format!("account secret backend: {error}")) 789 } 790 RadrootsSecretVaultError::FallbackDisallowed { .. } 791 | RadrootsSecretVaultError::HostVaultPolicyUnsupported { .. } => { 792 SecretBackendResolutionError::Invalid(format!("account secret backend: {error}")) 793 } 794 }) 795 } 796 797 fn secret_backend_resolution_error(error: SecretBackendResolutionError) -> RuntimeError { 798 match error { 799 SecretBackendResolutionError::Unavailable(reason) 800 | SecretBackendResolutionError::Invalid(reason) => RuntimeError::Config(reason), 801 } 802 } 803 804 fn secret_vault_for_backend( 805 config: &RuntimeConfig, 806 backend: RadrootsSecretBackend, 807 service_name: &str, 808 ) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> { 809 match backend { 810 RadrootsSecretBackend::HostVault(_) => { 811 Ok(Arc::new(RadrootsSecretVaultOsKeyring::new(service_name))) 812 } 813 RadrootsSecretBackend::EncryptedFile => Ok(Arc::new( 814 RadrootsProtectedFileSecretVault::new(config.account.secrets_dir.as_path()), 815 )), 816 RadrootsSecretBackend::ExternalCommand => Err(RuntimeError::Config( 817 "external_command account secret backend is not supported for CLI signer sessions" 818 .to_owned(), 819 )), 820 RadrootsSecretBackend::Memory => Err(RuntimeError::Config( 821 "memory account secret backend is not supported for persisted CLI signer sessions" 822 .to_owned(), 823 )), 824 } 825 } 826 827 fn account_secret_backend_selection(config: &RuntimeConfig) -> RadrootsSecretBackendSelection { 828 RadrootsSecretBackendSelection { 829 primary: config.account.secret_backend, 830 fallback: config.account.secret_fallback, 831 } 832 } 833 834 fn secret_backend_availability() -> Result<RadrootsSecretBackendAvailability, RuntimeError> { 835 Ok(RadrootsSecretBackendAvailability { 836 host_vault: host_vault_capabilities()?, 837 encrypted_file: true, 838 external_command: false, 839 memory: true, 840 }) 841 } 842 843 fn host_vault_capabilities() -> Result<RadrootsHostVaultCapabilities, RuntimeError> { 844 if let Some(available) = host_vault_availability_override()? { 845 return Ok(match available { 846 true => RadrootsHostVaultCapabilities::desktop_keyring(), 847 false => RadrootsHostVaultCapabilities::unavailable(), 848 }); 849 } 850 851 let keyring = RadrootsSecretVaultOsKeyring::new(HOST_VAULT_SERVICE_NAME); 852 match keyring.load_secret(HOST_VAULT_PROBE_SLOT) { 853 Ok(_) => Ok(RadrootsHostVaultCapabilities::desktop_keyring()), 854 Err(_) => Ok(RadrootsHostVaultCapabilities::unavailable()), 855 } 856 } 857 858 fn host_vault_availability_override() -> Result<Option<bool>, RuntimeError> { 859 let Ok(value) = std::env::var(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV) else { 860 return Ok(None); 861 }; 862 863 parse_bool_value(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV, value.trim()).map(Some) 864 } 865 866 fn parse_bool_value(key: &str, value: &str) -> Result<bool, RuntimeError> { 867 match value.trim().to_ascii_lowercase().as_str() { 868 "1" | "true" | "yes" | "on" => Ok(true), 869 "0" | "false" | "no" | "off" => Ok(false), 870 other => Err(RuntimeError::Config(format!( 871 "{key} must be a boolean value, got `{other}`" 872 ))), 873 } 874 } 875 876 #[derive(Debug, Clone)] 877 enum SecretBackendResolutionError { 878 Unavailable(String), 879 Invalid(String), 880 } 881 882 #[cfg(test)] 883 mod tests { 884 use radroots_protected_store::RadrootsProtectedFileSecretVault; 885 use radroots_secret_vault::RadrootsSecretVault; 886 use std::fs; 887 use tempfile::tempdir; 888 889 #[test] 890 fn protected_file_vault_round_trips_secret() { 891 let temp = tempdir().expect("tempdir"); 892 let vault = RadrootsProtectedFileSecretVault::new(temp.path()); 893 894 vault.store_secret("acct_demo", "deadbeef").expect("store"); 895 let loaded = vault.load_secret("acct_demo").expect("load"); 896 assert_eq!(loaded.as_deref(), Some("deadbeef")); 897 let raw = fs::read_to_string(temp.path().join("acct_demo.secret.json")).expect("raw file"); 898 assert!(!raw.contains("deadbeef")); 899 } 900 901 #[test] 902 fn protected_file_vault_removes_secret() { 903 let temp = tempdir().expect("tempdir"); 904 let vault = RadrootsProtectedFileSecretVault::new(temp.path()); 905 906 vault.store_secret("acct_demo", "deadbeef").expect("store"); 907 vault.remove_secret("acct_demo").expect("remove"); 908 assert!(vault.load_secret("acct_demo").expect("load").is_none()); 909 } 910 }