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
This commit is contained in:
Jamie Pine
2025-11-25 13:57:41 -08:00
parent 524b7e7507
commit 73da2bf1e9
9 changed files with 119 additions and 432 deletions

View File

@@ -24,15 +24,9 @@ pub enum PairCmd {
Join {
/// Pairing code (12 words or JSON). If not provided, enters interactive mode.
code: Option<String>,
/// Relay URL for internet pairing (optional)
#[arg(long)]
relay_url: Option<String>,
/// 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<String>,
/// Session ID for internet pairing (optional, required if relay_url is provided)
#[arg(long)]
session_id: Option<String>,
},
/// Show pairing sessions
Status,
@@ -52,28 +46,21 @@ impl PairCmd {
pub fn to_join_input(&self) -> Option<PairJoinInput> {
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 {

View File

@@ -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

View File

@@ -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::<iroh::RelayUrl>());
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::<Vec<_>>())
.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

View File

@@ -25,6 +25,9 @@ pub struct PersistedPairedDevice {
pub last_connected_at: Option<DateTime<Utc>>,
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<String>,
}
/// Encrypted device data for disk storage
@@ -259,6 +262,7 @@ impl DevicePersistence {
device_id: Uuid,
device_info: DeviceInfo,
session_keys: SessionKeys,
relay_url: Option<String>,
) -> 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();

View File

@@ -161,6 +161,7 @@ impl DeviceRegistry {
device_id: Uuid,
info: DeviceInfo,
session_keys: SessionKeys,
relay_url: Option<String>,
) -> 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

View File

@@ -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?;
}

View File

@@ -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?;
}

View File

@@ -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<Uuid> {
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)

View File

@@ -24,11 +24,8 @@ pub struct PairingCode {
/// Expiration timestamp
expires_at: DateTime<Utc>,
/// 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<NodeId>,
/// Initiator's home relay URL for cross-network pairing (optional for backward compatibility)
relay_url: Option<String>,
}
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<Self> {
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::<uuid::Uuid>()
.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::<NodeId>())
.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<String> = 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::<NodeId>().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<String>,
) -> crate::service::network::Result<Self> {
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<String>,
) -> 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<NodeId> {
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()
}