rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 22++++++++++++++++++++++
MCargo.toml | 3+++
Msrc/adapters/nostr/event.rs | 7+++++--
Msrc/features/trade_listing/handlers/dvm.rs | 75++++++++++++++++++++++++++-------------------------------------------------
Asrc/identity_storage.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 32+++++++++++++++++++-------------
Msrc/main.rs | 4++--
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, }, };