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 | ++ |
| M | Cargo.lock | | | 60 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | Cargo.toml | | | 3 | +++ |
| A | src/keys.rs | | | 201 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/lib.rs | | | 5 | +++++ |
| M | src/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}");
}
}