myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 1404b34245f6b2d0387bb893d321aa5b3760d5dd
parent 6d3df27f6fb70c70d1668284f7d01421c9a0bc21
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:03:47 +0000

discovery: harden bundle export and verification

- export discovery bundles through a staged directory swap so stale or partial files do not remain visible
- add structured bundle verification with manifest nip05 and handler consistency checks
- add discovery verify-bundle and reuse the verified bundle output shape for operator inspection
- validate with cargo check --locked cargo test --locked and cargo test --locked --test discovery_cli

Diffstat:
Msrc/cli.rs | 71+++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/discovery.rs | 297++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/error.rs | 8++++++++
Msrc/lib.rs | 1+
4 files changed, 322 insertions(+), 55 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -13,7 +13,7 @@ use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::{DEFAULT_CONFIG_PATH, MycConfig}; use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; -use crate::discovery::{MycDiscoveryContext, publish_nip89_event}; +use crate::discovery::{MycDiscoveryContext, publish_nip89_event, verify_bundle}; use crate::error::MycError; use crate::logging; @@ -123,6 +123,10 @@ pub enum MycDiscoveryCommand { #[arg(long)] out: PathBuf, }, + VerifyBundle { + #[arg(long)] + dir: PathBuf, + }, } #[derive(Debug, Args)] @@ -274,22 +278,24 @@ pub async fn run_from_env() -> Result<(), MycError> { } } } - MycCommand::Discovery { command } => { - let runtime = MycRuntime::bootstrap(config)?; - match command { - MycDiscoveryCommand::RenderNip05 { out, stdout } => { - if stdout && out.is_some() { - return Err(MycError::InvalidOperation( - "discovery render-nip05 cannot use --stdout and --out together" - .to_owned(), - )); - } - let context = MycDiscoveryContext::from_runtime(&runtime)?; - if stdout || (out.is_none() && context.nip05_output_path().is_none()) { - println!("{}", context.render_nip05_json_pretty()?); - Ok(()) - } else { - let output = context.write_nip05_document( + MycCommand::Discovery { command } => match command { + MycDiscoveryCommand::VerifyBundle { dir } => { + let output = verify_bundle(dir)?; + print_json(&output) + } + MycDiscoveryCommand::RenderNip05 { out, stdout } => { + let runtime = MycRuntime::bootstrap(config.clone())?; + if stdout && out.is_some() { + return Err(MycError::InvalidOperation( + "discovery render-nip05 cannot use --stdout and --out together".to_owned(), + )); + } + let context = MycDiscoveryContext::from_runtime(&runtime)?; + if stdout || (out.is_none() && context.nip05_output_path().is_none()) { + println!("{}", context.render_nip05_json_pretty()?); + Ok(()) + } else { + let output = context.write_nip05_document( out.as_deref().or(context.nip05_output_path()).ok_or_else(|| { MycError::InvalidOperation( "discovery render-nip05 requires --out or discovery.nip05_output_path" @@ -297,24 +303,25 @@ pub async fn run_from_env() -> Result<(), MycError> { ) })?, )?; - print_json(&output) - } - } - MycDiscoveryCommand::RenderNip89 => { - let output = - MycDiscoveryContext::from_runtime(&runtime)?.render_nip89_output()?; - print_json(&output) - } - MycDiscoveryCommand::PublishNip89 => { - let output = publish_nip89_event(&runtime).await?; - print_json(&output) - } - MycDiscoveryCommand::ExportBundle { out } => { - let output = MycDiscoveryContext::from_runtime(&runtime)?.write_bundle(out)?; print_json(&output) } } - } + MycDiscoveryCommand::RenderNip89 => { + let runtime = MycRuntime::bootstrap(config.clone())?; + let output = MycDiscoveryContext::from_runtime(&runtime)?.render_nip89_output()?; + print_json(&output) + } + MycDiscoveryCommand::PublishNip89 => { + let runtime = MycRuntime::bootstrap(config.clone())?; + let output = publish_nip89_event(&runtime).await?; + print_json(&output) + } + MycDiscoveryCommand::ExportBundle { out } => { + let runtime = MycRuntime::bootstrap(config)?; + let output = MycDiscoveryContext::from_runtime(&runtime)?.write_bundle(out)?; + print_json(&output) + } + }, } } diff --git a/src/discovery.rs b/src/discovery.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ @@ -8,7 +9,7 @@ use radroots_nostr::prelude::{ RadrootsNostrRelayUrl, radroots_nostr_build_application_handler_event, }; use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; @@ -36,13 +37,13 @@ pub struct MycDiscoveryContext { connect_timeout_secs: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MycNip05Document { pub names: BTreeMap<String, String>, pub nip46: MycNip05DocumentSection, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MycNip05DocumentSection { pub relays: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] @@ -76,7 +77,7 @@ pub struct MycPublishedNip89Output { pub event: RadrootsNostrEvent, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MycNip89HandlerDocument { pub kinds: Vec<u32>, pub identifier: String, @@ -87,7 +88,7 @@ pub struct MycNip89HandlerDocument { pub metadata: Option<RadrootsNostrMetadata>, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MycDiscoveryBundleManifest { pub version: u32, pub domain: String, @@ -281,31 +282,20 @@ impl MycDiscoveryContext { output_dir: impl AsRef<Path>, ) -> Result<MycDiscoveryBundleOutput, MycError> { let output_dir = output_dir.as_ref().to_path_buf(); - fs::create_dir_all(&output_dir).map_err(|source| MycError::DiscoveryIo { - path: output_dir.clone(), - source, - })?; + let staged_output_dir = prepare_staged_output_dir(&output_dir)?; let manifest = self.render_bundle_manifest(); let nip05_document = self.render_nip05_document(); let nip89_handler = self.render_nip89_handler_document(); - let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME); - let nip05_path = output_dir.join(DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH); - let nip89_handler_path = output_dir.join(DISCOVERY_BUNDLE_NIP89_FILE_NAME); + let manifest_path = staged_output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME); + let nip05_path = staged_output_dir.join(DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH); + let nip89_handler_path = staged_output_dir.join(DISCOVERY_BUNDLE_NIP89_FILE_NAME); write_pretty_json(&manifest_path, &manifest)?; write_pretty_json(&nip05_path, &nip05_document)?; write_pretty_json(&nip89_handler_path, &nip89_handler)?; - - Ok(MycDiscoveryBundleOutput { - output_dir, - manifest_path, - nip05_path, - nip89_handler_path, - manifest, - nip05_document, - nip89_handler, - }) + replace_directory_atomically(&staged_output_dir, &output_dir)?; + verify_bundle(&output_dir) } fn build_handler_spec(&self) -> RadrootsNostrApplicationHandlerSpec { @@ -392,6 +382,28 @@ pub async fn publish_nip89_event( }) } +pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleOutput, MycError> { + let output_dir = output_dir.as_ref().to_path_buf(); + let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME); + let manifest = read_json_file::<MycDiscoveryBundleManifest>(&manifest_path)?; + let nip05_path = output_dir.join(&manifest.nip05_relative_path); + let nip05_document = read_json_file::<MycNip05Document>(&nip05_path)?; + let nip89_handler_path = output_dir.join(&manifest.nip89_relative_path); + let nip89_handler = read_json_file::<MycNip89HandlerDocument>(&nip89_handler_path)?; + + let bundle = MycDiscoveryBundleOutput { + output_dir, + manifest_path, + nip05_path, + nip89_handler_path, + manifest, + nip05_document, + nip89_handler, + }; + bundle.validate()?; + Ok(bundle) +} + fn build_metadata(config: &MycDiscoveryMetadataConfig) -> Option<RadrootsNostrMetadata> { let mut metadata = RadrootsNostrMetadata::default(); metadata.name = sanitize_optional_string(config.name.as_deref()); @@ -439,6 +451,207 @@ where Ok(()) } +fn read_json_file<T>(path: &Path) -> Result<T, MycError> +where + T: serde::de::DeserializeOwned, +{ + let encoded = fs::read_to_string(path).map_err(|source| MycError::DiscoveryIo { + path: path.to_path_buf(), + source, + })?; + serde_json::from_str(&encoded).map_err(|source| MycError::DiscoveryParse { + path: path.to_path_buf(), + source, + }) +} + +fn prepare_staged_output_dir(output_dir: &Path) -> Result<PathBuf, MycError> { + let parent = output_dir.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|source| MycError::DiscoveryIo { + path: parent.to_path_buf(), + source, + })?; + + let bundle_name = output_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("discovery"); + let staged_output_dir = parent.join(format!( + ".{bundle_name}.staging-{}-{}", + std::process::id(), + now_unix_nanos() + )); + remove_path_if_exists(&staged_output_dir)?; + fs::create_dir_all(&staged_output_dir).map_err(|source| MycError::DiscoveryIo { + path: staged_output_dir.clone(), + source, + })?; + Ok(staged_output_dir) +} + +fn replace_directory_atomically( + staged_output_dir: &Path, + output_dir: &Path, +) -> Result<(), MycError> { + let parent = output_dir.parent().unwrap_or_else(|| Path::new(".")); + let bundle_name = output_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("discovery"); + let backup_dir = parent.join(format!( + ".{bundle_name}.backup-{}-{}", + std::process::id(), + now_unix_nanos() + )); + let had_existing_output = output_dir.exists(); + + if had_existing_output { + remove_path_if_exists(&backup_dir)?; + fs::rename(output_dir, &backup_dir).map_err(|source| MycError::DiscoveryIo { + path: output_dir.to_path_buf(), + source, + })?; + } + + match fs::rename(staged_output_dir, output_dir) { + Ok(()) => { + if had_existing_output { + remove_path_if_exists(&backup_dir)?; + } + Ok(()) + } + Err(source) => { + let staged_cleanup_result = remove_path_if_exists(staged_output_dir); + if had_existing_output && !output_dir.exists() { + let _ = fs::rename(&backup_dir, output_dir); + } + if let Err(cleanup_error) = staged_cleanup_result { + return Err(MycError::InvalidDiscoveryBundle(format!( + "failed to swap staged bundle into place: {source}; additionally failed to clean staged output: {cleanup_error}" + ))); + } + Err(MycError::DiscoveryIo { + path: output_dir.to_path_buf(), + source, + }) + } + } +} + +fn remove_path_if_exists(path: &Path) -> Result<(), MycError> { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(MycError::DiscoveryIo { + path: path.to_path_buf(), + source, + }); + } + }; + + if metadata.is_dir() { + fs::remove_dir_all(path).map_err(|source| MycError::DiscoveryIo { + path: path.to_path_buf(), + source, + })?; + } else { + fs::remove_file(path).map_err(|source| MycError::DiscoveryIo { + path: path.to_path_buf(), + source, + })?; + } + Ok(()) +} + +fn now_unix_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is before unix epoch") + .as_nanos() +} + +impl MycDiscoveryBundleOutput { + fn validate(&self) -> Result<(), MycError> { + if self.manifest.version != DISCOVERY_BUNDLE_VERSION { + return Err(MycError::InvalidDiscoveryBundle(format!( + "unsupported bundle version `{}`", + self.manifest.version + ))); + } + if self.manifest.domain.trim().is_empty() { + return Err(MycError::InvalidDiscoveryBundle( + "bundle domain must not be empty".to_owned(), + )); + } + if self.manifest.author_public_key_hex.trim().is_empty() + || self.manifest.signer_public_key_hex.trim().is_empty() + { + return Err(MycError::InvalidDiscoveryBundle( + "bundle author and signer pubkeys must not be empty".to_owned(), + )); + } + if self.manifest.nip05_relative_path != DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH { + return Err(MycError::InvalidDiscoveryBundle(format!( + "bundle manifest nip05_relative_path must be `{DISCOVERY_BUNDLE_NIP05_RELATIVE_PATH}`" + ))); + } + if self.manifest.nip89_relative_path != DISCOVERY_BUNDLE_NIP89_FILE_NAME { + return Err(MycError::InvalidDiscoveryBundle(format!( + "bundle manifest nip89_relative_path must be `{DISCOVERY_BUNDLE_NIP89_FILE_NAME}`" + ))); + } + if self.nip05_path != self.output_dir.join(&self.manifest.nip05_relative_path) { + return Err(MycError::InvalidDiscoveryBundle( + "bundle nip05 path does not match the manifest".to_owned(), + )); + } + if self.nip89_handler_path != self.output_dir.join(&self.manifest.nip89_relative_path) { + return Err(MycError::InvalidDiscoveryBundle( + "bundle NIP-89 handler path does not match the manifest".to_owned(), + )); + } + if self.nip05_document.names.get("_").map(String::as_str) + != Some(self.manifest.author_public_key_hex.as_str()) + { + return Err(MycError::InvalidDiscoveryBundle( + "bundle nip05 names._ does not match the manifest author pubkey".to_owned(), + )); + } + if self.nip05_document.nip46.relays != self.manifest.public_relays { + return Err(MycError::InvalidDiscoveryBundle( + "bundle nip05 relays do not match the manifest public relays".to_owned(), + )); + } + if self.nip05_document.nip46.nostrconnect_url != self.manifest.nostrconnect_url { + return Err(MycError::InvalidDiscoveryBundle( + "bundle nip05 nostrconnect_url does not match the manifest".to_owned(), + )); + } + if self.nip89_handler.kinds != vec![NIP46_RPC_KIND] { + return Err(MycError::InvalidDiscoveryBundle( + "bundle NIP-89 handler kinds must be [24133]".to_owned(), + )); + } + if self.nip89_handler.identifier.trim().is_empty() { + return Err(MycError::InvalidDiscoveryBundle( + "bundle NIP-89 handler identifier must not be empty".to_owned(), + )); + } + if self.nip89_handler.relays != self.manifest.public_relays { + return Err(MycError::InvalidDiscoveryBundle( + "bundle NIP-89 handler relays do not match the manifest public relays".to_owned(), + )); + } + if self.nip89_handler.nostrconnect_url != self.manifest.nostrconnect_url { + return Err(MycError::InvalidDiscoveryBundle( + "bundle NIP-89 handler nostrconnect_url does not match the manifest".to_owned(), + )); + } + Ok(()) + } +} + fn render_nostrconnect_url( template: &str, signer_identity: &RadrootsIdentity, @@ -471,7 +684,8 @@ mod tests { use crate::config::MycConfig; - use super::{MycDiscoveryContext, build_metadata}; + use super::{MycDiscoveryContext, build_metadata, verify_bundle, write_pretty_json}; + use crate::MycError; use crate::app::MycRuntime; fn write_identity(path: &Path, secret_key: &str) { @@ -630,4 +844,41 @@ mod tests { assert_eq!(nip05_first, nip05_second); assert_eq!(nip89_first, nip89_second); } + + #[test] + fn write_bundle_replaces_existing_directory_without_leaving_stale_files() { + let runtime = runtime(); + let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); + let bundle_dir = runtime.paths().state_dir.join("bundle"); + fs::create_dir_all(&bundle_dir).expect("create old bundle dir"); + fs::write(bundle_dir.join("stale.txt"), "stale").expect("write stale file"); + + let bundle = context.write_bundle(&bundle_dir).expect("write bundle"); + + assert_eq!(bundle.output_dir, bundle_dir); + assert!(!bundle.output_dir.join("stale.txt").exists()); + assert!(bundle.manifest_path.exists()); + assert!(bundle.nip05_path.exists()); + assert!(bundle.nip89_handler_path.exists()); + } + + #[test] + fn verify_bundle_rejects_tampered_nip05_author() { + let runtime = runtime(); + let context = MycDiscoveryContext::from_runtime(&runtime).expect("discovery context"); + let bundle_dir = runtime.paths().state_dir.join("bundle"); + let bundle = context.write_bundle(&bundle_dir).expect("write bundle"); + let mut tampered = bundle.nip05_document.clone(); + tampered.names.insert("_".to_owned(), "deadbeef".to_owned()); + write_pretty_json(&bundle.nip05_path, &tampered).expect("rewrite tampered nip05"); + + let error = verify_bundle(&bundle_dir).expect_err("bundle should be invalid"); + + assert!(matches!(error, MycError::InvalidDiscoveryBundle(_))); + assert!( + error + .to_string() + .contains("bundle nip05 names._ does not match the manifest author pubkey") + ); + } } diff --git a/src/error.rs b/src/error.rs @@ -63,6 +63,14 @@ pub enum MycError { #[source] source: std::io::Error, }, + #[error("discovery parse error at {path}: {source}")] + DiscoveryParse { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("invalid discovery bundle: {0}")] + InvalidDiscoveryBundle(String), #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] diff --git a/src/lib.rs b/src/lib.rs @@ -25,6 +25,7 @@ pub use discovery::{ MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext, MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument, MycPublishedNip89Output, MycRenderedNip05Output, MycRenderedNip89Output, publish_nip89_event, render_nip05_output, + verify_bundle, }; pub use error::MycError; pub use transport::{MycNostrTransport, MycTransportSnapshot};