rhi

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

commit 270b64d5782668c9cda9107332c35845bc765ffe
parent 731298213e787b3636fe78e322ea989cdc8c3a91
Author: triesap <137732411+triesap@users.noreply.github.com>
Date:   Sat, 12 Apr 2025 18:40:42 +0000

Adds KeyProfile to manage signing keys, profile metadata, and NIP-89 descriptor metadata.

Diffstat:
M.gitignore | 2++
MCargo.lock | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 3+++
Asrc/keys.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib.rs | 5+++++
Msrc/main.rs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
6 files changed, 358 insertions(+), 8 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -11,3 +11,4 @@ notes*.txt .DS_Store *.pem +*keys*.json +\ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock @@ -414,6 +414,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -789,6 +805,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] name = "litemap" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1172,9 +1194,12 @@ dependencies = [ "nostr-sdk", "serde", "serde_json", + "tempfile", + "thiserror 1.0.69", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1198,6 +1223,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] name = "rustls" version = "0.23.26" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1431,6 +1469,19 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1722,6 +1773,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", +] + +[[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -13,6 +13,9 @@ nostr = "0.40.0" nostr-sdk = "0.40.0" serde = "1.0" serde_json = "1.0" +tempfile = "3.19.1" +thiserror = "1.0" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" +uuid = { version = "1.16.0", features = ["v4"] } diff --git a/src/keys.rs b/src/keys.rs @@ -0,0 +1,201 @@ +use anyhow::Result; +use nostr::{ + Event, Keys, + event::{EventBuilder, Kind, Tag, TagKind}, + nips::nip01::Metadata, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::{BufReader, Write}, + path::{Path, PathBuf}, + str::FromStr, +}; +use tempfile::NamedTempFile; +use thiserror::Error; +use tracing::{error, warn}; +use uuid::Uuid; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use crate::{KIND_APPLICATION_HANDLER, KIND_JOB_REQUEST}; + +#[derive(Error, Debug)] +pub enum KeyProfileError { + #[error("Keys file does not exist at {0}")] + NotFound(PathBuf), + + #[error("Failed to open keys file at {0}: {1}")] + FileOpen(PathBuf, #[source] std::io::Error), + + #[error("Keys file already exists at {0}")] + AlreadyExists(PathBuf), + + #[error("Failed to parse keys file at {0}: {1}")] + FileParse(PathBuf, #[source] serde_json::Error), + + #[error("Failed to serialize keys: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("IO error during key write: {0}")] + Io(#[from] std::io::Error), + + #[error("Failed to persist keys to disk: {0}")] + Persist(#[from] tempfile::PersistError), + + #[error("Failed to build or sign nostr event: {0}")] + NostrBuilder(#[from] nostr::event::builder::Error), + + #[error("Invalid secret key for identifier: {0}")] + InvalidSecretKey(String), +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct KeyProfile { + key: String, + identifier: String, + pub metadata: Option<Event>, + pub application_handler: Option<Event>, + + #[serde(skip)] + path: Option<PathBuf>, +} + +impl KeyProfile { + pub fn init<P: AsRef<str>>( + path_str: P, + generate: bool, + identifier_tag: Option<String>, + ) -> Result<Self, KeyProfileError> { + let path = PathBuf::from(path_str.as_ref()); + + if path.exists() { + let file = File::open(&path).map_err(|e| KeyProfileError::FileOpen(path.clone(), e))?; + let reader = BufReader::new(file); + let mut profile: KeyProfile = serde_json::from_reader(reader) + .map_err(|e| KeyProfileError::FileParse(path.clone(), e))?; + profile.path = Some(path.clone()); + + if !profile.identifier.trim().is_empty() { + if let Some(new_id) = identifier_tag { + warn!( + "Provided identifier '{}' is being ignored because the keys file already contains identifier '{}'.", + new_id, profile.identifier + ); + } + } else { + profile.identifier = identifier_tag.unwrap_or_else(|| { + warn!( + "Missing NIP-89 application handler identifier in key file, generating UUID." + ); + Uuid::new_v4().to_string() + }); + profile.persist()?; + } + + Ok(profile) + } else if generate { + let keys = Keys::generate(); + let secret = keys.secret_key(); + let identifier = match identifier_tag { + Some(identifier) => identifier, + None => { + warn!( + "Missing NIP-89 application handler identifier in key file, generating UUID." + ); + Uuid::new_v4().to_string() + } + }; + + let profile = KeyProfile { + key: secret.to_secret_hex(), + identifier, + metadata: None, + application_handler: None, + path: Some(path.clone()), + }; + + profile.atomic_write(&path)?; + Ok(profile) + } else { + Err(KeyProfileError::NotFound(path)) + } + } + + pub fn keys(&self) -> Result<Keys, KeyProfileError> { + Keys::from_str(&self.key) + .map_err(|_| KeyProfileError::InvalidSecretKey(self.identifier.clone())) + } + + fn atomic_write<P: AsRef<Path>>(&self, path: P) -> Result<(), KeyProfileError> { + let json = serde_json::to_string(self)?; + + let dir = path.as_ref().parent().unwrap_or_else(|| Path::new(".")); + let mut temp_file = NamedTempFile::new_in(dir)?; + + temp_file.write_all(json.as_bytes())?; + temp_file.as_file_mut().sync_all()?; + + #[cfg(unix)] + { + fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?; + } + + temp_file.persist(path)?; + Ok(()) + } + + fn persist(&self) -> Result<(), KeyProfileError> { + match &self.path { + Some(p) => self.atomic_write(p), + None => Err(KeyProfileError::NotFound(PathBuf::from("[unknown path]"))), + } + } + + pub async fn build_metadata( + &mut self, + metadata: &Metadata, + ) -> Result<Option<Event>, KeyProfileError> { + if self.metadata.is_none() { + let keys = self.keys()?; + let event = EventBuilder::metadata(metadata).sign(&keys).await?; + self.metadata = Some(event.clone()); + self.persist()?; + Ok(Some(event)) + } else { + Ok(None) + } + } + + pub async fn build_application_handler(&mut self) -> Result<Option<Event>, KeyProfileError> { + if self.application_handler.is_none() { + let keys = self.keys()?; + let kind_0_content = self + .metadata + .as_ref() + .expect(&format!( + "The kind 0 metadata must be initialized before kind {} descriptor", + KIND_APPLICATION_HANDLER.to_string() + )) + .content + .clone(); + + let tags: Vec<Tag> = vec![ + Tag::custom(TagKind::Custom("k".into()), [KIND_JOB_REQUEST.to_string()]), + Tag::identifier(self.identifier.to_string()), + ]; + + let event = EventBuilder::new(Kind::Custom(KIND_APPLICATION_HANDLER), kind_0_content) + .tags(tags) + .sign(&keys) + .await?; + + self.application_handler = Some(event.clone()); + self.persist()?; + Ok(Some(event)) + } else { + Ok(None) + } + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod keys; + +pub const KIND_JOB_REQUEST: u16 = 5300; +pub const KIND_JOB_RESPONSE: u16 = 6300; +pub const KIND_APPLICATION_HANDLER: u16 = 31990; diff --git a/src/main.rs b/src/main.rs @@ -1,18 +1,21 @@ use anyhow::Result; use clap::Parser; -use nostr::{Filter, Keys, Kind, Timestamp}; +use nostr::{Filter, Keys, Kind, Timestamp, event::Event, nips::nip01::Metadata}; use nostr_sdk::{Client, RelayPoolNotification}; +use rhi::{KIND_JOB_REQUEST, keys::KeyProfile}; use tokio::signal::unix::{SignalKind, signal}; use tracing::{error, info}; +struct ConfigMetadata { + name: String, + nip_05: Option<String>, +} + fn init_tracing() { tracing_subscriber::fmt::init(); } -async fn subscribe(relays: Vec<String>) -> Result<()> { - info!("Subscription started for kind 5300"); - - let keys = Keys::generate(); +async fn subscribe(keys: Keys, relays: Vec<String>) -> Result<()> { let client = Client::new(keys); for relay in relays.iter() { client.add_relay(relay).await?; @@ -20,11 +23,15 @@ async fn subscribe(relays: Vec<String>) -> Result<()> { client.connect().await; let filter = Filter::new() - .kind(Kind::Custom(5300)) + .kind(Kind::Custom(KIND_JOB_REQUEST)) .since(Timestamp::now()); client.subscribe(filter, None).await?; + info!("Subscription started for kind {}", { + KIND_JOB_REQUEST.to_string() + }); + let mut notifications = client.notifications(); while let Ok(notification) = notifications.recv().await { @@ -49,8 +56,32 @@ async fn subscribe(relays: Vec<String>) -> Result<()> { version = env!("CARGO_PKG_VERSION") )] pub struct Args { - #[arg(long, help = "Adds nostr relays to the subscription", required = true)] + #[arg(long, help = "Adds the keys profiles file path.", required = true)] + pub keys: String, + + #[arg(long, help = "Adds nostr relays to the subscription.", required = true)] pub relays: Vec<String>, + + #[arg( + long, + help = "(Optional) Sets flag to generate keys if none are found.", + required = false + )] + pub generate_keys: bool, + + #[arg( + long, + help = "(Optional) Adds the application handler identifier tag (NIP-89)", + required = false + )] + pub identifier: Option<String>, + + #[arg( + long, + help = "(Optional) Adds the domain name (NIP-05) to metadata", + required = false + )] + pub nip05_domain: Option<String>, } #[tokio::main] @@ -62,9 +93,57 @@ async fn main() -> Result<()> { info!("Starting"); + let mut key_profile = KeyProfile::init(args.keys, args.generate_keys, args.identifier)?; + + let keys = key_profile.keys()?; + + let config = ConfigMetadata { + name: "rhi".to_string(), + nip_05: args.nip05_domain, + }; + + let metadata = Metadata { + name: Some(config.name.clone()), + display_name: None, + picture: None, + nip05: config + .nip_05 + .as_ref() + .map(|domain| format!("{}@{}", config.name, domain)), + ..Default::default() + }; + + let mut events: Vec<Event> = vec![]; + + if let Some(event) = key_profile.build_metadata(&metadata).await? { + events.push(event); + } + + if let Some(event) = key_profile.build_application_handler().await? { + events.push(event); + } + + if !events.is_empty() { + let client = Client::new(keys.clone()); + for relay in relays.iter() { + client.add_relay(relay).await?; + } + client.connect().await; + for event in events { + client.send_event(&event).await?; + info!("Sent kind {} event for key profile", { + event.clone().kind + }) + } + client.disconnect().await; + } + + let keys_sub = keys.clone(); + let relays_sub = relays.clone(); + tokio::spawn(async move { loop { - if let Err(e) = subscribe(relays.clone()).await { + if let Err(e) = subscribe(keys_sub.clone(), relays_sub.clone()).await { error!("Error on subscription: {e}"); } }