myc

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

commit cbaae379a05ff9c9713e21e113412bba85807de7
parent 7a884f4560d54a23691d62f7d1d09b8e8e18d8ed
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 21:25:35 +0000

adopt canonical secret posture in myc

Diffstat:
M.env.example | 21++++++++++++---------
MCargo.lock | 26+++++++++++++++++++++++++-
MCargo.toml | 5+++++
Msrc/app/backend.rs | 6++----
Msrc/app/mod.rs | 18++++++++++--------
Msrc/app/runtime.rs | 29++++++++++++++++++-----------
Msrc/cli.rs | 6++----
Msrc/config.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/control.rs | 6++----
Msrc/custody.rs | 109+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/discovery.rs | 6++----
Asrc/identity_storage.rs | 271+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 1+
Msrc/operability/mod.rs | 7+++----
Msrc/persistence.rs | 93++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/transport/nip46.rs | 7+++----
Mtests/discovery_cli.rs | 20+++++++++-----------
Mtests/logging_run.rs | 6++----
Mtests/nip46_e2e.rs | 32+++++++++++++++-----------------
Mtests/operability_cli.rs | 8+++-----
Mtests/operability_e2e.rs | 6++----
Mtests/operability_server.rs | 6++----
Mtests/persistence_cli.rs | 47+++++++++++++++++++++++++++++++++++++++--------
23 files changed, 662 insertions(+), 254 deletions(-)

diff --git a/.env.example b/.env.example @@ -5,20 +5,22 @@ MYC_LOGGING_STDOUT=true MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=10 MYC_PATHS_STATE_DIR=/var/lib/myc -MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem -# filesystem: identity file path +MYC_PATHS_SIGNER_IDENTITY_BACKEND=encrypted_file +# encrypted_file and plaintext_file: identity file path +# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME # managed_account: account store file path # external_command: signer helper executable path -MYC_PATHS_SIGNER_IDENTITY_PATH=/etc/myc/identities/signer-identity.json +MYC_PATHS_SIGNER_IDENTITY_PATH=/var/lib/myc/identities/signer-identity.secret.json MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID= -# os_keyring and managed_account both require a non-empty keyring service name +# host_vault 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 +MYC_PATHS_USER_IDENTITY_BACKEND=encrypted_file +# encrypted_file and plaintext_file: identity file path +# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME # managed_account: account store file path # external_command: signer helper executable path -MYC_PATHS_USER_IDENTITY_PATH=/etc/myc/identities/user-identity.json +MYC_PATHS_USER_IDENTITY_PATH=/var/lib/myc/identities/user-identity.secret.json MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID= MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.user MYC_PATHS_USER_IDENTITY_PROFILE_PATH= @@ -39,10 +41,11 @@ MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.radroots.org MYC_DISCOVERY_HANDLER_IDENTIFIER=myc MYC_DISCOVERY_APP_IDENTITY_BACKEND= -# filesystem: identity file path +# encrypted_file and plaintext_file: identity file path +# host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME # managed_account: account store file path # external_command: signer helper executable path -MYC_DISCOVERY_APP_IDENTITY_PATH=/etc/myc/identities/app-identity.json +MYC_DISCOVERY_APP_IDENTITY_PATH=/var/lib/myc/identities/app-identity.secret.json MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID= MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.discovery MYC_DISCOVERY_APP_IDENTITY_PROFILE_PATH= diff --git a/Cargo.lock b/Cargo.lock @@ -1347,8 +1347,10 @@ name = "myc" version = "0.1.0" dependencies = [ "axum", + "chacha20poly1305", "clap", "futures-util", + "getrandom 0.2.17", "nostr", "radroots-identity", "radroots-log", @@ -1356,6 +1358,8 @@ dependencies = [ "radroots-nostr-accounts", "radroots-nostr-connect", "radroots-nostr-signer", + "radroots-protected-store", + "radroots-secret-vault", "radroots-sql-core", "serde", "serde_json", @@ -1367,6 +1371,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zeroize", ] [[package]] @@ -1799,10 +1804,10 @@ dependencies = [ name = "radroots-nostr-accounts" version = "0.1.0-alpha.1" dependencies = [ - "keyring", "radroots-identity", "radroots-nostr-signer", "radroots-runtime", + "radroots-secret-vault", "serde", "serde_json", "thiserror 1.0.69", @@ -1839,6 +1844,18 @@ dependencies = [ ] [[package]] +name = "radroots-protected-store" +version = "0.1.0-alpha.1" +dependencies = [ + "chacha20poly1305", + "getrandom 0.2.17", + "radroots-secret-vault", + "serde", + "serde_json", + "zeroize", +] + +[[package]] name = "radroots-runtime" version = "0.1.0-alpha.1" dependencies = [ @@ -1855,6 +1872,13 @@ dependencies = [ ] [[package]] +name = "radroots-secret-vault" +version = "0.1.0-alpha.1" +dependencies = [ + "keyring", +] + +[[package]] name = "radroots-sql-core" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -15,7 +15,9 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] } +chacha20poly1305 = "0.10" clap = { version = "4.5", features = ["derive"] } +getrandom = "0.2" nostr = { version = "0.44.2", features = ["nip04", "nip44", "nip46"] } radroots-identity = { path = "../lib/crates/identity" } radroots-log = { path = "../lib/crates/log" } @@ -23,6 +25,8 @@ radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-featu radroots-nostr = { path = "../lib/crates/nostr", features = ["client", "events"] } radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } radroots-nostr-signer = { path = "../lib/crates/nostr-signer", features = ["native"] } +radroots-protected-store = { path = "../lib/crates/protected-store" } +radroots-secret-vault = { path = "../lib/crates/secret-vault", features = ["std", "os-keyring"] } radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -32,6 +36,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2.5" uuid = { version = "1.18", features = ["serde", "v7"] } +zeroize = "1.8" [dev-dependencies] futures-util = "0.3.32" diff --git a/src/app/backend.rs b/src/app/backend.rs @@ -359,10 +359,8 @@ mod tests { use crate::config::MycConfig; fn write_identity(path: &std::path::Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn test_runtime() -> MycRuntime { diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -50,10 +50,9 @@ mod tests { use super::MycApp; fn write_test_identity(path: &std::path::Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = + RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } #[test] @@ -81,21 +80,24 @@ mod tests { snapshot .signer_identity_path .as_ref() - .expect("filesystem signer path") + .expect("encrypted signer path") .ends_with("identity.json") ); assert!( snapshot .user_identity_path .as_ref() - .expect("filesystem user path") + .expect("encrypted user path") .ends_with("user.json") ); assert_eq!( snapshot.signer_identity_source.backend.as_str(), - "filesystem" + "encrypted_file" + ); + assert_eq!( + snapshot.user_identity_source.backend.as_str(), + "encrypted_file" ); - assert_eq!(snapshot.user_identity_source.backend.as_str(), "filesystem"); assert_eq!(snapshot.signer_state_backend.as_str(), "json_file"); assert!(snapshot.signer_state_path.ends_with("signer-state.json")); assert_eq!(snapshot.runtime_audit_backend.as_str(), "jsonl_file"); diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -100,8 +100,10 @@ pub struct MycRuntime { fn startup_identity_path(source: &MycIdentitySourceSpec) -> Option<PathBuf> { match source.backend { - MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount => source.path.clone(), - MycIdentityBackend::OsKeyring | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount => source.path.clone(), + MycIdentityBackend::HostVault | MycIdentityBackend::ExternalCommand => None, } } @@ -1109,10 +1111,16 @@ impl MycSignerContext { signer_identity_source: MycIdentitySourceSpec, user_identity_source: MycIdentitySourceSpec, ) -> Result<Self, MycError> { - let signer_identity_provider = - MycIdentityProvider::from_source("signer", signer_identity_source, external_command_timeout)?; - let user_identity_provider = - MycIdentityProvider::from_source("user", user_identity_source, external_command_timeout)?; + let signer_identity_provider = MycIdentityProvider::from_source( + "signer", + signer_identity_source, + external_command_timeout, + )?; + let user_identity_provider = MycIdentityProvider::from_source( + "user", + user_identity_source, + external_command_timeout, + )?; let signer_identity = signer_identity_provider.load_active_identity()?; let user_identity = user_identity_provider.load_active_identity()?; let signer_store = Self::build_signer_store(persistence, &paths.signer_state_path)?; @@ -1265,10 +1273,9 @@ mod tests { use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus}; fn write_test_identity(path: &std::path::Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = + RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } fn write_external_command_helper(path: &std::path::Path, secret_key: &str) -> RadrootsIdentity { @@ -1711,7 +1718,7 @@ mod tests { #[test] fn startup_identity_path_reporting_matches_backend_sources() { let mut config = MycConfig::default(); - config.paths.signer_identity_backend = MycIdentityBackend::OsKeyring; + config.paths.signer_identity_backend = MycIdentityBackend::HostVault; config.paths.signer_identity_keyring_account_id = Some("1111111111111111111111111111111111111111111111111111111111111111".to_owned()); config.paths.signer_identity_profile_path = Some(PathBuf::from("/tmp/signer-profile.json")); diff --git a/src/cli.rs b/src/cli.rs @@ -991,10 +991,8 @@ mod tests { use crate::app::MycRuntime; fn write_identity(path: &std::path::Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn runtime() -> MycRuntime { diff --git a/src/config.rs b/src/config.rs @@ -4,7 +4,6 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use nostr::PublicKey; -use radroots_identity::DEFAULT_IDENTITY_PATH; use radroots_nostr::prelude::RadrootsNostrRelayUrl; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement; @@ -140,10 +139,11 @@ pub enum MycConnectionApproval { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MycIdentityBackend { - Filesystem, - OsKeyring, + EncryptedFile, + HostVault, ManagedAccount, ExternalCommand, + PlaintextFile, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -244,15 +244,17 @@ impl Default for MycCustodyConfig { impl Default for MycPathsConfig { fn default() -> Self { + let state_dir = PathBuf::from("var"); + let identities_dir = state_dir.join("identities"); Self { - state_dir: PathBuf::from("var"), - signer_identity_backend: MycIdentityBackend::Filesystem, - signer_identity_path: PathBuf::from(DEFAULT_IDENTITY_PATH), + state_dir: state_dir.clone(), + signer_identity_backend: MycIdentityBackend::EncryptedFile, + signer_identity_path: identities_dir.join("signer.identity.secret.json"), signer_identity_keyring_account_id: None, signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(), signer_identity_profile_path: None, - user_identity_backend: MycIdentityBackend::Filesystem, - user_identity_path: PathBuf::from(DEFAULT_IDENTITY_PATH), + user_identity_backend: MycIdentityBackend::EncryptedFile, + user_identity_path: identities_dir.join("user.identity.secret.json"), user_identity_keyring_account_id: None, user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(), user_identity_profile_path: None, @@ -359,7 +361,7 @@ impl Default for MycPolicyConfig { impl Default for MycIdentityBackend { fn default() -> Self { - Self::Filesystem + Self::EncryptedFile } } @@ -385,10 +387,11 @@ impl MycTransportDeliveryPolicy { impl MycIdentityBackend { pub fn as_str(self) -> &'static str { match self { - Self::Filesystem => "filesystem", - Self::OsKeyring => "os_keyring", + Self::EncryptedFile => "encrypted_file", + Self::HostVault => "host_vault", Self::ManagedAccount => "managed_account", Self::ExternalCommand => "external_command", + Self::PlaintextFile => "plaintext_file", } } } @@ -416,28 +419,33 @@ impl MycPathsConfig { MycIdentitySourceSpec { backend: self.signer_identity_backend, path: match self.signer_identity_backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => Some(self.signer_identity_path.clone()), - MycIdentityBackend::OsKeyring => None, + MycIdentityBackend::HostVault => None, }, keyring_account_id: match self.signer_identity_backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring => self.signer_identity_keyring_account_id.clone(), + MycIdentityBackend::HostVault => self.signer_identity_keyring_account_id.clone(), }, keyring_service_name: match self.signer_identity_backend { - MycIdentityBackend::Filesystem | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { Some(self.signer_identity_keyring_service_name.clone()) } }, profile_path: match self.signer_identity_backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring => self.signer_identity_profile_path.clone(), + MycIdentityBackend::HostVault => self.signer_identity_profile_path.clone(), }, } } @@ -446,28 +454,33 @@ impl MycPathsConfig { MycIdentitySourceSpec { backend: self.user_identity_backend, path: match self.user_identity_backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => Some(self.user_identity_path.clone()), - MycIdentityBackend::OsKeyring => None, + MycIdentityBackend::HostVault => None, }, keyring_account_id: match self.user_identity_backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring => self.user_identity_keyring_account_id.clone(), + MycIdentityBackend::HostVault => self.user_identity_keyring_account_id.clone(), }, keyring_service_name: match self.user_identity_backend { - MycIdentityBackend::Filesystem | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { Some(self.user_identity_keyring_service_name.clone()) } }, profile_path: match self.user_identity_backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring => self.user_identity_profile_path.clone(), + MycIdentityBackend::HostVault => self.user_identity_profile_path.clone(), }, } } @@ -1407,15 +1420,18 @@ fn parse_identity_backend_env( line_number: usize, ) -> Result<MycIdentityBackend, MycError> { match value { - "filesystem" => Ok(MycIdentityBackend::Filesystem), - "os_keyring" => Ok(MycIdentityBackend::OsKeyring), + "encrypted_file" => Ok(MycIdentityBackend::EncryptedFile), + "host_vault" => Ok(MycIdentityBackend::HostVault), "managed_account" => Ok(MycIdentityBackend::ManagedAccount), "external_command" => Ok(MycIdentityBackend::ExternalCommand), + "plaintext_file" => Ok(MycIdentityBackend::PlaintextFile), + "filesystem" => Ok(MycIdentityBackend::PlaintextFile), + "os_keyring" => Ok(MycIdentityBackend::HostVault), _ => Err(config_parse_error( path, line_number, format!( - "{key} must be `filesystem`, `os_keyring`, `managed_account`, or `external_command`" + "{key} must be `encrypted_file`, `host_vault`, `managed_account`, `external_command`, or `plaintext_file`" ), )), } @@ -1595,15 +1611,35 @@ fn validate_identity_source_config( source: &MycIdentitySourceSpec, ) -> Result<(), MycError> { match source.backend { - MycIdentityBackend::Filesystem => { + MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => { let Some(path) = source.path.as_ref() else { return Err(MycError::InvalidConfig(format!( - "{label}.path must be set when backend is `filesystem`" + "{label}.path must be set when backend is `{}`", + source.backend.as_str() ))); }; if path.as_os_str().is_empty() { return Err(MycError::InvalidConfig(format!( - "{label}.path must not be empty when backend is `filesystem`" + "{label}.path must not be empty when backend is `{}`", + source.backend.as_str() + ))); + } + if source.keyring_account_id.is_some() { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_account_id must not be set when backend is `{}`", + source.backend.as_str() + ))); + } + if source.keyring_service_name.is_some() { + return Err(MycError::InvalidConfig(format!( + "{label}.keyring_service_name must not be set when backend is `{}`", + source.backend.as_str() + ))); + } + if source.profile_path.is_some() { + return Err(MycError::InvalidConfig(format!( + "{label}.profile_path must not be set when backend is `{}`", + source.backend.as_str() ))); } } @@ -1634,10 +1670,10 @@ fn validate_identity_source_config( ))); } } - MycIdentityBackend::OsKeyring => { + MycIdentityBackend::HostVault => { let Some(account_id) = source.keyring_account_id.as_deref() else { return Err(MycError::InvalidConfig(format!( - "{label}.keyring_account_id must be set when backend is `os_keyring`" + "{label}.keyring_account_id must be set when backend is `host_vault`" ))); }; let _ = radroots_identity::RadrootsIdentityId::parse(account_id).map_err(|_| { @@ -1647,12 +1683,12 @@ fn validate_identity_source_config( })?; 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 `os_keyring`" + "{label}.keyring_service_name must be set when backend is `host_vault`" ))); }; if service_name.trim().is_empty() { return Err(MycError::InvalidConfig(format!( - "{label}.keyring_service_name must not be empty when backend is `os_keyring`" + "{label}.keyring_service_name must not be empty when backend is `host_vault`" ))); } if let Some(profile_path) = source.profile_path.as_ref() @@ -1719,35 +1755,40 @@ impl MycDiscoveryConfig { pub fn app_identity_source(&self) -> Option<MycIdentitySourceSpec> { let backend = match (self.app_identity_backend, self.app_identity_path.as_ref()) { (Some(backend), _) => Some(backend), - (None, Some(_)) => Some(MycIdentityBackend::Filesystem), + (None, Some(_)) => Some(MycIdentityBackend::EncryptedFile), (None, None) => None, }?; Some(MycIdentitySourceSpec { backend, path: match backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => self.app_identity_path.clone(), - MycIdentityBackend::OsKeyring => None, + MycIdentityBackend::HostVault => None, }, keyring_account_id: match backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring => self.app_identity_keyring_account_id.clone(), + MycIdentityBackend::HostVault => self.app_identity_keyring_account_id.clone(), }, keyring_service_name: match backend { - MycIdentityBackend::Filesystem | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring | MycIdentityBackend::ManagedAccount => { + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ExternalCommand => None, + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => { self.app_identity_keyring_service_name.clone() } }, profile_path: match backend { - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand => None, - MycIdentityBackend::OsKeyring => self.app_identity_profile_path.clone(), + MycIdentityBackend::HostVault => self.app_identity_profile_path.clone(), }, }) } @@ -1919,11 +1960,11 @@ mod tests { assert_eq!(config.paths.state_dir, PathBuf::from("var")); assert_eq!( config.paths.signer_identity_backend, - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile ); assert_eq!( config.paths.signer_identity_path, - PathBuf::from(DEFAULT_IDENTITY_PATH) + PathBuf::from("var/identities/signer.identity.secret.json") ); assert_eq!(config.paths.signer_identity_keyring_account_id, None); assert_eq!( @@ -1933,11 +1974,11 @@ mod tests { assert_eq!(config.paths.signer_identity_profile_path, None); assert_eq!( config.paths.user_identity_backend, - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile ); assert_eq!( config.paths.user_identity_path, - PathBuf::from(DEFAULT_IDENTITY_PATH) + PathBuf::from("var/identities/user.identity.secret.json") ); assert_eq!(config.paths.user_identity_keyring_account_id, None); assert_eq!( @@ -2009,9 +2050,9 @@ MYC_LOGGING_FILTER=debug,myc=trace MYC_LOGGING_OUTPUT_DIR=/tmp/myc-logs MYC_LOGGING_STDOUT=false MYC_PATHS_STATE_DIR=/tmp/myc -MYC_PATHS_SIGNER_IDENTITY_BACKEND=filesystem +MYC_PATHS_SIGNER_IDENTITY_BACKEND=encrypted_file MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/myc-identity.json -MYC_PATHS_USER_IDENTITY_BACKEND=filesystem +MYC_PATHS_USER_IDENTITY_BACKEND=encrypted_file MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file @@ -2023,7 +2064,7 @@ MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550 MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.example.com MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main -MYC_DISCOVERY_APP_IDENTITY_BACKEND=filesystem +MYC_DISCOVERY_APP_IDENTITY_BACKEND=encrypted_file MYC_DISCOVERY_APP_IDENTITY_PATH=/tmp/myc-app.json MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.discovery.example.com MYC_DISCOVERY_PUBLISH_RELAYS=wss://relay.publish.example.com @@ -2069,7 +2110,7 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 assert_eq!(config.paths.state_dir, PathBuf::from("/tmp/myc")); assert_eq!( config.paths.signer_identity_backend, - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile ); assert_eq!( config.paths.signer_identity_path, @@ -2077,7 +2118,7 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 ); assert_eq!( config.paths.user_identity_backend, - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile ); assert_eq!( config.paths.user_identity_path, @@ -2104,7 +2145,7 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 assert_eq!(config.discovery.handler_identifier, "myc-main"); assert_eq!( config.discovery.app_identity_backend, - Some(MycIdentityBackend::Filesystem) + Some(MycIdentityBackend::EncryptedFile) ); assert_eq!( config.discovery.app_identity_path, @@ -2230,9 +2271,10 @@ MYC_UNKNOWN=nope config.custody.external_command_timeout_secs = 0; let err = config.validate().expect_err("invalid custody timeout"); - assert!(err - .to_string() - .contains("custody.external_command_timeout_secs")); + assert!( + err.to_string() + .contains("custody.external_command_timeout_secs") + ); } #[test] @@ -2388,19 +2430,19 @@ MYC_UNKNOWN=nope } #[test] - fn parse_and_validate_os_keyring_identity_backends() { + fn parse_and_validate_host_vault_identity_backends() { let config = MycConfig::from_env_str( r#" -MYC_PATHS_SIGNER_IDENTITY_BACKEND=os_keyring +MYC_PATHS_SIGNER_IDENTITY_BACKEND=host_vault MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111 MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer -MYC_PATHS_USER_IDENTITY_BACKEND=os_keyring +MYC_PATHS_USER_IDENTITY_BACKEND=host_vault MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID=2222222222222222222222222222222222222222222222222222222222222222 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=os_keyring +MYC_DISCOVERY_APP_IDENTITY_BACKEND=host_vault MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID=3333333333333333333333333333333333333333333333333333333333333333 MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery "#, @@ -2409,7 +2451,7 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery assert_eq!( config.paths.signer_identity_backend, - MycIdentityBackend::OsKeyring + MycIdentityBackend::HostVault ); assert_eq!( config.paths.signer_identity_keyring_account_id.as_deref(), @@ -2417,11 +2459,11 @@ MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery ); assert_eq!( config.paths.user_identity_backend, - MycIdentityBackend::OsKeyring + MycIdentityBackend::HostVault ); assert_eq!( config.discovery.app_identity_backend, - Some(MycIdentityBackend::OsKeyring) + Some(MycIdentityBackend::HostVault) ); assert_eq!( config @@ -2587,12 +2629,12 @@ MYC_LOGGING_OUTPUT_DIR=/tmp/myc logs MYC_LOGGING_STDOUT=false MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=17 MYC_PATHS_STATE_DIR=/tmp/myc state -MYC_PATHS_SIGNER_IDENTITY_BACKEND=os_keyring +MYC_PATHS_SIGNER_IDENTITY_BACKEND=host_vault MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/ignored-signer.json MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111 MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH=/tmp/signer-profile.json -MYC_PATHS_USER_IDENTITY_BACKEND=filesystem +MYC_PATHS_USER_IDENTITY_BACKEND=plaintext_file MYC_PATHS_USER_IDENTITY_PATH=/tmp/myc-user.json MYC_PATHS_USER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.user MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file @@ -2605,7 +2647,7 @@ MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550 MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.example.com MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main -MYC_DISCOVERY_APP_IDENTITY_BACKEND=filesystem +MYC_DISCOVERY_APP_IDENTITY_BACKEND=plaintext_file MYC_DISCOVERY_APP_IDENTITY_PATH=/tmp/myc-app.json MYC_DISCOVERY_APP_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery MYC_DISCOVERY_PUBLIC_RELAYS=wss://relay.discovery.example.com diff --git a/src/control.rs b/src/control.rs @@ -802,10 +802,8 @@ mod tests { use std::time::Duration; fn write_identity(path: &std::path::Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime diff --git a/src/custody.rs b/src/custody.rs @@ -12,13 +12,14 @@ use radroots_nostr::prelude::{ }; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, - RadrootsNostrSecretVault, RadrootsNostrSecretVaultOsKeyring, RadrootsNostrSelectedAccountStatus, }; +use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring}; use serde::{Deserialize, Serialize}; use crate::config::{MycIdentityBackend, MycIdentitySourceSpec}; use crate::error::MycError; +use crate::identity_storage::load_encrypted_identity; #[derive(Clone)] pub struct MycActiveIdentity { @@ -93,14 +94,17 @@ pub struct MycIdentityProvider { #[derive(Clone)] enum MycIdentityProviderBackend { - Filesystem { + EncryptedFile { path: PathBuf, }, - OsKeyring { + PlaintextFile { + path: PathBuf, + }, + HostVault { account_id: RadrootsIdentityId, service_name: String, profile_path: Option<PathBuf>, - vault: Arc<dyn RadrootsNostrSecretVault>, + vault: Arc<dyn RadrootsSecretVault>, }, ManagedAccount { account_store_path: PathBuf, @@ -196,7 +200,10 @@ impl MycExternalCommandExecutor for MycProcessCommandExecutor { } let deadline = Instant::now() + timeout; loop { - match child.try_wait().map_err(MycExternalCommandExecuteError::Io)? { + match child + .try_wait() + .map_err(MycExternalCommandExecuteError::Io)? + { Some(_) => break, None if Instant::now() >= deadline => { let _ = child.kill(); @@ -556,30 +563,38 @@ impl MycIdentityProvider { ) -> Result<Self, MycError> { let role = role.into(); let backend = match source.backend { - MycIdentityBackend::Filesystem => { + MycIdentityBackend::EncryptedFile => { + let path = source.path.clone().ok_or_else(|| { + MycError::InvalidConfig(format!( + "{role} identity encrypted_file backend requires a path" + )) + })?; + MycIdentityProviderBackend::EncryptedFile { path } + } + MycIdentityBackend::PlaintextFile => { let path = source.path.clone().ok_or_else(|| { MycError::InvalidConfig(format!( - "{role} identity filesystem backend requires a path" + "{role} identity plaintext_file backend requires a path" )) })?; - MycIdentityProviderBackend::Filesystem { path } + MycIdentityProviderBackend::PlaintextFile { path } } - MycIdentityBackend::OsKeyring => { + MycIdentityBackend::HostVault => { let account_id = RadrootsIdentityId::parse( source.keyring_account_id.as_deref().ok_or_else(|| { MycError::InvalidConfig(format!( - "{role} identity os_keyring backend requires keyring_account_id" + "{role} identity host_vault backend requires keyring_account_id" )) })?, ) .map_err(|_| { MycError::InvalidConfig(format!( - "{role} identity os_keyring backend requires a valid keyring_account_id" + "{role} identity host_vault backend requires a valid keyring_account_id" )) })?; let service_name = source.keyring_service_name.clone().ok_or_else(|| { MycError::InvalidConfig(format!( - "{role} identity os_keyring backend requires keyring_service_name" + "{role} identity host_vault backend requires keyring_service_name" )) })?; Self::vault_provider(role.as_str(), &source, account_id, service_name)? @@ -620,20 +635,21 @@ impl MycIdentityProvider { pub fn load_identity(&self) -> Result<RadrootsIdentity, MycError> { match &self.backend { - MycIdentityProviderBackend::Filesystem { path } => { + MycIdentityProviderBackend::EncryptedFile { path } => load_encrypted_identity(path), + MycIdentityProviderBackend::PlaintextFile { path } => { RadrootsIdentity::load_from_path_auto(path).map_err(Into::into) } - MycIdentityProviderBackend::OsKeyring { + MycIdentityProviderBackend::HostVault { account_id, service_name, profile_path, vault, } => { let secret_key_hex = vault - .load_secret_hex(account_id) + .load_secret(account_id.as_str()) .map_err(|source| MycError::CustodyVault { role: self.role.clone(), - source, + source: source.into(), })? .ok_or_else(|| MycError::CustodySecretNotFound { role: self.role.clone(), @@ -717,11 +733,8 @@ impl MycIdentityProvider { timeout, executor, } => { - let (public_identity, public_key) = self.load_external_command_identity( - command_path, - *timeout, - executor.as_ref(), - )?; + let (public_identity, public_key) = + self.load_external_command_identity(command_path, *timeout, executor.as_ref())?; Ok(MycActiveIdentity::from_operations( public_identity.clone(), public_key, @@ -1176,14 +1189,14 @@ impl MycIdentityProvider { ) -> Result<MycIdentityProviderBackend, MycError> { if service_name.trim().is_empty() { return Err(MycError::InvalidConfig(format!( - "{role} identity os_keyring backend requires a non-empty keyring_service_name" + "{role} identity host_vault backend requires a non-empty keyring_service_name" ))); } - Ok(MycIdentityProviderBackend::OsKeyring { + Ok(MycIdentityProviderBackend::HostVault { account_id, service_name: service_name.clone(), profile_path: source.profile_path.clone(), - vault: Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name)), + vault: Arc::new(RadrootsSecretVaultOsKeyring::new(service_name)), }) } @@ -1214,7 +1227,7 @@ impl MycIdentityProvider { Arc::new(RadrootsNostrFileAccountStore::new( account_store_path.as_path(), )), - Arc::new(RadrootsNostrSecretVaultOsKeyring::new(service_name.clone())), + Arc::new(RadrootsSecretVaultOsKeyring::new(service_name.clone())), ) .map_err(|source| MycError::CustodyManager { role: role.to_owned(), @@ -1380,22 +1393,21 @@ mod tests { use radroots_identity::RadrootsIdentity; use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVault, + RadrootsNostrAccountsManager, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, }; + use radroots_secret_vault::RadrootsSecretVault; use super::*; fn write_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn fixture_source(path: &Path) -> MycIdentitySourceSpec { MycIdentitySourceSpec { - backend: MycIdentityBackend::Filesystem, + backend: MycIdentityBackend::EncryptedFile, path: Some(path.to_path_buf()), keyring_account_id: None, keyring_service_name: None, @@ -1570,7 +1582,7 @@ mod tests { let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let manager = RadrootsNostrAccountsManager::new( Arc::new(RadrootsNostrMemoryAccountStore::new()), - vault.clone() as Arc<dyn RadrootsNostrSecretVault>, + vault.clone() as Arc<dyn RadrootsSecretVault>, ) .expect("manager"); ( @@ -1620,7 +1632,7 @@ mod tests { } #[test] - fn filesystem_provider_loads_identity() { + fn encrypted_file_provider_loads_identity() { let temp = tempfile::tempdir().expect("tempdir"); let path = temp.path().join("signer.json"); write_identity( @@ -1628,13 +1640,12 @@ mod tests { "1111111111111111111111111111111111111111111111111111111111111111", ); - let provider = - MycIdentityProvider::from_source( - "signer", - fixture_source(&path), - Duration::from_secs(10), - ) - .expect("provider"); + let provider = MycIdentityProvider::from_source( + "signer", + fixture_source(&path), + Duration::from_secs(10), + ) + .expect("provider"); let identity = provider.load_identity().expect("identity"); assert_eq!( @@ -1656,19 +1667,19 @@ mod tests { let account_id = identity.id(); let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); vault - .store_secret_hex(&account_id, identity.secret_key_hex().as_str()) + .store_secret(account_id.as_str(), identity.secret_key_hex().as_str()) .expect("store"); let provider = MycIdentityProvider { role: "signer".to_owned(), source: MycIdentitySourceSpec { - backend: MycIdentityBackend::OsKeyring, + backend: MycIdentityBackend::HostVault, path: None, keyring_account_id: Some(account_id.to_string()), keyring_service_name: Some("org.radroots.test".to_owned()), profile_path: Some(profile_path.clone()), }, - backend: MycIdentityProviderBackend::OsKeyring { + backend: MycIdentityProviderBackend::HostVault { account_id: account_id.clone(), service_name: "org.radroots.test".to_owned(), profile_path: Some(profile_path), @@ -1691,13 +1702,13 @@ mod tests { let provider = MycIdentityProvider { role: "user".to_owned(), source: MycIdentitySourceSpec { - backend: MycIdentityBackend::OsKeyring, + backend: MycIdentityBackend::HostVault, path: None, keyring_account_id: Some(account_id.to_string()), keyring_service_name: Some("org.radroots.test".to_owned()), profile_path: None, }, - backend: MycIdentityProviderBackend::OsKeyring { + backend: MycIdentityProviderBackend::HostVault { account_id: account_id.clone(), service_name: "org.radroots.test".to_owned(), profile_path: None, @@ -1770,7 +1781,9 @@ mod tests { .expect("selected account"); vault .remove_secret( - &RadrootsIdentityId::parse(selected_account_id.as_str()).expect("account id"), + RadrootsIdentityId::parse(selected_account_id.as_str()) + .expect("account id") + .as_str(), ) .expect("remove secret"); @@ -1975,7 +1988,9 @@ mod tests { (started_at.elapsed(), err) }); - let pid_deadline = Instant::now() + timeout + Duration::from_secs(1); + // Give the real helper a little slack to create its pid file under a busy full-test run + // before we conclude the timeout path never launched it. + let pid_deadline = Instant::now() + timeout + Duration::from_secs(5); let pid = loop { match fs::read_to_string(&pid_path) { Ok(value) => break value.trim().parse::<u32>().expect("pid"), diff --git a/src/discovery.rs b/src/discovery.rs @@ -2120,10 +2120,8 @@ mod tests { use crate::app::MycRuntime; fn write_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn runtime() -> MycRuntime { diff --git a/src/identity_storage.rs b/src/identity_storage.rs @@ -0,0 +1,271 @@ +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use getrandom::getrandom; +use radroots_identity::{RadrootsIdentity, RadrootsIdentityFile}; +use radroots_protected_store::{ + RADROOTS_PROTECTED_STORE_KEY_LENGTH, RADROOTS_PROTECTED_STORE_NONCE_LENGTH, + RadrootsProtectedStoreEnvelope, +}; +use radroots_secret_vault::{RadrootsSecretKeyWrapping, RadrootsSecretVaultAccessError}; +use zeroize::Zeroize; + +use crate::error::MycError; + +const ENCRYPTED_IDENTITY_KEY_SLOT: &str = "myc_identity"; +const ENCRYPTED_IDENTITY_KEY_SUFFIX: &str = ".key"; +const WRAPPED_KEY_VERSION: u8 = 1; + +#[derive(Debug, Clone)] +struct MycEncryptedIdentityKeySource { + key_path: PathBuf, +} + +impl MycEncryptedIdentityKeySource { + fn new(path: &Path) -> Self { + Self { + key_path: encrypted_identity_wrapping_key_path(path), + } + } + + fn load_or_create_wrapping_key( + &self, + ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> { + if self.key_path.exists() { + return self.load_wrapping_key(); + } + + if let Some(parent) = self.key_path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).map_err(io_backend_error)?; + } + let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH]; + getrandom(&mut key) + .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?; + fs::write(&self.key_path, key.as_slice()).map_err(io_backend_error)?; + set_secret_permissions(&self.key_path)?; + Ok(key) + } + + fn load_wrapping_key( + &self, + ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> { + let raw = fs::read(&self.key_path).map_err(io_backend_error)?; + if raw.len() != RADROOTS_PROTECTED_STORE_KEY_LENGTH { + return Err(RadrootsSecretVaultAccessError::Backend(format!( + "encrypted identity wrapping key {} has invalid length {}", + self.key_path.display(), + raw.len() + ))); + } + + let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH]; + key.copy_from_slice(&raw); + Ok(key) + } +} + +impl RadrootsSecretKeyWrapping for MycEncryptedIdentityKeySource { + type Error = RadrootsSecretVaultAccessError; + + fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + let mut master_key = self.load_or_create_wrapping_key()?; + let mut nonce = [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH]; + getrandom(&mut nonce) + .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext_key, + aad: key_slot.as_bytes(), + }, + ) + .map_err(|_| { + RadrootsSecretVaultAccessError::Backend( + "failed to wrap encrypted identity data key".into(), + ) + })?; + master_key.zeroize(); + + let mut encoded = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); + encoded.push(WRAPPED_KEY_VERSION); + encoded.extend_from_slice(&nonce); + encoded.extend_from_slice(ciphertext.as_slice()); + Ok(encoded) + } + + fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + if wrapped_key.len() <= 1 + RADROOTS_PROTECTED_STORE_NONCE_LENGTH { + return Err(RadrootsSecretVaultAccessError::Backend( + "wrapped encrypted identity data key is truncated".into(), + )); + } + if wrapped_key[0] != WRAPPED_KEY_VERSION { + return Err(RadrootsSecretVaultAccessError::Backend(format!( + "unsupported encrypted identity wrapped data key version {}", + wrapped_key[0] + ))); + } + + let mut master_key = self.load_wrapping_key()?; + let nonce_offset = 1; + let ciphertext_offset = nonce_offset + RADROOTS_PROTECTED_STORE_NONCE_LENGTH; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); + let plaintext = cipher + .decrypt( + XNonce::from_slice(&wrapped_key[nonce_offset..ciphertext_offset]), + Payload { + msg: &wrapped_key[ciphertext_offset..], + aad: key_slot.as_bytes(), + }, + ) + .map_err(|_| { + RadrootsSecretVaultAccessError::Backend( + "failed to unwrap encrypted identity data key".into(), + ) + })?; + master_key.zeroize(); + Ok(plaintext) + } +} + +pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf { + let path = path.as_ref(); + let mut value = OsString::from(path.as_os_str()); + value.push(ENCRYPTED_IDENTITY_KEY_SUFFIX); + PathBuf::from(value) +} + +pub fn store_encrypted_identity( + path: impl AsRef<Path>, + identity: &RadrootsIdentity, +) -> Result<(), MycError> { + let path = path.as_ref(); + if let Some(parent) = 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 payload = serde_json::to_vec(&identity.to_file())?; + let key_source = MycEncryptedIdentityKeySource::new(path); + let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( + &key_source, + ENCRYPTED_IDENTITY_KEY_SLOT, + &payload, + ) + .map_err(|error| { + MycError::InvalidOperation(format!( + "failed to seal encrypted identity {}: {error}", + path.display() + )) + })?; + let encoded = envelope.encode_json().map_err(|error| { + MycError::InvalidOperation(format!( + "failed to encode encrypted identity {}: {error}", + path.display() + )) + })?; + fs::write(path, encoded).map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + })?; + set_secret_permissions(path).map_err(secret_permission_error(path))?; + Ok(()) +} + +pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, MycError> { + let path = path.as_ref(); + let encoded = fs::read(path).map_err(|source| MycError::PersistenceIo { + path: path.to_path_buf(), + source, + })?; + let key_source = MycEncryptedIdentityKeySource::new(path); + let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| { + MycError::InvalidOperation(format!( + "failed to decode encrypted identity {}: {error}", + path.display() + )) + })?; + let plaintext = envelope + .open_with_wrapped_key(&key_source) + .map_err(|error| { + MycError::InvalidOperation(format!( + "failed to open encrypted identity {}: {error}", + path.display() + )) + })?; + let file: RadrootsIdentityFile = serde_json::from_slice(&plaintext).map_err(|error| { + MycError::InvalidOperation(format!( + "failed to parse encrypted identity {}: {error}", + path.display() + )) + })?; + RadrootsIdentity::try_from(file).map_err(MycError::from) +} + +pub fn store_plaintext_identity( + path: impl AsRef<Path>, + identity: &RadrootsIdentity, +) -> Result<(), MycError> { + identity.save_json(path).map_err(MycError::from) +} + +fn io_backend_error(source: std::io::Error) -> RadrootsSecretVaultAccessError { + RadrootsSecretVaultAccessError::Backend(source.to_string()) +} + +fn secret_permission_error( + path: &Path, +) -> impl FnOnce(RadrootsSecretVaultAccessError) -> MycError + '_ { + move |error| { + MycError::InvalidOperation(format!( + "failed to update permissions for {}: {error}", + path.display() + )) + } +} + +#[cfg(unix)] +fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { + use std::os::unix::fs::PermissionsExt; + + let permissions = std::fs::Permissions::from_mode(0o600); + fs::set_permissions(path, permissions).map_err(io_backend_error) +} + +#[cfg(not(unix))] +fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypted_identity_round_trips() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("identity.enc.json"); + let identity = RadrootsIdentity::from_secret_key_str( + "1111111111111111111111111111111111111111111111111111111111111111", + ) + .expect("identity"); + + store_encrypted_identity(&path, &identity).expect("store encrypted identity"); + + let loaded = load_encrypted_identity(&path).expect("load encrypted identity"); + assert_eq!(loaded.id(), identity.id()); + assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); + assert!(encrypted_identity_wrapping_key_path(&path).is_file()); + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -9,6 +9,7 @@ pub mod control; pub mod custody; pub mod discovery; pub mod error; +pub mod identity_storage; pub mod logging; pub mod operability; pub mod outbox; diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -1605,10 +1605,9 @@ mod tests { use crate::config::{MycConfig, MycRuntimeAuditBackend}; fn write_test_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = + RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } #[test] diff --git a/src/persistence.rs b/src/persistence.rs @@ -21,6 +21,7 @@ use crate::config::{ }; use crate::custody::MycIdentityProvider; use crate::error::MycError; +use crate::identity_storage::encrypted_identity_wrapping_key_path; use crate::outbox::{ MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore, }; @@ -192,6 +193,7 @@ struct MycPersistenceIdentityReferenceFileManifest { #[serde(rename_all = "snake_case")] enum MycPersistenceIdentityReferenceField { Path, + EncryptedKeyPath, ProfilePath, } @@ -451,19 +453,17 @@ pub fn verify_restored_state( )?; require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?; - let signer_identity_provider = - MycIdentityProvider::from_source( - "signer", - config.paths.signer_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - )?; + let signer_identity_provider = MycIdentityProvider::from_source( + "signer", + config.paths.signer_identity_source(), + Duration::from_secs(config.custody.external_command_timeout_secs), + )?; let signer_identity = signer_identity_provider.load_active_identity()?; - let user_identity_provider = - MycIdentityProvider::from_source( - "user", - config.paths.user_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - )?; + let user_identity_provider = MycIdentityProvider::from_source( + "user", + config.paths.user_identity_source(), + Duration::from_secs(config.custody.external_command_timeout_secs), + )?; let user_identity = user_identity_provider.load_active_identity()?; let discovery_app_identity = match config.discovery.app_identity_source() { Some(source) => Some(MycIdentityProvider::from_source( @@ -559,12 +559,11 @@ fn import_signer_state_json_to_sqlite( ); let source_store = RadrootsNostrFileSignerStore::new(&source_path); let source_state = source_store.load()?; - let signer_identity_provider = - MycIdentityProvider::from_source( - "signer", - config.paths.signer_identity_source(), - Duration::from_secs(config.custody.external_command_timeout_secs), - )?; + let signer_identity_provider = MycIdentityProvider::from_source( + "signer", + config.paths.signer_identity_source(), + Duration::from_secs(config.custody.external_command_timeout_secs), + )?; let configured_signer_identity = signer_identity_provider.load_identity()?.to_public(); if let Some(imported_signer_identity) = source_state.signer_identity.as_ref() { if imported_signer_identity.id != configured_signer_identity.id { @@ -655,6 +654,21 @@ fn backup_identity_reference( copied_files.push(backup_dir.join(relative_path)); } + if source.backend == MycIdentityBackend::EncryptedFile + && let Some(path) = source.path.as_ref() + { + let key_path = encrypted_identity_wrapping_key_path(path); + let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) + .join(role) + .join("encrypted-key-path"); + copy_file_required(&key_path, &backup_dir.join(&relative_path))?; + manifest_files.push(MycPersistenceIdentityReferenceFileManifest { + field: MycPersistenceIdentityReferenceField::EncryptedKeyPath, + relative_path: relative_path.clone(), + }); + copied_files.push(backup_dir.join(relative_path)); + } + if let Some(profile_path) = source.profile_path.as_ref() { let relative_path = PathBuf::from(MYC_PERSISTENCE_BACKUP_IDENTITIES_DIR_NAME) .join(role) @@ -685,10 +699,13 @@ fn backup_identity_reference( backend: source.backend, copied_file_count: copied_files.len(), copied_files, - contains_secret_material: source.backend == MycIdentityBackend::Filesystem, + contains_secret_material: matches!( + source.backend, + MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile + ), requires_out_of_backup_dependencies: matches!( source.backend, - MycIdentityBackend::OsKeyring + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand ), @@ -706,9 +723,13 @@ fn restore_identity_reference( for file in &manifest.files { let source_path = backup_dir.join(&file.relative_path); let destination_path = match file.field { - MycPersistenceIdentityReferenceField::Path => current_source.path.as_ref(), + MycPersistenceIdentityReferenceField::Path => current_source.path.clone(), + MycPersistenceIdentityReferenceField::EncryptedKeyPath => current_source + .path + .as_ref() + .map(|path| encrypted_identity_wrapping_key_path(path)), MycPersistenceIdentityReferenceField::ProfilePath => { - current_source.profile_path.as_ref() + current_source.profile_path.clone() } } .ok_or_else(|| { @@ -717,20 +738,23 @@ fn restore_identity_reference( manifest.role, match file.field { MycPersistenceIdentityReferenceField::Path => "path", + MycPersistenceIdentityReferenceField::EncryptedKeyPath => { + "encrypted_key_path" + } MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", } )) })?; ensure_restore_destination_file_clear( - destination_path, + &destination_path, format!( "{} identity {}", manifest.role, restore_field_label(file.field) ), )?; - copy_file_required(&source_path, destination_path)?; + copy_file_required(&source_path, &destination_path)?; restored_files.push(destination_path.clone()); } @@ -739,10 +763,13 @@ fn restore_identity_reference( backend: current_source.backend, restored_file_count: restored_files.len(), restored_files, - contains_secret_material: current_source.backend == MycIdentityBackend::Filesystem, + contains_secret_material: matches!( + current_source.backend, + MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile + ), requires_out_of_backup_dependencies: matches!( current_source.backend, - MycIdentityBackend::OsKeyring + MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand ), @@ -895,7 +922,8 @@ fn validate_manifest_relative_path(path: &Path, label: &str) -> Result<(), MycEr fn requires_identity_source_path_contract(backend: MycIdentityBackend) -> bool { matches!( backend, - MycIdentityBackend::Filesystem + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile | MycIdentityBackend::ManagedAccount | MycIdentityBackend::ExternalCommand ) @@ -904,7 +932,9 @@ fn requires_identity_source_path_contract(backend: MycIdentityBackend) -> bool { fn should_copy_identity_source_path(backend: MycIdentityBackend) -> bool { matches!( backend, - MycIdentityBackend::Filesystem | MycIdentityBackend::ManagedAccount + MycIdentityBackend::EncryptedFile + | MycIdentityBackend::PlaintextFile + | MycIdentityBackend::ManagedAccount ) } @@ -1102,6 +1132,7 @@ where fn restore_field_label(field: MycPersistenceIdentityReferenceField) -> &'static str { match field { MycPersistenceIdentityReferenceField::Path => "path", + MycPersistenceIdentityReferenceField::EncryptedKeyPath => "encrypted_key_path", MycPersistenceIdentityReferenceField::ProfilePath => "profile_path", } } @@ -1422,10 +1453,8 @@ mod tests { "3333333333333333333333333333333333333333333333333333333333333333"; fn write_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn identity(secret_key: &str) -> RadrootsIdentity { diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -1176,10 +1176,9 @@ mod tests { use super::{MycNip46HandledRequest, MycNip46Handler}; fn write_identity(path: &std::path::Path, secret_key: &str) { - radroots_identity::RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = + radroots_identity::RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + crate::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn runtime() -> MycRuntime { diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs @@ -304,10 +304,8 @@ async fn accept_published_event( } fn write_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn write_env_file( @@ -507,7 +505,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, @@ -621,7 +619,7 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, @@ -773,7 +771,7 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, @@ -874,7 +872,7 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, @@ -973,7 +971,7 @@ async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, @@ -1156,7 +1154,7 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, @@ -1295,7 +1293,7 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th &user_identity_path, "2222222222222222222222222222222222222222222222222222222222222222", ); - app_identity.save_json(&app_identity_path)?; + myc::identity_storage::store_encrypted_identity(&app_identity_path, &app_identity)?; write_env_file( &env_path, &state_dir, diff --git a/tests/logging_run.rs b/tests/logging_run.rs @@ -6,10 +6,8 @@ use std::thread; use std::time::{Duration, Instant}; fn write_test_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } fn wait_for_log_contents( diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -494,10 +494,8 @@ impl MycTestRuntime { } fn write_identity(path: &std::path::Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn identity(secret_key: &str) -> RadrootsIdentity { @@ -2882,7 +2880,7 @@ async fn explicit_nip89_publish_uses_app_identity_and_records_audit() -> TestRes let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3109,7 +3107,7 @@ async fn explicit_nip89_publish_retries_cleanly_after_rejection() -> TestResult< let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3355,7 +3353,7 @@ async fn diff_live_nip89_reports_matched_after_publish() -> TestResult<()> { let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3398,7 +3396,7 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<() let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3481,7 +3479,7 @@ async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relay MycConnectionApproval::ExplicitUser, ); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3565,7 +3563,7 @@ async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> { let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3625,7 +3623,7 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3702,7 +3700,7 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma MycConnectionApproval::ExplicitUser, ); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3793,7 +3791,7 @@ async fn refresh_nip89_reports_remaining_relays_after_mixed_targeted_repair() -> MycConnectionApproval::ExplicitUser, ); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3906,7 +3904,7 @@ async fn diff_live_nip89_reports_conflicted_when_live_groups_disagree() -> TestR let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -3947,7 +3945,7 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu MycConnectionApproval::ExplicitUser, ); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -4052,7 +4050,7 @@ async fn refresh_nip89_requires_force_when_any_discovery_relay_is_unavailable() MycConnectionApproval::ExplicitUser, ); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery @@ -4131,7 +4129,7 @@ async fn refresh_nip89_requires_force_when_live_handler_is_conflicted() -> TestR let test_runtime = MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); let runtime = test_runtime.runtime; - let app_identity = RadrootsIdentity::load_from_path_auto( + let app_identity = myc::identity_storage::load_encrypted_identity( runtime .config() .discovery diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -10,10 +10,8 @@ use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind}; use serde_json::Value; fn write_test_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } fn write_env_file(temp: &tempfile::TempDir) -> std::path::PathBuf { @@ -80,7 +78,7 @@ fn status_summary_command_emits_machine_readable_json() { let value: Value = serde_json::from_slice(&output.stdout).expect("status json"); assert_eq!(value["status"], "unready"); assert_eq!(value["ready"], false); - assert_eq!(value["custody"]["signer"]["backend"], "filesystem"); + assert_eq!(value["custody"]["signer"]["backend"], "encrypted_file"); assert_eq!(value["custody"]["signer"]["resolved"], true); assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file"); assert_eq!( diff --git a/tests/operability_e2e.rs b/tests/operability_e2e.rs @@ -117,10 +117,8 @@ impl Drop for HangingRelay { } fn write_test_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } fn signed_delivery_event(identity: &MycActiveIdentity, content: &str) -> nostr::Event { diff --git a/tests/operability_server.rs b/tests/operability_server.rs @@ -110,10 +110,8 @@ impl Drop for HangingRelay { } fn write_test_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("write identity"); } fn free_loopback_addr() -> SocketAddr { diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs @@ -11,10 +11,8 @@ use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft; use serde_json::Value; fn write_identity(path: &Path, secret_key: &str) { - RadrootsIdentity::from_secret_key_str(secret_key) - .expect("identity") - .save_json(path) - .expect("save identity"); + let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); + myc::identity_storage::store_encrypted_identity(path, &identity).expect("save identity"); } fn copy_dir_recursive(source: &Path, destination: &Path) { @@ -202,8 +200,8 @@ fn persistence_backup_cli_copies_sqlite_state_and_identity_files() { assert!(output.status.success(), "{:?}", output); let parsed: Value = serde_json::from_slice(&output.stdout).expect("backup json"); - assert_eq!(parsed["signer_identity_reference"]["copied_file_count"], 1); - assert_eq!(parsed["user_identity_reference"]["copied_file_count"], 1); + assert_eq!(parsed["signer_identity_reference"]["copied_file_count"], 2); + assert_eq!(parsed["user_identity_reference"]["copied_file_count"], 2); assert_eq!( parsed["discovery_app_identity_reference"], Value::Null, @@ -239,10 +237,24 @@ fn persistence_backup_cli_copies_sqlite_state_and_identity_files() { assert!( backup_dir .join("identity-references") + .join("signer") + .join("encrypted-key-path") + .is_file() + ); + assert!( + backup_dir + .join("identity-references") .join("user") .join("path") .is_file() ); + assert!( + backup_dir + .join("identity-references") + .join("user") + .join("encrypted-key-path") + .is_file() + ); } #[test] @@ -311,11 +323,11 @@ fn persistence_restore_cli_restores_backup_and_verify_restore_passes() { let restore_json: Value = serde_json::from_slice(&restore.stdout).expect("restore json"); assert_eq!( restore_json["signer_identity_reference"]["restored_file_count"], - 1 + 2 ); assert_eq!( restore_json["user_identity_reference"]["restored_file_count"], - 1 + 2 ); assert!( restored_config @@ -326,6 +338,18 @@ fn persistence_restore_cli_restores_backup_and_verify_restore_passes() { ); assert!(restored_config.paths.signer_identity_path.is_file()); assert!(restored_config.paths.user_identity_path.is_file()); + assert!( + myc::identity_storage::encrypted_identity_wrapping_key_path( + &restored_config.paths.signer_identity_path + ) + .is_file() + ); + assert!( + myc::identity_storage::encrypted_identity_wrapping_key_path( + &restored_config.paths.user_identity_path + ) + .is_file() + ); let output = run_myc(&restored_env, &["persistence", "verify-restore"]); @@ -454,6 +478,13 @@ fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() { ); std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) .expect("copy user identity"); + std::fs::copy( + myc::identity_storage::encrypted_identity_wrapping_key_path( + &sqlite_config.paths.user_identity_path, + ), + myc::identity_storage::encrypted_identity_wrapping_key_path(&restored_user), + ) + .expect("copy user identity wrapping key"); let mut restored_config = sqlite_config.clone(); restored_config.paths.state_dir = restored_state_dir;