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:
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;