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