Matthias Nott
2026-03-25 650b02ddcc20266acbb658b6ad669caf99f6aa74
feat: TOFU cert pinning - trust on first use with reset in settings
4 files modified
changed files
lib/screens/settings_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
lib/screens/settings_screen.dart
....@@ -1,5 +1,6 @@
11 import 'package:flutter/material.dart';
22 import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:shared_preferences/shared_preferences.dart';
34
45 import '../models/server_config.dart';
56 import '../providers/providers.dart';
....@@ -249,6 +250,44 @@
249250 label: const Text('Wake Mac'),
250251 ),
251252 const SizedBox(height: 12),
253
+
254
+ // Reset TLS Trust button
255
+ OutlinedButton.icon(
256
+ onPressed: () async {
257
+ final confirmed = await showDialog<bool>(
258
+ context: context,
259
+ builder: (ctx) => AlertDialog(
260
+ title: const Text('Reset Server Trust?'),
261
+ content: const Text(
262
+ 'This clears the saved server certificate fingerprint. '
263
+ 'Use this if you reinstalled AIBroker or changed servers. '
264
+ 'The app will trust the next server it connects to.',
265
+ ),
266
+ actions: [
267
+ TextButton(
268
+ onPressed: () => Navigator.pop(ctx, false),
269
+ child: const Text('Cancel'),
270
+ ),
271
+ TextButton(
272
+ onPressed: () => Navigator.pop(ctx, true),
273
+ child: const Text('Reset', style: TextStyle(color: AppColors.error)),
274
+ ),
275
+ ],
276
+ ),
277
+ );
278
+ if (confirmed == true && mounted) {
279
+ // Access MqttService through the provider and reset trust
280
+ final prefs = await SharedPreferences.getInstance();
281
+ await prefs.remove('trustedCertFingerprint');
282
+ ScaffoldMessenger.of(context).showSnackBar(
283
+ const SnackBar(content: Text('Server trust reset. Reconnect to trust the new server.')),
284
+ );
285
+ }
286
+ },
287
+ icon: const Icon(Icons.shield_outlined),
288
+ label: const Text('Reset Server Trust'),
289
+ ),
290
+ const SizedBox(height: 12),
252291 ],
253292 ),
254293 ),
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;
pubspec.lock
....@@ -170,7 +170,7 @@
170170 source: hosted
171171 version: "0.3.5+2"
172172 crypto:
173
- dependency: transitive
173
+ dependency: "direct main"
174174 description:
175175 name: crypto
176176 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
pubspec.yaml
....@@ -32,6 +32,7 @@
3232 file_picker: ^10.3.10
3333 flutter_markdown: ^0.7.7+1
3434 bonsoir: ^6.0.2
35
+ crypto: ^3.0.7
3536
3637 dev_dependencies:
3738 flutter_test: