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() + ); +}