Add secure five-digit LAN pairing with automatic host discovery, encrypted credential handoff, polished host/client setup UI, persistence, tests, and documentation.

Hop-State: A_06FNQN729W1JAHZXV6PY1N0
Hop-Proposal: R_06FNQN68SGDZRN85WXQGSWG
Hop-Task: T_06FNQHGRCHN66DEC5171JF8
Hop-Attempt: AT_06FNQHGRCHPM6DR3BVFFMXG
This commit is contained in:
2026-07-13 07:15:06 -07:00
committed by Hop
parent 38dc9b80e2
commit 76ec2ecd46
11 changed files with 1009 additions and 21 deletions
+121
View File
@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -493,6 +503,30 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -513,6 +547,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
@@ -624,6 +659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
@@ -666,6 +702,32 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"fiat-crypto",
"rand_core",
"rustc_version",
"subtle",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1004,6 +1066,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -2438,6 +2506,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.0"
@@ -2664,6 +2738,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -2777,6 +2862,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2890,9 +2984,14 @@ dependencies = [
name = "resonant"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"getrandom 0.3.4",
"hkdf",
"keyring",
"serde",
"serde_json",
"sha2",
"spake2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -3393,6 +3492,18 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spake2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a"
dependencies = [
"curve25519-dalek",
"hkdf",
"rand_core",
"sha2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4293,6 +4404,16 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "url"
version = "2.5.8"
+5
View File
@@ -13,9 +13,14 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
chacha20poly1305 = "0.10"
getrandom = "0.3"
hkdf = "0.12"
keyring = "4.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
spake2 = { version = "0.4", features = ["getrandom"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
+62 -4
View File
@@ -1,4 +1,7 @@
mod pairing;
use keyring::{Entry, Error as KeyringError};
use pairing::{PairedServerConfig, PairingServiceState, PairingState, PairingStatus};
use serde::Serialize;
use std::{fs, net::UdpSocket, path::PathBuf, sync::Mutex};
use tauri::{AppHandle, Manager, State, WindowEvent};
@@ -10,8 +13,8 @@ use tauri_plugin_shell::{
const NAVIDROME_VERSION: &str = "0.61.2";
const MANAGED_SERVER_URL: &str = "http://127.0.0.1:4533";
const CREDENTIAL_SERVICE: &str = "xyz.gnosyslabs.resonant";
const LOCAL_CREDENTIAL: &str = "local-admin";
const REMOTE_CREDENTIAL: &str = "remote-server";
pub(crate) const LOCAL_CREDENTIAL: &str = "local-admin";
pub(crate) const REMOTE_CREDENTIAL: &str = "remote-server";
struct ManagedServer {
child: CommandChild,
@@ -46,7 +49,7 @@ fn credential_entry(account: &str) -> Result<Entry, String> {
.map_err(|error| format!("The system credential store is unavailable: {error}"))
}
fn read_password(account: &str) -> Result<Option<String>, String> {
pub(crate) fn read_password(account: &str) -> Result<Option<String>, String> {
match credential_entry(account)?.get_password() {
Ok(password) => Ok(Some(password)),
Err(KeyringError::NoEntry) => Ok(None),
@@ -54,7 +57,7 @@ fn read_password(account: &str) -> Result<Option<String>, String> {
}
}
fn write_password(account: &str, password: &str) -> Result<(), String> {
pub(crate) fn write_password(account: &str, password: &str) -> Result<(), String> {
credential_entry(account)?
.set_password(password)
.map_err(|error| format!("The credential could not be saved securely: {error}"))
@@ -116,6 +119,43 @@ fn save_remote_password(password: String) -> Result<(), String> {
write_password(REMOTE_CREDENTIAL, &password)
}
#[tauri::command]
fn pairing_status(
state: State<'_, PairingState>,
service: State<'_, PairingServiceState>,
) -> Result<PairingStatus, String> {
pairing::status(state.inner(), service.inner())
}
#[tauri::command]
fn begin_device_pairing(
managed_server: State<'_, ManagedServerState>,
state: State<'_, PairingState>,
service: State<'_, PairingServiceState>,
) -> Result<PairingStatus, String> {
let running = managed_server
.0
.lock()
.map_err(|_| "The local server state could not be accessed.".to_string())?
.is_some();
pairing::begin(running, state.inner(), service.inner())
}
#[tauri::command]
fn cancel_device_pairing(
state: State<'_, PairingState>,
service: State<'_, PairingServiceState>,
) -> Result<PairingStatus, String> {
pairing::cancel(state.inner(), service.inner())
}
#[tauri::command]
async fn pair_with_code(code: String) -> Result<PairedServerConfig, String> {
tauri::async_runtime::spawn_blocking(move || pairing::pair_with_code(code))
.await
.map_err(|error| format!("The pairing task could not finish: {error}"))?
}
fn stop_server(state: &ManagedServerState) -> Result<(), String> {
let mut server = state
.0
@@ -145,6 +185,7 @@ fn managed_server_status(
fn start_managed_server(
app: AppHandle,
state: State<'_, ManagedServerState>,
pairing_state: State<'_, PairingState>,
music_folder: String,
password: String,
) -> Result<ManagedServerStatus, String> {
@@ -159,6 +200,7 @@ fn start_managed_server(
return Err("Choose a folder that contains your music.".to_string());
}
write_password(LOCAL_CREDENTIAL, &password)?;
pairing::clear(pairing_state.inner());
stop_server(&state)?;
@@ -219,6 +261,8 @@ fn start_managed_server(
server.take();
}
}
let pairing_state = event_app.state::<PairingState>();
pairing::clear(pairing_state.inner());
break;
}
}
@@ -236,7 +280,9 @@ fn start_managed_server(
#[tauri::command]
fn stop_managed_server(
state: State<'_, ManagedServerState>,
pairing_state: State<'_, PairingState>,
) -> Result<ManagedServerStatus, String> {
pairing::clear(pairing_state.inner());
stop_server(&state)?;
Ok(status_for(None))
}
@@ -247,10 +293,20 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.manage(ManagedServerState::default())
.manage(PairingState::default())
.manage(PairingServiceState::default())
.setup(|app| {
pairing::start_service(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
managed_server_status,
load_saved_credentials,
save_remote_password,
pairing_status,
begin_device_pairing,
cancel_device_pairing,
pair_with_code,
start_managed_server,
stop_managed_server
])
@@ -258,6 +314,8 @@ pub fn run() {
if matches!(event, WindowEvent::Destroyed) {
let state = window.app_handle().state::<ManagedServerState>();
let _ = stop_server(&state);
let pairing_state = window.app_handle().state::<PairingState>();
pairing::clear(pairing_state.inner());
}
})
.run(tauri::generate_context!())
+579
View File
@@ -0,0 +1,579 @@
use chacha20poly1305::{
aead::{Aead, KeyInit, Payload},
ChaCha20Poly1305, Nonce,
};
use getrandom::fill as fill_random;
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use spake2::{Ed25519Group, Identity, Password, Spake2};
use std::{
collections::HashSet,
io::{ErrorKind, Read, Write},
net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener, TcpStream, UdpSocket},
sync::Mutex,
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::{AppHandle, Manager};
const PAIRING_PORT: u16 = 4534;
const PAIRING_LIFETIME: Duration = Duration::from_secs(120);
const PAIRING_ATTEMPTS: u8 = 5;
const DISCOVERY_WINDOW: Duration = Duration::from_secs(2);
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_FRAME_SIZE: usize = 4096;
const DISCOVERY_REQUEST: &[u8] = b"resonant-pair-discover-v1";
const DISCOVERY_RESPONSE: &[u8] = b"resonant-pair-host-v1";
const CLIENT_ID: &[u8] = b"resonant-client-v1";
const HOST_ID: &[u8] = b"resonant-host-v1";
const KEY_CONTEXT: &[u8] = b"resonant-lan-pairing-v1";
const CREDENTIAL_CONTEXT: &[u8] = b"resonant-pairing-credentials-v1";
const ACK_CONTEXT: &[u8] = b"resonant-pairing-ack-v1";
const ACK_MESSAGE: &[u8] = b"credentials-received";
#[derive(Default)]
struct PairingData {
session: Option<PairingSession>,
paired: bool,
}
struct PairingSession {
code: String,
expires_at: SystemTime,
attempts: u8,
generation: u64,
}
#[derive(Default)]
pub(crate) struct PairingState(Mutex<PairingData>);
#[derive(Default)]
struct ServiceHealth {
available: bool,
error: Option<String>,
}
#[derive(Default)]
pub(crate) struct PairingServiceState(Mutex<ServiceHealth>);
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PairingStatus {
available: bool,
active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<u64>,
attempts_remaining: u8,
paired: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PairedServerConfig {
pub(crate) url: String,
pub(crate) username: String,
pub(crate) password: String,
}
fn unix_millis(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn clear_expired(data: &mut PairingData) {
if data
.session
.as_ref()
.is_some_and(|session| SystemTime::now() >= session.expires_at)
{
data.session = None;
data.paired = false;
}
}
fn random_u32() -> Result<u32, String> {
let mut bytes = [0_u8; 4];
fill_random(&mut bytes)
.map_err(|error| format!("Secure randomness is unavailable: {error}"))?;
Ok(u32::from_le_bytes(bytes))
}
fn random_u64() -> Result<u64, String> {
let mut bytes = [0_u8; 8];
fill_random(&mut bytes)
.map_err(|error| format!("Secure randomness is unavailable: {error}"))?;
Ok(u64::from_le_bytes(bytes))
}
fn generate_pairing_code() -> Result<String, String> {
const CODE_SPACE: u32 = 100_000;
const UNBIASED_LIMIT: u32 = u32::MAX - (u32::MAX % CODE_SPACE);
loop {
let value = random_u32()?;
if value < UNBIASED_LIMIT {
return Ok(format!("{:05}", value % CODE_SPACE));
}
}
}
fn derive_key(shared_secret: &[u8]) -> Result<[u8; 32], String> {
let hkdf = Hkdf::<Sha256>::new(Some(KEY_CONTEXT), shared_secret);
let mut key = [0_u8; 32];
hkdf.expand(b"credential-channel", &mut key)
.map_err(|_| "The pairing session key could not be derived.".to_string())?;
Ok(key)
}
fn seal(key: &[u8; 32], plaintext: &[u8], context: &[u8]) -> Result<Vec<u8>, String> {
let cipher = ChaCha20Poly1305::new_from_slice(key)
.map_err(|_| "The pairing cipher could not be initialized.".to_string())?;
let mut nonce = [0_u8; 12];
fill_random(&mut nonce)
.map_err(|error| format!("Secure randomness is unavailable: {error}"))?;
let ciphertext = cipher
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad: context,
},
)
.map_err(|_| "The pairing message could not be encrypted.".to_string())?;
let mut message = nonce.to_vec();
message.extend_from_slice(&ciphertext);
Ok(message)
}
fn open(key: &[u8; 32], message: &[u8], context: &[u8]) -> Result<Vec<u8>, String> {
if message.len() < 12 {
return Err("The pairing response was incomplete.".to_string());
}
let cipher = ChaCha20Poly1305::new_from_slice(key)
.map_err(|_| "The pairing cipher could not be initialized.".to_string())?;
cipher
.decrypt(
Nonce::from_slice(&message[..12]),
Payload {
msg: &message[12..],
aad: context,
},
)
.map_err(|_| "That pairing code was not accepted.".to_string())
}
fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> Result<(), String> {
if payload.len() > MAX_FRAME_SIZE {
return Err("The pairing message was too large.".to_string());
}
stream
.write_all(&(payload.len() as u32).to_be_bytes())
.and_then(|_| stream.write_all(payload))
.map_err(|error| format!("The pairing message could not be sent: {error}"))
}
fn read_frame(stream: &mut TcpStream) -> Result<Vec<u8>, String> {
let mut length = [0_u8; 4];
stream
.read_exact(&mut length)
.map_err(|error| format!("The pairing response could not be read: {error}"))?;
let length = u32::from_be_bytes(length) as usize;
if length == 0 || length > MAX_FRAME_SIZE {
return Err("The pairing response had an invalid size.".to_string());
}
let mut payload = vec![0_u8; length];
stream
.read_exact(&mut payload)
.map_err(|error| format!("The pairing response could not be read: {error}"))?;
Ok(payload)
}
fn service_status(service: &PairingServiceState) -> Result<(), String> {
let health = service
.0
.lock()
.map_err(|_| "The pairing service state could not be accessed.".to_string())?;
if health.available {
Ok(())
} else {
Err(health
.error
.clone()
.unwrap_or_else(|| "Device pairing is unavailable on this computer.".to_string()))
}
}
pub(crate) fn status(
state: &PairingState,
service: &PairingServiceState,
) -> Result<PairingStatus, String> {
let available = {
let health = service
.0
.lock()
.map_err(|_| "The pairing service state could not be accessed.".to_string())?;
health.available
};
let mut data = state
.0
.lock()
.map_err(|_| "The pairing state could not be accessed.".to_string())?;
clear_expired(&mut data);
let session = data.session.as_ref();
Ok(PairingStatus {
available,
active: session.is_some(),
code: session.map(|session| session.code.clone()),
expires_at: session.map(|session| unix_millis(session.expires_at)),
attempts_remaining: session
.map(|session| PAIRING_ATTEMPTS.saturating_sub(session.attempts))
.unwrap_or(0),
paired: data.paired,
})
}
pub(crate) fn begin(
managed_server_running: bool,
state: &PairingState,
service: &PairingServiceState,
) -> Result<PairingStatus, String> {
if !managed_server_running {
return Err("Start this computers library before pairing another device.".to_string());
}
service_status(service)?;
if crate::read_password(crate::LOCAL_CREDENTIAL)?.is_none() {
return Err(
"The saved library credential is unavailable. Restart the library first.".to_string(),
);
}
let session = PairingSession {
code: generate_pairing_code()?,
expires_at: SystemTime::now() + PAIRING_LIFETIME,
attempts: 0,
generation: random_u64()?,
};
{
let mut data = state
.0
.lock()
.map_err(|_| "The pairing state could not be accessed.".to_string())?;
data.session = Some(session);
data.paired = false;
}
status(state, service)
}
pub(crate) fn cancel(
state: &PairingState,
service: &PairingServiceState,
) -> Result<PairingStatus, String> {
clear(state);
status(state, service)
}
pub(crate) fn clear(state: &PairingState) {
if let Ok(mut data) = state.0.lock() {
data.session = None;
data.paired = false;
}
}
fn pairing_is_active(app: &AppHandle) -> bool {
let state = app.state::<PairingState>();
let Ok(mut data) = state.0.lock() else {
return false;
};
clear_expired(&mut data);
data.session.is_some()
}
fn reserve_attempt(app: &AppHandle) -> Result<(String, u64), String> {
let state = app.state::<PairingState>();
let mut data = state
.0
.lock()
.map_err(|_| "The pairing state could not be accessed.".to_string())?;
clear_expired(&mut data);
let session = data
.session
.as_mut()
.ok_or_else(|| "No pairing code is active.".to_string())?;
if session.attempts >= PAIRING_ATTEMPTS {
return Err("The pairing attempt limit has been reached.".to_string());
}
session.attempts += 1;
Ok((session.code.clone(), session.generation))
}
fn finish_attempt(app: &AppHandle, generation: u64, paired: bool) {
let state = app.state::<PairingState>();
let Ok(mut data) = state.0.lock() else {
return;
};
let Some(session) = data.session.as_ref() else {
return;
};
if session.generation != generation {
return;
}
if paired {
data.session = None;
data.paired = true;
} else if session.attempts >= PAIRING_ATTEMPTS {
data.session = None;
data.paired = false;
}
}
fn handle_host_connection(mut stream: TcpStream, code: &str) -> Result<(), String> {
stream
.set_read_timeout(Some(CONNECTION_TIMEOUT))
.and_then(|_| stream.set_write_timeout(Some(CONNECTION_TIMEOUT)))
.map_err(|error| format!("The pairing connection could not be configured: {error}"))?;
let host_address = stream
.local_addr()
.map_err(|error| format!("The host address could not be read: {error}"))?;
let client_message = read_frame(&mut stream)?;
let (exchange, host_message) = Spake2::<Ed25519Group>::start_b(
&Password::new(code.as_bytes()),
&Identity::new(CLIENT_ID),
&Identity::new(HOST_ID),
);
let shared_secret = exchange
.finish(&client_message)
.map_err(|_| "The pairing request was invalid.".to_string())?;
write_frame(&mut stream, &host_message)?;
let key = derive_key(&shared_secret)?;
let password = crate::read_password(crate::LOCAL_CREDENTIAL)?
.ok_or_else(|| "The library credential is unavailable.".to_string())?;
let credentials = PairedServerConfig {
url: format!("http://{}:4533", host_address.ip()),
username: "admin".to_string(),
password,
};
let credentials = serde_json::to_vec(&credentials)
.map_err(|error| format!("The pairing credentials could not be encoded: {error}"))?;
let encrypted_credentials = seal(&key, &credentials, CREDENTIAL_CONTEXT)?;
write_frame(&mut stream, &encrypted_credentials)?;
let encrypted_ack = read_frame(&mut stream)?;
let ack = open(&key, &encrypted_ack, ACK_CONTEXT)?;
if ack != ACK_MESSAGE {
return Err("The paired device did not confirm receipt.".to_string());
}
Ok(())
}
fn discovery_loop(app: AppHandle, socket: UdpSocket) {
let mut buffer = [0_u8; 64];
loop {
match socket.recv_from(&mut buffer) {
Ok((length, source)) if &buffer[..length] == DISCOVERY_REQUEST => {
if pairing_is_active(&app) {
let _ = socket.send_to(DISCOVERY_RESPONSE, source);
}
}
Ok(_) => {}
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
Err(_) => thread::sleep(Duration::from_millis(250)),
}
}
}
fn connection_loop(app: AppHandle, listener: TcpListener) {
loop {
match listener.accept() {
Ok((stream, _)) => {
let Ok((code, generation)) = reserve_attempt(&app) else {
continue;
};
let connection_app = app.clone();
thread::spawn(move || {
let paired = handle_host_connection(stream, &code).is_ok();
finish_attempt(&connection_app, generation, paired);
});
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(100));
}
Err(_) => thread::sleep(Duration::from_millis(250)),
}
}
}
pub(crate) fn start_service(app: AppHandle) {
let service = app.state::<PairingServiceState>();
let result = (|| -> Result<(UdpSocket, TcpListener), String> {
let udp = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, PAIRING_PORT)).map_err(|error| {
format!("Device pairing could not use UDP port {PAIRING_PORT}: {error}")
})?;
udp.set_read_timeout(Some(Duration::from_millis(500)))
.map_err(|error| format!("Device pairing discovery could not start: {error}"))?;
let tcp = TcpListener::bind((Ipv4Addr::UNSPECIFIED, PAIRING_PORT)).map_err(|error| {
format!("Device pairing could not use TCP port {PAIRING_PORT}: {error}")
})?;
tcp.set_nonblocking(true)
.map_err(|error| format!("Device pairing could not start: {error}"))?;
Ok((udp, tcp))
})();
match result {
Ok((udp, tcp)) => {
if let Ok(mut health) = service.0.lock() {
health.available = true;
health.error = None;
}
let discovery_app = app.clone();
thread::spawn(move || discovery_loop(discovery_app, udp));
thread::spawn(move || connection_loop(app, tcp));
}
Err(error) => {
if let Ok(mut health) = service.0.lock() {
health.available = false;
health.error = Some(error);
}
}
}
}
fn discover_hosts() -> Result<Vec<SocketAddr>, String> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
.map_err(|error| format!("Device discovery could not start: {error}"))?;
socket
.set_broadcast(true)
.and_then(|_| socket.set_read_timeout(Some(Duration::from_millis(250))))
.map_err(|error| format!("Device discovery could not be configured: {error}"))?;
let destinations = [
SocketAddrV4::new(Ipv4Addr::BROADCAST, PAIRING_PORT),
SocketAddrV4::new(Ipv4Addr::LOCALHOST, PAIRING_PORT),
];
let mut sent = false;
for destination in destinations {
sent |= socket.send_to(DISCOVERY_REQUEST, destination).is_ok();
}
if !sent {
return Err("The local network could not be searched for a Resonant host.".to_string());
}
let deadline = Instant::now() + DISCOVERY_WINDOW;
let mut hosts = HashSet::new();
let mut buffer = [0_u8; 64];
while Instant::now() < deadline {
match socket.recv_from(&mut buffer) {
Ok((length, source)) if &buffer[..length] == DISCOVERY_RESPONSE => {
if matches!(source.ip(), IpAddr::V4(_)) {
hosts.insert(SocketAddr::new(source.ip(), PAIRING_PORT));
}
}
Ok(_) => {}
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
Err(error) => return Err(format!("Device discovery failed: {error}")),
}
}
Ok(hosts.into_iter().collect())
}
fn pair_with_host(host: SocketAddr, code: &str) -> Result<PairedServerConfig, String> {
let mut stream = TcpStream::connect_timeout(&host, CONNECTION_TIMEOUT)
.map_err(|error| format!("The Resonant host could not be reached: {error}"))?;
stream
.set_read_timeout(Some(CONNECTION_TIMEOUT))
.and_then(|_| stream.set_write_timeout(Some(CONNECTION_TIMEOUT)))
.map_err(|error| format!("The pairing connection could not be configured: {error}"))?;
let (exchange, client_message) = Spake2::<Ed25519Group>::start_a(
&Password::new(code.as_bytes()),
&Identity::new(CLIENT_ID),
&Identity::new(HOST_ID),
);
write_frame(&mut stream, &client_message)?;
let host_message = read_frame(&mut stream)?;
let shared_secret = exchange
.finish(&host_message)
.map_err(|_| "The host returned an invalid pairing response.".to_string())?;
let key = derive_key(&shared_secret)?;
let encrypted_credentials = read_frame(&mut stream)?;
let credentials = open(&key, &encrypted_credentials, CREDENTIAL_CONTEXT)?;
let credentials: PairedServerConfig = serde_json::from_slice(&credentials)
.map_err(|_| "The host returned invalid library details.".to_string())?;
if credentials.username.is_empty()
|| credentials.password.is_empty()
|| !credentials.url.starts_with("http://")
{
return Err("The host returned incomplete library details.".to_string());
}
let ack = seal(&key, ACK_MESSAGE, ACK_CONTEXT)?;
write_frame(&mut stream, &ack)?;
Ok(credentials)
}
pub(crate) fn pair_with_code(code: String) -> Result<PairedServerConfig, String> {
let code = code.trim();
if code.len() != 5 || !code.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("Enter the five-digit code shown on the host computer.".to_string());
}
let hosts = discover_hosts()?;
if hosts.is_empty() {
return Err("No Resonant host is sharing a pairing code on this network.".to_string());
}
let mut last_error = None;
for host in hosts {
match pair_with_host(host, code) {
Ok(credentials) => {
crate::write_password(crate::REMOTE_CREDENTIAL, &credentials.password)?;
return Ok(credentials);
}
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| "That pairing code was not accepted.".to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn exchange_keys(client_code: &str, host_code: &str) -> ([u8; 32], [u8; 32]) {
let (client, client_message) = Spake2::<Ed25519Group>::start_a(
&Password::new(client_code.as_bytes()),
&Identity::new(CLIENT_ID),
&Identity::new(HOST_ID),
);
let (host, host_message) = Spake2::<Ed25519Group>::start_b(
&Password::new(host_code.as_bytes()),
&Identity::new(CLIENT_ID),
&Identity::new(HOST_ID),
);
let client_key = derive_key(&client.finish(&host_message).unwrap()).unwrap();
let host_key = derive_key(&host.finish(&client_message).unwrap()).unwrap();
(client_key, host_key)
}
#[test]
fn generated_codes_are_exactly_five_digits() {
for _ in 0..100 {
let code = generate_pairing_code().unwrap();
assert_eq!(code.len(), 5);
assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
}
}
#[test]
fn matching_codes_create_an_authenticated_channel() {
let (client_key, host_key) = exchange_keys("12345", "12345");
let sealed = seal(&host_key, b"library-secret", CREDENTIAL_CONTEXT).unwrap();
assert_eq!(
open(&client_key, &sealed, CREDENTIAL_CONTEXT).unwrap(),
b"library-secret"
);
}
#[test]
fn a_wrong_code_cannot_decrypt_credentials() {
let (client_key, host_key) = exchange_keys("12345", "54321");
let sealed = seal(&host_key, b"library-secret", CREDENTIAL_CONTEXT).unwrap();
assert!(open(&client_key, &sealed, CREDENTIAL_CONTEXT).is_err());
}
}