commit ecfd865d1e84e08ab0635c41a5c5d55821fd1d73
parent 3616100d243a6f653d3b3295ad506164196c4162
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 22:00:44 +0000
worker: store service identity as encrypted envelope
Diffstat:
7 files changed, 153 insertions(+), 66 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1654,13 +1654,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",
@@ -1668,9 +1684,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 = [
@@ -1820,6 +1841,7 @@ dependencies = [
"radroots-trade",
"serde",
"serde_json",
+ "tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
diff --git a/Cargo.toml b/Cargo.toml
@@ -38,3 +38,6 @@ serde_json = { version = "1", default-features = false }
tokio = { version = "1", features = ["full"] }
thiserror = { version = "2" }
tracing = { version = "0.1" }
+
+[dev-dependencies]
+tempfile = { version = "3" }
diff --git a/src/adapters/nostr/event.rs b/src/adapters/nostr/event.rs
@@ -83,7 +83,7 @@ mod tests {
use super::NostrEventAdapter;
use radroots_events_codec::job::traits::{JobEventBorrow, JobEventLike};
use radroots_nostr::prelude::{
- RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKind, RadrootsNostrKeys,
+ RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, RadrootsNostrKind,
RadrootsNostrTag, RadrootsNostrTagKind,
};
@@ -111,7 +111,10 @@ mod tests {
let adapter = NostrEventAdapter::new(&event);
assert_eq!(JobEventBorrow::raw_id(&adapter), event.id.to_hex());
- assert_eq!(JobEventBorrow::raw_author(&adapter), event.pubkey.to_string());
+ assert_eq!(
+ JobEventBorrow::raw_author(&adapter),
+ event.pubkey.to_string()
+ );
assert_eq!(JobEventBorrow::raw_content(&adapter), "content");
assert_eq!(JobEventBorrow::raw_kind(&adapter), 5322);
diff --git a/src/features/trade_listing/handlers/dvm.rs b/src/features/trade_listing/handlers/dvm.rs
@@ -28,8 +28,7 @@ use radroots_events::trade::{
use radroots_events_codec::trade::{
RadrootsTradeEnvelopeParseError as TradeListingEnvelopeParseError,
RadrootsTradeListingAddress as TradeListingAddress,
- trade_envelope_from_event,
- trade_envelope_event_build as trade_listing_envelope_event_build,
+ trade_envelope_event_build as trade_listing_envelope_event_build, trade_envelope_from_event,
};
use radroots_nostr::prelude::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter,
@@ -273,8 +272,9 @@ pub async fn handle_event(
return Ok(());
}
- let envelope_hint: TradeListingEnvelope<serde_json::Value> = serde_json::from_str(&event.content)
- .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))?;
+ let envelope_hint: TradeListingEnvelope<serde_json::Value> =
+ serde_json::from_str(&event.content)
+ .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))?;
if envelope_hint.message_type.kind() != kind {
return Err(TradeListingDvmError::TagMismatch("kind"));
}
@@ -468,7 +468,9 @@ fn map_trade_envelope_parse_error(error: TradeListingEnvelopeParseError) -> Trad
TradeListingEnvelopeParseError::ListingAddrTagMismatch => {
TradeListingDvmError::TagMismatch("a")
}
- TradeListingEnvelopeParseError::OrderIdTagMismatch => TradeListingDvmError::TagMismatch("d"),
+ TradeListingEnvelopeParseError::OrderIdTagMismatch => {
+ TradeListingDvmError::TagMismatch("d")
+ }
TradeListingEnvelopeParseError::InvalidListingAddr(_) => {
TradeListingDvmError::InvalidListingAddr
}
@@ -626,10 +628,7 @@ fn workflow_message_from_event(
.map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))
}
-fn ensure_order_counterparty(
- actual: &str,
- expected: &str,
-) -> Result<(), TradeListingDvmError> {
+fn ensure_order_counterparty(actual: &str, expected: &str) -> Result<(), TradeListingDvmError> {
if actual == expected {
Ok(())
} else {
@@ -1760,15 +1759,15 @@ mod tests {
errors: Vec::new(),
})
}
- TradeListingMessageType::OrderRequest => TradeListingMessagePayload::OrderRequest(
- make_order(
+ TradeListingMessageType::OrderRequest => {
+ TradeListingMessagePayload::OrderRequest(make_order(
order_id,
listing_addr,
buyer_pub,
seller_pub,
TradeOrderStatus::Requested,
- ),
- ),
+ ))
+ }
TradeListingMessageType::OrderResponse => {
TradeListingMessagePayload::OrderResponse(TradeOrderResponse {
accepted: true,
@@ -4499,12 +4498,10 @@ mod tests {
"order-1",
&buyer_pub,
&seller_pub,
- TradeListingMessagePayload::OrderRevisionAccept(
- TradeOrderRevisionResponse {
- accepted: false,
- reason: None,
- },
- ),
+ TradeListingMessagePayload::OrderRevisionAccept(TradeOrderRevisionResponse {
+ accepted: false,
+ reason: None,
+ },),
listing_event_id,
root_event_id,
prev_event_id,
@@ -4538,12 +4535,10 @@ mod tests {
"order-1",
&buyer_pub,
&seller_pub,
- TradeListingMessagePayload::OrderRevisionDecline(
- TradeOrderRevisionResponse {
- accepted: true,
- reason: None,
- },
- ),
+ TradeListingMessagePayload::OrderRevisionDecline(TradeOrderRevisionResponse {
+ accepted: true,
+ reason: None,
+ },),
listing_event_id,
root_event_id,
prev_event_id,
@@ -5669,14 +5664,8 @@ mod tests {
Some(&state),
)
.await;
- let result = handle_event(
- event,
- tags,
- rhi_keys.clone(),
- client.clone(),
- state.clone(),
- )
- .await;
+ let result =
+ handle_event(event, tags, rhi_keys.clone(), client.clone(), state.clone()).await;
assert!(
matches!(
result,
@@ -5709,10 +5698,7 @@ mod tests {
TradeListingMessageType::Question,
TradeOrderStatus::Completed,
),
- (
- TradeListingMessageType::Answer,
- TradeOrderStatus::Completed,
- ),
+ (TradeListingMessageType::Answer, TradeOrderStatus::Completed),
(
TradeListingMessageType::DiscountOffer,
TradeOrderStatus::Completed,
@@ -5725,10 +5711,7 @@ mod tests {
TradeListingMessageType::DiscountDecline,
TradeOrderStatus::Completed,
),
- (
- TradeListingMessageType::Cancel,
- TradeOrderStatus::Completed,
- ),
+ (TradeListingMessageType::Cancel, TradeOrderStatus::Completed),
(
TradeListingMessageType::FulfillmentUpdate,
TradeOrderStatus::Completed,
@@ -5751,14 +5734,8 @@ mod tests {
Some(&state),
)
.await;
- let result = handle_event(
- event,
- tags,
- rhi_keys.clone(),
- client.clone(),
- state.clone(),
- )
- .await;
+ let result =
+ handle_event(event, tags, rhi_keys.clone(), client.clone(), state.clone()).await;
assert!(matches!(
result,
Err(TradeListingDvmError::State(
diff --git a/src/identity_storage.rs b/src/identity_storage.rs
@@ -0,0 +1,76 @@
+use std::path::{Path, PathBuf};
+
+use anyhow::Result;
+use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityFile};
+
+const RHI_IDENTITY_KEY_SLOT: &str = "rhi_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, RHI_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, RHI_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("rhi-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("rhi-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/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod adapters;
pub mod cli;
pub mod config;
pub mod features;
+pub mod identity_storage;
pub mod rhi;
pub use cli::Args as cli_args;
@@ -13,6 +14,7 @@ use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT, TRADE_LISTING_KIN
use std::time::Duration;
use crate::features::trade_listing::state::{TradeListingRuntime, TradeListingRuntimeConfig};
+use crate::identity_storage::load_service_identity;
use crate::rhi::{Rhi, start_subscriber};
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
@@ -106,8 +108,8 @@ async fn wait_for_shutdown_or_stopped(handle: crate::rhi::RhiHandle) -> RunRhiWa
}
pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()> {
- 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 keys = identity.keys().clone();
@@ -253,7 +255,12 @@ mod tests {
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos();
- std::env::temp_dir().join(format!("rhi-{suffix}-{nanos}.json"))
+ std::env::temp_dir().join(format!("rhi-{suffix}-{nanos}.secret.json"))
+ }
+
+ fn cleanup_identity_artifacts(path: &std::path::Path) {
+ let _ = std::fs::remove_file(path);
+ let _ = std::fs::remove_file(crate::identity_storage::encrypted_identity_key_path(path));
}
fn unique_state_path(suffix: &str) -> PathBuf {
@@ -274,7 +281,7 @@ mod tests {
let settings = settings_with_relays(Vec::new());
let result = run_rhi(&settings, &args).await;
RUN_RHI_AUTO_STOP.store(false, Ordering::Relaxed);
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
assert!(result.is_ok());
}
@@ -291,7 +298,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(()));
let ok = run_rhi(&settings_ok, &args_ok).await;
- let _ = std::fs::remove_file(path_ok);
+ cleanup_identity_artifacts(&path_ok);
assert!(ok.is_ok());
let path_err = unique_identity_path("presence-err");
@@ -304,7 +311,7 @@ mod tests {
let err = run_rhi(&settings_err, &args_err).await;
RUN_RHI_AUTO_STOP.store(false, Ordering::Relaxed);
RUN_RHI_SKIP_SUBSCRIBER.store(false, Ordering::Relaxed);
- let _ = std::fs::remove_file(path_err);
+ cleanup_identity_artifacts(&path_err);
assert!(err.is_ok());
}
@@ -312,9 +319,8 @@ mod tests {
async fn bootstrap_presence_fallback_path_is_callable() {
let _guard = test_guard();
let identity_path = unique_identity_path("bootstrap");
- let identity =
- radroots_identity::RadrootsIdentity::load_or_generate(Some(&identity_path), true)
- .expect("identity");
+ let identity = crate::identity_storage::load_service_identity(Some(&identity_path), true)
+ .expect("identity");
let client = radroots_nostr::prelude::RadrootsNostrClient::new(identity.keys().clone());
let metadata: radroots_nostr::prelude::RadrootsNostrMetadata =
serde_json::from_str(r#"{"name":"bootstrap"}"#).expect("bootstrap metadata");
@@ -327,7 +333,7 @@ mod tests {
nostrconnect_url: None,
};
let result = bootstrap_presence(&client, &identity, &metadata, &handler_spec).await;
- let _ = std::fs::remove_file(identity_path);
+ cleanup_identity_artifacts(&identity_path);
assert!(result.is_err());
}
@@ -344,7 +350,7 @@ mod tests {
let args = args_for_identity(path.clone());
let settings = settings_with_relays(Vec::new());
let result = run_rhi(&settings, &args).await;
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
assert!(result.is_ok());
}
@@ -358,7 +364,7 @@ mod tests {
let args = args_for_identity(path.clone());
let settings = settings_with_relays(vec!["not-a-relay-url".to_string()]);
let result = run_rhi(&settings, &args).await;
- let _ = std::fs::remove_file(path);
+ cleanup_identity_artifacts(&path);
assert!(result.is_err());
}
@@ -371,7 +377,7 @@ mod tests {
let args = cli_args {
service: radroots_runtime::RadrootsServiceCliArgs {
config: PathBuf::from("config.toml"),
- identity: Some(PathBuf::from("/tmp/rhi-lib-missing-identity.json")),
+ identity: Some(PathBuf::from("/tmp/rhi-lib-missing-identity.secret.json")),
allow_generate_identity: false,
},
};
diff --git a/src/main.rs b/src/main.rs
@@ -108,7 +108,7 @@ mod tests {
let args = cli_args {
service: radroots_runtime::RadrootsServiceCliArgs {
config: PathBuf::from("config.toml"),
- identity: Some(PathBuf::from("/tmp/rhi-missing-identity.json")),
+ identity: Some(PathBuf::from("/tmp/rhi-missing-identity.secret.json")),
allow_generate_identity: false,
},
};
@@ -130,7 +130,7 @@ mod tests {
let args = cli_args {
service: radroots_runtime::RadrootsServiceCliArgs {
config: PathBuf::from("config.toml"),
- identity: Some(PathBuf::from("/tmp/rhi-run-hook-missing.json")),
+ identity: Some(PathBuf::from("/tmp/rhi-run-hook-missing.secret.json")),
allow_generate_identity: false,
},
};