diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index ceee1cab..4976732c 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -83,6 +83,11 @@ class ReceiveController { for (final entry in event.files.entries) entry.key: entry.value.toDart(), }; + // The fingerprint of the sender's mTLS certificate cannot be spoofed, unlike the + // self-reported fingerprint in the JSON payload which is only used as fallback + // when encryption is disabled. + final senderFingerprint = event.certFingerprint ?? event.info.fingerprint; + _logger.info('Session Id: $sessionId'); _logger.info('Destination Directory: $destinationDir'); @@ -91,8 +96,8 @@ class ReceiveController { session: ReceiveSessionState( sessionId: sessionId, status: SessionStatus.waiting, - sender: event.info.toDevice(event.ip, null), - senderAlias: server.ref.read(favoritesProvider).firstWhereOrNull((e) => e.fingerprint == event.info.fingerprint)?.alias ?? event.info.alias, + sender: event.info.toDevice(event.ip, null).copyWith(fingerprint: senderFingerprint), + senderAlias: server.ref.read(favoritesProvider).firstWhereOrNull((e) => e.fingerprint == senderFingerprint)?.alias ?? event.info.alias, files: { for (final file in files.values) file.id: ReceivingFile( @@ -118,7 +123,7 @@ class ReceiveController { bool quickSave = settings.quickSave && server.getState().session?.message == null; final quickSaveFromFavorites = settings.quickSaveFromFavorites && server.getState().session?.message == null; if (quickSaveFromFavorites) { - final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == event.info.fingerprint); + final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == senderFingerprint); if (isFavorite) { quickSave = true; } @@ -357,9 +362,7 @@ class ReceiveController { // (e.g. preparing the save target failed), the Rust server is still // waiting for it and the sender's request would hang forever. // Rejecting fails the request; a no-op if the target was already sent. - server.ref - .redux(parentIsolateProvider) - .dispatch(IsolateHttpServerRejectFileUploadAction(sessionId: event.sessionId, fileId: fileId)); + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerRejectFileUploadAction(sessionId: event.sessionId, fileId: fileId)); } server.ref diff --git a/app/lib/provider/network/server/controller/send_controller.dart b/app/lib/provider/network/server/controller/send_controller.dart index d25b8428..f60df1ab 100644 --- a/app/lib/provider/network/server/controller/send_controller.dart +++ b/app/lib/provider/network/server/controller/send_controller.dart @@ -138,9 +138,7 @@ class SendController { } catch (e, st) { _logger.severe('Failed to resolve source for web send file ${event.fileId}', e, st); // Unblock the web client's request waiting for the content source. - server.ref - .redux(parentIsolateProvider) - .dispatch(IsolateHttpServerRejectFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId)); + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerRejectFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId)); return; } diff --git a/packages/core/src/crypto/cert.rs b/packages/core/src/crypto/cert.rs index 335e4534..99dfd1b1 100644 --- a/packages/core/src/crypto/cert.rs +++ b/packages/core/src/crypto/cert.rs @@ -45,6 +45,15 @@ fn verify_cert_from_cert(cert: X509Certificate, public_key: Option<&str>) -> any Ok(()) } +/// Computes the SHA-256 fingerprint of a certificate in DER format. +/// Encoded as uppercase hex, the format used for LocalSend fingerprints. +pub fn fingerprint_from_cert_der(cert: &[u8]) -> String { + crate::crypto::hash::sha256(cert) + .iter() + .map(|byte| format!("{byte:02X}")) + .collect() +} + /// Extracts the public key from the certificate which is in DER format. /// Encodes the public key in PEM format. pub fn public_key_from_cert_der(cert: &[u8]) -> anyhow::Result { @@ -74,9 +83,7 @@ iK5jecOBG0RVVWLuw+TkuX8TUgrpIktH2+qEM1KdLyAMnL71hx2wMvE+lDKFKK9p bQIDAQAB -----END PUBLIC KEY-----"; - #[test] - fn test_verify_cert() { - let cert = "-----BEGIN CERTIFICATE----- + static CERT: &str = "-----BEGIN CERTIFICATE----- MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG A1UECBMAMQkwBwYDVQQGEwAwHhcNMjUwMjA5MDAwMzE0WhcNMzUwMjA3MDAwMzE0 @@ -94,10 +101,12 @@ bfSiFW4hXWp75grVO8xfML7ZcWMlhKrOsOMUGiy1qs3qsyJ3w7B2Tz78HhXGO5dd jyPmZarhixKO92UpEvKGxjO0E/3UUNUzxKTAAgFfhKpuwHUgIijM/EppZtA8OcSh fEztiV0xKfcPVx4d6dqRt/NMElK1Ivw2vUuxTymphZkkFOzht9m73/kyKaeFp8Ij VRus1zGVD8IVpIdPMyz01WJyS7M0fWaHXKWo+Bo= ------END CERTIFICATE-----" - .to_string(); +-----END CERTIFICATE-----"; + + #[test] + fn test_verify_cert() { assert_eq!( - verify_cert_from_pem(cert, Some(PUBLIC_KEY)).map_err(|e| e.to_string()), + verify_cert_from_pem(CERT.to_string(), Some(PUBLIC_KEY)).map_err(|e| e.to_string()), Ok(()) ); @@ -178,4 +187,14 @@ nidU/qXQvBJ7NPUkXXgbcgqxK735iijOqQHmKts= Err("Time validity error".to_string()) ); } + + #[test] + fn test_fingerprint_from_cert_der() { + let (cert_pem, _) = Pem::read(Cursor::new(CERT.as_bytes().to_vec())).unwrap(); + assert_eq!( + fingerprint_from_cert_der(&cert_pem.contents), + // Must match the Dart side: uppercase hex SHA-256 of the DER bytes. + "4BADDE53A7F7CDEEED93189FD898E02BF6B4806CA4C05DE0ACE08319B86552FA" + ); + } } diff --git a/packages/core/src/http/server/mod.rs b/packages/core/src/http/server/mod.rs index a38d2c2d..dedb66fd 100644 --- a/packages/core/src/http/server/mod.rs +++ b/packages/core/src/http/server/mod.rs @@ -4,7 +4,7 @@ pub mod v2; pub mod v3; pub mod web; -use crate::crypto::cert::public_key_from_cert_der; +use crate::crypto::cert::{fingerprint_from_cert_der, public_key_from_cert_der}; use crate::http::server::internal::{InternalConfig, InternalState}; use crate::http::server::v2::ServerEventV2; use crate::http::server::web::WebSendConfig; @@ -367,6 +367,13 @@ pub struct RequestClientInfo { } impl RequestClientInfo { + /// The SHA-256 fingerprint (uppercase hex) of the client certificate + /// verified during the mTLS handshake. + /// `None` when the server runs without TLS. + fn cert_fingerprint(&self) -> Option { + self.cert.as_deref().map(fingerprint_from_cert_der) + } + fn extract_public_key(&self) -> Option { match &self.cert { Some(cert) => match public_key_from_cert_der(cert) { diff --git a/packages/core/src/http/server/v2.rs b/packages/core/src/http/server/v2.rs index bb4365db..f1c45e8c 100644 --- a/packages/core/src/http/server/v2.rs +++ b/packages/core/src/http/server/v2.rs @@ -25,6 +25,10 @@ use uuid::Uuid; #[derive(Debug)] pub enum ServerEventV2 { /// A device registered itself via `POST /api/localsend/v2/register`. + /// + /// On TLS, this event is only emitted when `info.fingerprint` matches the + /// SHA-256 fingerprint of the client certificate verified during the mTLS + /// handshake, so the fingerprint cannot be spoofed. Register { /// The IP address of the remote device. ip: IpAddr, @@ -49,6 +53,12 @@ pub enum ServerEventV2 { /// The device information of the sender. info: RegisterDtoV2, + /// The SHA-256 fingerprint (uppercase hex) of the sender's client + /// certificate verified during the mTLS handshake. Unlike + /// `info.fingerprint`, this value cannot be spoofed. + /// `None` when the server runs without TLS. + cert_fingerprint: Option, + /// The offered files, mapped by file ID. files: HashMap, @@ -137,14 +147,28 @@ pub(crate) async fn register( ) -> Result, AppError> { let payload = body.collect_to_json::().await?; + // On TLS, only trust registrations whose claimed fingerprint is proven + // by the client certificate of the mTLS handshake. + let fingerprint_valid = match client_info.cert_fingerprint() { + Some(cert_fingerprint) => payload.fingerprint.to_ascii_uppercase() == cert_fingerprint, + None => true, + }; + if let Some(v2) = &state.v2 { - let _ = v2 - .event_tx - .send(ServerEventV2::Register { - ip: client_info.ip, - info: payload, - }) - .await; + if fingerprint_valid { + let _ = v2 + .event_tx + .send(ServerEventV2::Register { + ip: client_info.ip, + info: payload, + }) + .await; + } else { + tracing::warn!( + "Ignoring register from {}: claimed fingerprint does not match the client certificate", + client_info.ip + ); + } } let info = state.info.lock().await.clone(); @@ -221,6 +245,7 @@ pub(crate) async fn prepare_upload( session_id: session_id.clone(), ip: client_info.ip, info: payload.info, + cert_fingerprint: client_info.cert_fingerprint(), files: payload.files.clone(), decision_tx, }; @@ -375,8 +400,7 @@ pub(crate) async fn cancel( let mut slot = v2.session.lock().await; match slot.as_ref() { Some(SessionStateV2::Active(session)) - if session.session_id == *session_id - && session.sender_ip == client_info.ip => + if session.session_id == *session_id && session.sender_ip == client_info.ip => { *slot = None; true diff --git a/packages/localsend_isolates/lib/rust/api/server.dart b/packages/localsend_isolates/lib/rust/api/server.dart index cc06da0b..9f6365c3 100644 --- a/packages/localsend_isolates/lib/rust/api/server.dart +++ b/packages/localsend_isolates/lib/rust/api/server.dart @@ -166,6 +166,10 @@ sealed class RsServerEvent with _$RsServerEvent { const RsServerEvent._(); /// A device registered itself via `POST /api/localsend/v2/register`. + /// + /// On TLS, this event is only emitted when `info.fingerprint` matches the + /// fingerprint of the client certificate verified during the mTLS + /// handshake, so the fingerprint cannot be spoofed. const factory RsServerEvent.register({ required String ip, required RegisterDtoV2 info, @@ -177,6 +181,12 @@ sealed class RsServerEvent with _$RsServerEvent { required String sessionId, required String ip, required RegisterDtoV2 info, + + /// The SHA-256 fingerprint (uppercase hex) of the sender's client + /// certificate verified during the mTLS handshake. Unlike + /// `info.fingerprint`, this value cannot be spoofed. + /// `None` when the server runs without TLS. + String? certFingerprint, required Map files, }) = RsServerEvent_PrepareUpload; diff --git a/packages/localsend_isolates/lib/rust/api/server.freezed.dart b/packages/localsend_isolates/lib/rust/api/server.freezed.dart index aad44400..fd837e10 100644 --- a/packages/localsend_isolates/lib/rust/api/server.freezed.dart +++ b/packages/localsend_isolates/lib/rust/api/server.freezed.dart @@ -140,11 +140,11 @@ return show_(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String ip, RegisterDtoV2 info)? register,TResult Function( String sessionId, String ip, RegisterDtoV2 info, Map files)? prepareUpload,TResult Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult Function( String sessionId)? prepareUploadAborted,TResult Function( String ip, String sessionId)? cancelReceived,TResult Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult Function( List args)? show_,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String ip, RegisterDtoV2 info)? register,TResult Function( String sessionId, String ip, RegisterDtoV2 info, String? certFingerprint, Map files)? prepareUpload,TResult Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult Function( String sessionId)? prepareUploadAborted,TResult Function( String ip, String sessionId)? cancelReceived,TResult Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult Function( List args)? show_,required TResult orElse(),}) {final _that = this; switch (_that) { case RsServerEvent_Register() when register != null: return register(_that.ip,_that.info);case RsServerEvent_PrepareUpload() when prepareUpload != null: -return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: +return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.certFingerprint,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: return fileUpload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_SessionEnd() when sessionEnd != null: return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_PrepareUploadAborted() when prepareUploadAborted != null: return prepareUploadAborted(_that.sessionId);case RsServerEvent_CancelReceived() when cancelReceived != null: @@ -169,11 +169,11 @@ return show_(_that.args);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String ip, RegisterDtoV2 info) register,required TResult Function( String sessionId, String ip, RegisterDtoV2 info, Map files) prepareUpload,required TResult Function( String sessionId, String fileId, FileDto file) fileUpload,required TResult Function( String sessionId, SessionEndReasonV2 reason) sessionEnd,required TResult Function( String sessionId) prepareUploadAborted,required TResult Function( String ip, String sessionId) cancelReceived,required TResult Function( String ip, String sessionId, String? userAgent) webPrepareDownload,required TResult Function( String sessionId, String fileId, FileDto file) webFileDownload,required TResult Function( List args) show_,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String ip, RegisterDtoV2 info) register,required TResult Function( String sessionId, String ip, RegisterDtoV2 info, String? certFingerprint, Map files) prepareUpload,required TResult Function( String sessionId, String fileId, FileDto file) fileUpload,required TResult Function( String sessionId, SessionEndReasonV2 reason) sessionEnd,required TResult Function( String sessionId) prepareUploadAborted,required TResult Function( String ip, String sessionId) cancelReceived,required TResult Function( String ip, String sessionId, String? userAgent) webPrepareDownload,required TResult Function( String sessionId, String fileId, FileDto file) webFileDownload,required TResult Function( List args) show_,}) {final _that = this; switch (_that) { case RsServerEvent_Register(): return register(_that.ip,_that.info);case RsServerEvent_PrepareUpload(): -return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload(): +return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.certFingerprint,_that.files);case RsServerEvent_FileUpload(): return fileUpload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_SessionEnd(): return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_PrepareUploadAborted(): return prepareUploadAborted(_that.sessionId);case RsServerEvent_CancelReceived(): @@ -194,11 +194,11 @@ return show_(_that.args);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String ip, RegisterDtoV2 info)? register,TResult? Function( String sessionId, String ip, RegisterDtoV2 info, Map files)? prepareUpload,TResult? Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult? Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult? Function( String sessionId)? prepareUploadAborted,TResult? Function( String ip, String sessionId)? cancelReceived,TResult? Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult? Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult? Function( List args)? show_,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String ip, RegisterDtoV2 info)? register,TResult? Function( String sessionId, String ip, RegisterDtoV2 info, String? certFingerprint, Map files)? prepareUpload,TResult? Function( String sessionId, String fileId, FileDto file)? fileUpload,TResult? Function( String sessionId, SessionEndReasonV2 reason)? sessionEnd,TResult? Function( String sessionId)? prepareUploadAborted,TResult? Function( String ip, String sessionId)? cancelReceived,TResult? Function( String ip, String sessionId, String? userAgent)? webPrepareDownload,TResult? Function( String sessionId, String fileId, FileDto file)? webFileDownload,TResult? Function( List args)? show_,}) {final _that = this; switch (_that) { case RsServerEvent_Register() when register != null: return register(_that.ip,_that.info);case RsServerEvent_PrepareUpload() when prepareUpload != null: -return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: +return prepareUpload(_that.sessionId,_that.ip,_that.info,_that.certFingerprint,_that.files);case RsServerEvent_FileUpload() when fileUpload != null: return fileUpload(_that.sessionId,_that.fileId,_that.file);case RsServerEvent_SessionEnd() when sessionEnd != null: return sessionEnd(_that.sessionId,_that.reason);case RsServerEvent_PrepareUploadAborted() when prepareUploadAborted != null: return prepareUploadAborted(_that.sessionId);case RsServerEvent_CancelReceived() when cancelReceived != null: @@ -285,13 +285,18 @@ as RegisterDtoV2, class RsServerEvent_PrepareUpload extends RsServerEvent { - const RsServerEvent_PrepareUpload({required this.sessionId, required this.ip, required this.info, required final Map files}): _files = files,super._(); + const RsServerEvent_PrepareUpload({required this.sessionId, required this.ip, required this.info, this.certFingerprint, required final Map files}): _files = files,super._(); /// The session ID the upload session will have when the request is accepted. final String sessionId; final String ip; final RegisterDtoV2 info; +/// The SHA-256 fingerprint (uppercase hex) of the sender's client +/// certificate verified during the mTLS handshake. Unlike +/// `info.fingerprint`, this value cannot be spoofed. +/// `None` when the server runs without TLS. + final String? certFingerprint; final Map _files; Map get files { if (_files is EqualUnmodifiableMapView) return _files; @@ -310,16 +315,16 @@ $RsServerEvent_PrepareUploadCopyWith get copyWith = @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is RsServerEvent_PrepareUpload&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.info, info) || other.info == info)&&const DeepCollectionEquality().equals(other._files, _files)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is RsServerEvent_PrepareUpload&&(identical(other.sessionId, sessionId) || other.sessionId == sessionId)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.info, info) || other.info == info)&&(identical(other.certFingerprint, certFingerprint) || other.certFingerprint == certFingerprint)&&const DeepCollectionEquality().equals(other._files, _files)); } @override -int get hashCode => Object.hash(runtimeType,sessionId,ip,info,const DeepCollectionEquality().hash(_files)); +int get hashCode => Object.hash(runtimeType,sessionId,ip,info,certFingerprint,const DeepCollectionEquality().hash(_files)); @override String toString() { - return 'RsServerEvent.prepareUpload(sessionId: $sessionId, ip: $ip, info: $info, files: $files)'; + return 'RsServerEvent.prepareUpload(sessionId: $sessionId, ip: $ip, info: $info, certFingerprint: $certFingerprint, files: $files)'; } @@ -330,7 +335,7 @@ abstract mixin class $RsServerEvent_PrepareUploadCopyWith<$Res> implements $RsSe factory $RsServerEvent_PrepareUploadCopyWith(RsServerEvent_PrepareUpload value, $Res Function(RsServerEvent_PrepareUpload) _then) = _$RsServerEvent_PrepareUploadCopyWithImpl; @useResult $Res call({ - String sessionId, String ip, RegisterDtoV2 info, Map files + String sessionId, String ip, RegisterDtoV2 info, String? certFingerprint, Map files }); @@ -347,12 +352,13 @@ class _$RsServerEvent_PrepareUploadCopyWithImpl<$Res> /// Create a copy of RsServerEvent /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? sessionId = null,Object? ip = null,Object? info = null,Object? files = null,}) { +@pragma('vm:prefer-inline') $Res call({Object? sessionId = null,Object? ip = null,Object? info = null,Object? certFingerprint = freezed,Object? files = null,}) { return _then(RsServerEvent_PrepareUpload( sessionId: null == sessionId ? _self.sessionId : sessionId // ignore: cast_nullable_to_non_nullable as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable as String,info: null == info ? _self.info : info // ignore: cast_nullable_to_non_nullable -as RegisterDtoV2,files: null == files ? _self._files : files // ignore: cast_nullable_to_non_nullable +as RegisterDtoV2,certFingerprint: freezed == certFingerprint ? _self.certFingerprint : certFingerprint // ignore: cast_nullable_to_non_nullable +as String?,files: null == files ? _self._files : files // ignore: cast_nullable_to_non_nullable as Map, )); } diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index d7a6676b..5ea25cbc 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -2542,7 +2542,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sessionId: dco_decode_String(raw[1]), ip: dco_decode_String(raw[2]), info: dco_decode_box_autoadd_register_dto_v_2(raw[3]), - files: dco_decode_Map_String_file_dto_None(raw[4]), + certFingerprint: dco_decode_opt_String(raw[4]), + files: dco_decode_Map_String_file_dto_None(raw[5]), ); case 2: return RsServerEvent_FileUpload( @@ -3694,8 +3695,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_sessionId = sse_decode_String(deserializer); var var_ip = sse_decode_String(deserializer); var var_info = sse_decode_box_autoadd_register_dto_v_2(deserializer); + var var_certFingerprint = sse_decode_opt_String(deserializer); var var_files = sse_decode_Map_String_file_dto_None(deserializer); - return RsServerEvent_PrepareUpload(sessionId: var_sessionId, ip: var_ip, info: var_info, files: var_files); + return RsServerEvent_PrepareUpload( + sessionId: var_sessionId, + ip: var_ip, + info: var_info, + certFingerprint: var_certFingerprint, + files: var_files, + ); case 2: var var_sessionId = sse_decode_String(deserializer); var var_fileId = sse_decode_String(deserializer); @@ -4838,11 +4846,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(0, serializer); sse_encode_String(ip, serializer); sse_encode_box_autoadd_register_dto_v_2(info, serializer); - case RsServerEvent_PrepareUpload(sessionId: final sessionId, ip: final ip, info: final info, files: final files): + case RsServerEvent_PrepareUpload( + sessionId: final sessionId, + ip: final ip, + info: final info, + certFingerprint: final certFingerprint, + files: final files, + ): sse_encode_i_32(1, serializer); sse_encode_String(sessionId, serializer); sse_encode_String(ip, serializer); sse_encode_box_autoadd_register_dto_v_2(info, serializer); + sse_encode_opt_String(certFingerprint, serializer); sse_encode_Map_String_file_dto_None(files, serializer); case RsServerEvent_FileUpload(sessionId: final sessionId, fileId: final fileId, file: final file): sse_encode_i_32(2, serializer); diff --git a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart index 178b257c..d6c992e1 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart @@ -156,6 +156,10 @@ sealed class HttpServerEvent {} class HttpServerStartedEvent extends HttpServerEvent {} /// A device registered itself on this server. +/// +/// On TLS, this event is only emitted when [RegisterDtoV2.fingerprint] matches +/// the fingerprint of the client certificate verified during the mTLS +/// handshake, so the fingerprint cannot be spoofed. class HttpServerRegisterEvent extends HttpServerEvent { final String ip; final RegisterDtoV2 info; @@ -173,12 +177,20 @@ class HttpServerPrepareUploadEvent extends HttpServerEvent { final String sessionId; final String ip; final RegisterDtoV2 info; + + /// The SHA-256 fingerprint (uppercase hex) of the sender's client + /// certificate verified during the mTLS handshake. Unlike + /// [RegisterDtoV2.fingerprint], this value cannot be spoofed. + /// `null` when the server runs without TLS. + final String? certFingerprint; + final Map files; HttpServerPrepareUploadEvent({ required this.sessionId, required this.ip, required this.info, + required this.certFingerprint, required this.files, }); } @@ -359,12 +371,14 @@ Future setupHttpServerIsolate( id: task.id, data: switch (event) { RsServerEvent_Register(:final ip, :final info) => HttpServerRegisterEvent(ip: ip, info: info), - RsServerEvent_PrepareUpload(:final sessionId, :final ip, :final info, :final files) => HttpServerPrepareUploadEvent( - sessionId: sessionId, - ip: ip, - info: info, - files: files, - ), + RsServerEvent_PrepareUpload(:final sessionId, :final ip, :final info, :final certFingerprint, :final files) => + HttpServerPrepareUploadEvent( + sessionId: sessionId, + ip: ip, + info: info, + certFingerprint: certFingerprint, + files: files, + ), RsServerEvent_FileUpload(:final sessionId, :final fileId, :final file) => HttpServerFileUploadEvent( sessionId: sessionId, fileId: fileId, diff --git a/packages/localsend_isolates/rust/src/api/server.rs b/packages/localsend_isolates/rust/src/api/server.rs index d5ebc90a..cedc35d6 100644 --- a/packages/localsend_isolates/rust/src/api/server.rs +++ b/packages/localsend_isolates/rust/src/api/server.rs @@ -21,6 +21,10 @@ use tokio::sync::{Mutex, mpsc, oneshot}; /// and [RsServerEvent::FileUpload] with [RsHttpServer::respond_file_upload]. pub enum RsServerEvent { /// A device registered itself via `POST /api/localsend/v2/register`. + /// + /// On TLS, this event is only emitted when `info.fingerprint` matches the + /// fingerprint of the client certificate verified during the mTLS + /// handshake, so the fingerprint cannot be spoofed. Register { ip: String, info: RegisterDtoV2 }, /// A sender requests to upload files via `POST /api/localsend/v2/prepare-upload`. @@ -29,6 +33,11 @@ pub enum RsServerEvent { session_id: String, ip: String, info: RegisterDtoV2, + /// The SHA-256 fingerprint (uppercase hex) of the sender's client + /// certificate verified during the mTLS handshake. Unlike + /// `info.fingerprint`, this value cannot be spoofed. + /// `None` when the server runs without TLS. + cert_fingerprint: Option, files: HashMap, }, @@ -250,6 +259,7 @@ impl RsHttpServer { session_id, ip, info, + cert_fingerprint, files, decision_tx, } => { @@ -258,6 +268,7 @@ impl RsHttpServer { session_id, ip: ip.to_string(), info, + cert_fingerprint, files, }); } diff --git a/packages/localsend_isolates/rust/src/frb_generated.rs b/packages/localsend_isolates/rust/src/frb_generated.rs index 1ed3e852..0a182587 100644 --- a/packages/localsend_isolates/rust/src/frb_generated.rs +++ b/packages/localsend_isolates/rust/src/frb_generated.rs @@ -3704,6 +3704,7 @@ impl SseDecode for crate::api::server::RsServerEvent { let mut var_sessionId = ::sse_decode(deserializer); let mut var_ip = ::sse_decode(deserializer); let mut var_info = ::sse_decode(deserializer); + let mut var_certFingerprint = >::sse_decode(deserializer); let mut var_files = >::sse_decode( deserializer, @@ -3712,6 +3713,7 @@ impl SseDecode for crate::api::server::RsServerEvent { session_id: var_sessionId, ip: var_ip, info: var_info, + cert_fingerprint: var_certFingerprint, files: var_files, }; } @@ -4867,12 +4869,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::server::RsServerEvent { session_id, ip, info, + cert_fingerprint, files, } => [ 1.into_dart(), session_id.into_into_dart().into_dart(), ip.into_into_dart().into_dart(), info.into_into_dart().into_dart(), + cert_fingerprint.into_into_dart().into_dart(), files.into_into_dart().into_dart(), ] .into_dart(), @@ -5990,12 +5994,14 @@ impl SseEncode for crate::api::server::RsServerEvent { session_id, ip, info, + cert_fingerprint, files, } => { ::sse_encode(1, serializer); ::sse_encode(session_id, serializer); ::sse_encode(ip, serializer); ::sse_encode(info, serializer); + >::sse_encode(cert_fingerprint, serializer); >::sse_encode( files, serializer, );