commit 69790f6f6af6dcfbb8a567f7559d9b7e28f16a46
parent 5dec0c01c8184e04b96ff3eec544d90a1df1880d
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 22:00:44 +0000
app: store service identity as encrypted envelope
Diffstat:
5 files changed, 123 insertions(+), 12 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1823,13 +1823,29 @@ 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 = [
"anyhow",
+ "chacha20poly1305",
"clap",
"config",
+ "getrandom 0.2.17",
"radroots-log",
+ "radroots-protected-store",
+ "radroots-secret-vault",
"serde",
"serde_json",
"tempfile",
@@ -1837,9 +1853,14 @@ dependencies = [
"tokio",
"toml",
"tracing",
+ "zeroize",
]
[[package]]
+name = "radroots-secret-vault"
+version = "0.1.0-alpha.1"
+
+[[package]]
name = "radroots-trade"
version = "0.1.0-alpha.1"
dependencies = [
@@ -1872,6 +1893,7 @@ dependencies = [
"serde_json",
"serde_qs",
"sha2",
+ "tempfile",
"thiserror 2.0.18",
"tokio",
"tower",
diff --git a/Cargo.toml b/Cargo.toml
@@ -48,3 +48,6 @@ tower = { version = "0.5.3", features = ["util"] }
tracing = { version = "0.1" }
uuid = { version = "1.22.0", features = ["v4"] }
url = { version = "2.5.8" }
+
+[dev-dependencies]
+tempfile = { version = "3" }
diff --git a/src/app/identity_storage.rs b/src/app/identity_storage.rs
@@ -0,0 +1,76 @@
+use std::path::{Path, PathBuf};
+
+use anyhow::Result;
+use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityFile};
+
+const RADROOTSD_IDENTITY_KEY_SLOT: &str = "radrootsd_identity";
+
+#[cfg(test)]
+pub fn encrypted_identity_key_path(path: impl AsRef<Path>) -> PathBuf {
+ radroots_runtime::local_wrapping_key_path(path)
+}
+
+pub fn load_service_identity(
+ path: Option<&Path>,
+ allow_generate: bool,
+) -> Result<RadrootsIdentity> {
+ let path = resolved_identity_path(path);
+ if path.exists() {
+ return load_encrypted_identity(&path);
+ }
+ if !allow_generate {
+ return Err(IdentityError::GenerationNotAllowed(path).into());
+ }
+
+ let identity = RadrootsIdentity::generate();
+ store_encrypted_identity(&path, &identity)?;
+ Ok(identity)
+}
+
+pub fn store_encrypted_identity(path: impl AsRef<Path>, identity: &RadrootsIdentity) -> Result<()> {
+ let payload = serde_json::to_vec(&identity.to_file())?;
+ radroots_runtime::seal_local_secret_file(path, RADROOTSD_IDENTITY_KEY_SLOT, &payload)?;
+ Ok(())
+}
+
+pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity> {
+ let payload = radroots_runtime::open_local_secret_file(path, RADROOTSD_IDENTITY_KEY_SLOT)?;
+ let file: RadrootsIdentityFile = serde_json::from_slice(&payload)?;
+ Ok(RadrootsIdentity::try_from(file)?)
+}
+
+fn resolved_identity_path(path: Option<&Path>) -> PathBuf {
+ path.map(Path::to_path_buf)
+ .unwrap_or_else(|| PathBuf::from(radroots_runtime::DEFAULT_SERVICE_IDENTITY_PATH))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{encrypted_identity_key_path, load_service_identity};
+
+ #[test]
+ fn load_service_identity_generates_encrypted_identity_artifacts() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("radrootsd-identity.secret.json");
+
+ let generated =
+ load_service_identity(Some(&path), true).expect("generate encrypted identity");
+ let loaded = load_service_identity(Some(&path), false).expect("load encrypted identity");
+
+ assert_eq!(generated.id(), loaded.id());
+ assert!(path.is_file());
+ assert!(encrypted_identity_key_path(&path).is_file());
+ }
+
+ #[test]
+ fn load_service_identity_fails_when_wrapping_key_is_missing() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("radrootsd-identity.secret.json");
+ let _ = load_service_identity(Some(&path), true).expect("generate encrypted identity");
+ std::fs::remove_file(encrypted_identity_key_path(&path)).expect("remove wrapping key");
+
+ let err = load_service_identity(Some(&path), false)
+ .expect_err("missing wrapping key should fail");
+ assert!(err.to_string().contains("identity"));
+ }
+}
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -1,5 +1,6 @@
pub mod cli;
pub mod config;
+mod identity_storage;
mod runtime;
pub use cli::Args;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -4,6 +4,7 @@ use radroots_identity::RadrootsIdentity;
use std::time::Duration;
use tracing::{info, warn};
+use crate::app::identity_storage::load_service_identity;
use crate::app::{cli, config};
use crate::core::Radrootsd;
use crate::transport::jsonrpc;
@@ -266,8 +267,8 @@ pub async fn run() -> Result<()> {
info!("Starting radrootsd");
- let identity = RadrootsIdentity::load_or_generate(
- args.service.identity.as_ref(),
+ let identity = load_service_identity(
+ args.service.identity.as_deref(),
args.service.allow_generate_identity,
)?;
let radrootsd = Radrootsd::new(
@@ -335,6 +336,7 @@ mod tests {
use radroots_events::kinds::KIND_LISTING;
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::RadrootsNostrMetadata;
+ use std::path::Path;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};
@@ -364,7 +366,14 @@ mod tests {
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos();
- std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.json"))
+ std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.secret.json"))
+ }
+
+ fn cleanup_identity_artifacts(path: &Path) {
+ let _ = std::fs::remove_file(path);
+ let _ = std::fs::remove_file(crate::app::identity_storage::encrypted_identity_key_path(
+ path,
+ ));
}
fn args_for_identity(path: PathBuf, allow_generate: bool) -> cli::Args {
@@ -429,7 +438,7 @@ mod tests {
#[tokio::test]
async fn run_returns_error_when_identity_missing() {
let _guard = test_guard();
- let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.json"), false);
+ let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.secret.json"), false);
let settings = settings_with_relays(Vec::new());
*run_load_hook()
.lock()
@@ -475,7 +484,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(()));
assert!(run().await.is_ok());
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -500,7 +509,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("boom".to_string()));
assert!(run().await.is_ok());
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -521,7 +530,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
assert!(run().await.is_ok());
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -536,7 +545,7 @@ mod tests {
let err = run().await.expect_err("invalid relay should error");
let msg = format!("{err:#}");
assert!(!msg.is_empty());
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -552,7 +561,7 @@ mod tests {
let err = run().await.expect_err("invalid rpc addr should error");
let msg = format!("{err:#}");
assert!(msg.contains("invalid"));
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -571,7 +580,7 @@ mod tests {
let err = run().await.expect_err("rpc start hook should fail");
let msg = format!("{err:#}");
assert!(msg.contains("rpc start failed"));
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -589,7 +598,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle));
assert!(run().await.is_ok());
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[tokio::test]
@@ -605,7 +614,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown);
assert!(run().await.is_ok());
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
}
#[test]