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/screens/settings_screen.dart |   39 +++++++++++++++++++
 lib/services/mqtt_service.dart   |   68 ++++++++++++++++++++++++++++++++-
 pubspec.lock                     |    2 
 pubspec.yaml                     |    1 
 4 files changed, 106 insertions(+), 4 deletions(-)

diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 2a41067..f4b36e3 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
 
 import '../models/server_config.dart';
 import '../providers/providers.dart';
@@ -249,6 +250,44 @@
                 label: const Text('Wake Mac'),
               ),
               const SizedBox(height: 12),
+
+              // Reset TLS Trust button
+              OutlinedButton.icon(
+                onPressed: () async {
+                  final confirmed = await showDialog<bool>(
+                    context: context,
+                    builder: (ctx) => AlertDialog(
+                      title: const Text('Reset Server Trust?'),
+                      content: const Text(
+                        'This clears the saved server certificate fingerprint. '
+                        'Use this if you reinstalled AIBroker or changed servers. '
+                        'The app will trust the next server it connects to.',
+                      ),
+                      actions: [
+                        TextButton(
+                          onPressed: () => Navigator.pop(ctx, false),
+                          child: const Text('Cancel'),
+                        ),
+                        TextButton(
+                          onPressed: () => Navigator.pop(ctx, true),
+                          child: const Text('Reset', style: TextStyle(color: AppColors.error)),
+                        ),
+                      ],
+                    ),
+                  );
+                  if (confirmed == true && mounted) {
+                    // Access MqttService through the provider and reset trust
+                    final prefs = await SharedPreferences.getInstance();
+                    await prefs.remove('trustedCertFingerprint');
+                    ScaffoldMessenger.of(context).showSnackBar(
+                      const SnackBar(content: Text('Server trust reset. Reconnect to trust the new server.')),
+                    );
+                  }
+                },
+                icon: const Icon(Icons.shield_outlined),
+                label: const Text('Reset Server Trust'),
+              ),
+              const SizedBox(height: 12),
             ],
           ),
         ),
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;
diff --git a/pubspec.lock b/pubspec.lock
index 9b819d8..911513c 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -170,7 +170,7 @@
     source: hosted
     version: "0.3.5+2"
   crypto:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: crypto
       sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
diff --git a/pubspec.yaml b/pubspec.yaml
index c54e2d0..10bdb1b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -32,6 +32,7 @@
   file_picker: ^10.3.10
   flutter_markdown: ^0.7.7+1
   bonsoir: ^6.0.2
+  crypto: ^3.0.7
 
 dev_dependencies:
   flutter_test:

--
Gitblit v1.3.1