docs/core/proxy-pairing.mdx
Proxy pairing lets a new device join a network after a single direct pairing. The device that paired directly can vouch for the new device to other trusted devices. This reduces repeated pairing while keeping explicit user approval.
Direct pairings remain the base trust relationship. Proxy pairings are derived from a trusted voucher.
A <-> direct <-> B <-> direct <-> C
|
+-> proxied to C
Persisted paired devices record the pairing type and the voucher.
pub struct PersistedPairedDevice {
pub device_info: DeviceInfo,
pub session_keys: SessionKeys,
pub paired_at: DateTime<Utc>,
pub last_connected_at: Option<DateTime<Utc>>,
pub connection_attempts: u32,
pub trust_level: TrustLevel,
pub relay_url: Option<String>,
pub pairing_type: PairingType,
pub vouched_by: Option<Uuid>,
pub vouched_at: Option<DateTime<Utc>>,
}
pub enum PairingType {
Direct,
Proxied,
}
Proxy pairing builds on the direct confirmation flow. The direct pairing must reach a confirmed state before any vouching starts. The voucher then offers to vouch the new device to other devices. Receiving devices can auto accept or ask for user confirmation.
pub enum PairingMessage {
// Existing messages:
// PairingRequest, Challenge, Response, Complete, Reject
ProxyPairingRequest {
session_id: Uuid,
vouchee_device_info: DeviceInfo,
vouchee_public_key: Vec<u8>,
voucher_device_id: Uuid,
voucher_signature: Vec<u8>,
timestamp: DateTime<Utc>,
},
ProxyPairingResponse {
session_id: Uuid,
accepting_device_id: Uuid,
accepted: bool,
reason: Option<String>,
},
ProxyPairingComplete {
session_id: Uuid,
accepted_by: Vec<AcceptedDevice>,
rejected_by: Vec<RejectedDevice>,
},
}
pub struct AcceptedDevice {
pub device_id: Uuid,
pub device_name: String,
pub node_id: Option<String>,
}
pub struct RejectedDevice {
pub device_id: Uuid,
pub device_name: String,
pub reason: String,
}
The vouching session is a resource that the UI can subscribe to with ResourceChanged events.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VouchingSession {
pub id: Uuid,
pub vouchee_device_id: Uuid,
pub vouchee_device_name: String,
pub voucher_device_id: Uuid,
pub created_at: DateTime<Utc>,
pub state: VouchingSessionState,
pub vouches: HashMap<Uuid, VouchState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum VouchingSessionState {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VouchState {
pub device_id: Uuid,
pub device_name: String,
pub status: VouchStatus,
pub updated_at: DateTime<Utc>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum VouchStatus {
Selected,
Queued,
Waiting,
Accepted,
Rejected,
Unreachable,
}
impl Identifiable for VouchingSession {
type Id = Uuid;
fn id(&self) -> Self::Id {
self.id
}
}
crate::register_resource_type!(VouchingSession, "vouching_session");
The vouching session is driven by resource updates. Events are only needed for confirmation prompts and UI entry points.
pub enum Event {
ProxyPairingConfirmationRequired {
session_id: Uuid,
vouchee_device_name: String,
vouchee_device_os: String,
voucher_device_name: String,
voucher_device_id: Uuid,
expires_at: String,
},
ProxyPairingVouchingReady {
session_id: Uuid,
vouchee_device_id: Uuid,
},
}
pub struct PairVouchInput {
pub session_id: Uuid,
pub target_device_ids: Vec<Uuid>,
}
pub struct PairVouchOutput {
pub success: bool,
pub accepted_by: Vec<AcceptedDevice>,
pub rejected_by: Vec<RejectedDevice>,
pub pending_count: u32,
}
pub struct PairConfirmProxyInput {
pub session_id: Uuid,
pub accepted: bool,
}
pub struct PairConfirmProxyOutput {
pub success: bool,
pub error: Option<String>,
}
PairingRequest.VouchingSession resource in Pending.ProxyPairingVouchingReady so the UI can open a modal.network.pair.vouch.Waiting and receive ProxyPairingRequest.Queued and are stored in vouching_queue.Accepted or Rejected.Completed.ProxyPairingComplete to the vouchee.ProxyPairingRequest.ProxyPairingConfirmationRequired.ProxyPairingResponse with accepted or rejected.pairing_type: Proxied when accepted.ProxyPairingComplete.pairing_type: Proxied.pub struct VouchPayload {
pub vouchee_device_id: Uuid,
pub vouchee_public_key: Vec<u8>,
pub vouchee_device_info: DeviceInfo,
pub timestamp: DateTime<Utc>,
pub session_id: Uuid,
}
impl VouchPayload {
pub fn sign(&self, signing_key: &SigningKey) -> Vec<u8> {
let serialized = bincode::serialize(self).unwrap();
signing_key.sign(&serialized).to_bytes().to_vec()
}
pub fn verify(&self, signature: &[u8], verifying_key: &VerifyingKey) -> bool {
let serialized = bincode::serialize(self).unwrap();
let signature = Signature::from_bytes(signature.try_into().unwrap());
verifying_key.verify(&serialized, &signature).is_ok()
}
}
The receiver accepts vouches that are within the configured age window and that come from a trusted voucher.
The receiving device and the vouchee derive keys from the voucher and vouchee shared secret.
pub fn derive_proxied_session_keys(
voucher_device_id: Uuid,
vouchee_device_id: Uuid,
vouchee_public_key: &[u8],
voucher_vouchee_shared_secret: &[u8],
) -> SessionKeys {
let context = format!(
"spacedrive-proxy-pairing-{}:{}:{}",
voucher_device_id,
vouchee_device_id,
hex::encode(vouchee_public_key)
);
let hkdf = Hkdf::<Sha256>::new(None, voucher_vouchee_shared_secret);
let mut send_key = [0u8; 32];
let mut receive_key = [0u8; 32];
hkdf.expand(format!("{}-send", context).as_bytes(), &mut send_key).unwrap();
hkdf.expand(format!("{}-recv", context).as_bytes(), &mut receive_key).unwrap();
SessionKeys {
send_key: send_key.to_vec(),
receive_key: receive_key.to_vec(),
created_at: Utc::now(),
expires_at: Some(Utc::now() + Duration::days(1)),
}
}
The voucher includes derived keys in the proxy request for the receiving device, encrypted with the existing shared secret. The voucher also includes keys in the completion message sent to the vouchee.
Queued vouches are stored in sync.db so the system can retry when a device comes online.
CREATE TABLE vouching_queue (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
target_device_id TEXT NOT NULL,
vouchee_device_id TEXT NOT NULL,
vouchee_device_info TEXT NOT NULL,
vouchee_public_key BLOB NOT NULL,
vouch_signature BLOB NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
retry_count INTEGER DEFAULT 0,
last_attempt_at TEXT,
UNIQUE(session_id, target_device_id)
);
CREATE INDEX idx_vouching_queue_target ON vouching_queue(target_device_id);
CREATE INDEX idx_vouching_queue_expires ON vouching_queue(expires_at);
Processing logic:
ProxyPairingRequest and move the vouch to Waiting.expires_at.pub struct ProxyPairingConfig {
pub auto_accept_vouched: bool,
pub auto_vouch_to_all: bool,
pub vouch_signature_max_age: u64,
pub vouch_response_timeout: u64,
}
Suggested defaults:
auto_accept_vouched: trueauto_vouch_to_all: falsevouch_signature_max_age: 300vouch_response_timeout: 30ProxyPairingRequest.VouchingSession data one hour after completion.