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:
Generated
+121
@@ -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"
|
||||
|
||||
@@ -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
@@ -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!())
|
||||
|
||||
@@ -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 computer’s 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user