Matthias Nott
2026-03-25 650b02ddcc20266acbb658b6ad669caf99f6aa74
lib/services/mqtt_service.dart
....@@ -1,6 +1,8 @@
11 import 'dart:async';
22 import 'dart:convert';
33 import 'dart:io';
4
+import 'dart:typed_data';
5
+import 'package:crypto/crypto.dart';
46
57 import 'package:bonsoir/bonsoir.dart';
68 import 'package:flutter/widgets.dart';
....@@ -95,6 +97,9 @@
9597
9698 _intentionalClose = false;
9799 _setStatus(ConnectionStatus.connecting);
100
+
101
+ // Load trusted cert fingerprint for TOFU verification
102
+ if (_trustedFingerprint == null) await _loadTrustedFingerprint();
98103
99104 // Send Wake-on-LAN if MAC configured
100105 if (config.macAddress != null && config.macAddress!.isNotEmpty) {
....@@ -241,6 +246,60 @@
241246 return null;
242247 }
243248
249
+ // --- TOFU (Trust On First Use) certificate pinning ---
250
+
251
+ String? _trustedFingerprint; // Loaded from SharedPreferences at startup
252
+
253
+ /// Load the trusted cert fingerprint from storage.
254
+ Future<void> _loadTrustedFingerprint() async {
255
+ final prefs = await SharedPreferences.getInstance();
256
+ _trustedFingerprint = prefs.getString('trustedCertFingerprint');
257
+ if (_trustedFingerprint != null) {
258
+ _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
259
+ }
260
+ }
261
+
262
+ /// Compute SHA-256 fingerprint of a certificate's DER bytes.
263
+ String _certFingerprint(X509Certificate cert) {
264
+ final der = cert.der;
265
+ final digest = sha256.convert(der);
266
+ return digest.toString();
267
+ }
268
+
269
+ /// TOFU verification: accept on first use, reject if fingerprint changes.
270
+ bool _verifyCertTofu(dynamic certificate) {
271
+ if (certificate is! X509Certificate) return true; // Can't verify, accept
272
+
273
+ final fingerprint = _certFingerprint(certificate);
274
+
275
+ if (_trustedFingerprint == null) {
276
+ // First connection — trust and save
277
+ _trustedFingerprint = fingerprint;
278
+ SharedPreferences.getInstance().then((prefs) {
279
+ prefs.setString('trustedCertFingerprint', fingerprint);
280
+ });
281
+ _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
282
+ return true;
283
+ }
284
+
285
+ if (_trustedFingerprint == fingerprint) {
286
+ return true; // Known cert, trusted
287
+ }
288
+
289
+ // Fingerprint mismatch — possible MITM or server reinstall
290
+ _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
291
+ // Reject the connection. User must reset trust in settings.
292
+ return false;
293
+ }
294
+
295
+ /// Reset the trusted cert fingerprint (e.g., after server reinstall).
296
+ Future<void> resetTrustedCert() async {
297
+ _trustedFingerprint = null;
298
+ final prefs = await SharedPreferences.getInstance();
299
+ await prefs.remove('trustedCertFingerprint');
300
+ _mqttLog('TOFU: trust reset');
301
+ }
302
+
244303 /// Probe a single host:port with a TLS connection attempt (1s timeout).
245304 /// Uses SecureSocket since the broker now requires TLS.
246305 Future<String?> _probeHost(String host, int port) async {
....@@ -267,11 +326,14 @@
267326 // client.maxConnectionAttempts is final — can't set it
268327 client.logging(on: false);
269328
270
- // TLS: broker uses a self-signed certificate.
271
- // TODO: pin the cert fingerprint once cert rotation story is defined.
329
+ // TLS with TOFU (Trust On First Use) cert pinning.
330
+ // First connection: accept cert, save its SHA-256 fingerprint.
331
+ // Future connections: only accept certs matching the saved fingerprint.
272332 client.secure = true;
273333 client.securityContext = SecurityContext(withTrustedRoots: true);
274
- client.onBadCertificate = (dynamic certificate) => true;
334
+ client.onBadCertificate = (dynamic certificate) {
335
+ return _verifyCertTofu(certificate);
336
+ };
275337
276338 client.onConnected = _onConnected;
277339 client.onDisconnected = _onDisconnected;