From 650b02ddcc20266acbb658b6ad669caf99f6aa74 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Wed, 25 Mar 2026 14:50:59 +0100
Subject: [PATCH] feat: TOFU cert pinning - trust on first use with reset in settings

---
 lib/services/mqtt_service.dart |   68 ++++++++++++++++++++++++++++++++-
 1 files changed, 65 insertions(+), 3 deletions(-)

diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index cb04d2f..5df0b7b 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -1,6 +1,8 @@
 import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
+import 'dart:typed_data';
+import 'package:crypto/crypto.dart';
 
 import 'package:bonsoir/bonsoir.dart';
 import 'package:flutter/widgets.dart';
@@ -95,6 +97,9 @@
 
     _intentionalClose = false;
     _setStatus(ConnectionStatus.connecting);
+
+    // Load trusted cert fingerprint for TOFU verification
+    if (_trustedFingerprint == null) await _loadTrustedFingerprint();
 
     // Send Wake-on-LAN if MAC configured
     if (config.macAddress != null && config.macAddress!.isNotEmpty) {
@@ -241,6 +246,60 @@
     return null;
   }
 
+  // --- TOFU (Trust On First Use) certificate pinning ---
+
+  String? _trustedFingerprint; // Loaded from SharedPreferences at startup
+
+  /// Load the trusted cert fingerprint from storage.
+  Future<void> _loadTrustedFingerprint() async {
+    final prefs = await SharedPreferences.getInstance();
+    _trustedFingerprint = prefs.getString('trustedCertFingerprint');
+    if (_trustedFingerprint != null) {
+      _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
+    }
+  }
+
+  /// Compute SHA-256 fingerprint of a certificate's DER bytes.
+  String _certFingerprint(X509Certificate cert) {
+    final der = cert.der;
+    final digest = sha256.convert(der);
+    return digest.toString();
+  }
+
+  /// TOFU verification: accept on first use, reject if fingerprint changes.
+  bool _verifyCertTofu(dynamic certificate) {
+    if (certificate is! X509Certificate) return true; // Can't verify, accept
+
+    final fingerprint = _certFingerprint(certificate);
+
+    if (_trustedFingerprint == null) {
+      // First connection — trust and save
+      _trustedFingerprint = fingerprint;
+      SharedPreferences.getInstance().then((prefs) {
+        prefs.setString('trustedCertFingerprint', fingerprint);
+      });
+      _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
+      return true;
+    }
+
+    if (_trustedFingerprint == fingerprint) {
+      return true; // Known cert, trusted
+    }
+
+    // Fingerprint mismatch — possible MITM or server reinstall
+    _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
+    // Reject the connection. User must reset trust in settings.
+    return false;
+  }
+
+  /// Reset the trusted cert fingerprint (e.g., after server reinstall).
+  Future<void> resetTrustedCert() async {
+    _trustedFingerprint = null;
+    final prefs = await SharedPreferences.getInstance();
+    await prefs.remove('trustedCertFingerprint');
+    _mqttLog('TOFU: trust reset');
+  }
+
   /// Probe a single host:port with a TLS connection attempt (1s timeout).
   /// Uses SecureSocket since the broker now requires TLS.
   Future<String?> _probeHost(String host, int port) async {
@@ -267,11 +326,14 @@
       // client.maxConnectionAttempts is final — can't set it
       client.logging(on: false);
 
-      // TLS: broker uses a self-signed certificate.
-      // TODO: pin the cert fingerprint once cert rotation story is defined.
+      // TLS with TOFU (Trust On First Use) cert pinning.
+      // First connection: accept cert, save its SHA-256 fingerprint.
+      // Future connections: only accept certs matching the saved fingerprint.
       client.secure = true;
       client.securityContext = SecurityContext(withTrustedRoots: true);
-      client.onBadCertificate = (dynamic certificate) => true;
+      client.onBadCertificate = (dynamic certificate) {
+        return _verifyCertTofu(certificate);
+      };
 
       client.onConnected = _onConnected;
       client.onDisconnected = _onDisconnected;

--
Gitblit v1.3.1