myc

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

commit 506e299aed7867d7fdebfbe5d73cf775cc50c865
parent 16f972d0b3e5443ae74775dbdf9fe899074b3646
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 00:57:56 +0000

custody: add managed account lifecycle

Diffstat:
M.env.example | 7+++++++
Msrc/cli.rs | 173++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/config.rs | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/custody.rs | 543++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/error.rs | 17+++++++++++++++++
Msrc/lib.rs | 5++++-
6 files changed, 851 insertions(+), 19 deletions(-)

diff --git a/.env.example b/.env.example @@ -5,11 +5,16 @@ MYC_LOGGING_STDOUT=true MYC_PATHS_STATE_DIR=/var/lib/myc MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem +# filesystem: identity file path +# managed_account: account store file path MYC_PATHS_SIGNER_IDENTITY_PATH=/etc/myc/identities/signer-identity.json MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID= +# os_keyring and managed_account both require a non-empty keyring service name MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.signer MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH= MYC_PATHS_USER_IDENTITY_BACKEND=filesystem +# filesystem: identity file path +# managed_account: account store file path MYC_PATHS_USER_IDENTITY_PATH=/etc/myc/identities/user-identity.json MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID= MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.user @@ -31,6 +36,8 @@ MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.radroots.org MYC_DISCOVERY_HANDLER_IDENTIFIER=myc MYC_DISCOVERY_APP_IDENTITY_BACKEND= +# filesystem: identity file path +# managed_account: account store file path MYC_DISCOVERY_APP_IDENTITY_PATH=/etc/myc/identities/app-identity.json MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID= MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.discovery diff --git a/src/cli.rs b/src/cli.rs @@ -51,6 +51,10 @@ pub enum MycCommand { #[command(subcommand)] command: MycPersistenceCommand, }, + Custody { + #[command(subcommand)] + command: MycCustodyCommand, + }, Connections { #[command(subcommand)] command: MycConnectionsCommand, @@ -92,6 +96,44 @@ pub enum MycPersistenceCommand { } #[derive(Debug, Subcommand)] +pub enum MycCustodyCommand { + List { + #[arg(long, value_enum)] + role: MycCustodyRole, + }, + Generate { + #[arg(long, value_enum)] + role: MycCustodyRole, + #[arg(long)] + label: Option<String>, + #[arg(long)] + select: bool, + }, + ImportFile { + #[arg(long, value_enum)] + role: MycCustodyRole, + #[arg(long)] + path: PathBuf, + #[arg(long)] + label: Option<String>, + #[arg(long)] + select: bool, + }, + Select { + #[arg(long, value_enum)] + role: MycCustodyRole, + #[arg(long)] + account_id: String, + }, + Remove { + #[arg(long, value_enum)] + role: MycCustodyRole, + #[arg(long)] + account_id: String, + }, +} + +#[derive(Debug, Subcommand)] pub enum MycAuditCommand { List { #[arg(long)] @@ -150,6 +192,13 @@ pub enum MycMetricsFormat { Prometheus, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum MycCustodyRole { + Signer, + User, + DiscoveryApp, +} + #[derive(Debug, Subcommand)] pub enum MycAuthCommand { Require { @@ -330,6 +379,33 @@ pub async fn run_from_env() -> Result<(), MycError> { print_json(&output) } }, + MycCommand::Custody { command } => { + let provider = custody_provider_for_command(&config, &command)?; + match command { + MycCustodyCommand::List { .. } => print_json(&provider.list_managed_accounts()?), + MycCustodyCommand::Generate { label, select, .. } => { + let output = provider.generate_managed_account(label, select)?; + print_json(&output) + } + MycCustodyCommand::ImportFile { + path, + label, + select, + .. + } => { + let output = provider.import_managed_account_file(path, label, select)?; + print_json(&output) + } + MycCustodyCommand::Select { account_id, .. } => { + let output = provider.select_managed_account(account_id.as_str())?; + print_json(&output) + } + MycCustodyCommand::Remove { account_id, .. } => { + let output = provider.remove_managed_account(account_id.as_str())?; + print_json(&output) + } + } + } MycCommand::Connections { command } => { let runtime = MycRuntime::bootstrap(config)?; match command { @@ -505,6 +581,45 @@ fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> { } } +fn custody_provider_for_command( + config: &MycConfig, + command: &MycCustodyCommand, +) -> Result<crate::custody::MycIdentityProvider, MycError> { + let role = match command { + MycCustodyCommand::List { role } + | MycCustodyCommand::Generate { role, .. } + | MycCustodyCommand::ImportFile { role, .. } + | MycCustodyCommand::Select { role, .. } + | MycCustodyCommand::Remove { role, .. } => *role, + }; + + custody_provider_for_role(config, role) +} + +fn custody_provider_for_role( + config: &MycConfig, + role: MycCustodyRole, +) -> Result<crate::custody::MycIdentityProvider, MycError> { + match role { + MycCustodyRole::Signer => crate::custody::MycIdentityProvider::from_source( + "signer", + config.paths.signer_identity_source(), + ), + MycCustodyRole::User => crate::custody::MycIdentityProvider::from_source( + "user", + config.paths.user_identity_source(), + ), + MycCustodyRole::DiscoveryApp => { + let Some(source) = config.discovery.app_identity_source() else { + return Err(MycError::InvalidOperation( + "discovery app identity is not separately configured; it currently reuses the signer identity".to_owned(), + )); + }; + crate::custody::MycIdentityProvider::from_source("discovery app", source) + } + } +} + fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> { Ok(RadrootsNostrSignerConnectionId::parse(value)?) } @@ -833,6 +948,7 @@ fn print_text(value: &str) { mod tests { use std::path::PathBuf; + use clap::Parser; use nostr::Timestamp; use radroots_identity::RadrootsIdentity; use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest; @@ -843,7 +959,8 @@ mod tests { use crate::config::MycConfig; use super::{ - MycAuditScope, granted_permissions_for_approval, load_audit_output, summarize_audit_output, + MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole, + granted_permissions_for_approval, load_audit_output, summarize_audit_output, }; use crate::app::MycRuntime; @@ -1166,4 +1283,58 @@ mod tests { 1 ); } + + #[test] + fn parses_custody_list_command() { + let cli = MycCli::try_parse_from(["myc", "custody", "list", "--role", "signer"]) + .expect("parse custody list"); + + assert!(matches!( + cli.command, + Some(MycCommand::Custody { + command: MycCustodyCommand::List { + role: MycCustodyRole::Signer + } + }) + )); + } + + #[test] + fn parses_custody_generate_and_import_commands() { + let generate = MycCli::try_parse_from([ + "myc", "custody", "generate", "--role", "user", "--label", "primary", "--select", + ]) + .expect("parse custody generate"); + assert!(matches!( + generate.command, + Some(MycCommand::Custody { + command: MycCustodyCommand::Generate { + role: MycCustodyRole::User, + select: true, + .. + } + }) + )); + + let import = MycCli::try_parse_from([ + "myc", + "custody", + "import-file", + "--role", + "discovery-app", + "--path", + "/tmp/discovery.json", + ]) + .expect("parse custody import"); + assert!(matches!( + import.command, + Some(MycCommand::Custody { + command: MycCustodyCommand::ImportFile { + role: MycCustodyRole::DiscoveryApp, + select: false, + .. + } + }) + )); + } } diff --git a/src/config.rs b/src/config.rs @@ -135,6 +135,7 @@ pub enum MycConnectionApproval { pub enum MycIdentityBackend { Filesystem, OsKeyring, + ManagedAccount, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -361,6 +362,7 @@ impl MycIdentityBackend { match self { Self::Filesystem => "filesystem", Self::OsKeyring => "os_keyring", + Self::ManagedAccount => "managed_account", } } } @@ -388,21 +390,23 @@ impl MycPathsConfig { MycIdentitySourceSpec { backend: self.signer_identity_backend, path: match self.signer_identity_backend { - MycIdentityBackend::Filesystem => Some(self.signer_identity_path.clone()), + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => { + Some(self.signer_identity_path.clone()) + } MycIdentityBackend::OsKeyring => None, }, keyring_account_id: match self.signer_identity_backend { - MycIdentityBackend::Filesystem => None, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None, MycIdentityBackend::OsKeyring => self.signer_identity_keyring_account_id.clone(), }, keyring_service_name: match self.signer_identity_backend { MycIdentityBackend::Filesystem => None, - MycIdentityBackend::OsKeyring => { + MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => { Some(self.signer_identity_keyring_service_name.clone()) } }, profile_path: match self.signer_identity_backend { - MycIdentityBackend::Filesystem => None, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None, MycIdentityBackend::OsKeyring => self.signer_identity_profile_path.clone(), }, } @@ -412,21 +416,23 @@ impl MycPathsConfig { MycIdentitySourceSpec { backend: self.user_identity_backend, path: match self.user_identity_backend { - MycIdentityBackend::Filesystem => Some(self.user_identity_path.clone()), + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => { + Some(self.user_identity_path.clone()) + } MycIdentityBackend::OsKeyring => None, }, keyring_account_id: match self.user_identity_backend { - MycIdentityBackend::Filesystem => None, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None, MycIdentityBackend::OsKeyring => self.user_identity_keyring_account_id.clone(), }, keyring_service_name: match self.user_identity_backend { MycIdentityBackend::Filesystem => None, - MycIdentityBackend::OsKeyring => { + MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => { Some(self.user_identity_keyring_service_name.clone()) } }, profile_path: match self.user_identity_backend { - MycIdentityBackend::Filesystem => None, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None, MycIdentityBackend::OsKeyring => self.user_identity_profile_path.clone(), }, } @@ -1308,10 +1314,11 @@ fn parse_identity_backend_env( match value { "filesystem" => Ok(MycIdentityBackend::Filesystem), "os_keyring" => Ok(MycIdentityBackend::OsKeyring), + "managed_account" => Ok(MycIdentityBackend::ManagedAccount), _ => Err(config_parse_error( path, line_number, - format!("{key} must be `filesystem` or `os_keyring`"), + format!("{key} must be `filesystem`, `os_keyring`, or `managed_account`"), )), } } @@ -1505,6 +1512,38 @@ fn validate_identity_source_config( ))); } } + MycIdentityBackend::ManagedAccount => { + let Some(path) = source.path.as_ref() else { + return Err(MycError::InvalidConfig(format!( + "{label}.path must be set when backend is `managed_account`" + ))); + }; + if path.as_os_str().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{label}.path must not be empty when backend is `managed_account`" + ))); + } + let Some(service_name) = source.keyring_service_name.as_deref() else { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_service_name must be set when backend is `managed_account`" + ))); + }; + if service_name.trim().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_service_name must not be empty when backend is `managed_account`" + ))); + } + if source.keyring_account_id.is_some() { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_account_id must not be set when backend is `managed_account`" + ))); + } + if source.profile_path.is_some() { + return Err(MycError::InvalidConfig(format!( + "{label}.profile_path must not be set when backend is `managed_account`" + ))); + } + } } Ok(()) @@ -1536,19 +1575,23 @@ impl MycDiscoveryConfig { Some(MycIdentitySourceSpec { backend, path: match backend { - MycIdentityBackend::Filesystem => self.app_identity_path.clone(), + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => { + self.app_identity_path.clone() + } MycIdentityBackend::OsKeyring => None, }, keyring_account_id: match backend { - MycIdentityBackend::Filesystem => None, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None, MycIdentityBackend::OsKeyring => self.app_identity_keyring_account_id.clone(), }, keyring_service_name: match backend { MycIdentityBackend::Filesystem => None, - MycIdentityBackend::OsKeyring => self.app_identity_keyring_service_name.clone(), + MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => { + self.app_identity_keyring_service_name.clone() + } }, profile_path: match backend { - MycIdentityBackend::Filesystem => None, + MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => None, MycIdentityBackend::OsKeyring => self.app_identity_profile_path.clone(), }, }) @@ -2187,6 +2230,62 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery } #[test] + fn parse_and_validate_managed_account_identity_backends() { + let config = MycConfig::from_env_str( + r#" +MYC_PATHS_SIGNER_IDENTITY_BACKEND=managed_account +MYC_PATHS_SIGNER_IDENTITY_PATH=/var/lib/myc/custody/signer-accounts.json +MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer +MYC_PATHS_USER_IDENTITY_BACKEND=managed_account +MYC_PATHS_USER_IDENTITY_PATH=/var/lib/myc/custody/user-accounts.json +MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.user +MYC_DISCOVERY_ENABLED=true +MYC_DISCOVERY_DOMAIN=myc.example.com +MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.example.com +MYC_DISCOVERY_APP_IDENTITY_BACKEND=managed_account +MYC_DISCOVERY_APP_IDENTITY_PATH=/var/lib/myc/custody/discovery-accounts.json +MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery + "#, + ) + .expect("config"); + + assert_eq!( + config.paths.signer_identity_backend, + MycIdentityBackend::ManagedAccount + ); + assert_eq!( + config.paths.signer_identity_source().path, + Some(PathBuf::from("/var/lib/myc/custody/signer-accounts.json")) + ); + assert_eq!( + config + .paths + .signer_identity_source() + .keyring_service_name + .as_deref(), + Some("org.radroots.myc.test.signer") + ); + assert_eq!( + config.paths.user_identity_backend, + MycIdentityBackend::ManagedAccount + ); + assert_eq!( + config.discovery.app_identity_backend, + Some(MycIdentityBackend::ManagedAccount) + ); + assert_eq!( + config + .discovery + .app_identity_source() + .expect("app identity source") + .path, + Some(PathBuf::from( + "/var/lib/myc/custody/discovery-accounts.json" + )) + ); + } + + #[test] fn example_env_parses_and_validates() { let example = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example")) diff --git a/src/custody.rs b/src/custody.rs @@ -1,3 +1,4 @@ +use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -8,7 +9,9 @@ use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrPublicKey, }; use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrSecretVault, RadrootsNostrSecretVaultOsKeyring, + RadrootsNostrSelectedAccountStatus, }; use serde::Serialize; @@ -21,6 +24,14 @@ pub struct MycActiveIdentity { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycManagedAccountSelectionState { + NotConfigured, + PublicOnly, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycIdentityStatusOutput { pub backend: MycIdentityBackend, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -35,6 +46,12 @@ pub struct MycIdentityStatusOutput { pub inherited_from: Option<String>, pub resolved: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_account_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_account_label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_account_state: Option<MycManagedAccountSelectionState>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub identity_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub public_key_hex: Option<String>, @@ -42,6 +59,27 @@ pub struct MycIdentityStatusOutput { pub error: Option<String>, } +#[derive(Debug, Clone, Serialize)] +pub struct MycManagedAccountsOutput { + pub role: String, + pub backend: MycIdentityBackend, + pub account_store_path: PathBuf, + pub keyring_service_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_account_id: Option<String>, + pub selected_account_state: MycManagedAccountSelectionState, + pub accounts: Vec<RadrootsNostrAccountRecord>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MycManagedAccountMutationOutput { + pub role: String, + pub action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option<String>, + pub state: MycManagedAccountsOutput, +} + #[derive(Clone)] pub struct MycIdentityProvider { role: String, @@ -60,6 +98,11 @@ enum MycIdentityProviderBackend { profile_path: Option<PathBuf>, vault: Arc<dyn RadrootsNostrSecretVault>, }, + ManagedAccount { + account_store_path: PathBuf, + service_name: String, + manager: RadrootsNostrAccountsManager, + }, } impl MycIdentityProvider { @@ -97,6 +140,19 @@ impl MycIdentityProvider { })?; Self::vault_provider(role.as_str(), &source, account_id, service_name)? } + MycIdentityBackend::ManagedAccount => { + let account_store_path = source.path.clone().ok_or_else(|| { + MycError::InvalidConfig(format!( + "{role} identity managed_account backend requires a path" + )) + })?; + let service_name = source.keyring_service_name.clone().ok_or_else(|| { + MycError::InvalidConfig(format!( + "{role} identity managed_account backend requires keyring_service_name" + )) + })?; + Self::managed_account_provider(role.as_str(), account_store_path, service_name)? + } }; Ok(Self { @@ -153,6 +209,41 @@ impl MycIdentityProvider { } Ok(identity) } + MycIdentityProviderBackend::ManagedAccount { + account_store_path, + service_name, + manager, + } => match manager.selected_account_status().map_err(|source| { + MycError::CustodyManager { + role: self.role.clone(), + source, + } + })? { + RadrootsNostrSelectedAccountStatus::NotConfigured => { + Err(MycError::CustodyManagedAccountNotConfigured { + role: self.role.clone(), + path: account_store_path.clone(), + }) + } + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + Err(MycError::CustodyManagedAccountPublicOnly { + role: self.role.clone(), + path: account_store_path.clone(), + service_name: service_name.clone(), + account_id: account.account_id.to_string(), + }) + } + RadrootsNostrSelectedAccountStatus::Ready { .. } => manager + .selected_signing_identity() + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })? + .ok_or_else(|| MycError::CustodyManagedAccountNotConfigured { + role: self.role.clone(), + path: account_store_path.clone(), + }), + }, } } @@ -161,17 +252,126 @@ impl MycIdentityProvider { } pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput { - self.status_with_result(Ok(identity.as_identity())) + match &self.backend { + MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status( + Ok(identity.as_identity()), + self.selected_managed_account_record_result(), + ), + _ => self.status_with_result(Ok(identity.as_identity())), + } } pub fn probe_status(&self) -> MycIdentityStatusOutput { - self.status_with_result(self.load_identity().as_ref()) + match &self.backend { + MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status( + self.load_identity().as_ref(), + self.selected_managed_account_record_result(), + ), + _ => self.status_with_result(self.load_identity().as_ref()), + } } pub fn source(&self) -> &MycIdentitySourceSpec { &self.source } + pub fn list_managed_accounts(&self) -> Result<MycManagedAccountsOutput, MycError> { + self.managed_accounts_output() + } + + pub fn generate_managed_account( + &self, + label: Option<String>, + make_selected: bool, + ) -> Result<MycManagedAccountMutationOutput, MycError> { + let account_id = { + let manager = self.managed_accounts_manager()?; + manager + .generate_identity(label, make_selected) + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })? + }; + Ok(MycManagedAccountMutationOutput { + role: self.role.clone(), + action: "generate".to_owned(), + account_id: Some(account_id.to_string()), + state: self.managed_accounts_output()?, + }) + } + + pub fn import_managed_account_file( + &self, + path: impl AsRef<std::path::Path>, + label: Option<String>, + make_selected: bool, + ) -> Result<MycManagedAccountMutationOutput, MycError> { + let account_id = { + let manager = self.managed_accounts_manager()?; + manager + .migrate_legacy_identity_file(path, label, make_selected) + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })? + }; + Ok(MycManagedAccountMutationOutput { + role: self.role.clone(), + action: "import_file".to_owned(), + account_id: Some(account_id.to_string()), + state: self.managed_accounts_output()?, + }) + } + + pub fn select_managed_account( + &self, + account_id: &str, + ) -> Result<MycManagedAccountMutationOutput, MycError> { + let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| { + MycError::InvalidOperation(format!("invalid managed account id `{account_id}`")) + })?; + { + let manager = self.managed_accounts_manager()?; + manager + .select_account(&account_id) + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })?; + } + Ok(MycManagedAccountMutationOutput { + role: self.role.clone(), + action: "select".to_owned(), + account_id: Some(account_id.to_string()), + state: self.managed_accounts_output()?, + }) + } + + pub fn remove_managed_account( + &self, + account_id: &str, + ) -> Result<MycManagedAccountMutationOutput, MycError> { + let account_id = RadrootsIdentityId::parse(account_id).map_err(|_| { + MycError::InvalidOperation(format!("invalid managed account id `{account_id}`")) + })?; + { + let manager = self.managed_accounts_manager()?; + manager + .remove_account(&account_id) + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })?; + } + Ok(MycManagedAccountMutationOutput { + role: self.role.clone(), + action: "remove".to_owned(), + account_id: Some(account_id.to_string()), + state: self.managed_accounts_output()?, + }) + } + fn status_with_result( &self, result: Result<&RadrootsIdentity, &MycError>, @@ -185,6 +385,9 @@ impl MycIdentityProvider { profile_path: self.source.profile_path.clone(), inherited_from: None, resolved: true, + selected_account_id: None, + selected_account_label: None, + selected_account_state: None, identity_id: Some(identity.id().to_string()), public_key_hex: Some(identity.public_key_hex()), error: None, @@ -197,6 +400,9 @@ impl MycIdentityProvider { profile_path: self.source.profile_path.clone(), inherited_from: None, resolved: false, + selected_account_id: None, + selected_account_label: None, + selected_account_state: None, identity_id: None, public_key_hex: None, error: Some(error.to_string()), @@ -204,6 +410,185 @@ impl MycIdentityProvider { } } + fn selected_managed_account_record_result( + &self, + ) -> Result<Option<RadrootsNostrAccountRecord>, MycError> { + let manager = self.managed_accounts_manager()?; + manager + .selected_account() + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + }) + } + + fn managed_account_status( + &self, + identity_result: Result<&RadrootsIdentity, &MycError>, + account_result: Result<Option<RadrootsNostrAccountRecord>, MycError>, + ) -> MycIdentityStatusOutput { + let MycIdentityProviderBackend::ManagedAccount { + account_store_path, + service_name, + manager, + } = &self.backend + else { + return self.status_with_result(identity_result); + }; + + let (selected_account_id, selected_account_label, identity_id, public_key_hex) = + match account_result { + Ok(Some(account)) => ( + Some(account.account_id.to_string()), + account.label.clone(), + Some(account.account_id.to_string()), + Some(account.public_identity.public_key_hex), + ), + Ok(None) => (None, None, None, None), + Err(error) => { + return MycIdentityStatusOutput { + backend: self.source.backend, + path: Some(account_store_path.clone()), + keyring_account_id: None, + keyring_service_name: Some(service_name.clone()), + profile_path: None, + inherited_from: None, + resolved: false, + selected_account_id: None, + selected_account_label: None, + selected_account_state: None, + identity_id: None, + public_key_hex: None, + error: Some(error.to_string()), + }; + } + }; + + let (resolved, selected_account_state, error) = match manager + .selected_account_status() + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + }) { + Ok(RadrootsNostrSelectedAccountStatus::NotConfigured) => ( + false, + Some(MycManagedAccountSelectionState::NotConfigured), + Some( + MycError::CustodyManagedAccountNotConfigured { + role: self.role.clone(), + path: account_store_path.clone(), + } + .to_string(), + ), + ), + Ok(RadrootsNostrSelectedAccountStatus::PublicOnly { account }) => ( + false, + Some(MycManagedAccountSelectionState::PublicOnly), + Some( + MycError::CustodyManagedAccountPublicOnly { + role: self.role.clone(), + path: account_store_path.clone(), + service_name: service_name.clone(), + account_id: account.account_id.to_string(), + } + .to_string(), + ), + ), + Ok(RadrootsNostrSelectedAccountStatus::Ready { .. }) => match identity_result { + Ok(_) => (true, Some(MycManagedAccountSelectionState::Ready), None), + Err(error) => ( + false, + Some(MycManagedAccountSelectionState::Ready), + Some(error.to_string()), + ), + }, + Err(error) => (false, None, Some(error.to_string())), + }; + + MycIdentityStatusOutput { + backend: self.source.backend, + path: Some(account_store_path.clone()), + keyring_account_id: None, + keyring_service_name: Some(service_name.clone()), + profile_path: None, + inherited_from: None, + resolved, + selected_account_id, + selected_account_label, + selected_account_state, + identity_id, + public_key_hex, + error, + } + } + + fn managed_accounts_output(&self) -> Result<MycManagedAccountsOutput, MycError> { + let MycIdentityProviderBackend::ManagedAccount { + account_store_path, + service_name, + manager, + } = &self.backend + else { + return Err(MycError::InvalidOperation(format!( + "{} identity backend `{}` does not support managed account lifecycle commands", + self.role, + self.source.backend.as_str(), + ))); + }; + + let accounts = manager + .list_accounts() + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })?; + let selected_account_id = manager + .selected_account_id() + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })? + .map(|value| value.to_string()); + let selected_account_state = + match manager + .selected_account_status() + .map_err(|source| MycError::CustodyManager { + role: self.role.clone(), + source, + })? { + RadrootsNostrSelectedAccountStatus::NotConfigured => { + MycManagedAccountSelectionState::NotConfigured + } + RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => { + MycManagedAccountSelectionState::PublicOnly + } + RadrootsNostrSelectedAccountStatus::Ready { .. } => { + MycManagedAccountSelectionState::Ready + } + }; + + Ok(MycManagedAccountsOutput { + role: self.role.clone(), + backend: self.source.backend, + account_store_path: account_store_path.clone(), + keyring_service_name: service_name.clone(), + selected_account_id, + selected_account_state, + accounts, + }) + } + + fn managed_accounts_manager(&self) -> Result<&RadrootsNostrAccountsManager, MycError> { + match &self.backend { + MycIdentityProviderBackend::ManagedAccount { manager, .. } => Ok(manager), + _ => Err(MycError::InvalidOperation(format!( + "{} identity backend `{}` does not support managed account lifecycle commands", + self.role, + self.source.backend.as_str(), + ))), + } + } + fn vault_provider( role: &str, source: &MycIdentitySourceSpec, @@ -222,6 +607,46 @@ impl MycIdentityProvider { vault: Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name)), }) } + + fn managed_account_provider( + role: &str, + account_store_path: PathBuf, + service_name: String, + ) -> Result<MycIdentityProviderBackend, MycError> { + if account_store_path.as_os_str().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{role} identity managed_account backend requires a non-empty path" + ))); + } + if service_name.trim().is_empty() { + return Err(MycError::InvalidConfig(format!( + "{role} identity managed_account backend requires a non-empty keyring_service_name" + ))); + } + if let Some(parent) = account_store_path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).map_err(|source| MycError::CreateDir { + path: parent.to_path_buf(), + source, + })?; + } + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrFileAccountStore::new( + account_store_path.as_path(), + )), + Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name.clone())), + ) + .map_err(|source| MycError::CustodyManager { + role: role.to_owned(), + source, + })?; + Ok(MycIdentityProviderBackend::ManagedAccount { + account_store_path, + service_name, + manager, + }) + } } impl MycActiveIdentity { @@ -350,10 +775,13 @@ impl MycIdentityStatusOutput { #[cfg(test)] mod tests { - use std::path::Path; + use std::path::{Path, PathBuf}; use radroots_identity::RadrootsIdentity; - use radroots_nostr_accounts::prelude::RadrootsNostrSecretVaultMemory; + use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVault, + RadrootsNostrSecretVaultMemory, + }; use super::*; @@ -374,6 +802,36 @@ mod tests { } } + fn managed_account_provider( + role: &str, + service_name: &str, + ) -> (MycIdentityProvider, Arc<RadrootsNostrSecretVaultMemory>) { + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + vault.clone() as Arc<dyn RadrootsNostrSecretVault>, + ) + .expect("manager"); + ( + MycIdentityProvider { + role: role.to_owned(), + source: MycIdentitySourceSpec { + backend: MycIdentityBackend::ManagedAccount, + path: Some(PathBuf::from(format!("/tmp/{role}-accounts.json"))), + keyring_account_id: None, + keyring_service_name: Some(service_name.to_owned()), + profile_path: None, + }, + backend: MycIdentityProviderBackend::ManagedAccount { + account_store_path: PathBuf::from(format!("/tmp/{role}-accounts.json")), + service_name: service_name.to_owned(), + manager, + }, + }, + vault, + ) + } + #[test] fn filesystem_provider_loads_identity() { let temp = tempfile::tempdir().expect("tempdir"); @@ -459,4 +917,81 @@ mod tests { assert!(matches!(err, MycError::CustodySecretNotFound { .. })); assert!(!provider.probe_status().resolved); } + + #[test] + fn managed_account_provider_loads_selected_identity() { + let (provider, _vault) = managed_account_provider("signer", "org.radroots.test.signer"); + let generated = provider + .generate_managed_account(Some("primary".to_owned()), true) + .expect("generate"); + + let identity = provider.load_identity().expect("identity"); + let identity_id = identity.id().to_string(); + assert_eq!( + generated.state.selected_account_id.as_deref(), + Some(identity_id.as_str()) + ); + let status = provider.probe_status(); + assert!(status.resolved); + assert_eq!( + status.selected_account_state, + Some(MycManagedAccountSelectionState::Ready) + ); + } + + #[test] + fn managed_account_provider_reports_not_configured() { + let (provider, _vault) = managed_account_provider("user", "org.radroots.test.user"); + + let err = provider + .load_identity() + .expect_err("missing selected account"); + assert!(matches!( + err, + MycError::CustodyManagedAccountNotConfigured { .. } + )); + let status = provider.probe_status(); + assert!(!status.resolved); + assert_eq!( + status.selected_account_state, + Some(MycManagedAccountSelectionState::NotConfigured) + ); + } + + #[test] + fn managed_account_provider_reports_public_only_selected_account() { + let (provider, vault) = managed_account_provider("user", "org.radroots.test.user"); + let identity = RadrootsIdentity::from_secret_key_str( + "3333333333333333333333333333333333333333333333333333333333333333", + ) + .expect("identity"); + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("legacy.json"); + identity.save_json(&path).expect("save"); + let record = provider + .import_managed_account_file(&path, Some("legacy".to_owned()), true) + .expect("import"); + let selected_account_id = record + .state + .selected_account_id + .clone() + .expect("selected account"); + vault + .remove_secret( + &RadrootsIdentityId::parse(selected_account_id.as_str()).expect("account id"), + ) + .expect("remove secret"); + + let err = provider.load_identity().expect_err("public only"); + assert!(matches!( + err, + MycError::CustodyManagedAccountPublicOnly { .. } + )); + let status = provider.probe_status(); + assert!(!status.resolved); + assert_eq!( + status.selected_account_state, + Some(MycManagedAccountSelectionState::PublicOnly) + ); + } } diff --git a/src/error.rs b/src/error.rs @@ -134,6 +134,12 @@ pub enum MycError { #[source] source: Box<MycError>, }, + #[error("custody manager error for {role} identity: {source}")] + CustodyManager { + role: String, + #[source] + source: RadrootsNostrAccountsError, + }, #[error("custody vault error for {role} identity: {source}")] CustodyVault { role: String, @@ -166,6 +172,17 @@ pub enum MycError { account_id: String, profile_identity_id: String, }, + #[error("no selected managed account configured for {role} identity store {path}")] + CustodyManagedAccountNotConfigured { role: String, path: PathBuf }, + #[error( + "selected managed account `{account_id}` in {path} for {role} identity has no secret in keyring service `{service_name}`" + )] + CustodyManagedAccountPublicOnly { + role: String, + path: PathBuf, + service_name: String, + account_id: String, + }, #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] diff --git a/src/lib.rs b/src/lib.rs @@ -31,7 +31,10 @@ pub use config::{ MycTransportDeliveryPolicy, }; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; -pub use custody::{MycActiveIdentity, MycIdentityProvider, MycIdentityStatusOutput}; +pub use custody::{ + MycActiveIdentity, MycIdentityProvider, MycIdentityStatusOutput, + MycManagedAccountMutationOutput, MycManagedAccountSelectionState, MycManagedAccountsOutput, +}; pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus,