From 73da2bf1e920fd59363d1b23307b2ecbc85acbde Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 25 Nov 2025 13:57:41 -0800 Subject: [PATCH] Migrate pairing to pkarr-based remote discovery - Remove relay-URL based pairing flow and rely on pkarr/DNS for remote discovery - Extend pairing code with optional node_id and adopt version 2 QR format - Generate pairing codes using node_id and drop relay_url in non-QR flows - Enable combined mDNS, pkarr, and DNS discovery in the endpoint - Cache relay_url in persisted device info for reconnection optimization - Update protocol handlers to propagate node_id and pkarr-based flow - Adjust tests and logging to reflect the new cross-network pairing --- apps/cli/src/domains/network/args.rs | 25 +- apps/cli/src/domains/network/mod.rs | 2 +- core/src/service/network/core/mod.rs | 263 ++++-------------- .../src/service/network/device/persistence.rs | 15 +- core/src/service/network/device/registry.rs | 5 +- .../network/protocol/pairing/initiator.rs | 11 +- .../network/protocol/pairing/joiner.rs | 11 +- .../service/network/protocol/pairing/mod.rs | 4 +- .../service/network/protocol/pairing/types.rs | 215 ++------------ 9 files changed, 119 insertions(+), 432 deletions(-) diff --git a/apps/cli/src/domains/network/args.rs b/apps/cli/src/domains/network/args.rs index 056acaeef..1303f6cba 100644 --- a/apps/cli/src/domains/network/args.rs +++ b/apps/cli/src/domains/network/args.rs @@ -24,15 +24,9 @@ pub enum PairCmd { Join { /// Pairing code (12 words or JSON). If not provided, enters interactive mode. code: Option, - /// Relay URL for internet pairing (optional) - #[arg(long)] - relay_url: Option, - /// Node ID for internet pairing (optional, required if relay_url is provided) + /// Node ID for remote pairing via pkarr (optional - enables relay path) #[arg(long)] node_id: Option, - /// Session ID for internet pairing (optional, required if relay_url is provided) - #[arg(long)] - session_id: Option, }, /// Show pairing sessions Status, @@ -52,28 +46,21 @@ impl PairCmd { pub fn to_join_input(&self) -> Option { match self { - Self::Join { code, relay_url, node_id, session_id } => { + Self::Join { code, node_id } => { // Code is required for non-interactive mode let code = code.as_ref()?.clone(); - // If relay URL is provided, construct QR JSON format - let code = if let Some(relay_url) = relay_url { - // Validate node_id and session_id are also provided - let node_id = node_id.as_ref().expect("--node-id is required when --relay-url is provided"); - let session_id = session_id.as_ref().expect("--session-id is required when --relay-url is provided"); - - // First try to parse as JSON (in case they passed the full QR JSON) + // If node_id provided via CLI, wrap in QR JSON format for remote pairing + let code = if let Some(node_id) = node_id { if code.trim().starts_with('{') { // Already JSON, just use it code } else { - // Plain words - construct QR JSON format + // Plain words + node_id - construct QR JSON format (v2) serde_json::json!({ - "version": 1, + "version": 2, "words": code, "node_id": node_id, - "relay_url": relay_url, - "session_id": session_id }).to_string() } } else { diff --git a/apps/cli/src/domains/network/mod.rs b/apps/cli/src/domains/network/mod.rs index a13d1e323..5c32c9df7 100644 --- a/apps/cli/src/domains/network/mod.rs +++ b/apps/cli/src/domains/network/mod.rs @@ -100,7 +100,7 @@ pub async fn run(ctx: &Context, cmd: NetworkCmd) -> Result<()> { println!("Expires at: {}", o.expires_at); }); } - PairCmd::Join { ref code, ref relay_url, ref node_id, ref session_id } => { + PairCmd::Join { ref code, ref node_id } => { // Check if we should run interactive mode let input = if let Some(input) = pc.to_join_input() { // Non-interactive: code and possibly flags were provided diff --git a/core/src/service/network/core/mod.rs b/core/src/service/network/core/mod.rs index fa5122c1e..67c886dd3 100644 --- a/core/src/service/network/core/mod.rs +++ b/core/src/service/network/core/mod.rs @@ -9,7 +9,9 @@ use crate::service::network::{ utils::{logging::NetworkLogger, NetworkIdentity}, NetworkingError, Result, }; -use iroh::discovery::{mdns::MdnsDiscovery, Discovery}; +use iroh::discovery::{ + dns::DnsDiscovery, mdns::MdnsDiscovery, pkarr::PkarrPublisher, Discovery, +}; use iroh::endpoint::Connection; use iroh::{Endpoint, NodeAddr, NodeId, RelayMode, RelayUrl, Watcher}; use std::sync::Arc; @@ -175,17 +177,17 @@ impl NetworkingService { // Create Iroh endpoint with discovery and relay configuration let secret_key = self.identity.to_iroh_secret_key()?; - // Create discovery service - using mDNS discovery - let discovery = MdnsDiscovery::builder(); - self.logger .info(&format!( - "Created MdnsDiscovery builder for node {}", + "Creating endpoint with mDNS + pkarr discovery for node {}", self.node_id )) .await; - // Create endpoint with discovery + // Create endpoint with combined discovery: + // - mDNS for local network discovery + // - PkarrPublisher to publish our address to dns.iroh.link (enables remote discovery) + // - DnsDiscovery to resolve other nodes from dns.iroh.link let endpoint = Endpoint::builder() .secret_key(secret_key) .alpns(vec![ @@ -195,7 +197,9 @@ impl NetworkingService { SYNC_ALPN.to_vec(), ]) .relay_mode(iroh::RelayMode::Default) - .add_discovery(discovery) + .add_discovery(MdnsDiscovery::builder()) + .add_discovery(PkarrPublisher::n0_dns()) + .add_discovery(DnsDiscovery::n0_dns()) .bind_addr_v4(std::net::SocketAddrV4::new( std::net::Ipv4Addr::UNSPECIFIED, 0, @@ -214,7 +218,7 @@ impl NetworkingService { self.endpoint = Some(endpoint.clone()); self.logger - .info("Endpoint bound successfully with mDNS discovery enabled") + .info("Endpoint bound successfully with mDNS + pkarr discovery enabled") .await; // Create and start event loop @@ -1098,23 +1102,20 @@ impl NetworkingService { )) } - /// Try to discover the initiator via relay (works across networks) + /// Try to discover the initiator via pkarr/DNS (works across networks) + /// Pkarr discovery automatically resolves node_id to relay_url and direct addresses async fn try_relay_discovery( &self, pairing_code: &crate::service::network::protocol::pairing::PairingCode, ) -> Result<()> { - // Get the NodeId from the pairing code (should always be present in new implementation) + // Get the NodeId from the pairing code let node_id = pairing_code.node_id().ok_or_else(|| { NetworkingError::ConnectionFailed( - "Pairing code missing NodeId - this indicates a bug in the new implementation" + "Pairing code missing NodeId - cannot use pkarr discovery for remote pairing" .to_string(), ) })?; - let relay_url = pairing_code - .relay_url() - .map(|url| url.parse::()); - let endpoint = self .endpoint .as_ref() @@ -1124,69 +1125,39 @@ impl NetworkingService { self.logger .info(&format!( - "[Relay] Attempting to connect to initiator {} via relay", + "[Pkarr] Attempting to discover and connect to initiator {} via pkarr/DNS", node_id.fmt_short() )) .await; - // Build NodeAddr with relay information - let relay_url_parsed = if let Some(relay_url) = relay_url { - match relay_url { - Ok(url) => { - self.logger - .debug(&format!("[Relay] Using relay URL: {}", url)) - .await; - Some(url) - } - Err(e) => { - self.logger - .warn(&format!("[Relay] Failed to parse relay URL: {}", e)) - .await; - None - } - } - } else { - self.logger - .warn("[Relay] No relay URL in pairing code, using default relay") - .await; - None - }; + // Just provide the node_id - pkarr discovery will automatically: + // 1. Query dns.iroh.link/pkarr for the node's published address info + // 2. Get the relay_url and any direct addresses + // 3. Try to connect via the best available path + let node_addr = NodeAddr::new(node_id); - let node_addr = iroh::NodeAddr::from_parts( - node_id, - relay_url_parsed, - vec![], // No direct addresses initially, will use relay - ); - - // Try to connect via relay - let timeout = tokio::time::Duration::from_secs(10); // Longer timeout for relay - match tokio::time::timeout(timeout, endpoint.connect(node_addr.clone(), PAIRING_ALPN)).await - { + // Try to connect - pkarr discovery runs in the background + let timeout = tokio::time::Duration::from_secs(15); + match tokio::time::timeout(timeout, endpoint.connect(node_addr, PAIRING_ALPN)).await { Ok(Ok(conn)) => { self.logger - .info("[Relay] Successfully connected to initiator via relay!") + .info("[Pkarr] Successfully connected to initiator!") .await; - // Track the connection so it stays alive for the pairing protocol (with PAIRING_ALPN) + // Track the connection for the pairing protocol { let mut connections = self.active_connections.write().await; connections.insert((node_id, PAIRING_ALPN.to_vec()), conn); - self.logger - .info(&format!( - "[Relay] Tracked relay pairing connection to {}", - node_id.fmt_short() - )) - .await; } Ok(()) } Ok(Err(e)) => Err(NetworkingError::ConnectionFailed(format!( - "Failed to connect via relay: {}", + "Failed to connect via pkarr discovery: {}", e ))), Err(_timeout) => Err(NetworkingError::ConnectionFailed( - "Relay connection timeout".to_string(), + "Pkarr discovery connection timeout".to_string(), )), } } @@ -1215,29 +1186,15 @@ impl NetworkingService { "Invalid pairing handler type".to_string(), ))?; - // Get our node information for relay discovery + // Get our node ID for inclusion in QR code (enables pkarr lookup for remote pairing) let initiator_node_id = self.node_id(); - // Get our relay URL from the endpoint (wait for relay to connect) - let relay_url = if let Some(endpoint) = &self.endpoint { - // Wait for relay to initialize (this is critical!) - let relay = endpoint.home_relay().initialized().await; - Some(relay.to_string()) - } else { - None - }; - - // Generate pairing code with relay information for cross-network pairing - let random_seed = uuid::Uuid::new_v4(); + // Generate pairing code with node_id for remote discovery via pkarr + // Note: relay_url is no longer included - joiner discovers it via pkarr/DNS let pairing_code = - crate::service::network::protocol::pairing::PairingCode::from_session_id_with_relay_info( - random_seed, - initiator_node_id, - relay_url, - ); + crate::service::network::protocol::pairing::PairingCode::generate()? + .with_node_id(initiator_node_id); - // The session_id derived from the pairing code - // This ensures both initiator and joiner derive the same session_id from the BIP39 words let session_id = pairing_code.session_id(); // Start pairing session with the derived session_id @@ -1245,154 +1202,44 @@ impl NetworkingService { .start_pairing_session_with_id(session_id, pairing_code.clone()) .await?; - // Get our node address first (needed for device registry) - let mut node_addr = self.get_node_addr()?; - - // If we don't have any direct addresses yet, wait a bit for them to be discovered - if let Some(addr) = &node_addr { - if addr.direct_addresses().count() == 0 { - self.logger - .info("No direct addresses discovered yet, waiting for endpoint to discover addresses...") - .await; - - // Wait up to 5 seconds for addresses to be discovered - let mut attempts = 0; - const MAX_ATTEMPTS: u32 = 10; - const WAIT_TIME_MS: u64 = 500; - - while attempts < MAX_ATTEMPTS { - tokio::time::sleep(tokio::time::Duration::from_millis(WAIT_TIME_MS)).await; - node_addr = self.get_node_addr()?; - - if let Some(addr) = &node_addr { - if addr.direct_addresses().count() > 0 { - self.logger - .info(&format!( - "Discovered {} direct addresses", - addr.direct_addresses().count() - )) - .await; - break; - } - } - - attempts += 1; - } - } - } - - if node_addr - .as_ref() - .map_or(true, |addr| addr.direct_addresses().count() == 0) - { - self.logger - .warn("No direct addresses discovered after waiting, proceeding with relay-only address") - .await; - } - - self.logger - .info(&format!("Node address: {:?}", node_addr)) - .await; - self.logger - .info(&format!( - "Direct addresses: {:?}", - node_addr - .as_ref() - .map(|addr| addr.direct_addresses().collect::>()) - .unwrap_or_default() - )) - .await; - self.logger - .info(&format!( - "Relay URL: {:?}", - node_addr.as_ref().and_then(|addr| addr.relay_url()) - )) - .await; - - // Register in device registry with the node address + // Register in device registry let initiator_device_id = self.device_id(); - let initiator_node_id = self.node_id(); + let node_addr = self + .get_node_addr()? + .unwrap_or(NodeAddr::new(initiator_node_id)); let device_registry = self.device_registry(); { let mut registry = device_registry.write().await; - // Use node_addr or create an empty one if not available - let addr_for_registry = node_addr - .clone() - .unwrap_or(NodeAddr::new(initiator_node_id)); registry.start_pairing( initiator_device_id, initiator_node_id, session_id, - addr_for_registry, + node_addr, )?; } // Publish pairing session via mDNS using user_data field // The joiner will filter discovered nodes by this session_id - if let Some(endpoint) = &self.endpoint { - let user_data = - iroh::node_info::UserData::try_from(session_id.to_string()).map_err(|e| { - NetworkingError::Protocol(format!("Failed to create user data: {}", e)) - })?; + let endpoint = self.endpoint.as_ref().ok_or(NetworkingError::Protocol( + "Networking not started".to_string(), + ))?; - self.logger - .debug(&format!( - "Setting user_data for discovery: {}", - user_data.as_ref() - )) - .await; + let user_data = + iroh::node_info::UserData::try_from(session_id.to_string()).map_err(|e| { + NetworkingError::Protocol(format!("Failed to create user data: {}", e)) + })?; - // Get current user_data before setting to verify the change - let current_node_data = endpoint.node_addr().get(); - self.logger - .debug(&format!( - "Current node user_data before set: {:?}", - current_node_data.as_ref().and_then(|addr| { - // NodeAddr doesn't expose user_data, so we can't check it here - Some("(NodeAddr doesn't expose user_data)") - }) - )) - .await; + endpoint.set_user_data_for_discovery(Some(user_data)); - endpoint.set_user_data_for_discovery(Some(user_data.clone())); + self.logger + .info(&format!( + "Broadcasting pairing session {} via mDNS + pkarr", + session_id + )) + .await; - self.logger - .debug(&format!( - "Called endpoint.set_user_data_for_discovery with: {}", - user_data.as_ref() - )) - .await; - - self.logger - .info(&format!( - "Broadcasting pairing session {} via mDNS (set_user_data_for_discovery called)", - session_id - )) - .await; - - // Wait for mDNS re-advertisement to propagate - // When user_data changes, the endpoint triggers a re-publish to discovery services - // This delay ensures the updated broadcast (with session_id) is sent before joiners start listening - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - self.logger - .debug("mDNS re-advertisement delay completed, session_id should now be broadcast") - .await; - - // Log current node address to verify what's being broadcast - if let Some(node_addr) = self.get_node_addr().ok().flatten() { - self.logger - .debug(&format!( - "Broadcasting with {} direct addresses", - node_addr.direct_addresses().count() - )) - .await; - } - } else { - return Err(NetworkingError::Protocol( - "Networking not started".to_string(), - )); - } + // Wait for discovery re-advertisement to propagate + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; let expires_in = 300; // 5 minutes diff --git a/core/src/service/network/device/persistence.rs b/core/src/service/network/device/persistence.rs index ec08005b6..32c9e6a94 100644 --- a/core/src/service/network/device/persistence.rs +++ b/core/src/service/network/device/persistence.rs @@ -25,6 +25,9 @@ pub struct PersistedPairedDevice { pub last_connected_at: Option>, pub connection_attempts: u32, pub trust_level: TrustLevel, + /// Cached relay URL for reconnection optimization (discovered via pkarr or connection) + #[serde(default)] + pub relay_url: Option, } /// Encrypted device data for disk storage @@ -259,6 +262,7 @@ impl DevicePersistence { device_id: Uuid, device_info: DeviceInfo, session_keys: SessionKeys, + relay_url: Option, ) -> Result<()> { let mut devices = self.load_paired_devices().await?; @@ -269,6 +273,7 @@ impl DevicePersistence { last_connected_at: None, connection_attempts: 0, trust_level: TrustLevel::Trusted, + relay_url, }; devices.insert(device_id, paired_device); @@ -436,7 +441,7 @@ mod tests { // Add paired device persistence - .add_paired_device(device_id, device_info.clone(), session_keys.clone()) + .add_paired_device(device_id, device_info.clone(), session_keys.clone(), None) .await .unwrap(); @@ -460,7 +465,7 @@ mod tests { let session_keys = SessionKeys::from_shared_secret(vec![1, 2, 3, 4]); persistence - .add_paired_device(device_id, device_info, session_keys) + .add_paired_device(device_id, device_info, session_keys, None) .await .unwrap(); @@ -478,7 +483,7 @@ mod tests { let session_keys = SessionKeys::from_shared_secret(vec![1, 2, 3, 4]); persistence - .add_paired_device(device_id, device_info, session_keys) + .add_paired_device(device_id, device_info, session_keys, None) .await .unwrap(); @@ -503,7 +508,7 @@ mod tests { // Add device (this will encrypt and save) persistence - .add_paired_device(device_id, device_info.clone(), session_keys.clone()) + .add_paired_device(device_id, device_info.clone(), session_keys.clone(), None) .await .unwrap(); @@ -531,7 +536,7 @@ mod tests { // Add device persistence - .add_paired_device(device_id, device_info, session_keys) + .add_paired_device(device_id, device_info, session_keys, None) .await .unwrap(); diff --git a/core/src/service/network/device/registry.rs b/core/src/service/network/device/registry.rs index 4eef52746..1009fde49 100644 --- a/core/src/service/network/device/registry.rs +++ b/core/src/service/network/device/registry.rs @@ -161,6 +161,7 @@ impl DeviceRegistry { device_id: Uuid, info: DeviceInfo, session_keys: SessionKeys, + relay_url: Option, ) -> Result<()> { // Parse node ID from network fingerprint let node_id = info @@ -208,10 +209,10 @@ impl DeviceRegistry { .await; } - // Persist the paired device for future reconnection + // Persist the paired device for future reconnection (with relay_url for optimization) if let Err(e) = self .persistence - .add_paired_device(device_id, info.clone(), session_keys.clone()) + .add_paired_device(device_id, info.clone(), session_keys.clone(), relay_url) .await { self.logger diff --git a/core/src/service/network/protocol/pairing/initiator.rs b/core/src/service/network/protocol/pairing/initiator.rs index 2169857cc..03f29ccf8 100644 --- a/core/src/service/network/protocol/pairing/initiator.rs +++ b/core/src/service/network/protocol/pairing/initiator.rs @@ -10,7 +10,7 @@ use crate::service::network::{ device::{DeviceInfo, SessionKeys}, NetworkingError, Result, }; -use iroh::NodeId; +use iroh::{NodeId, Watcher}; use uuid::Uuid; impl PairingProtocolHandler { @@ -229,11 +229,18 @@ impl PairingProtocolHandler { .ok(); } + // Get relay URL from endpoint for caching (enables reconnection via relay) + let relay_url = self + .endpoint + .as_ref() + .and_then(|ep| ep.home_relay().get().into_iter().next()) + .map(|r| r.to_string()); + // Complete pairing in device registry { let mut registry = self.device_registry.write().await; registry - .complete_pairing(actual_device_id, device_info.clone(), session_keys) + .complete_pairing(actual_device_id, device_info.clone(), session_keys, relay_url) .await?; } diff --git a/core/src/service/network/protocol/pairing/joiner.rs b/core/src/service/network/protocol/pairing/joiner.rs index bfba3ea81..94bf21385 100644 --- a/core/src/service/network/protocol/pairing/joiner.rs +++ b/core/src/service/network/protocol/pairing/joiner.rs @@ -9,7 +9,7 @@ use crate::service::network::{ device::{DeviceInfo, SessionKeys}, NetworkingError, Result, }; -use iroh::NodeId; +use iroh::{NodeId, Watcher}; use uuid::Uuid; impl PairingProtocolHandler { @@ -180,11 +180,18 @@ impl PairingProtocolHandler { .ok(); } + // Get relay URL from endpoint for caching (enables reconnection via relay) + let relay_url = self + .endpoint + .as_ref() + .and_then(|ep| ep.home_relay().get().into_iter().next()) + .map(|r| r.to_string()); + // Complete pairing in device registry { let mut registry = self.device_registry.write().await; registry - .complete_pairing(device_id, initiator_device_info.clone(), session_keys) + .complete_pairing(device_id, initiator_device_info.clone(), session_keys, relay_url) .await?; } diff --git a/core/src/service/network/protocol/pairing/mod.rs b/core/src/service/network/protocol/pairing/mod.rs index 2cd46a5f4..5888bd44d 100644 --- a/core/src/service/network/protocol/pairing/mod.rs +++ b/core/src/service/network/protocol/pairing/mod.rs @@ -198,8 +198,8 @@ impl PairingProtocolHandler { /// Start a new pairing session as initiator /// Returns the session ID which should be advertised via DHT by the caller pub async fn start_pairing_session(&self) -> Result { - let session_id = Uuid::new_v4(); - let pairing_code = PairingCode::from_session_id(session_id); + let pairing_code = PairingCode::generate()?; + let session_id = pairing_code.session_id(); self.start_pairing_session_with_id(session_id, pairing_code) .await?; Ok(session_id) diff --git a/core/src/service/network/protocol/pairing/types.rs b/core/src/service/network/protocol/pairing/types.rs index b6ce6d9be..2d96dbe85 100644 --- a/core/src/service/network/protocol/pairing/types.rs +++ b/core/src/service/network/protocol/pairing/types.rs @@ -24,11 +24,8 @@ pub struct PairingCode { /// Expiration timestamp expires_at: DateTime, - /// Initiator's NodeId for relay discovery (optional for backward compatibility) + /// Initiator's NodeId for remote discovery via pkarr (optional - enables relay path) node_id: Option, - - /// Initiator's home relay URL for cross-network pairing (optional for backward compatibility) - relay_url: Option, } impl PairingCode { @@ -51,44 +48,13 @@ impl PairingCode { session_id, expires_at: Utc::now() + chrono::Duration::minutes(5), node_id: None, - relay_url: None, }) } - /// Generate a pairing code from a session ID - pub fn from_session_id(session_id: Uuid) -> Self { - // Use the session ID as the BIP39 entropy source (16 bytes) - let entropy = session_id.as_bytes(); - - // Expand entropy to full 32-byte secret deterministically - // This matches the logic in decode_from_bip39_words to ensure round-trip compatibility - let mut hasher = blake3::Hasher::new(); - hasher.update(b"spacedrive-pairing-entropy-extension-v1"); - hasher.update(entropy); - let derived_bytes = hasher.finalize(); - - let mut secret = [0u8; 32]; - secret[..16].copy_from_slice(entropy); - secret[16..].copy_from_slice(&derived_bytes.as_bytes()[..16]); - - // Generate BIP39 words from the entropy (first 16 bytes only, as per BIP39 standard) - let words = Self::encode_to_bip39_words(&secret).unwrap_or_else(|_| { - // Fallback to empty words if BIP39 fails - [const { String::new() }; 12] - }); - - // Derive session ID from the secret to ensure consistency with parsing - // both encode and decode paths must derive the same session_id - let derived_session_id = Self::derive_session_id(&secret); - - Self { - secret, - words, - session_id: derived_session_id, - expires_at: Utc::now() + chrono::Duration::minutes(5), - node_id: None, - relay_url: None, - } + /// Add node_id for remote pairing via pkarr discovery + pub fn with_node_id(mut self, node_id: NodeId) -> Self { + self.node_id = Some(node_id); + self } /// Parse a pairing code from a BIP39 mnemonic string (for local pairing) @@ -124,6 +90,7 @@ impl PairingCode { } /// Parse a pairing code from QR code JSON (for remote pairing) + /// Version 2 format: {version, words, node_id} - session_id is derived from words pub fn from_qr_json(json: &str) -> crate::service::network::Result { let data: serde_json::Value = serde_json::from_str(json).map_err(|e| { crate::service::network::NetworkingError::Protocol(format!( @@ -132,89 +99,28 @@ impl PairingCode { )) })?; - // Extract session_id - let session_id = data - .get("session_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - crate::service::network::NetworkingError::Protocol( - "Missing session_id in QR code".to_string(), - ) - })? - .parse::() - .map_err(|e| { - crate::service::network::NetworkingError::Protocol(format!( - "Invalid session_id in QR code: {}", - e - )) - })?; - - // Extract node_id - let node_id = data - .get("node_id") - .and_then(|v| v.as_str()) - .map(|s| s.parse::()) - .transpose() - .map_err(|e| { - crate::service::network::NetworkingError::Protocol(format!( - "Invalid node_id in QR code: {}", - e - )) - })?; - - // Extract relay_url - let relay_url = data - .get("relay_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // Extract words (BIP39 mnemonic) + // Extract words (BIP39 mnemonic) - required let words_str = data.get("words").and_then(|v| v.as_str()).ok_or_else(|| { crate::service::network::NetworkingError::Protocol( "Missing words in QR code".to_string(), ) })?; - // Parse the BIP39 words to get the secret - let words: Vec = words_str - .split_whitespace() - .map(|s| s.to_lowercase()) - .collect(); + // Parse words to get the base pairing code (session_id is derived from words) + let mut code = Self::from_string(words_str)?; - if words.len() != 12 { - return Err(crate::service::network::NetworkingError::Protocol(format!( - "Invalid word count in QR code - expected 12 but got {}", - words.len() - ))); + // Extract node_id (optional - enables remote pairing via pkarr) + if let Some(node_id_str) = data.get("node_id").and_then(|v| v.as_str()) { + let node_id = node_id_str.parse::().map_err(|e| { + crate::service::network::NetworkingError::Protocol(format!( + "Invalid node_id in QR code: {}", + e + )) + })?; + code.node_id = Some(node_id); } - let words_array: [String; 12] = words.try_into().map_err(|_| { - crate::service::network::NetworkingError::Protocol( - "Failed to convert words to array".to_string(), - ) - })?; - - // Decode BIP39 words to get secret - let secret = Self::decode_from_bip39_words(&words_array)?; - - // Use the session_id directly from the QR code (don't re-derive it) - // This is critical for cross-network pairing where the initiator and joiner - // must use the exact same session_id - // node_id is required for QR code relay pairing - let node_id = node_id.ok_or_else(|| { - crate::service::network::NetworkingError::Protocol( - "Missing node_id in QR code".to_string(), - ) - })?; - - Ok(PairingCode { - secret, - words: words_array, - session_id, // Use the session_id from the QR code - expires_at: Utc::now() + chrono::Duration::minutes(5), - node_id: Some(node_id), - relay_url, - }) + Ok(code) } /// Create pairing code from BIP39 words @@ -222,7 +128,7 @@ impl PairingCode { // Decode BIP39 words back to secret let secret = Self::decode_from_bip39_words(words)?; - // Derive session ID from the secret using the same method as from_session_id() + // Derive session ID from the secret // This ensures both the initiator and joiner get the same session_id let session_id = Self::derive_session_id(&secret); @@ -232,76 +138,9 @@ impl PairingCode { session_id, expires_at: Utc::now() + chrono::Duration::minutes(5), node_id: None, - relay_url: None, }) } - /// Create a new pairing code with relay information for cross-network pairing - pub fn generate_with_relay_info( - node_id: NodeId, - relay_url: Option, - ) -> crate::service::network::Result { - use rand::RngCore; - - let mut secret = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut secret); - - // Convert secret to 12 BIP39 words using proper mnemonic encoding - let words = Self::encode_to_bip39_words(&secret)?; - - // Derive session ID from secret - let session_id = Self::derive_session_id(&secret); - - Ok(PairingCode { - secret, - words, - session_id, - expires_at: Utc::now() + chrono::Duration::minutes(5), - node_id: Some(node_id), - relay_url, - }) - } - - /// Create a pairing code from session ID with relay information - pub fn from_session_id_with_relay_info( - session_id: Uuid, - node_id: NodeId, - relay_url: Option, - ) -> Self { - // Use the session ID as the BIP39 entropy source (16 bytes) - let entropy = session_id.as_bytes(); - - // Expand entropy to full 32-byte secret deterministically - // This matches the logic in decode_from_bip39_words to ensure round-trip compatibility - let mut hasher = blake3::Hasher::new(); - hasher.update(b"spacedrive-pairing-entropy-extension-v1"); - hasher.update(entropy); - let derived_bytes = hasher.finalize(); - - let mut secret = [0u8; 32]; - secret[..16].copy_from_slice(entropy); - secret[16..].copy_from_slice(&derived_bytes.as_bytes()[..16]); - - // Generate BIP39 words from the entropy (first 16 bytes only, as per BIP39 standard) - let words = Self::encode_to_bip39_words(&secret).unwrap_or_else(|_| { - // Fallback to empty words if BIP39 fails - [const { String::new() }; 12] - }); - - // Derive session ID from the secret to ensure consistency with parsing - // both encode and decode paths must derive the same session_id - let derived_session_id = Self::derive_session_id(&secret); - - Self { - secret, - words, - session_id: derived_session_id, - expires_at: Utc::now() + chrono::Duration::minutes(5), - node_id: Some(node_id), - relay_url, - } - } - /// Get the session ID from this pairing code pub fn session_id(&self) -> Uuid { self.session_id @@ -312,29 +151,23 @@ impl PairingCode { &self.secret } - /// Get the initiator's NodeId for relay discovery + /// Get the initiator's NodeId for pkarr discovery pub fn node_id(&self) -> Option { self.node_id } - /// Get the initiator's home relay URL for cross-network pairing - pub fn relay_url(&self) -> Option<&str> { - self.relay_url.as_deref() - } - /// Convert to display string (for local pairing - BIP39 words only) pub fn to_string(&self) -> String { self.words.join(" ") } - /// Convert to QR code JSON (for remote pairing - includes all metadata) + /// Convert to QR code JSON (for remote pairing) + /// Version 2: {version, words, node_id} - session_id derived from words, relay discovered via pkarr pub fn to_qr_json(&self) -> String { serde_json::json!({ - "version": 1, - "session_id": self.session_id, + "version": 2, "words": self.to_string(), "node_id": self.node_id.map(|id| id.to_string()), - "relay_url": self.relay_url }) .to_string() }