From 18ffa3516851bf062b14f70b917ab8ae0f1fd443 Mon Sep 17 00:00:00 2001 From: AlexDevFlow <107987666+AlexDevFlow@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:00:55 +0200 Subject: [PATCH] Align with LocalSend 1.18: present the client certificate, protocol 2.2 LocalSend 1.18 requires a client certificate on its HTTPS server whenever it is not serving its web pages, which is the normal receive state. lsq built its discovery HTTP client without an identity, so its reply to an announcement died in the handshake with a CertificateRequired alert and the peer never learned lsq existed. Discovery, presence announcements and pull now all present the certificate, the way the official client does on every request. Bump the protocol to 2.2 and answer a checksum mismatch with 422 instead of 500: that status is the one thing 2.2 adds, and it is what the official server returns. Protocol v3 is deliberately not implemented. 1.18.2 routes only /v3/nonce and /v3/register, its v3 register handler is a stub marked "not wired up yet", and the app pins its own HTTP client to v2. Devices still announce 2.2 and still transfer over the v2 endpoints, so there is nothing live to talk to on v3. Checked against the LocalSend 1.18.2 core in both directions, with the files arriving byte-identical. tests/mtls.rs is a self-contained regression guard: it stands up a server that demands a client certificate and asserts that a certless client is refused and lsq's identity client gets through. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +++++-- src/cli.rs | 31 ++++++---- src/discovery.rs | 12 +++- src/proto.rs | 6 +- src/pull.rs | 5 +- src/receiver.rs | 14 ++++- tests/download.rs | 16 ++--- tests/loopback.rs | 8 ++- tests/mtls.rs | 147 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 225 insertions(+), 36 deletions(-) create mode 100644 tests/mtls.rs diff --git a/README.md b/README.md index 8e0fd75..3c91237 100644 --- a/README.md +++ b/README.md @@ -107,16 +107,28 @@ shared link the browser shows a PIN form; `lsq pull` takes `--pin`. ## What works and what doesn't -It implements LocalSend protocol v2.1: multicast discovery, the upload API in +It implements LocalSend protocol v2.2: multicast discovery, the upload API in both directions, the download API (`share`/`pull`, including the browser -page), PIN, and cancel. I've run the upload path against the desktop app -(v1.17.0) both ways, with single and multiple files and with a PIN set, and -files come across intact. +page), PIN, cancel, and the 422 answer to a checksum mismatch that 2.2 adds. + +Checked against the LocalSend 1.18.2 core in both directions — lsq sending to +a real receiver in its normal (non-browser) receive mode, and a real client +sending to `lsq receive` — plus the discovery handshake, with the files +arriving byte-identical. + +Note for anyone reading about "protocol v3": LocalSend 1.18.2 ships a `v3` +namespace, but only `POST /api/localsend/v3/nonce` and +`POST /api/localsend/v3/register` are routed, the register handler is a stub +marked *not wired up yet*, and the app builds its own HTTP client with the v2 +version pinned. Devices still announce `2.2` and still transfer over the v2 +endpoints, so there is nothing live to talk to on v3 yet. Still missing: - IPv6 -- anything newer than protocol v2.1 +- protocol v3: the nonce/signed-token handshake, pairing, and the WebRTC + transport. Dormant in the app as of 1.18.2; worth revisiting when the app + starts using it. ## Tests diff --git a/src/cli.rs b/src/cli.rs index f242f82..3510255 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -205,7 +205,7 @@ async fn cmd_list(alias: String, port: u16, wait: u64, json: bool, eph: bool) -> protocol: Protocol::Https, download: false, }; - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; if json { let out: Vec<_> = peers .iter() @@ -341,11 +341,11 @@ async fn cmd_send( if t.parse::().is_ok() || t.parse::().is_ok() { resolve_target(&[], Some(t))? } else { - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; resolve_target(&peers, Some(t))? } } else { - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; resolve_target(&peers, None)? }; @@ -461,7 +461,7 @@ async fn cmd_receive( // one peer can't permanently block the receiver. tokio::spawn(receiver::reap_stale_sessions(state.clone())); - announce_presence(&me, peers).await?; + announce_presence(&me, peers, Some(&identity)).await?; if !quiet { eprintln!( @@ -508,9 +508,14 @@ async fn serve_until_interrupted( /// Multicast presence for a long-running server (receive/share): initial /// announcement burst, reply loop, and periodic re-announce. -async fn announce_presence(me: &SelfDevice, peers: discovery::PeerMap) -> Result<()> { +async fn announce_presence( + me: &SelfDevice, + peers: discovery::PeerMap, + identity: Option<&crate::certs::Identity>, +) -> Result<()> { let udp = Arc::new(discovery::bind_multicast_socket(MULTICAST_PORT)?); - let http_client = crate::sender::insecure_client()?; + // Presents our certificate: see the note in `discovery::discover`. + let http_client = crate::sender::client_with_identity(identity)?; tokio::spawn(discovery::listen_loop( udp.clone(), me.clone(), @@ -579,7 +584,7 @@ async fn cmd_share( axum_server::bind(addr).serve(app).await.map_err(anyhow::Error::from) }); - announce_presence(&me, peers).await?; + announce_presence(&me, peers, Some(&identity)).await?; if !quiet { eprintln!("sharing {count} file(s), {total} bytes as \"{alias}\""); @@ -610,12 +615,13 @@ async fn cmd_pull( tokio::fs::create_dir_all(&dest) .await .with_context(|| format!("cannot create dest dir {}", dest.display()))?; + let identity = identity(eph)?; // Full URL target: use it as-is. if let Some(t) = from.as_deref() { if t.starts_with("http://") || t.starts_with("https://") { let outcome = - crate::pull::pull_files(t, &dest, pin.as_deref(), max_size, quiet).await?; + crate::pull::pull_files(t, &dest, pin.as_deref(), max_size, quiet, Some(&identity)).await?; return report_pull(outcome, &dest, quiet); } } @@ -627,14 +633,14 @@ async fn cmd_pull( let peer = resolve_target(&[], Some(t))?; let host = format!("{}:{}", peer.addr, peer.info.port_or(DEFAULT_PORT)); let outcome = match crate::pull::pull_files( - &format!("https://{host}"), &dest, pin.as_deref(), max_size, quiet, + &format!("https://{host}"), &dest, pin.as_deref(), max_size, quiet, Some(&identity), ) .await { Ok(o) => o, Err(e) if e.unreachable => { crate::pull::pull_files( - &format!("http://{host}"), &dest, pin.as_deref(), max_size, quiet, + &format!("http://{host}"), &dest, pin.as_deref(), max_size, quiet, Some(&identity), ) .await .map_err(|e2| anyhow::anyhow!("{e}; also failed over http: {e2}"))? @@ -647,7 +653,6 @@ async fn cmd_pull( // Alias/fingerprint target (or no target): discover, keep peers that // announce the download flag. - let identity = identity(eph)?; let me = SelfDevice { alias, fingerprint: identity.fingerprint.clone(), @@ -655,7 +660,7 @@ async fn cmd_pull( protocol: Protocol::Https, download: false, }; - let peers = discovery::discover(&me, Duration::from_secs(wait)).await?; + let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?; let peer = if from.is_some() { let p = resolve_target(&peers, from.as_deref())?; if !p.info.download { @@ -674,7 +679,7 @@ async fn cmd_pull( } let base = crate::sender::base_url(&peer); let outcome = - crate::pull::pull_files(&base, &dest, pin.as_deref(), max_size, quiet).await?; + crate::pull::pull_files(&base, &dest, pin.as_deref(), max_size, quiet, Some(&identity)).await?; report_pull(outcome, &dest, quiet) } diff --git a/src/discovery.rs b/src/discovery.rs index 5406b68..1106bf9 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -287,10 +287,18 @@ async fn spawn_register_endpoint( } /// Active discovery: announce, listen for replies for `wait`, return peers. -pub async fn discover(me: &SelfDevice, wait: Duration) -> Result> { +pub async fn discover( + me: &SelfDevice, + wait: Duration, + identity: Option<&crate::certs::Identity>, +) -> Result> { let sock = Arc::new(bind_multicast_socket(MULTICAST_PORT)?); let peers: PeerMap = Arc::new(Mutex::new(HashMap::new())); - let http = crate::sender::insecure_client()?; + // The reply below is an HTTPS request to the peer. LocalSend 1.18+ makes + // the client certificate mandatory whenever it is not serving its web + // pages, so a certless client is dropped with a `CertificateRequired` + // TLS alert and the peer never learns we exist. + let http = crate::sender::client_with_identity(identity)?; // Announce the ephemeral register port (plain http) so TCP replies // reach us and not some other process on the default port. diff --git a/src/proto.rs b/src/proto.rs index 0b20832..009d5d0 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -1,11 +1,11 @@ -//! LocalSend protocol v2.1 wire models. -//! Field shapes follow the LocalSend v2.1 protocol; unknown fields are ignored +//! LocalSend protocol v2.2 wire models. +//! Field shapes follow the LocalSend v2.2 protocol; unknown fields are ignored //! and unknown enum values fall back gracefully (spec §7.1). use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -pub const PROTOCOL_VERSION: &str = "2.1"; +pub const PROTOCOL_VERSION: &str = "2.2"; pub const DEFAULT_PORT: u16 = 53317; pub const MULTICAST_ADDR: &str = "224.0.0.167"; /// Discovery always happens on this fixed group port, even when the HTTP diff --git a/src/pull.rs b/src/pull.rs index fb429af..aad1b7b 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -58,8 +58,11 @@ pub async fn pull_files( pin: Option<&str>, max_bytes: Option, quiet: bool, + identity: Option<&crate::certs::Identity>, ) -> std::result::Result { - let client = crate::sender::insecure_client().map_err(PullError::rejected)?; + // Presents our certificate like the official client does on every + // request; a peer that requires one would otherwise refuse the handshake. + let client = crate::sender::client_with_identity(identity).map_err(PullError::rejected)?; let base = base.trim_end_matches('/'); // 1. prepare-download diff --git a/src/receiver.rs b/src/receiver.rs index 26a2aac..055e78a 100644 --- a/src/receiver.rs +++ b/src/receiver.rs @@ -451,6 +451,11 @@ async fn upload( Ok(pair) => pair, Err(e) => { let _ = state.events.send(format!("upload failed: {e}")); + // Protocol 2.2: a checksum mismatch is the sender's problem to + // retry, not a receiver fault, and has its own status code. + if e.downcast_ref::().is_some() { + return err(StatusCode::UNPROCESSABLE_ENTITY, "Checksum mismatch"); + } return err( StatusCode::INTERNAL_SERVER_ERROR, "Could not save file. Check receiving device for more information.", @@ -499,6 +504,13 @@ async fn upload( StatusCode::OK.into_response() } +/// A received body did not match the `sha256` its sender declared. +/// Protocol 2.2 answers this with 422 so the sender can tell a corrupted +/// transfer apart from a fault on the receiving side. +#[derive(Debug, thiserror::Error)] +#[error("sha256 mismatch")] +pub(crate) struct ChecksumMismatch; + /// Stream a body to a unique `.lsq-*.part` temp file in the dest dir, /// enforcing the declared size (and sha256 when provided) and an idle read /// timeout. Returns the temp path plus an armed cleanup guard; on any error @@ -546,7 +558,7 @@ where use sha2::Digest; let actual: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect(); if !actual.eq_ignore_ascii_case(expected) { - anyhow::bail!("sha256 mismatch"); + return Err(ChecksumMismatch.into()); } } // fsync the data before the caller makes the final name visible, so a crash diff --git a/tests/download.rs b/tests/download.rs index 94b3768..f3c7288 100644 --- a/tests/download.rs +++ b/tests/download.rs @@ -338,7 +338,7 @@ async fn pull_module_end_to_end() { let b = b"second file".to_vec(); let s = start_share(&[("data.bin", &a), ("note.txt", &b)], None).await; let dest = TempDir::new().unwrap(); - let outcome = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + let outcome = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap(); assert_eq!(outcome.fetched, 2); @@ -354,12 +354,12 @@ async fn pull_module_end_to_end() { async fn pull_uses_pin_and_reports_wrong_pin() { let s = start_share(&[("a.txt", b"x")], Some("9999")).await; let dest = TempDir::new().unwrap(); - let err = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + let err = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap_err(); assert!(err.to_string().contains("PIN")); assert!(!err.unreachable); - let outcome = lsq::pull::pull_files(&base(&s), dest.path(), Some("9999"), None, true) + let outcome = lsq::pull::pull_files(&base(&s), dest.path(), Some("9999"), None, true, None) .await .unwrap(); assert_eq!(outcome.fetched, 1); @@ -370,7 +370,7 @@ async fn pull_never_overwrites_existing_files() { let s = start_share(&[("keep.txt", b"new")], None).await; let dest = TempDir::new().unwrap(); std::fs::write(dest.path().join("keep.txt"), b"original").unwrap(); - lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap(); assert_eq!(std::fs::read(dest.path().join("keep.txt")).unwrap(), b"original"); @@ -381,7 +381,7 @@ async fn pull_never_overwrites_existing_files() { async fn pull_sanitizes_hostile_names() { let s = start_share(&[("../../../tmp/lsq-pull-escape.txt", b"x")], None).await; let dest = TempDir::new().unwrap(); - lsq::pull::pull_files(&base(&s), dest.path(), None, None, true) + lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None) .await .unwrap(); assert!(!std::path::Path::new("/tmp/lsq-pull-escape.txt").exists()); @@ -392,7 +392,7 @@ async fn pull_sanitizes_hostile_names() { async fn pull_respects_max_size() { let s = start_share(&[("big.bin", &vec![0u8; 5000])], None).await; let dest = TempDir::new().unwrap(); - let err = lsq::pull::pull_files(&base(&s), dest.path(), None, Some(1024), true) + let err = lsq::pull::pull_files(&base(&s), dest.path(), None, Some(1024), true, None) .await .unwrap_err(); assert!(err.to_string().contains("max-size")); @@ -404,7 +404,7 @@ async fn pull_marks_dead_peer_unreachable() { // unroutable TEST-NET address → connect timeout flagged as unreachable let dest = TempDir::new().unwrap(); let start = std::time::Instant::now(); - let err = lsq::pull::pull_files("http://192.0.2.1:53317", dest.path(), None, None, true) + let err = lsq::pull::pull_files("http://192.0.2.1:53317", dest.path(), None, None, true, None) .await .unwrap_err(); assert!(err.unreachable); @@ -438,7 +438,7 @@ async fn pull_rejects_oversized_body_from_lying_server() { tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let dest = TempDir::new().unwrap(); - let err = lsq::pull::pull_files(&format!("http://{addr}"), dest.path(), None, None, true) + let err = lsq::pull::pull_files(&format!("http://{addr}"), dest.path(), None, None, true, None) .await .unwrap_err(); assert!(err.to_string().contains("exceeds declared size")); diff --git a/tests/loopback.rs b/tests/loopback.rs index 7660c01..855b23c 100644 --- a/tests/loopback.rs +++ b/tests/loopback.rs @@ -279,13 +279,15 @@ async fn sha256_verified_when_provided() { .json(&req).send().await.unwrap(); let sess: PrepareUploadResponse = resp.json().await.unwrap(); assert_eq!(do_upload(&c, &s, &sess, "f", b"payload").await, 200); - // wrong hash rejected, file absent, no partials + // Wrong hash rejected with 422 (protocol 2.2), file absent, no partials. + // 422 and not 500: the transfer is the sender's to retry, and the + // official server answers a checksum mismatch the same way. let mut req = prepare_req(&[("g", "bad.txt", b"payload")]); req.files.get_mut("g").unwrap().sha256 = Some("00".repeat(32)); let resp = c.post(format!("{}{API_BASE}/prepare-upload", base(&s))) .json(&req).send().await.unwrap(); let sess: PrepareUploadResponse = resp.json().await.unwrap(); - assert_eq!(do_upload(&c, &s, &sess, "g", b"payload").await, 500); + assert_eq!(do_upload(&c, &s, &sess, "g", b"payload").await, 422); assert!(!dest_file(&s, "bad.txt").exists()); assert!(no_part_files(s.dest.path())); } @@ -672,7 +674,7 @@ async fn register_returns_own_info_and_records_peer() { assert_eq!(r.status(), 200); let body: RegisterResponse = r.json().await.unwrap(); assert_eq!(body.alias, "test-receiver"); - assert_eq!(body.version, "2.1"); + assert_eq!(body.version, PROTOCOL_VERSION); assert!(!body.fingerprint.is_empty()); } diff --git a/tests/mtls.rs b/tests/mtls.rs new file mode 100644 index 0000000..9752fb2 --- /dev/null +++ b/tests/mtls.rs @@ -0,0 +1,147 @@ +//! Regression guard for the client certificate. +//! +//! LocalSend 1.18 makes the client certificate mandatory on its HTTPS server +//! whenever it is not serving its web pages — which is the normal receive +//! state. A request from a client that presents no certificate is dropped +//! during the handshake with a `CertificateRequired` alert, so a discovery +//! reply sent that way never reaches the peer and the peer never learns that +//! this device exists. +//! +//! Every outgoing HTTPS request lsq makes must therefore carry its identity. + +use std::sync::Arc; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, UnixTime}; +use rustls::server::danger::{ClientCertVerified, ClientCertVerifier}; +use rustls::{DigitallySignedStruct, DistinguishedName, Error, SignatureScheme}; + +/// Mirrors the shape of LocalSend's own verifier: client auth is mandatory, +/// and any certificate that verifies is trusted (peers are identified by +/// fingerprint, not by an authority). +#[derive(Debug)] +struct MandatoryClientCert; + +impl ClientCertVerifier for MandatoryClientCert { + fn offer_client_auth(&self) -> bool { + true + } + + fn client_auth_mandatory(&self) -> bool { + true + } + + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + + fn verify_client_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: UnixTime, + ) -> Result { + Ok(ClientCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Starts an HTTPS server that refuses clients without a certificate, +/// the way a LocalSend 1.18 device in receive mode does. +async fn start_mtls_server() -> u16 { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut params = rcgen::CertificateParams::default(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "LocalSend User"); + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + + let config = rustls::ServerConfig::builder() + .with_client_cert_verifier(Arc::new(MandatoryClientCert)) + .with_single_cert( + vec![CertificateDer::from(cert.der().to_vec())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())), + ) + .unwrap(); + + // Hand the already-bound listener to the server: the port cannot be taken + // in between, and a client that connects before the accept loop is running + // waits in the backlog instead of being refused. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let app = axum::Router::new().route( + "/api/localsend/v2/register", + axum::routing::post(|| async { "{}" }), + ); + let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(config)); + tokio::spawn(async move { + let _ = axum_server::from_tcp_rustls(listener, tls) + .serve(app.into_make_service()) + .await; + }); + port +} + +#[tokio::test] +async fn client_without_identity_is_refused_by_an_mtls_peer() { + let port = start_mtls_server().await; + let url = format!("https://127.0.0.1:{port}/api/localsend/v2/register"); + + let certless = lsq::sender::insecure_client().unwrap(); + assert!( + certless.post(&url).send().await.is_err(), + "a certless client must not be able to reach an mTLS peer — if this \ + starts passing, the premise of the test below no longer holds" + ); +} + +#[tokio::test] +async fn client_with_identity_reaches_an_mtls_peer() { + let port = start_mtls_server().await; + let url = format!("https://127.0.0.1:{port}/api/localsend/v2/register"); + + let id = lsq::certs::generate_identity("").unwrap(); + let client = lsq::sender::client_with_identity(Some(&id)).unwrap(); + let res = client.post(&url).send().await; + assert!( + res.is_ok(), + "lsq's identity client must reach a peer that requires a client \ + certificate: {:?}", + res.err() + ); +}