diff --git a/app/lib/provider/http_provider.dart b/app/lib/provider/http_provider.dart index 1d25a4c9..083f0234 100644 --- a/app/lib/provider/http_provider.dart +++ b/app/lib/provider/http_provider.dart @@ -3,17 +3,51 @@ import 'package:localsend_isolates/rust/api/http.dart'; import 'package:refena_flutter/refena_flutter.dart'; class HttpClientCollection { - final RsHttpClient v2; + final String _privateKey; + final String _certificate; + + /// A client that accepts any valid peer certificate. + /// + /// Only for discovery, where the certificate of the peer is not known yet + /// and is learned from the response. Never use this to transfer files: + /// it cannot tell the discovered device apart from anyone else answering + /// on that address. + final RsHttpClient discovery; HttpClientCollection({ - required this.v2, - }); + required String privateKey, + required String certificate, + required this.discovery, + }) : _privateKey = privateKey, + _certificate = certificate; + + /// A client that only talks to the peer holding the certificate with the + /// given [fingerprint]. + /// + /// The check happens during the TLS handshake, so a different peer never + /// receives the request. Use this for everything that carries file data or + /// belongs to a session with a device the user has picked. + RsHttpClient pinnedTo(String fingerprint) { + return createClient( + privateKey: _privateKey, + cert: _certificate, + version: LsHttpClientVersion.v2, + expectedFingerprint: fingerprint, + ); + } } /// Provides an HTTP client for each protocol version. final httpProvider = ViewProvider((ref) { final securityContext = ref.watch(securityProvider); return HttpClientCollection( - v2: createClient(privateKey: securityContext.privateKey, cert: securityContext.certificate, version: LsHttpClientVersion.v2), + privateKey: securityContext.privateKey, + certificate: securityContext.certificate, + discovery: createClient( + privateKey: securityContext.privateKey, + cert: securityContext.certificate, + version: LsHttpClientVersion.v2, + expectedFingerprint: null, + ), ); }); diff --git a/app/lib/provider/network/send_provider.dart b/app/lib/provider/network/send_provider.dart index 01ecad9b..095714d1 100644 --- a/app/lib/provider/network/send_provider.dart +++ b/app/lib/provider/network/send_provider.dart @@ -64,7 +64,9 @@ class SendNotifier extends Notifier> { required List files, required bool background, }) async { - final client = ref.read(httpProvider).v2; + // Pinned to the device the user picked, so the request is not sent at all + // if someone else answers on that address. + final client = ref.read(httpProvider).pinnedTo(target.fingerprint); final sessionId = _uuid.v4(); // The ids are assigned upfront, so the checksums calculated below @@ -201,7 +203,8 @@ class SendNotifier extends Notifier> { ip: target.ip!, port: target.port, payload: requestDto, - // TODO + // The peer is already verified during the TLS handshake by the + // fingerprint the client is pinned to. publicKey: null, pin: pin, ); @@ -634,7 +637,7 @@ class SendNotifier extends Notifier> { try { ref .read(httpProvider) - .v2 + .pinnedTo(target.fingerprint) // ignore: discarded_futures .cancel( protocol: target.getProtocolType(), diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index 3a9b2ff7..ab881ab7 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -645,7 +645,7 @@ class ReceiveController { try { server.ref .read(httpProvider) - .v2 + .pinnedTo(target.fingerprint) // ignore: unawaited_futures .cancel( protocol: target.getProtocolType(), diff --git a/app/lib/widget/dialogs/address_input_dialog.dart b/app/lib/widget/dialogs/address_input_dialog.dart index dc904231..aff7c661 100644 --- a/app/lib/widget/dialogs/address_input_dialog.dart +++ b/app/lib/widget/dialogs/address_input_dialog.dart @@ -75,7 +75,7 @@ class _AddressInputDialogState extends State with Refena { try { final response = await ref .read(httpProvider) - .v2 + .discovery .register( protocol: https ? ProtocolType.https : ProtocolType.http, ip: ip, diff --git a/app/lib/widget/dialogs/favorite_dialog.dart b/app/lib/widget/dialogs/favorite_dialog.dart index 63135097..7cd4925c 100644 --- a/app/lib/widget/dialogs/favorite_dialog.dart +++ b/app/lib/widget/dialogs/favorite_dialog.dart @@ -38,7 +38,7 @@ class _FavoritesDialogState extends State with Refena { final payload = ref.read(deviceFullInfoProvider).toRegisterDto(); final response = await ref .read(httpProvider) - .v2 + .discovery .register( protocol: https ? ProtocolType.https : ProtocolType.http, ip: favorite.ip, diff --git a/app/lib/widget/dialogs/favorite_edit_dialog.dart b/app/lib/widget/dialogs/favorite_edit_dialog.dart index 45af080a..ad93a7ce 100644 --- a/app/lib/widget/dialogs/favorite_edit_dialog.dart +++ b/app/lib/widget/dialogs/favorite_edit_dialog.dart @@ -191,7 +191,7 @@ class _FavoriteEditDialogState extends State with Refena { final payload = ref.read(deviceFullInfoProvider).toRegisterDto(); final response = await ref .read(httpProvider) - .v2 + .discovery .register( protocol: https ? ProtocolType.https : ProtocolType.http, ip: ip, diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index f7818c79..55ae4a87 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -1251,6 +1251,7 @@ dependencies = [ "pem", "percent-encoding", "rand 0.9.2", + "rcgen", "reqwest", "rsa", "rustls", diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 7a62a87f..5fee9e20 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -38,6 +38,9 @@ uuid = { version = "1.20.0", features = ["serde", "v4"] } webrtc = { version = "0.14.0", optional = true } x509-parser = { version = "0.18.0", features = ["verify"], optional = true } +[dev-dependencies] +rcgen = "0.13.2" + [features] default = [] crypto = ["ed25519-dalek", "rsa", "sha2", "tokio-util"] diff --git a/packages/core/src/http/client/mod.rs b/packages/core/src/http/client/mod.rs index fce6646f..e338fc69 100644 --- a/packages/core/src/http/client/mod.rs +++ b/packages/core/src/http/client/mod.rs @@ -1,3 +1,4 @@ +mod server_cert_verifier; mod url; pub mod v2; pub mod v3; @@ -10,7 +11,10 @@ use crate::{crypto, http, model}; use bytes::Bytes; use futures_util::StreamExt; use reqwest::Response; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use thiserror::Error; use tokio_stream::wrappers::ReceiverStream; @@ -46,19 +50,32 @@ pub enum ClientError { } impl LsHttpClient { + /// Creates a client for the given protocol version. + /// + /// `expected_fingerprint` pins the peer to the certificate with that + /// SHA-256 fingerprint (uppercase hex). It is checked during the TLS + /// handshake, so a mismatching peer never receives the request. Pass + /// [`None`] only when the peer is not known yet, i.e. for discovery. pub fn new( private_key: &str, cert: &str, version: LsHttpClientVersion, + expected_fingerprint: Option, timeout: Option, ) -> Result { let client = match version { - LsHttpClientVersion::V2 => { - LsHttpClient::V2(LsHttpClientV2::try_new(&private_key, &cert, timeout)?) - } - LsHttpClientVersion::V3 => { - LsHttpClient::V3(LsHttpClientV3::try_new(&private_key, &cert, timeout)?) - } + LsHttpClientVersion::V2 => LsHttpClient::V2(LsHttpClientV2::try_new( + private_key, + cert, + expected_fingerprint, + timeout, + )?), + LsHttpClientVersion::V3 => LsHttpClient::V3(LsHttpClientV3::try_new( + private_key, + cert, + expected_fingerprint, + timeout, + )?), }; Ok(client) @@ -168,23 +185,45 @@ pub(super) fn upload_body( reqwest::Body::wrap_stream(stream) } +/// Builds the reqwest client used for all outgoing requests. +/// +/// The TLS config is assembled by hand instead of using reqwest's own TLS +/// options, because only a preconfigured [`rustls::ClientConfig`] can carry a +/// custom certificate verifier. reqwest passes such a config straight through, +/// which means the client certificate and ALPN have to be set here as well: +/// `identity()` and the HTTP version preference of the builder no longer apply. pub(super) fn create_reqwest_client( private_key: &str, cert: &str, + expected_fingerprint: Option, timeout: Option, ) -> Result { let _ = rustls::crypto::ring::default_provider().install_default(); - let identity = { - let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); - reqwest::Identity::from_pem(pem)? + let mut tls_config = { + let certs = + vec![CertificateDer::from_pem_slice(cert.as_bytes()).map_err(anyhow::Error::from)?]; + let key = + PrivateKeyDer::from_pem_slice(private_key.as_bytes()).map_err(anyhow::Error::from)?; + + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new( + server_cert_verifier::PinnedServerCertVerifier::try_new( + cert, + expected_fingerprint, + )?, + )) + .with_client_auth_cert(certs, key) + .map_err(anyhow::Error::from)? }; + // Must be set explicitly, see the doc comment above. + tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + let mut builder = reqwest::Client::builder() - .use_rustls_tls() - .danger_accept_invalid_certs(true) - .tls_info(true) - .identity(identity); + .tls_backend_preconfigured(tls_config) + .tls_info(true); if let Some(timeout) = timeout { builder = builder.timeout(timeout); diff --git a/packages/core/src/http/client/server_cert_verifier.rs b/packages/core/src/http/client/server_cert_verifier.rs new file mode 100644 index 00000000..f617dda6 --- /dev/null +++ b/packages/core/src/http/client/server_cert_verifier.rs @@ -0,0 +1,177 @@ +use crate::crypto::cert::fingerprint_from_cert_der; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::client::WebPkiServerVerifier; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{CertificateError, DigitallySignedStruct, Error, RootCertStore, SignatureScheme}; +use std::fmt::{Debug, Formatter}; +use std::sync::Arc; +use x509_parser::nom::AsBytes; + +/// Verifies the certificate of the remote peer during the TLS handshake. +/// +/// LocalSend peers use self-signed certificates, so there is no authority to +/// chain to. The peer identity is the uppercase-hex SHA-256 of the certificate +/// in DER format, the same identity the server side enforces for client +/// certificates in [`crate::http::server`]. +/// +/// Running the check here instead of on the response is what makes it useful: +/// the handshake fails before any request bytes are written, so a mismatching +/// peer never receives the request metadata or the file content. +pub(crate) struct PinnedServerCertVerifier { + inner: Arc, + + /// The SHA-256 fingerprint (uppercase hex) the peer certificate must have. + /// + /// [`None`] accepts any valid certificate. This is trust on first use and + /// is only appropriate for discovery, where the fingerprint of the peer is + /// not known yet and is read from the response instead. + expected_fingerprint: Option, +} + +impl PinnedServerCertVerifier { + pub(crate) fn try_new( + cert: &str, + expected_fingerprint: Option, + ) -> anyhow::Result { + // The root store must not be empty, so we add our own certificate. + // It is never used as an authority: `verify_server_cert` below does not + // delegate. We only need the inner verifier for the signature methods. + let mut root_cert_store = RootCertStore::empty(); + root_cert_store.add(PemObject::from_pem_slice(cert.as_bytes())?)?; + + Ok(Self { + inner: WebPkiServerVerifier::builder(Arc::new(root_cert_store)).build()?, + expected_fingerprint: expected_fingerprint.map(|f| f.to_ascii_uppercase()), + }) + } +} + +impl Debug for PinnedServerCertVerifier { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.inner.fmt(f) + } +} + +impl ServerCertVerifier for PinnedServerCertVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: UnixTime, + ) -> Result { + // The hostname is deliberately ignored: peers are addressed by IP and + // their certificates carry no matching SAN. The fingerprint below is + // what identifies the peer. + crate::crypto::cert::verify_cert_from_der(end_entity.as_bytes(), None).map_err(|e| { + tracing::warn!("Server certificate verification failed: {e:#}"); + Error::InvalidCertificate(CertificateError::ApplicationVerificationFailure) + })?; + + if let Some(expected) = &self.expected_fingerprint { + let actual = fingerprint_from_cert_der(end_entity.as_bytes()); + if &actual != expected { + tracing::warn!( + "Server certificate fingerprint mismatch: expected {expected}, got {actual}" + ); + return Err(Error::InvalidCertificate( + CertificateError::ApplicationVerificationFailure, + )); + } + } + + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustls::pki_types::pem::PemObject; + + /// Valid until 2035, so the time validity check passes. + static CERT: &str = "-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh +bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG +A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjA5MDAwMzE0WhcNMzUwMjA3MDAwMzE0 +WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL +EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCL24MxhGfrdJm0Q8ZGiBkZ27ldcEChB4w7rSbJ +yiKeosoNbJl2kyj5dZjfBhWGgDLGDMM5w+Mh/5SrWgTL/QrhbB+lsrxILLznWqBi +R8wJP0P2YW9fBahQskJQcUXt/3jsCsMTWea4rWc3HZGh03bAkJfLM+PDSOfTpvAZ +6DQSp9QLzC9bgVNnq3W0SvOZGpF0xRa4InCyTUgxsNsV4+GIrmN5w4EbRFVVYu7D +5OS5fxNSCukiS0fb6oQzUp0vIAycvvWHHbAy8T6UMoUor2nfvNcryiaOX5WBMLyh +yMZ5gMOyXjdm1bT1XSlvtXPYUzxvsGAzTqS8mXjw8h7mm5htAgMBAAEwDQYJKoZI +hvcNAQELBQADggEBABZ+I7D6wkeSrsi1NBLP2zoZ5oGh+INNcGTravfOQHs4Fbas +/CysaUYjsD3fmaDh4MxgWEqAmWnnBiojfpGX2SGuFqRBKyT9DgitBt0L7Ezg1k3h +bfSiFW4hXWp75grVO8xfML7ZcWMlhKrOsOMUGiy1qs3qsyJ3w7B2Tz78HhXGO5dd +jyPmZarhixKO92UpEvKGxjO0E/3UUNUzxKTAAgFfhKpuwHUgIijM/EppZtA8OcSh +fEztiV0xKfcPVx4d6dqRt/NMElK1Ivw2vUuxTymphZkkFOzht9m73/kyKaeFp8Ij +VRus1zGVD8IVpIdPMyz01WJyS7M0fWaHXKWo+Bo= +-----END CERTIFICATE-----"; + + static FINGERPRINT: &str = "4BADDE53A7F7CDEEED93189FD898E02BF6B4806CA4C05DE0ACE08319B86552FA"; + + fn verify(expected_fingerprint: Option<&str>) -> Result<(), Error> { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let verifier = + PinnedServerCertVerifier::try_new(CERT, expected_fingerprint.map(|f| f.to_string())) + .unwrap(); + + verifier + .verify_server_cert( + &CertificateDer::from_pem_slice(CERT.as_bytes()).unwrap(), + &[], + &ServerName::try_from("192.168.1.1").unwrap(), + &[], + UnixTime::now(), + ) + .map(|_| ()) + } + + #[test] + fn accepts_matching_fingerprint() { + assert!(verify(Some(FINGERPRINT)).is_ok()); + } + + #[test] + fn accepts_lowercase_fingerprint() { + assert!(verify(Some(&FINGERPRINT.to_ascii_lowercase())).is_ok()); + } + + #[test] + fn rejects_mismatching_fingerprint() { + let other = "0".repeat(64); + assert!(verify(Some(&other)).is_err()); + } + + #[test] + fn accepts_any_certificate_without_pin() { + assert!(verify(None).is_ok()); + } +} diff --git a/packages/core/src/http/client/v2.rs b/packages/core/src/http/client/v2.rs index fb7ea955..d268e515 100644 --- a/packages/core/src/http/client/v2.rs +++ b/packages/core/src/http/client/v2.rs @@ -21,6 +21,10 @@ impl LsHttpClientV2 { /// # Arguments /// * `private_key` - PEM-encoded private key for client certificate /// * `cert` - PEM-encoded certificate for client authentication + /// * `expected_fingerprint` - SHA-256 fingerprint (uppercase hex) the peer + /// certificate must have. Enforced during the TLS handshake, so nothing + /// is sent to a mismatching peer. [`None`] accepts any valid certificate + /// and must only be used for discovery. /// * `timeout` - Optional total request timeout (e.g. for discovery scans) /// /// # Returns @@ -28,10 +32,11 @@ impl LsHttpClientV2 { pub fn try_new( private_key: &str, cert: &str, + expected_fingerprint: Option, timeout: Option, ) -> Result { Ok(Self { - client: super::create_reqwest_client(private_key, cert, timeout)?, + client: super::create_reqwest_client(private_key, cert, expected_fingerprint, timeout)?, }) } diff --git a/packages/core/src/http/client/v3.rs b/packages/core/src/http/client/v3.rs index 926ec856..7ac3db9e 100644 --- a/packages/core/src/http/client/v3.rs +++ b/packages/core/src/http/client/v3.rs @@ -24,10 +24,11 @@ impl LsHttpClientV3 { pub fn try_new( private_key: &str, cert: &str, + expected_fingerprint: Option, timeout: Option, ) -> Result { Ok(Self { - client: super::create_reqwest_client(private_key, cert, timeout)?, + client: super::create_reqwest_client(private_key, cert, expected_fingerprint, timeout)?, received_nonce_map: Arc::new(Mutex::new(LruCache::new( NonZeroUsize::new(200).unwrap(), ))), diff --git a/packages/core/src/http/server/common/save.rs b/packages/core/src/http/server/common/save.rs index f6690969..38a916bc 100644 --- a/packages/core/src/http/server/common/save.rs +++ b/packages/core/src/http/server/common/save.rs @@ -180,7 +180,10 @@ pub(crate) async fn save_req_to_target( }, false => None, }; - break 'outcome (SaveResult::Failed, error.or(Some("Upload aborted".to_string()))); + break 'outcome ( + SaveResult::Failed, + error.or(Some("Upload aborted".to_string())), + ); } match result_rx.await { diff --git a/packages/core/tests/v2_server.rs b/packages/core/tests/v2_server.rs index 6bde4789..449b95aa 100644 --- a/packages/core/tests/v2_server.rs +++ b/packages/core/tests/v2_server.rs @@ -557,7 +557,10 @@ async fn test_upload_retry_reuses_the_same_path() { file_names.push(entry.file_name().to_string_lossy().to_string()); } assert_eq!(file_names, vec!["file-a".to_string()]); - assert_eq!(tokio::fs::read(save_dir.join("file-a")).await.unwrap(), bytes); + assert_eq!( + tokio::fs::read(save_dir.join("file-a")).await.unwrap(), + bytes + ); tokio::fs::remove_dir_all(&save_dir).await.unwrap(); } diff --git a/packages/core/tests/v2_tls_pinning.rs b/packages/core/tests/v2_tls_pinning.rs new file mode 100644 index 00000000..b37bab40 --- /dev/null +++ b/packages/core/tests/v2_tls_pinning.rs @@ -0,0 +1,392 @@ +#![cfg(feature = "http")] + +//! Verifies that the client authenticates the peer certificate during the TLS +//! handshake, i.e. before any request data is written. + +use bytes::Bytes; +use futures_util::StreamExt; +use localsend::crypto::cert::fingerprint_from_cert_der; +use localsend::http::client::{ClientError, LsHttpClientV2}; +use localsend::http::dto::ProtocolType; +use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, ProtocolTypeV2, RegisterDtoV2}; +use localsend::http::server::common::save::FileUploadTarget; +use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2}; +use localsend::http::server::{start_with_port, ServerConfigV2, TlsConfig}; +use localsend::http::state::ClientInfo; +use localsend::model::transfer::FileDto; +use std::sync::atomic::{AtomicU16, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::sync::CancellationToken; + +/// A generated self-signed certificate, as LocalSend peers use them. +struct Identity { + cert: String, + private_key: String, + /// Uppercase-hex SHA-256 of the certificate in DER format. + fingerprint: String, +} + +fn generate_identity() -> Identity { + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["LocalSend User".to_string()]) + .unwrap() + .self_signed(&key_pair) + .unwrap(); + + Identity { + cert: cert.pem(), + private_key: key_pair.serialize_pem(), + fingerprint: fingerprint_from_cert_der(cert.der()), + } +} + +struct TestServer { + port: u16, + /// Requests that reached the application layer. + prepare_uploads: Arc>>, + /// Uploaded file contents, mapped by file ID. + received: Arc)>>>, + _stop_tx: oneshot::Sender<()>, +} + +/// Starts a test server over TLS using the given identity. +async fn start_tls_server(identity: &Identity) -> TestServer { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + let port = free_port(); + let prepare_uploads: Arc>> = Arc::new(Mutex::new(Vec::new())); + let received: Arc)>>> = Arc::new(Mutex::new(Vec::new())); + + let (event_tx, mut event_rx) = mpsc::channel::(16); + + tokio::spawn({ + let prepare_uploads = prepare_uploads.clone(); + let received = received.clone(); + async move { + while let Some(event) = event_rx.recv().await { + match event { + ServerEventV2::PrepareUpload { + files, decision_tx, .. + } => { + prepare_uploads + .lock() + .await + .extend(files.keys().cloned().collect::>()); + let _ = decision_tx.send(PrepareUploadDecisionV2::Accept( + files.keys().cloned().collect(), + )); + } + ServerEventV2::FileUpload { + file_id, target_tx, .. + } => { + let received = received.clone(); + let (binary_tx, mut binary_rx) = mpsc::channel(16); + let (result_tx, result_rx) = oneshot::channel(); + let _ = target_tx.send(FileUploadTarget::Stream { + binary_tx, + result_rx, + }); + tokio::spawn(async move { + let mut bytes = Vec::new(); + while let Some(chunk) = binary_rx.recv().await { + bytes.extend_from_slice(&chunk); + } + received.lock().await.push((file_id, bytes)); + let _ = result_tx.send(Ok(())); + }); + } + _ => {} + } + } + } + }); + + let (stop_tx, stop_rx) = oneshot::channel::<()>(); + + start_with_port( + port, + Some(TlsConfig { + cert: identity.cert.clone(), + private_key: identity.private_key.clone(), + }), + ClientInfo { + alias: "Test Server".to_string(), + version: "2.1".to_string(), + device_model: Some("Rust".to_string()), + device_type: None, + token: identity.fingerprint.clone(), + }, + None, + Some(ServerConfigV2 { + pin: None, + event_tx, + }), + None, + stop_rx, + ) + .await + .expect("Failed to start server"); + + wait_until_reachable(port).await; + + TestServer { + port, + prepare_uploads, + received, + _stop_tx: stop_tx, + } +} + +/// Returns a free port. +fn free_port() -> u16 { + static PORT_COUNTER: AtomicU16 = AtomicU16::new(40951); + + loop { + let port = PORT_COUNTER.fetch_add(1, Ordering::SeqCst); + if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { + return port; + } + } +} + +async fn wait_until_reachable(port: u16) { + for _ in 0..100 { + if tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() + { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("Server did not become reachable on port {port}"); +} + +fn client(sender: &Identity, expected_fingerprint: Option<&str>) -> LsHttpClientV2 { + LsHttpClientV2::try_new( + &sender.private_key, + &sender.cert, + expected_fingerprint.map(|f| f.to_string()), + None, + ) + .unwrap() +} + +fn sender_info(fingerprint: &str) -> RegisterDtoV2 { + RegisterDtoV2 { + alias: "Test Sender".to_string(), + version: "2.1".to_string(), + device_model: Some("Rust".to_string()), + device_type: None, + fingerprint: fingerprint.to_string(), + port: 53317, + protocol: ProtocolTypeV2::Https, + download: false, + } +} + +fn file_dto(id: &str, size: u64) -> FileDto { + FileDto { + id: id.to_string(), + file_name: format!("{id}.bin"), + size, + file_type: "application/octet-stream".to_string(), + sha256: None, + preview: None, + metadata: None, + } +} + +fn prepare_upload_request(sender: &Identity, files: &[FileDto]) -> PrepareUploadRequestDtoV2 { + PrepareUploadRequestDtoV2 { + info: sender_info(&sender.fingerprint), + files: files + .iter() + .map(|file| (file.id.clone(), file.clone())) + .collect(), + } +} + +/// Uploads `bytes` and returns how many bytes were actually read from the +/// source stream, so a test can assert that nothing left the machine. +async fn upload_bytes( + client: &LsHttpClientV2, + port: u16, + session_id: &str, + file_id: &str, + token: &str, + bytes: &[u8], +) -> (Result<(), ClientError>, u64) { + let (tx, rx) = mpsc::channel::(4); + let chunks: Vec> = bytes.chunks(1024).map(|chunk| chunk.to_vec()).collect(); + tokio::spawn(async move { + for chunk in chunks { + if tx.send(Bytes::from(chunk)).await.is_err() { + break; + } + } + }); + + let sent = Arc::new(AtomicU64::new(0)); + let progress = sent.clone(); + let body = + localsend::reqwest::Body::wrap_stream(ReceiverStream::new(rx).map(move |chunk: Bytes| { + progress.fetch_add(chunk.len() as u64, Ordering::Relaxed); + Ok::(chunk) + })); + + let result = client + .upload( + ProtocolType::Https, + "127.0.0.1", + port, + None, + session_id, + file_id, + token, + body, + CancellationToken::new(), + ) + .await; + + (result, sent.load(Ordering::Relaxed)) +} + +/// The happy path: the pinned fingerprint matches, so the transfer works. +/// This also covers that mTLS still functions with the hand-built TLS config. +#[tokio::test] +async fn test_transfer_with_matching_fingerprint() { + let server_identity = generate_identity(); + let sender = generate_identity(); + let server = start_tls_server(&server_identity).await; + let client = client(&sender, Some(&server_identity.fingerprint)); + + let content = b"hello localsend".repeat(100); + let files = vec![file_dto("file-1", content.len() as u64)]; + + let result = client + .prepare_upload( + ProtocolType::Https, + "127.0.0.1", + server.port, + None, + prepare_upload_request(&sender, &files), + None, + ) + .await + .expect("prepare-upload should succeed"); + + let response = result.response.expect("expected a session"); + let token = response.files.get("file-1").unwrap(); + + let (upload_result, sent) = upload_bytes( + &client, + server.port, + &response.session_id, + "file-1", + token, + &content, + ) + .await; + + upload_result.expect("upload should succeed"); + assert_eq!(sent, content.len() as u64); + + let received = server.received.lock().await; + assert_eq!(received.len(), 1); + assert_eq!(received[0].1, content); +} + +/// The peer presents a valid self-signed certificate, but not the one that was +/// discovered. The handshake must fail, and the request must never reach the +/// application layer of the impostor. +#[tokio::test] +async fn test_prepare_upload_rejected_on_fingerprint_mismatch() { + let server_identity = generate_identity(); + let sender = generate_identity(); + let server = start_tls_server(&server_identity).await; + + // Pin some other device, e.g. the one the user actually picked. + let expected = generate_identity(); + let client = client(&sender, Some(&expected.fingerprint)); + + let files = vec![file_dto("file-1", 10)]; + let result = client + .prepare_upload( + ProtocolType::Https, + "127.0.0.1", + server.port, + None, + prepare_upload_request(&sender, &files), + None, + ) + .await; + + // A transport error, not a status code: the peer never got to answer. + assert!( + matches!(result, Err(ClientError::Reqwest(_))), + "expected the handshake to fail, got {:?}", + result.err() + ); + + // The decisive assertion: the request body never reached the peer. + assert!( + server.prepare_uploads.lock().await.is_empty(), + "the impostor must not receive the file metadata" + ); +} + +/// The file content must not be streamed to a peer that fails the pin check. +#[tokio::test] +async fn test_upload_body_not_sent_on_fingerprint_mismatch() { + let server_identity = generate_identity(); + let sender = generate_identity(); + let server = start_tls_server(&server_identity).await; + + let expected = generate_identity(); + let client = client(&sender, Some(&expected.fingerprint)); + + let content = b"secret".repeat(10_000); + let (result, sent) = upload_bytes( + &client, + server.port, + "some-session", + "file-1", + "some-token", + &content, + ) + .await; + + assert!( + matches!(result, Err(ClientError::Reqwest(_))), + "expected the handshake to fail, got {:?}", + result.err() + ); + assert_eq!(sent, 0, "no file content may be read or sent"); + assert!(server.received.lock().await.is_empty()); +} + +/// Discovery has no fingerprint to pin yet, so any valid certificate is +/// accepted and the public key is read from the response. +#[tokio::test] +async fn test_register_without_pin_returns_public_key() { + let server_identity = generate_identity(); + let sender = generate_identity(); + let server = start_tls_server(&server_identity).await; + let client = client(&sender, None); + + let response = client + .register( + ProtocolType::Https, + "127.0.0.1", + server.port, + sender_info(&sender.fingerprint), + ) + .await + .expect("register should succeed"); + + assert!(response.public_key.is_some()); + assert_eq!(response.body.fingerprint, server_identity.fingerprint); +} diff --git a/packages/localsend_isolates/lib/rust/api/http.dart b/packages/localsend_isolates/lib/rust/api/http.dart index fbe1c951..20f2ba2f 100644 --- a/packages/localsend_isolates/lib/rust/api/http.dart +++ b/packages/localsend_isolates/lib/rust/api/http.dart @@ -15,8 +15,25 @@ part 'http.freezed.dart'; // These functions are ignored because they are not marked as `pub`: `resolve_file_content` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `from` -RsHttpClient createClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs}) => - RustLib.instance.api.crateApiHttpCreateClient(privateKey: privateKey, cert: cert, version: version, timeoutMs: timeoutMs); +/// Creates an HTTP client. +/// +/// `expected_fingerprint` pins the peer to the certificate with that SHA-256 +/// fingerprint (uppercase hex). It is enforced during the TLS handshake, so a +/// peer that does not present the expected certificate never receives the +/// request. Pass `None` only for discovery, where the peer is not known yet. +RsHttpClient createClient({ + required String privateKey, + required String cert, + required LsHttpClientVersion version, + String? expectedFingerprint, + int? timeoutMs, +}) => RustLib.instance.api.crateApiHttpCreateClient( + privateKey: privateKey, + cert: cert, + version: version, + expectedFingerprint: expectedFingerprint, + timeoutMs: timeoutMs, +); // Rust type: RustOpaqueMoi> abstract class RsHttpClient implements RustOpaqueInterface { diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index 7f2eb417..bc4731ee 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -225,7 +225,13 @@ abstract class RustLibApi extends BaseApi { RsCancellationToken crateApiCancelCreateCancellationToken(); - RsHttpClient crateApiHttpCreateClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs}); + RsHttpClient crateApiHttpCreateClient({ + required String privateKey, + required String cert, + required LsHttpClientVersion version, + String? expectedFingerprint, + int? timeoutMs, + }); Future<(Dart2RustStreamSink, Dart2RustStreamReceiver)> crateApiStreamCreateStream(); @@ -1447,7 +1453,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - RsHttpClient crateApiHttpCreateClient({required String privateKey, required String cert, required LsHttpClientVersion version, int? timeoutMs}) { + RsHttpClient crateApiHttpCreateClient({ + required String privateKey, + required String cert, + required LsHttpClientVersion version, + String? expectedFingerprint, + int? timeoutMs, + }) { return handler.executeSync( SyncTask( callFfi: () { @@ -1455,6 +1467,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(privateKey, serializer); sse_encode_String(cert, serializer); sse_encode_ls_http_client_version(version, serializer); + sse_encode_opt_String(expectedFingerprint, serializer); sse_encode_opt_box_autoadd_u_32(timeoutMs, serializer); return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; }, @@ -1463,7 +1476,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_rs_http_client_error, ), constMeta: kCrateApiHttpCreateClientConstMeta, - argValues: [privateKey, cert, version, timeoutMs], + argValues: [privateKey, cert, version, expectedFingerprint, timeoutMs], apiImpl: this, ), ); @@ -1471,7 +1484,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiHttpCreateClientConstMeta => const TaskConstMeta( debugName: 'create_client', - argNames: ['privateKey', 'cert', 'version', 'timeoutMs'], + argNames: ['privateKey', 'cert', 'version', 'expectedFingerprint', 'timeoutMs'], ); @override diff --git a/packages/localsend_isolates/lib/src/isolate/child/http_provider.dart b/packages/localsend_isolates/lib/src/isolate/child/http_provider.dart index 21639f15..374d38f5 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/http_provider.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/http_provider.dart @@ -3,13 +3,38 @@ import 'package:localsend_isolates/src/isolate/child/sync_provider.dart'; import 'package:refena_flutter/refena_flutter.dart'; class HttpClientCollection { + final String _privateKey; + final String _certificate; + + /// A client that accepts any valid peer certificate. + /// + /// Only for discovery, where the certificate of the peer is not known yet + /// and is learned from the response. Never use this to transfer files: + /// it cannot tell the discovered device apart from anyone else answering + /// on that address. final RsHttpClient discovery; - final RsHttpClient longLiving; HttpClientCollection({ + required String privateKey, + required String certificate, required this.discovery, - required this.longLiving, - }); + }) : _privateKey = privateKey, + _certificate = certificate; + + /// A client that only talks to the peer holding the certificate with the + /// given [fingerprint]. + /// + /// The check happens during the TLS handshake, so a different peer never + /// receives the request. Create one per upload task and reuse it for all + /// files of that task, so the connection is kept alive between them. + RsHttpClient pinnedTo(String fingerprint) { + return createClient( + privateKey: _privateKey, + cert: _certificate, + version: LsHttpClientVersion.v2, + expectedFingerprint: fingerprint, + ); + } } final httpProvider = ViewProvider((ref) { @@ -17,16 +42,14 @@ final httpProvider = ViewProvider((ref) { syncProvider.select((state) => (state.securityContext, state.discoveryTimeout)), ); return HttpClientCollection( + privateKey: securityContext.privateKey, + certificate: securityContext.certificate, discovery: createClient( privateKey: securityContext.privateKey, cert: securityContext.certificate, version: LsHttpClientVersion.v2, + expectedFingerprint: null, timeoutMs: discoveryTimeout, ), - longLiving: createClient( - privateKey: securityContext.privateKey, - cert: securityContext.certificate, - version: LsHttpClientVersion.v2, - ), ); }); diff --git a/packages/localsend_isolates/lib/src/isolate/child/upload_isolate.dart b/packages/localsend_isolates/lib/src/isolate/child/upload_isolate.dart index 1a8dbc65..df5b28f6 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/upload_isolate.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/upload_isolate.dart @@ -3,6 +3,7 @@ import 'package:localsend_isolates/isolate.dart'; import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/rust/api/cancel.dart'; import 'package:localsend_isolates/rust/api/http.dart'; +import 'package:localsend_isolates/src/isolate/child/http_provider.dart'; import 'package:localsend_isolates/src/isolate/child/main.dart'; import 'package:localsend_isolates/src/isolate/dto/send_to_isolate_data.dart'; import 'package:localsend_isolates/src/task/upload/http_upload.dart'; @@ -134,6 +135,11 @@ Future setupHttpUploadIsolate( return; } + // One client for the whole task: pinned to the receiver, so no file + // content can be streamed to a different peer, and shared by all files + // of the task so the connection is reused. + final client = ref.read(httpProvider).pinnedTo(uploadTask.device.fingerprint); + final cancelToken = createCancellationToken(); ref.read(_cancelTokenProvider).putIfAbsent(task.id, () => cancelToken); try { @@ -163,6 +169,7 @@ Future setupHttpUploadIsolate( await ref .read(httpUploadProvider) .upload( + client: client, stream: filePath == null && file.fileBytes != null ? Stream.value(file.fileBytes!) : null, path: !isContentUri ? filePath : null, fileDescriptor: fileDescriptor, diff --git a/packages/localsend_isolates/lib/src/task/upload/http_upload.dart b/packages/localsend_isolates/lib/src/task/upload/http_upload.dart index 9ed262eb..ceb05b2d 100644 --- a/packages/localsend_isolates/lib/src/task/upload/http_upload.dart +++ b/packages/localsend_isolates/lib/src/task/upload/http_upload.dart @@ -2,21 +2,22 @@ import 'package:localsend_isolates/model/device.dart'; import 'package:localsend_isolates/rust/api/cancel.dart'; import 'package:localsend_isolates/rust/api/http.dart'; import 'package:localsend_isolates/rust/api/stream.dart'; -import 'package:localsend_isolates/src/isolate/child/http_provider.dart'; import 'package:localsend_isolates/util/rust.dart'; import 'package:refena_flutter/refena_flutter.dart'; -final httpUploadProvider = ViewProvider((ref) { - final client = ref.watch(httpProvider).longLiving; - return HttpUploadService(client); -}); +final httpUploadProvider = ViewProvider((ref) => HttpUploadService()); class HttpUploadService { - final RsHttpClient _client; - - HttpUploadService(this._client); + const HttpUploadService(); + /// Uploads a single file. + /// + /// [client] must be pinned to [target] so the file content is not streamed + /// to a peer other than the one the session was negotiated with. It is + /// passed in rather than resolved here so that all files of one task share + /// a connection. Future upload({ + required RsHttpClient client, required Stream>? stream, required String? path, required int? fileDescriptor, @@ -30,11 +31,13 @@ class HttpUploadService { }) async { final (sink, receiver) = stream != null ? await createStream() : (null, null); - final uploadFuture = _client + final uploadFuture = client .upload( protocol: target.getProtocolType(), ip: target.ip!, port: target.port, + // The peer is already verified during the TLS handshake by the + // fingerprint [client] is pinned to. publicKey: null, sessionId: remoteSessionId ?? '', fileId: fileId, diff --git a/packages/localsend_isolates/pubspec.lock b/packages/localsend_isolates/pubspec.lock index c6052792..01fe5f68 100644 --- a/packages/localsend_isolates/pubspec.lock +++ b/packages/localsend_isolates/pubspec.lock @@ -327,10 +327,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: "direct main" description: diff --git a/packages/localsend_isolates/rust/src/api/http.rs b/packages/localsend_isolates/rust/src/api/http.rs index 9d0d85af..c476bbd1 100644 --- a/packages/localsend_isolates/rust/src/api/http.rs +++ b/packages/localsend_isolates/rust/src/api/http.rs @@ -12,17 +12,25 @@ pub struct RsHttpClient { inner: localsend::http::client::LsHttpClient, } +/// Creates an HTTP client. +/// +/// `expected_fingerprint` pins the peer to the certificate with that SHA-256 +/// fingerprint (uppercase hex). It is enforced during the TLS handshake, so a +/// peer that does not present the expected certificate never receives the +/// request. Pass `None` only for discovery, where the peer is not known yet. #[frb(sync)] pub fn create_client( private_key: String, cert: String, version: LsHttpClientVersion, + expected_fingerprint: Option, timeout_ms: Option, ) -> Result { let inner = localsend::http::client::LsHttpClient::new( &private_key, &cert, version, + expected_fingerprint, timeout_ms.map(|ms| std::time::Duration::from_millis(ms as u64)), ) .map_err(RsHttpClientError::from)?; diff --git a/packages/localsend_isolates/rust/src/frb_generated.rs b/packages/localsend_isolates/rust/src/frb_generated.rs index a67a63c9..25f0104b 100644 --- a/packages/localsend_isolates/rust/src/frb_generated.rs +++ b/packages/localsend_isolates/rust/src/frb_generated.rs @@ -2328,6 +2328,7 @@ fn wire__crate__api__http__create_client_impl( let api_cert = ::sse_decode(&mut deserializer); let api_version = ::sse_decode(&mut deserializer); + let api_expected_fingerprint = >::sse_decode(&mut deserializer); let api_timeout_ms = >::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, crate::api::http::RsHttpClientError>((move || { @@ -2335,6 +2336,7 @@ fn wire__crate__api__http__create_client_impl( api_private_key, api_cert, api_version, + api_expected_fingerprint, api_timeout_ms, )?; Ok(output_ok)