docs/core/pairing.mdx
Device pairing establishes trust between Spacedrive instances using cryptographic signatures and user-friendly codes. Once paired, devices can communicate securely and share data directly.
Pairing uses a 12-word code to create a secure connection between two devices. The initiator generates the code, and the joiner enters it to establish trust.
Spacedrive uses BIP39 mnemonic codes for pairing, which come in two formats:
A 12-word BIP39 mnemonic for manual entry:
brave lion sunset river eagle mountain forest ocean thunder crystal diamond phoenix
This format:
A JSON structure that enables both local and cross-network pairing:
{
"version": 2,
"words": "brave lion sunset river eagle mountain forest ocean thunder crystal diamond phoenix",
"node_id": "6jn4e7l3pzx2kqhv..."
}
This format:
The pairing protocol provides multiple security guarantees:
Authentication: Devices prove their identity using Ed25519 signatures Confidentiality: All communication encrypted with session keys Integrity: Challenge-response prevents tampering Forward secrecy: New keys for each session
Text-based codes are best for:
Limitations:
QR codes are recommended for:
Benefits:
// For local network pairing (manual entry)
console.log(Share this code: ${result.code});
// For cross-network pairing (QR code)
console.log(QR code data: ${result.qr_json});
// Contains: { version: 2, words: "...", node_id: "..." }
</Step>
<Step title="Wait for Connection">
The device advertises via mDNS (local) and pkarr (internet) and waits for a joiner. The code expires after 5 minutes.
**Advertisement includes:**
- Session ID (via mDNS user_data)
- Node address published to dns.iroh.link (via pkarr)
</Step>
<Step title="Verify Joiner">
When a joiner connects, the initiator sends a cryptographic challenge to verify they have the correct code and own their device keys.
</Step>
<Step title="Complete Pairing">
After verification, both devices exchange session keys and save the pairing relationship.
</Step>
</Steps>
### For the Joiner
<Steps>
<Step title="Enter Code">
Enter the code from the initiator (text or QR):
```typescript
// Manual entry (local network only)
await client.action("network.pair.join", {
code: "brave lion sunset river eagle mountain forest ocean thunder crystal diamond phoenix"
});
// QR code scan (local + internet)
await client.action("network.pair.join", {
code: '{"version":2,"words":"brave lion sunset...","node_id":"..."}'
});
// Manual entry with node_id (enables internet pairing)
await client.action("network.pair.join", {
code: "brave lion sunset...",
node_id: "6jn4e7l3pzx2kqhv..."
});
With QR codes, both paths run simultaneously and the first to succeed wins.
Proxy pairing lets a new device join a network after a single direct pairing. The device that completed the direct pairing can vouch for the new device to other trusted devices. Each receiving device can accept or reject the vouch.
See Proxy Pairing for the full protocol, resource model, and flows.
The pairing protocol uses four message types:
pub enum PairingMessage {
// Joiner → Initiator: "I want to pair"
PairingRequest {
session_id: Uuid,
device_info: DeviceInfo,
public_key: Vec<u8>,
},
// Initiator → Joiner: "Prove you have the code"
Challenge {
session_id: Uuid,
challenge: Vec<u8>, // 32 random bytes
device_info: DeviceInfo,
},
// Joiner → Initiator: "Here's my signature"
Response {
session_id: Uuid,
response: Vec<u8>, // 64-byte Ed25519 signature
device_info: DeviceInfo,
},
// Initiator → Joiner: "Pairing complete"
Complete {
session_id: Uuid,
success: bool,
reason: Option<String>,
},
}
The PairingProtocolHandler manages session state:
pub enum PairingState {
// Initiator states
WaitingForConnection, // Code generated, waiting
ChallengeIssued, // Sent challenge to joiner
// Joiner states
Connecting, // Looking for initiator
ChallengeReceived, // Got challenge, signing
// Terminal states
Completed, // Success!
Failed(String), // Something went wrong
}
Each pairing attempt creates a session:
pub struct PairingSession {
session_id: Uuid, // Derived from code
state: PairingState, // Current state
remote_device: Option<DeviceInfo>,
created_at: SystemTime,
expires_at: SystemTime, // 5 minutes later
}
Devices find each other through multiple methods, depending on the pairing code format:
On the same network, devices discover each other instantly using multicast DNS:
// Initiator broadcasts session_id via user_data
endpoint.set_user_data_for_discovery(Some(session_id));
// Joiner listens for matching session_id
discovery_stream.filter(|item| {
item.node_info().data.user_data() == session_id
});
How it works:
For pairing across networks, Spacedrive uses pkarr to publish and resolve node addresses via DNS:
// Automatic pkarr publishing (done by Iroh)
.add_discovery(PkarrPublisher::n0_dns()) // Publish to dns.iroh.link
.add_discovery(DnsDiscovery::n0_dns()) // Resolve from dns.iroh.link
// Joiner queries by node_id
let node_addr = NodeAddr::new(node_id); // Pkarr resolves in background
endpoint.connect(node_addr, PAIRING_ALPN).await?;
How it works:
dns.iroh.link via pkarrdns.iroh.link with the node_id from QR codeWhen using QR codes (with node_id), Spacedrive races both discovery methods:
tokio::select! {
result = try_mdns_discovery(session_id) => {
// Fast path: local network
}
result = try_relay_discovery(node_id) => {
// Reliable path: internet via pkarr
}
}
// First to succeed wins, other is canceled
This approach optimizes for speed on local networks while ensuring reliability across the internet.
When direct connection fails, devices automatically connect through relay servers:
// Relay mode configured at startup
.relay_mode(RelayMode::Default) // Uses n0's production relays
// Automatic relay fallback during connection
endpoint.connect(node_addr, PAIRING_ALPN).await?; // Tries direct, then relay
Current Configuration:
The challenge-response prevents replay attacks and verifies device identity:
// Initiator generates challenge
let challenge = rand::thread_rng().gen::<[u8; 32]>();
// Joiner signs challenge
let signature = signing_key.sign(&challenge);
// Initiator verifies signature
let valid = verifying_key.verify(&challenge, &signature).is_ok();
Session keys are derived from the pairing code and device identities:
// Derive shared secret from pairing code
let shared_secret = hkdf::extract(
&pairing_code.secret,
&[initiator_id, joiner_id].concat()
);
// Generate session keys
let (tx_key, rx_key) = hkdf::expand(
&shared_secret,
b"spacedrive-session-keys",
64
);
Spacedrive uses pkarr for decentralized node address resolution:
// Automatic publishing (initiator)
let endpoint = Endpoint::builder()
.add_discovery(PkarrPublisher::n0_dns()) // Publishes to dns.iroh.link
.bind().await?;
// Automatic resolution (joiner)
let endpoint = Endpoint::builder()
.add_discovery(DnsDiscovery::n0_dns()) // Resolves from dns.iroh.link
.bind().await?;
// Discovery happens automatically during connection
endpoint.connect(NodeAddr::new(node_id), PAIRING_ALPN).await?;
How Pkarr Works:
All pairing communication uses encrypted channels:
pub enum PairingError {
// User errors
InvalidCode, // Wrong or malformed code
CodeExpired, // Took too long
// Network errors
DeviceNotFound, // Can't find initiator
ConnectionFailed, // Network issues
// Security errors
InvalidSignature, // Challenge verification failed
UntrustedDevice, // Device key mismatch
// State errors
SessionNotFound, // Unknown session ID
InvalidState, // Wrong state transition
}
Invalid code: Check spelling, ensure correct code Connection failed: Check network, firewall settings Timeout: Generate new code and try again Signature failed: Restart both applications
// High-level API
pub async fn start_pairing_as_initiator(
&self
) -> Result<PairingCode> {
// Generate secure code
let code = PairingCode::generate();
let session_id = code.derive_session_id();
// Create session
let session = PairingSession::new_initiator(session_id);
self.sessions.insert(session_id, session);
// Advertise on network
self.advertise_pairing(session_id).await?;
Ok(code)
}
// High-level API
pub async fn start_pairing_as_joiner(
&self,
code: &str
) -> Result<()> {
// Parse and validate code
let pairing_code = PairingCode::from_str(code)?;
let session_id = pairing_code.derive_session_id();
// Create session
let session = PairingSession::new_joiner(session_id);
self.sessions.insert(session_id, session);
// Find and connect to initiator
let initiator = self.discover_initiator(session_id).await?;
self.connect_and_pair(initiator, session_id).await?;
Ok(())
}
impl PairingProtocolHandler {
async fn handle_message(
&mut self,
msg: PairingMessage,
peer_id: PeerId,
) -> Result<()> {
match msg {
PairingMessage::PairingRequest { .. } => {
self.handle_pairing_request(..);
}
PairingMessage::Challenge { .. } => {
self.handle_challenge(..);
}
PairingMessage::Response { .. } => {
self.handle_response(..);
}
PairingMessage::Complete { .. } => {
self.handle_complete(..);
}
}
}
}
#[test]
fn test_pairing_code_generation() {
let code = PairingCode::generate();
assert_eq!(code.words.len(), 12);
assert!(code.is_valid());
}
#[test]
fn test_challenge_response() {
let (signing_key, verifying_key) = generate_keypair();
let challenge = generate_challenge();
let signature = signing_key.sign(&challenge);
assert!(verifying_key.verify(&challenge, &signature).is_ok());
}
#[tokio::test]
async fn test_full_pairing_flow() {
// Start initiator
let code = initiator.start_pairing_as_initiator().await?;
// Join with code
joiner.start_pairing_as_joiner(&code.to_string()).await?;
// Verify both paired
assert!(initiator.is_paired_with(joiner.device_id()));
assert!(joiner.is_paired_with(initiator.device_id()));
}
Check:
For text-based codes:
For QR codes:
Solutions: