From 538f255f07ff6cb6a857bcd4b562956873393c58 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 24 Aug 2026 14:38:39 +0700 Subject: [PATCH] fix(security): hard contract parity, --hermes-url support, strict status probe gating, and canonical fixture --- src/app.rs | 158 ++++++++++---- src/cli.rs | 215 ++++++++++-------- src/hermes.rs | 130 +++++++++-- src/main.rs | 52 ++++- src/pairing.rs | 230 +++++++++++++++++--- tests/unit_and_contract_tests.rs | 363 +++++++++++++++++++++++++++++-- 6 files changed, 942 insertions(+), 206 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4158071..e0c031f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,9 @@ use crate::hermes::{HermesProbeClient, ProbeState}; use crate::identity::{get_display_name, get_host_id}; use crate::models::{NetworkInterfaceInfo, PairingPayloadV1}; use crate::network::discover_network_interfaces; -use crate::pairing::{create_pairing_payload, encode_pairing_uri}; +use crate::pairing::{ + create_pairing_payload, encode_pairing_uri, MAX_TTL_SECONDS, MIN_TTL_SECONDS, +}; use crate::qr::render_egui_image; use eframe::egui::{self, Color32, RichText, TextureHandle, Vec2}; use std::net::Ipv4Addr; @@ -12,6 +14,8 @@ use std::time::{Duration, Instant}; pub struct HermesPairApp { config: AppConfig, + hermes_url: Option, + scheme: String, port: u16, ttl: u64, interfaces: Vec, @@ -34,10 +38,14 @@ impl HermesPairApp { pub fn new( cc: &eframe::CreationContext<'_>, config: AppConfig, + hermes_url: Option, + scheme: String, port: u16, explicit_interface: Option, ttl: u64, ) -> Self { + let ttl = ttl.clamp(MIN_TTL_SECONDS, MAX_TTL_SECONDS); + let mut interfaces = discover_network_interfaces().unwrap_or_default(); if interfaces.is_empty() { interfaces.push(NetworkInterfaceInfo { @@ -68,7 +76,7 @@ impl HermesPairApp { display_name, host_ip.to_string(), port, - "http".to_string(), + scheme.clone(), ttl, ); let current_uri = encode_pairing_uri(¤t_payload); @@ -79,6 +87,8 @@ impl HermesPairApp { let mut app = Self { config, + hermes_url, + scheme, port, ttl, interfaces, @@ -118,7 +128,7 @@ impl HermesPairApp { display_name, host_ip.to_string(), self.port, - "http".to_string(), + self.scheme.clone(), self.ttl, ); self.current_uri = encode_pairing_uri(&self.current_payload); @@ -139,6 +149,8 @@ impl HermesPairApp { } self.is_probing = true; + let hermes_url = self.hermes_url.clone(); + let scheme = self.scheme.clone(); let port = self.port; let lan_ip = self.selected_ip(); let tx = self.probe_tx.clone(); @@ -151,7 +163,9 @@ impl HermesPairApp { if let Ok(rt) = rt { rt.block_on(async { let client = HermesProbeClient::new(); - let res = client.probe(port, Some(lan_ip)).await; + let res = client + .probe(hermes_url.as_deref(), &scheme, port, Some(lan_ip)) + .await; let _ = tx.send(res); }); } @@ -206,13 +220,22 @@ impl eframe::App for HermesPairApp { let ver = resp.version.as_deref().unwrap_or("unknown"); ui.label(format!("v{}", ver)); if resp.auth_required { - ui.colored_label(Color32::from_rgb(52, 152, 219), "[Auth: Required]"); + ui.colored_label( + Color32::from_rgb(52, 152, 219), + "[Auth: Required]", + ); } else { - ui.colored_label(Color32::from_rgb(230, 126, 34), "[Auth: None]"); + ui.colored_label( + Color32::from_rgb(230, 126, 34), + "[Auth: None]", + ); } } ProbeState::LoopbackOnly { .. } => { - ui.colored_label(Color32::from_rgb(241, 196, 15), "● Loopback Only"); + ui.colored_label( + Color32::from_rgb(241, 196, 15), + "● Loopback Only", + ); } ProbeState::Offline(err) => { ui.colored_label(Color32::from_rgb(231, 76, 60), "● Offline"); @@ -229,7 +252,7 @@ impl eframe::App for HermesPairApp { }); // Warning Banners - if let ProbeState::LoopbackOnly { .. } = &self.probe_state { + if let ProbeState::LoopbackOnly { lan_error, .. } = &self.probe_state { ui.add_space(2.0); egui::Frame::NONE .fill(Color32::from_rgb(60, 45, 10)) @@ -241,10 +264,11 @@ impl eframe::App for HermesPairApp { ui.label(RichText::new("⚠️").size(16.0)); ui.label( RichText::new(format!( - "Hermes is currently only reachable from this computer (127.0.0.1).\n\ - Start Hermes with LAN-accessible bind:\n\ + "Hermes is bound to loopback only (127.0.0.1).\n\ + LAN connection failed: {}\n\ + Start Hermes with LAN access:\n\ hermes serve --host 0.0.0.0 --port {}", - self.port + lan_error, self.port )) .size(11.5) .color(Color32::from_rgb(241, 196, 15)), @@ -279,11 +303,12 @@ impl eframe::App for HermesPairApp { // Network Interface Selector ui.horizontal(|ui| { ui.label("Network Interface:"); - let current_label = if let Some(iface) = self.interfaces.get(self.selected_iface_index) { - format!("{} ({})", iface.name, iface.ip) - } else { - "None".to_string() - }; + let current_label = + if let Some(iface) = self.interfaces.get(self.selected_iface_index) { + format!("{} ({})", iface.name, iface.ip) + } else { + "None".to_string() + }; let prev_idx = self.selected_iface_index; egui::ComboBox::from_id_salt("interface_select") @@ -311,41 +336,87 @@ impl eframe::App for HermesPairApp { // Address display ui.horizontal(|ui| { ui.label(RichText::new("Target Address:").strong()); - ui.monospace(format!("http://{}:{}", self.selected_ip(), self.port)); + ui.monospace(format!( + "{}://{}:{}", + self.scheme, + self.selected_ip(), + self.port + )); }); ui.add_space(4.0); - // Center QR Code + // Center QR Code or Placeholder Card ui.vertical_centered(|ui| { - if let Some(ref texture) = self.qr_texture { - ui.image((texture.id(), Vec2::new(260.0, 260.0))); - } else { - ui.label("Failed to load QR code"); - } - - ui.add_space(6.0); - - // Expiry timer - if is_expired { - ui.label( - RichText::new("⚠️ QR Code Expired") - .color(Color32::from_rgb(231, 76, 60)) - .strong(), - ); - } else { - let mins = remaining / 60; - let secs = remaining % 60; - ui.label( - RichText::new(format!("Expires in {:02}:{:02}", mins, secs)) - .size(13.0) - .color(Color32::LIGHT_GRAY), - ); + match &self.probe_state { + ProbeState::Online(_) => { + if is_expired { + egui::Frame::group(ui.style()) + .inner_margin(32.0) + .show(ui, |ui| { + ui.label( + RichText::new("⚠️ QR Code Expired") + .color(Color32::from_rgb(231, 76, 60)) + .strong() + .size(16.0), + ); + ui.add_space(8.0); + ui.label( + "Click [ 🔄 Regenerate QR ] below to create a fresh pairing code.", + ); + }); + } else if let Some(ref texture) = self.qr_texture { + ui.image((texture.id(), Vec2::new(260.0, 260.0))); + ui.add_space(6.0); + let mins = remaining / 60; + let secs = remaining % 60; + ui.label( + RichText::new(format!("Expires in {:02}:{:02}", mins, secs)) + .size(13.0) + .color(Color32::LIGHT_GRAY), + ); + } else { + ui.label("Failed to render QR code"); + } + } + ProbeState::LoopbackOnly { .. } => { + egui::Frame::group(ui.style()) + .inner_margin(32.0) + .show(ui, |ui| { + ui.colored_label( + Color32::from_rgb(241, 196, 15), + RichText::new("⚠️ Pairing QR Hidden").size(15.0).strong(), + ); + ui.add_space(8.0); + ui.label( + "Hermes is running locally on 127.0.0.1, but unreachable on this LAN interface.\n\ + Start Hermes with --host 0.0.0.0 to enable network pairing.", + ); + }); + } + ProbeState::Offline(err) => { + egui::Frame::group(ui.style()) + .inner_margin(32.0) + .show(ui, |ui| { + ui.colored_label( + Color32::from_rgb(231, 76, 60), + RichText::new("● Hermes Offline").size(15.0).strong(), + ); + ui.add_space(8.0); + ui.label(format!( + "Hermes is not responding on this endpoint ({}).\n\ + Start Hermes Agent and click [ 🔄 Check ] to connect.", + err + )); + }); + } } }); ui.add_space(6.0); + let can_copy = self.probe_state.is_online() && !is_expired; + // Action Buttons ui.horizontal(|ui| { ui.columns(2, |cols| { @@ -355,7 +426,10 @@ impl eframe::App for HermesPairApp { self.trigger_probe(); } - if cols[1].button("📋 Copy Link").clicked() { + if cols[1] + .add_enabled(can_copy, egui::Button::new("📋 Copy Link")) + .clicked() + { ctx.copy_text(self.current_uri.clone()); self.copied_banner_timer = Some(Instant::now()); } diff --git a/src/cli.rs b/src/cli.rs index e4c0898..68813b0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use crate::hermes::{HermesProbeClient, ProbeState}; use crate::identity::{get_display_name, get_host_id}; use crate::models::NetworkInterfaceInfo; use crate::network::discover_network_interfaces; -use crate::pairing::{create_pairing_payload, encode_pairing_uri}; +use crate::pairing::{create_pairing_payload, encode_pairing_uri, validate_ttl}; use crate::qr::render_terminal_qr; use clap::{Args, Parser, Subcommand}; use std::net::Ipv4Addr; @@ -27,11 +27,11 @@ pub struct CliArgs { #[arg(long = "no-gui")] pub no_gui: bool, - /// Port of Hermes Agent (default 9119) - #[arg(long, default_value = "9119")] - pub port: u16, + /// Port of Hermes Agent (default: 9119) + #[arg(long)] + pub port: Option, - /// Hermes status API URL (e.g. http://127.0.0.1:9119) + /// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222) #[arg(long = "hermes-url")] pub hermes_url: Option, @@ -39,7 +39,7 @@ pub struct CliArgs { #[arg(long, short = 'i')] pub interface: Option, - /// Pairing QR validity TTL in seconds (default 120) + /// Pairing QR validity TTL in seconds (default 120, range 10..=600) #[arg(long, default_value = "120")] pub ttl: u64, @@ -59,15 +59,55 @@ pub struct QrArgs { #[arg(long)] pub port: Option, + /// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222) + #[arg(long = "hermes-url")] + pub hermes_url: Option, + /// Specific network interface name or IPv4 address #[arg(long, short = 'i')] pub interface: Option, - /// Pairing QR validity TTL in seconds + /// Pairing QR validity TTL in seconds (range 10..=600) #[arg(long)] pub ttl: Option, } +/// Parses a Hermes URL into its scheme, host, and port components. +pub fn parse_hermes_url(raw_url: &str) -> Result<(String, String, u16), String> { + let parsed = url::Url::parse(raw_url) + .map_err(|e| format!("Invalid --hermes-url '{}': {}", raw_url, e))?; + let scheme = parsed.scheme().to_lowercase(); + if scheme != "http" && scheme != "https" { + return Err(format!( + "Invalid scheme '{}' in --hermes-url: must be 'http' or 'https'", + scheme + )); + } + let host = parsed + .host_str() + .ok_or_else(|| format!("Missing host in --hermes-url '{}'", raw_url))? + .to_string(); + let port = parsed + .port_or_known_default() + .ok_or_else(|| format!("Could not determine port from --hermes-url '{}'", raw_url))?; + Ok((scheme, host, port)) +} + +/// Resolves the scheme and port based on --hermes-url and explicit --port arguments. +pub fn resolve_cli_endpoint( + hermes_url: Option<&str>, + explicit_port: Option, +) -> Result<(String, u16), String> { + if let Some(url_str) = hermes_url { + let (parsed_scheme, _parsed_host, parsed_port) = parse_hermes_url(url_str)?; + let final_port = explicit_port.unwrap_or(parsed_port); + Ok((parsed_scheme, final_port)) + } else { + let final_port = explicit_port.unwrap_or(9119); + Ok(("http".to_string(), final_port)) + } +} + /// Resolves the selected IPv4 address based on user input or automatic interface detection. pub fn resolve_selected_ip( explicit_interface: Option<&str>, @@ -97,33 +137,23 @@ pub fn resolve_selected_ip( /// Runs single-shot terminal output mode. pub async fn run_once( config: &AppConfig, + hermes_url: Option<&str>, + scheme: &str, port: u16, explicit_interface: Option<&str>, ttl: u64, ) -> Result<(), Box> { + validate_ttl(ttl)?; + let interfaces = discover_network_interfaces().unwrap_or_default(); let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); let client = HermesProbeClient::new(); - let probe_state = client.probe(port, Some(host_ip)).await; + let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await; let host_id = get_host_id(config); let display_name = get_display_name(config); - let scheme = "http".to_string(); - let payload = create_pairing_payload( - host_id.clone(), - display_name.clone(), - host_ip.to_string(), - port, - scheme, - ttl, - ); - - let uri = encode_pairing_uri(&payload); - let qr_rendered = render_terminal_qr(&uri).map_err(|e| format!("QR Render Error: {}", e))?; - - // Format Hermes status line match &probe_state { ProbeState::Online(status) => { let ver = status.version.as_deref().unwrap_or("unknown"); @@ -133,58 +163,70 @@ pub async fn run_once( "None" }; println!("Hermes: Running (v{}, Auth: {})", ver, auth); + + let payload = create_pairing_payload( + host_id.clone(), + display_name.clone(), + host_ip.to_string(), + port, + scheme.to_string(), + ttl, + ); + + let uri = encode_pairing_uri(&payload); + let qr_rendered = + render_terminal_qr(&uri).map_err(|e| format!("QR Render Error: {}", e))?; + + let short_id = if host_id.len() >= 8 { + format!("{}...", &host_id[..8]) + } else { + host_id.clone() + }; + + println!("Host: {}", display_name); + println!("Address: {}://{}:{}", scheme, host_ip, port); + println!("Host ID: {}", short_id); + println!("Expires in: {:02}:{:02}", ttl / 60, ttl % 60); + println!("\n{}", qr_rendered); + println!("Pairing Link: {}", uri); + + Ok(()) } ProbeState::LoopbackOnly { lan_error, .. } => { - println!( + eprintln!( "Hermes: Running locally (127.0.0.1), but LAN unreachable: {}", lan_error ); - println!( - "⚠️ Warning: Hermes is bound to loopback only. Start Hermes with --host 0.0.0.0" + eprintln!( + "⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access, for example:" ); + eprintln!(" hermes serve --host 0.0.0.0 --port {}", port); + eprintln!( + "\n[Pairing QR not displayed because Hermes is unreachable from other devices]" + ); + Err("Hermes is bound to loopback only (LAN unreachable)".into()) } ProbeState::Offline(err) => { - println!("Hermes: Offline ({})", err); + eprintln!("Hermes: Offline ({})", err); + eprintln!("⚠️ Hermes Agent is unreachable. Please start Hermes before generating pairing QR."); + eprintln!("\n[Pairing QR not displayed because Hermes is offline]"); + Err(format!("Hermes is offline: {}", err).into()) } } - - let short_id = if host_id.len() >= 8 { - format!("{}...", &host_id[..8]) - } else { - host_id.clone() - }; - - println!("Host: {}", display_name); - println!("Address: http://{}:{}", host_ip, port); - println!("Host ID: {}", short_id); - println!("Expires in: {:02}:{:02}", ttl / 60, ttl % 60); - println!("\n{}", qr_rendered); - println!("Pairing Link: {}", uri); - - Ok(()) } /// Runs interactive terminal UI mode that updates in a loop. pub async fn run_terminal_loop( config: &AppConfig, + hermes_url: Option<&str>, + scheme: &str, port: u16, explicit_interface: Option<&str>, ttl: u64, ) -> Result<(), Box> { - let mut last_generated_at = std::time::Instant::now(); - let mut current_payload = { - let interfaces = discover_network_interfaces().unwrap_or_default(); - let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); - create_pairing_payload( - get_host_id(config), - get_display_name(config), - host_ip.to_string(), - port, - "http".to_string(), - ttl, - ) - }; + validate_ttl(ttl)?; + let mut last_generated_at = std::time::Instant::now(); let client = HermesProbeClient::new(); loop { @@ -194,24 +236,12 @@ pub async fn run_terminal_loop( let interfaces = discover_network_interfaces().unwrap_or_default(); let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); - // Regenerate if expired if elapsed >= ttl { - current_payload = create_pairing_payload( - get_host_id(config), - get_display_name(config), - host_ip.to_string(), - port, - "http".to_string(), - ttl, - ); last_generated_at = std::time::Instant::now(); } - let remaining = ttl.saturating_sub(now.duration_since(last_generated_at).as_secs()); - let probe_state = client.probe(port, Some(host_ip)).await; - let uri = encode_pairing_uri(¤t_payload); - let qr_rendered = render_terminal_qr(&uri).unwrap_or_default(); + let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await; // Clear terminal screen (cross-platform ANSI) print!("\x1B[2J\x1B[1;1H"); @@ -225,6 +255,31 @@ pub async fn run_terminal_loop( "None" }; println!("Hermes: Running (v{}, Auth: {})", ver, auth); + + let payload = create_pairing_payload( + get_host_id(config), + get_display_name(config), + host_ip.to_string(), + port, + scheme.to_string(), + ttl, + ); + let uri = encode_pairing_uri(&payload); + let qr_rendered = render_terminal_qr(&uri).unwrap_or_default(); + + let host_id = &payload.host_id; + let short_id = if host_id.len() >= 8 { + format!("{}...", &host_id[..8]) + } else { + host_id.clone() + }; + + println!("Host: {}", payload.name); + println!("Address: {}://{}:{}", scheme, payload.host, payload.port); + println!("Host ID: {}", short_id); + println!("Expires in: {:02}:{:02}", remaining / 60, remaining % 60); + println!("\n{}", qr_rendered); + println!("Pairing Link: {}", uri); } ProbeState::LoopbackOnly { lan_error, .. } => { println!( @@ -232,33 +287,23 @@ pub async fn run_terminal_loop( lan_error ); println!( - "⚠️ Run Hermes with: hermes serve --host 0.0.0.0 --port {}", + "⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access:\n hermes serve --host 0.0.0.0 --port {}", port ); + println!("\n[QR Code hidden: Hermes is unreachable over LAN]"); + println!("Address: {}://{}:{}", scheme, host_ip, port); + println!("Retrying probe every second..."); } ProbeState::Offline(err) => { println!("Hermes: Offline ({})", err); + println!("⚠️ Hermes Agent is unreachable. Please start Hermes."); + println!("\n[QR Code hidden: Hermes is offline]"); + println!("Address: {}://{}:{}", scheme, host_ip, port); + println!("Retrying probe every second..."); } } - let host_id = ¤t_payload.host_id; - let short_id = if host_id.len() >= 8 { - format!("{}...", &host_id[..8]) - } else { - host_id.clone() - }; - - println!("Host: {}", current_payload.name); - println!( - "Address: http://{}:{}", - current_payload.host, current_payload.port - ); - println!("Host ID: {}", short_id); - println!("Expires in: {:02}:{:02}", remaining / 60, remaining % 60); - println!("\n{}", qr_rendered); - println!("Pairing Link: {}", uri); println!("\nPress Ctrl+C to exit."); - sleep(Duration::from_secs(1)).await; } } diff --git a/src/hermes.rs b/src/hermes.rs index a5f6ec3..11e5a85 100644 --- a/src/hermes.rs +++ b/src/hermes.rs @@ -1,4 +1,5 @@ use crate::models::HermesStatusResponse; +use crate::network::is_loopback; use std::net::Ipv4Addr; use std::time::Duration; @@ -21,6 +22,10 @@ impl ProbeState { matches!(self, ProbeState::LoopbackOnly { .. }) } + pub fn is_offline(&self) -> bool { + matches!(self, ProbeState::Offline(_)) + } + pub fn status_response(&self) -> Option<&HermesStatusResponse> { match self { ProbeState::Online(ref resp) => Some(resp), @@ -53,10 +58,11 @@ impl HermesProbeClient { } pub async fn fetch_status(&self, base_url: &str) -> Result { - let url = if base_url.ends_with('/') { - format!("{}api/status", base_url) + let trimmed = base_url.trim().trim_end_matches('/'); + let url = if trimmed.ends_with("/api/status") { + trimmed.to_string() } else { - format!("{}/api/status", base_url) + format!("{}/api/status", trimmed) }; let response = self @@ -75,43 +81,119 @@ impl HermesProbeClient { .await .map_err(|e| format!("Failed to parse status response: {}", e))?; + if body.status.trim().is_empty() { + return Err("Invalid status response: missing 'status' field".to_string()); + } + Ok(body) } - pub async fn probe(&self, port: u16, lan_ip: Option) -> ProbeState { - let local_url = format!("http://127.0.0.1:{}", port); - let local_result = self.fetch_status(&local_url).await; + pub async fn probe( + &self, + hermes_url: Option<&str>, + scheme: &str, + port: u16, + lan_ip: Option, + ) -> ProbeState { + if let Some(url_str) = hermes_url { + let direct_res = self.fetch_status(url_str).await; - match (local_result, lan_ip) { - (Ok(local_status), Some(lan)) => { - let lan_url = format!("http://{}:{}", lan, port); - match self.fetch_status(&lan_url).await { - Ok(lan_status) => ProbeState::Online(lan_status), - Err(lan_err) => ProbeState::LoopbackOnly { - local_status, - lan_error: lan_err, - }, + if is_url_loopback(url_str) { + match direct_res { + Ok(local_status) => { + if let Some(lan) = lan_ip { + if !is_loopback(&lan) { + let lan_url = format!("{}://{}:{}", scheme, lan, port); + match self.fetch_status(&lan_url).await { + Ok(lan_status) => ProbeState::Online(lan_status), + Err(lan_err) => ProbeState::LoopbackOnly { + local_status, + lan_error: lan_err, + }, + } + } else { + ProbeState::Online(local_status) + } + } else { + ProbeState::Online(local_status) + } + } + Err(err) => { + if let Some(lan) = lan_ip { + if !is_loopback(&lan) { + let lan_url = format!("{}://{}:{}", scheme, lan, port); + match self.fetch_status(&lan_url).await { + Ok(lan_status) => ProbeState::Online(lan_status), + Err(_) => ProbeState::Offline(err), + } + } else { + ProbeState::Offline(err) + } + } else { + ProbeState::Offline(err) + } + } + } + } else { + match direct_res { + Ok(status) => ProbeState::Online(status), + Err(err) => ProbeState::Offline(err), } } - (Ok(local_status), None) => ProbeState::Online(local_status), - (Err(local_err), Some(lan)) => { - let lan_url = format!("http://{}:{}", lan, port); - match self.fetch_status(&lan_url).await { - Ok(lan_status) => ProbeState::Online(lan_status), - Err(_) => ProbeState::Offline(local_err), + } else { + let local_url = format!("{}://127.0.0.1:{}", scheme, port); + let local_result = self.fetch_status(&local_url).await; + + match (local_result, lan_ip) { + (Ok(local_status), Some(lan)) if !is_loopback(&lan) => { + let lan_url = format!("{}://{}:{}", scheme, lan, port); + match self.fetch_status(&lan_url).await { + Ok(lan_status) => ProbeState::Online(lan_status), + Err(lan_err) => ProbeState::LoopbackOnly { + local_status, + lan_error: lan_err, + }, + } } + (Ok(local_status), _) => ProbeState::Online(local_status), + (Err(local_err), Some(lan)) if !is_loopback(&lan) => { + let lan_url = format!("{}://{}:{}", scheme, lan, port); + match self.fetch_status(&lan_url).await { + Ok(lan_status) => ProbeState::Online(lan_status), + Err(_) => ProbeState::Offline(local_err), + } + } + (Err(local_err), _) => ProbeState::Offline(local_err), } - (Err(local_err), None) => ProbeState::Offline(local_err), } } } +fn is_url_loopback(url_or_host: &str) -> bool { + let host = if let Ok(parsed) = url::Url::parse(url_or_host) { + parsed.host_str().unwrap_or("").to_string() + } else { + url_or_host.to_string() + }; + let host_lower = host.trim().to_lowercase(); + host_lower == "127.0.0.1" + || host_lower == "localhost" + || host_lower == "::1" + || host_lower == "[::1]" + || host_lower.starts_with("127.") +} + pub async fn probe_hermes_status(base_url: &str) -> Result { let client = HermesProbeClient::new(); client.fetch_status(base_url).await } -pub async fn probe_hermes(port: u16, lan_ip: Option) -> ProbeState { +pub async fn probe_hermes( + hermes_url: Option<&str>, + scheme: &str, + port: u16, + lan_ip: Option, +) -> ProbeState { let client = HermesProbeClient::new(); - client.probe(port, lan_ip).await + client.probe(hermes_url, scheme, port, lan_ip).await } diff --git a/src/main.rs b/src/main.rs index f857d92..d6cbe70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,51 @@ use clap::Parser; use eframe::egui::Vec2; use hermes_pair::app::HermesPairApp; -use hermes_pair::cli::{run_once, run_terminal_loop, CliArgs, CliCommand}; +use hermes_pair::cli::{resolve_cli_endpoint, run_once, run_terminal_loop, CliArgs, CliCommand}; use hermes_pair::config::load_or_create_config; +use hermes_pair::pairing::validate_ttl; #[tokio::main] async fn main() -> Result<(), Box> { let args = CliArgs::parse(); let config = load_or_create_config()?; - if let Some(CliCommand::Qr(qr_args)) = &args.command { - let port = qr_args.port.unwrap_or(args.port); + if let Some(CliCommand::Qr(ref qr_args)) = args.command { + let hermes_url = qr_args.hermes_url.as_deref().or(args.hermes_url.as_deref()); + let (scheme, port) = resolve_cli_endpoint(hermes_url, qr_args.port.or(args.port))?; let iface = qr_args.interface.as_deref().or(args.interface.as_deref()); let ttl = qr_args.ttl.unwrap_or(args.ttl); - return run_once(&config, port, iface, ttl).await; + validate_ttl(ttl)?; + return run_once(&config, hermes_url, &scheme, port, iface, ttl).await; } + let hermes_url_str = args.hermes_url.as_deref(); + let (scheme, port) = resolve_cli_endpoint(hermes_url_str, args.port)?; + let ttl = args.ttl; + validate_ttl(ttl)?; + if args.no_gui { - return run_once(&config, args.port, args.interface.as_deref(), args.ttl).await; + return run_once( + &config, + hermes_url_str, + &scheme, + port, + args.interface.as_deref(), + ttl, + ) + .await; } if args.terminal { - return run_terminal_loop(&config, args.port, args.interface.as_deref(), args.ttl).await; + return run_terminal_loop( + &config, + hermes_url_str, + &scheme, + port, + args.interface.as_deref(), + ttl, + ) + .await; } // Launch GUI @@ -34,9 +58,9 @@ async fn main() -> Result<(), Box> { }; let config_clone = config.clone(); - let port = args.port; + let hermes_url_owned = args.hermes_url.clone(); + let scheme_clone = scheme.clone(); let iface = args.interface.clone(); - let ttl = args.ttl; let res = eframe::run_native( "Hermes Pair", @@ -45,6 +69,8 @@ async fn main() -> Result<(), Box> { Ok(Box::new(HermesPairApp::new( cc, config_clone, + hermes_url_owned, + scheme_clone, port, iface, ttl, @@ -55,7 +81,15 @@ async fn main() -> Result<(), Box> { if let Err(e) = res { eprintln!("Failed to launch GUI: {}", e); eprintln!("Falling back to terminal mode..."); - return run_terminal_loop(&config, args.port, args.interface.as_deref(), args.ttl).await; + return run_terminal_loop( + &config, + args.hermes_url.as_deref(), + &scheme, + port, + args.interface.as_deref(), + ttl, + ) + .await; } Ok(()) diff --git a/src/pairing.rs b/src/pairing.rs index bb16604..694f172 100644 --- a/src/pairing.rs +++ b/src/pairing.rs @@ -7,19 +7,36 @@ use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; use uuid::Uuid; +pub const MIN_TTL_SECONDS: u64 = 10; +pub const MAX_TTL_SECONDS: u64 = 600; +pub const DEFAULT_TTL_SECONDS: u64 = 120; +pub const MAX_CLOCK_SKEW_SECONDS: u64 = 30; +pub const MAX_ENCODED_URI_BYTES: usize = 4096; +pub const MAX_DECODED_JSON_BYTES: usize = 2048; +pub const MAX_NAME_LENGTH: usize = 128; +pub const MIN_NONCE_BYTES: usize = 16; +pub const MAX_NONCE_BYTES: usize = 64; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PairingError { InvalidUriScheme(String), InvalidUriFormat(String), MissingDataParameter, + PayloadTooLarge { size: usize, max: usize }, Base64DecodeError(String), JsonDecodeError(String), UnsupportedVersion(u32), InvalidPayloadType(String), InvalidHostId(String), + InvalidName(String), EmptyHost, + InvalidHost(String), InvalidPort(u16), + InvalidScheme(String), + InvalidNonce(String), PayloadExpired { expires_at: u64, now: u64 }, + TtlExceedsMaximum { expires_at: u64, max_allowed: u64 }, + InvalidTtl { ttl: u64, min: u64, max: u64 }, } impl fmt::Display for PairingError { @@ -32,6 +49,13 @@ impl fmt::Display for PairingError { PairingError::MissingDataParameter => { write!(f, "Missing 'data' query parameter in pairing URI") } + PairingError::PayloadTooLarge { size, max } => { + write!( + f, + "Payload size ({} bytes) exceeds maximum limit of {} bytes", + size, max + ) + } PairingError::Base64DecodeError(e) => { write!(f, "Failed to decode Base64URL payload: {}", e) } @@ -43,8 +67,14 @@ impl fmt::Display for PairingError { write!(f, "Invalid payload type '{}', expected 'hermes-pair'", t) } PairingError::InvalidHostId(id) => write!(f, "Invalid host UUID: '{}'", id), + PairingError::InvalidName(msg) => write!(f, "Invalid host display name: {}", msg), PairingError::EmptyHost => write!(f, "Host address cannot be empty"), + PairingError::InvalidHost(msg) => write!(f, "Invalid host address: {}", msg), PairingError::InvalidPort(p) => write!(f, "Invalid port number: {}", p), + PairingError::InvalidScheme(s) => { + write!(f, "Invalid scheme '{}', expected 'http' or 'https'", s) + } + PairingError::InvalidNonce(msg) => write!(f, "Invalid nonce: {}", msg), PairingError::PayloadExpired { expires_at, now } => { write!( f, @@ -52,6 +82,23 @@ impl fmt::Display for PairingError { expires_at, now ) } + PairingError::TtlExceedsMaximum { + expires_at, + max_allowed, + } => { + write!( + f, + "Pairing payload expiry timestamp {} exceeds maximum allowed {}", + expires_at, max_allowed + ) + } + PairingError::InvalidTtl { ttl, min, max } => { + write!( + f, + "Invalid TTL {}s: TTL must be between {} and {} seconds", + ttl, min, max + ) + } } } } @@ -66,11 +113,145 @@ pub fn current_unix_timestamp() -> u64 { } pub fn generate_nonce() -> String { - let mut bytes = [0u8; 16]; + let mut bytes = [0u8; 32]; rand::thread_rng().fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } +pub fn validate_ttl(ttl: u64) -> Result<(), PairingError> { + if !(MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&ttl) { + return Err(PairingError::InvalidTtl { + ttl, + min: MIN_TTL_SECONDS, + max: MAX_TTL_SECONDS, + }); + } + Ok(()) +} + +pub fn validate_payload(payload: &PairingPayloadV1, current_time: u64) -> Result<(), PairingError> { + // 1. Version must be 1 + if payload.v != 1 { + return Err(PairingError::UnsupportedVersion(payload.v)); + } + + // 2. Payload type must be "hermes-pair" + if payload.payload_type != "hermes-pair" { + return Err(PairingError::InvalidPayloadType( + payload.payload_type.clone(), + )); + } + + // 3. Host ID must be a valid UUIDv4 string + let host_uuid = Uuid::parse_str(&payload.host_id).map_err(|_| { + PairingError::InvalidHostId(format!("'{}' is not a valid UUID", payload.host_id)) + })?; + if host_uuid.get_version_num() != 4 { + return Err(PairingError::InvalidHostId(format!( + "UUID must be version 4 (random), got version {}", + host_uuid.get_version_num() + ))); + } + + // 4. Name: not blank, trimmed <= 128 chars, no control characters + let trimmed_name = payload.name.trim(); + if trimmed_name.is_empty() { + return Err(PairingError::InvalidName( + "Host display name cannot be blank".into(), + )); + } + if trimmed_name.chars().count() > MAX_NAME_LENGTH { + return Err(PairingError::InvalidName(format!( + "Host display name length ({}) exceeds maximum allowed {}", + trimmed_name.chars().count(), + MAX_NAME_LENGTH + ))); + } + if payload + .name + .chars() + .any(|c| (c as u32) < 0x20 || (c as u32) == 0x7F) + { + return Err(PairingError::InvalidName( + "Host display name contains forbidden control characters".into(), + )); + } + + // 5. Host: not blank, no whitespace, no forbidden chars: / \ ? # @ : control chars + let trimmed_host = payload.host.trim(); + if trimmed_host.is_empty() { + return Err(PairingError::EmptyHost); + } + if payload.host.chars().any(|c| { + c.is_whitespace() + || ['/', '\\', '?', '#', '@', ':'].contains(&c) + || (c as u32) < 0x20 + || (c as u32) == 0x7F + }) { + return Err(PairingError::InvalidHost(format!( + "Host '{}' contains forbidden characters (whitespace, delimiters, or control characters)", + payload.host + ))); + } + + // 6. Port: 1..=65535 (u16 is <= 65535, port 0 is invalid) + if payload.port == 0 { + return Err(PairingError::InvalidPort(0)); + } + + // 7. Scheme: "http" or "https" + if payload.scheme != "http" && payload.scheme != "https" { + return Err(PairingError::InvalidScheme(format!( + "Invalid scheme '{}', must be 'http' or 'https'", + payload.scheme + ))); + } + + // 8. Nonce: Base64URL-encoded, decodes to >= 16 bytes and <= 64 bytes + let trimmed_nonce = payload.nonce.trim(); + if trimmed_nonce.is_empty() { + return Err(PairingError::InvalidNonce("Nonce cannot be empty".into())); + } + let decoded_nonce = URL_SAFE_NO_PAD + .decode(trimmed_nonce.as_bytes()) + .or_else(|_| URL_SAFE.decode(trimmed_nonce.as_bytes())) + .or_else(|_| STANDARD.decode(trimmed_nonce.as_bytes())) + .map_err(|e| PairingError::InvalidNonce(format!("Nonce Base64 decode failed: {}", e)))?; + + if decoded_nonce.len() < MIN_NONCE_BYTES { + return Err(PairingError::InvalidNonce(format!( + "Nonce length {} bytes is below minimum {} bytes (128 bits)", + decoded_nonce.len(), + MIN_NONCE_BYTES + ))); + } + if decoded_nonce.len() > MAX_NONCE_BYTES { + return Err(PairingError::InvalidNonce(format!( + "Nonce length {} bytes exceeds maximum {} bytes", + decoded_nonce.len(), + MAX_NONCE_BYTES + ))); + } + + // 9. Expires at: now - 30 <= expires_at <= now + 600 + let min_allowed_expiry = current_time.saturating_sub(MAX_CLOCK_SKEW_SECONDS); + if payload.expires_at < min_allowed_expiry { + return Err(PairingError::PayloadExpired { + expires_at: payload.expires_at, + now: current_time, + }); + } + let max_allowed_expiry = current_time + MAX_TTL_SECONDS; + if payload.expires_at > max_allowed_expiry { + return Err(PairingError::TtlExceedsMaximum { + expires_at: payload.expires_at, + max_allowed: max_allowed_expiry, + }); + } + + Ok(()) +} + pub fn create_pairing_payload( host_id: String, name: String, @@ -80,7 +261,8 @@ pub fn create_pairing_payload( ttl_seconds: u64, ) -> PairingPayloadV1 { let now = current_unix_timestamp(); - let expires_at = now + ttl_seconds; + let ttl = ttl_seconds.clamp(MIN_TTL_SECONDS, MAX_TTL_SECONDS); + let expires_at = now + ttl; let nonce = generate_nonce(); PairingPayloadV1 { @@ -107,6 +289,14 @@ pub fn decode_pairing_uri_at_time( uri: &str, current_time: u64, ) -> Result { + // Check encoded URI length bound + if uri.len() > MAX_ENCODED_URI_BYTES { + return Err(PairingError::PayloadTooLarge { + size: uri.len(), + max: MAX_ENCODED_URI_BYTES, + }); + } + let data_str = if let Ok(url) = Url::parse(uri) { if url.scheme() != "hermes" { return Err(PairingError::InvalidUriScheme(url.scheme().to_string())); @@ -148,36 +338,18 @@ pub fn decode_pairing_uri_at_time( .or_else(|_| STANDARD.decode(data_str.as_bytes())) .map_err(|e| PairingError::Base64DecodeError(e.to_string()))?; + // Check decoded payload size limit + if decoded_bytes.len() > MAX_DECODED_JSON_BYTES { + return Err(PairingError::PayloadTooLarge { + size: decoded_bytes.len(), + max: MAX_DECODED_JSON_BYTES, + }); + } + let payload: PairingPayloadV1 = serde_json::from_slice(&decoded_bytes) .map_err(|e| PairingError::JsonDecodeError(e.to_string()))?; - // Validations - if payload.v != 1 { - return Err(PairingError::UnsupportedVersion(payload.v)); - } - - if payload.payload_type != "hermes-pair" { - return Err(PairingError::InvalidPayloadType(payload.payload_type)); - } - - if Uuid::parse_str(&payload.host_id).is_err() { - return Err(PairingError::InvalidHostId(payload.host_id)); - } - - if payload.host.trim().is_empty() { - return Err(PairingError::EmptyHost); - } - - if payload.port == 0 { - return Err(PairingError::InvalidPort(payload.port)); - } - - if payload.expires_at <= current_time { - return Err(PairingError::PayloadExpired { - expires_at: payload.expires_at, - now: current_time, - }); - } + validate_payload(&payload, current_time)?; Ok(payload) } diff --git a/tests/unit_and_contract_tests.rs b/tests/unit_and_contract_tests.rs index fe4924b..d4c0885 100644 --- a/tests/unit_and_contract_tests.rs +++ b/tests/unit_and_contract_tests.rs @@ -1,12 +1,13 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; +use hermes_pair::cli::{parse_hermes_url, resolve_cli_endpoint}; use hermes_pair::config::load_or_create_config_from_path; use hermes_pair::hermes::HermesProbeClient; use hermes_pair::models::{NetworkInterfaceInfo, PairingPayloadV1}; use hermes_pair::network::filter_and_sort_interfaces; use hermes_pair::pairing::{ create_pairing_payload, decode_pairing_uri, decode_pairing_uri_at_time, encode_pairing_uri, - PairingError, + validate_payload, validate_ttl, PairingError, MAX_DECODED_JSON_BYTES, MAX_ENCODED_URI_BYTES, }; use std::net::Ipv4Addr; use std::path::PathBuf; @@ -14,25 +15,65 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use uuid::Uuid; +#[test] +fn test_canonical_cross_contract_fixture() { + let payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id: "58af1471-a0a2-4e2b-9426-5068f2a2deab".to_string(), + name: "Office-PC".to_string(), + host: "192.168.1.150".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1800000000, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + + let json_str = serde_json::to_string(&payload).expect("Serialization failed"); + assert!(json_str.contains("\"v\":1")); + assert!(json_str.contains("\"type\":\"hermes-pair\"")); + assert!(json_str.contains("\"host_id\":\"58af1471-a0a2-4e2b-9426-5068f2a2deab\"")); + assert!(json_str.contains("\"name\":\"Office-PC\"")); + assert!(json_str.contains("\"host\":\"192.168.1.150\"")); + assert!(json_str.contains("\"port\":9119")); + assert!(json_str.contains("\"scheme\":\"http\"")); + assert!(json_str.contains("\"expires_at\":1800000000")); + assert!(json_str.contains("\"nonce\":\"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY\"")); + + let uri = encode_pairing_uri(&payload); + assert!(uri.starts_with("hermes://pair?data=")); + + // Decode at current_time = expires_at - 60 (well within validity window) + let decode_time = 1800000000 - 60; + let decoded = decode_pairing_uri_at_time(&uri, decode_time) + .expect("Canonical fixture must decode successfully"); + + assert_eq!(decoded.v, 1); + assert_eq!(decoded.payload_type, "hermes-pair"); + assert_eq!(decoded.host_id, "58af1471-a0a2-4e2b-9426-5068f2a2deab"); + assert_eq!(decoded.name, "Office-PC"); + assert_eq!(decoded.host, "192.168.1.150"); + assert_eq!(decoded.port, 9119); + assert_eq!(decoded.scheme, "http"); + assert_eq!(decoded.expires_at, 1800000000); + assert_eq!(decoded.nonce, "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"); +} + #[test] fn test_config_persistence() { let tmp_dir = std::env::temp_dir(); let config_path: PathBuf = tmp_dir.join(format!("hermes_test_config_{}.json", Uuid::new_v4())); - // Ensure clean state let _ = std::fs::remove_file(&config_path); - // First load -> generates new config and persists it let config1 = load_or_create_config_from_path(&config_path).expect("Should create new config"); assert!(!config1.host_id.is_empty()); assert!(Uuid::parse_str(&config1.host_id).is_ok()); - // Second load -> loads existing config and keeps same host_id let config2 = load_or_create_config_from_path(&config_path).expect("Should load existing config"); assert_eq!(config1.host_id, config2.host_id); - // Clean up let _ = std::fs::remove_file(&config_path); } @@ -48,7 +89,7 @@ fn test_pairing_payload_serde() { port: 9119, scheme: "http".to_string(), expires_at: 1800000000, - nonce: "test_nonce_1234".to_string(), + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), }; let json = serde_json::to_string(&payload).expect("Serialization failed"); @@ -56,7 +97,6 @@ fn test_pairing_payload_serde() { serde_json::from_str(&json).expect("Deserialization failed"); assert_eq!(payload, deserialized); - // Verify Base64URL round-trip let b64 = URL_SAFE_NO_PAD.encode(json.as_bytes()); let decoded_bytes = URL_SAFE_NO_PAD .decode(b64.as_bytes()) @@ -91,6 +131,18 @@ fn test_pairing_uri_encoding_and_validation() { assert_eq!(decoded.scheme, "http"); } +#[test] +fn test_ttl_validation_bounds() { + assert!(validate_ttl(0).is_err()); + assert!(validate_ttl(5).is_err()); + assert!(validate_ttl(9).is_err()); + assert!(validate_ttl(10).is_ok()); + assert!(validate_ttl(120).is_ok()); + assert!(validate_ttl(600).is_ok()); + assert!(validate_ttl(601).is_err()); + assert!(validate_ttl(1000).is_err()); +} + #[test] fn test_expired_payload_rejection() { let host_id = Uuid::new_v4().to_string(); @@ -103,7 +155,7 @@ fn test_expired_payload_rejection() { port: 9119, scheme: "http".to_string(), expires_at: 1000, - nonce: "test_nonce".to_string(), + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), }; let uri = encode_pairing_uri(&payload); @@ -119,22 +171,46 @@ fn test_expired_payload_rejection() { } #[test] -fn test_invalid_version_rejection() { +fn test_excessive_future_ttl_rejection() { let host_id = Uuid::new_v4().to_string(); let payload = PairingPayloadV1 { - v: 2, // Unsupported version + v: 1, payload_type: "hermes-pair".to_string(), host_id, name: "Future-Node".to_string(), host: "10.0.0.5".to_string(), port: 9119, scheme: "http".to_string(), - expires_at: 3000000000, - nonce: "test_nonce".to_string(), + expires_at: 1000 + 700, // Exceeds now + 600 + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), }; let uri = encode_pairing_uri(&payload); - let result = decode_pairing_uri_at_time(&uri, 10000); + let result = decode_pairing_uri_at_time(&uri, 1000); + + match result { + Err(PairingError::TtlExceedsMaximum { .. }) => {} + other => panic!("Expected TtlExceedsMaximum error, got {:?}", other), + } +} + +#[test] +fn test_invalid_version_rejection() { + let host_id = Uuid::new_v4().to_string(); + let payload = PairingPayloadV1 { + v: 2, + payload_type: "hermes-pair".to_string(), + host_id, + name: "Future-Node".to_string(), + host: "10.0.0.5".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + + let uri = encode_pairing_uri(&payload); + let result = decode_pairing_uri_at_time(&uri, 1000); match result { Err(PairingError::UnsupportedVersion(v)) => { @@ -144,6 +220,127 @@ fn test_invalid_version_rejection() { } } +#[test] +fn test_invalid_payload_type_rejection() { + let host_id = Uuid::new_v4().to_string(); + let payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-auth".to_string(), + host_id, + name: "Bad-Type-Node".to_string(), + host: "10.0.0.5".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + + let uri = encode_pairing_uri(&payload); + let result = decode_pairing_uri_at_time(&uri, 1000); + + match result { + Err(PairingError::InvalidPayloadType(t)) => { + assert_eq!(t, "hermes-auth"); + } + other => panic!("Expected InvalidPayloadType error, got {:?}", other), + } +} + +#[test] +fn test_invalid_uuid_rejection() { + let payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id: "not-a-valid-uuid".to_string(), + name: "Bad-UUID-Node".to_string(), + host: "10.0.0.5".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + + let uri = encode_pairing_uri(&payload); + let result = decode_pairing_uri_at_time(&uri, 1000); + + match result { + Err(PairingError::InvalidHostId(_)) => {} + other => panic!("Expected InvalidHostId error, got {:?}", other), + } +} + +#[test] +fn test_blank_or_oversized_name_rejection() { + let host_id = Uuid::new_v4().to_string(); + + // Blank name + let mut payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id: host_id.clone(), + name: " ".to_string(), + host: "10.0.0.5".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + assert!(validate_payload(&payload, 1000).is_err()); + + // Control characters in name + payload.name = "Office\x00PC".to_string(); + assert!(validate_payload(&payload, 1000).is_err()); + payload.name = "Office\nPC".to_string(); + assert!(validate_payload(&payload, 1000).is_err()); + + // Oversized name (>128 chars) + payload.name = "A".repeat(129); + assert!(validate_payload(&payload, 1000).is_err()); + + // Valid 128-char name + payload.name = "A".repeat(128); + assert!(validate_payload(&payload, 1000).is_ok()); +} + +#[test] +fn test_malicious_host_rejection() { + let host_id = Uuid::new_v4().to_string(); + let base_payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id, + name: "Node".to_string(), + host: "".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + + let forbidden_hosts = vec![ + "", + " ", + "user@evil.com", + "evil.com/path", + "evil.com\\path", + "evil.com?param=1", + "evil.com#frag", + "192.168.1.1:9119", + "192.168.1.1 evil.com", + "192.168.1.1\x00", + ]; + + for bad_host in forbidden_hosts { + let mut p = base_payload.clone(); + p.host = bad_host.to_string(); + assert!( + validate_payload(&p, 1000).is_err(), + "Host '{}' should be rejected", + bad_host + ); + } +} + #[test] fn test_invalid_port_rejection() { let host_id = Uuid::new_v4().to_string(); @@ -153,14 +350,14 @@ fn test_invalid_port_rejection() { host_id, name: "Invalid-Port-Node".to_string(), host: "192.168.1.1".to_string(), - port: 0, // Port 0 is invalid + port: 0, scheme: "http".to_string(), - expires_at: 3000000000, - nonce: "test_nonce".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), }; let uri = encode_pairing_uri(&payload); - let result = decode_pairing_uri_at_time(&uri, 10000); + let result = decode_pairing_uri_at_time(&uri, 1000); match result { Err(PairingError::InvalidPort(p)) => { @@ -170,6 +367,138 @@ fn test_invalid_port_rejection() { } } +#[test] +fn test_invalid_scheme_rejection() { + let host_id = Uuid::new_v4().to_string(); + let mut payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id, + name: "Node".to_string(), + host: "192.168.1.1".to_string(), + port: 9119, + scheme: "ftp".to_string(), + expires_at: 1100, + nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(), + }; + + assert!(validate_payload(&payload, 1000).is_err()); + + payload.scheme = "ws".to_string(); + assert!(validate_payload(&payload, 1000).is_err()); + + payload.scheme = "http".to_string(); + assert!(validate_payload(&payload, 1000).is_ok()); + + payload.scheme = "https".to_string(); + assert!(validate_payload(&payload, 1000).is_ok()); +} + +#[test] +fn test_nonce_validation() { + let host_id = Uuid::new_v4().to_string(); + let mut payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id, + name: "Node".to_string(), + host: "192.168.1.1".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1100, + nonce: "".to_string(), + }; + + // Empty nonce rejected + assert!(validate_payload(&payload, 1000).is_err()); + + // Nonce too short (< 16 bytes decoded) + let short_bytes = [1u8; 15]; + payload.nonce = URL_SAFE_NO_PAD.encode(short_bytes); + assert!(validate_payload(&payload, 1000).is_err()); + + // Valid 16-byte nonce + let valid_16 = [1u8; 16]; + payload.nonce = URL_SAFE_NO_PAD.encode(valid_16); + assert!(validate_payload(&payload, 1000).is_ok()); + + // Valid 32-byte nonce + let valid_32 = [1u8; 32]; + payload.nonce = URL_SAFE_NO_PAD.encode(valid_32); + assert!(validate_payload(&payload, 1000).is_ok()); + + // Valid 64-byte nonce + let valid_64 = [1u8; 64]; + payload.nonce = URL_SAFE_NO_PAD.encode(valid_64); + assert!(validate_payload(&payload, 1000).is_ok()); + + // Nonce too long (> 64 bytes decoded) + let too_long = [1u8; 65]; + payload.nonce = URL_SAFE_NO_PAD.encode(too_long); + assert!(validate_payload(&payload, 1000).is_err()); + + // Invalid base64 characters + payload.nonce = "not-valid-base64!@#$%".to_string(); + assert!(validate_payload(&payload, 1000).is_err()); +} + +#[test] +fn test_oversized_payload_rejection() { + let huge_uri = format!( + "hermes://pair?data={}", + "A".repeat(MAX_ENCODED_URI_BYTES + 10) + ); + let result = decode_pairing_uri(&huge_uri); + match result { + Err(PairingError::PayloadTooLarge { .. }) => {} + other => panic!("Expected PayloadTooLarge error, got {:?}", other), + } + + // Huge JSON decoded payload + let huge_data = vec![b' '; MAX_DECODED_JSON_BYTES + 100]; + let encoded = URL_SAFE_NO_PAD.encode(&huge_data); + let uri = format!("hermes://pair?data={}", encoded); + let result = decode_pairing_uri(&uri); + match result { + Err(PairingError::PayloadTooLarge { .. }) => {} + other => panic!("Expected PayloadTooLarge error, got {:?}", other), + } +} + +#[test] +fn test_cli_parse_hermes_url_and_endpoint_resolution() { + let (scheme, host, port) = + parse_hermes_url("http://127.0.0.1:9222").expect("Should parse hermes url"); + assert_eq!(scheme, "http"); + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 9222); + + let (scheme, host, port) = + parse_hermes_url("https://localhost:8443").expect("Should parse https hermes url"); + assert_eq!(scheme, "https"); + assert_eq!(host, "localhost"); + assert_eq!(port, 8443); + + assert!(parse_hermes_url("ftp://127.0.0.1:9119").is_err()); + + // Endpoint resolution + let (s, p) = resolve_cli_endpoint(Some("http://127.0.0.1:9222"), None).unwrap(); + assert_eq!(s, "http"); + assert_eq!(p, 9222); + + let (s, p) = resolve_cli_endpoint(Some("https://127.0.0.1:9222"), Some(8888)).unwrap(); + assert_eq!(s, "https"); + assert_eq!(p, 8888); + + let (s, p) = resolve_cli_endpoint(None, None).unwrap(); + assert_eq!(s, "http"); + assert_eq!(p, 9119); + + let (s, p) = resolve_cli_endpoint(None, Some(9555)).unwrap(); + assert_eq!(s, "http"); + assert_eq!(p, 9555); +} + #[test] fn test_network_interface_filtering() { let test_interfaces = vec![