| .. | .. |
|---|
| 1 | 1 | import 'dart:async'; |
|---|
| 2 | 2 | import 'dart:convert'; |
|---|
| 3 | 3 | import 'dart:io'; |
|---|
| 4 | +import 'dart:typed_data'; |
|---|
| 5 | +import 'package:crypto/crypto.dart'; |
|---|
| 4 | 6 | |
|---|
| 5 | 7 | import 'package:bonsoir/bonsoir.dart'; |
|---|
| 6 | 8 | import 'package:flutter/widgets.dart'; |
|---|
| .. | .. |
|---|
| 95 | 97 | |
|---|
| 96 | 98 | _intentionalClose = false; |
|---|
| 97 | 99 | _setStatus(ConnectionStatus.connecting); |
|---|
| 100 | + |
|---|
| 101 | + // Load trusted cert fingerprint for TOFU verification |
|---|
| 102 | + if (_trustedFingerprint == null) await _loadTrustedFingerprint(); |
|---|
| 98 | 103 | |
|---|
| 99 | 104 | // Send Wake-on-LAN if MAC configured |
|---|
| 100 | 105 | if (config.macAddress != null && config.macAddress!.isNotEmpty) { |
|---|
| .. | .. |
|---|
| 241 | 246 | return null; |
|---|
| 242 | 247 | } |
|---|
| 243 | 248 | |
|---|
| 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 | + |
|---|
| 244 | 303 | /// Probe a single host:port with a TLS connection attempt (1s timeout). |
|---|
| 245 | 304 | /// Uses SecureSocket since the broker now requires TLS. |
|---|
| 246 | 305 | Future<String?> _probeHost(String host, int port) async { |
|---|
| .. | .. |
|---|
| 267 | 326 | // client.maxConnectionAttempts is final — can't set it |
|---|
| 268 | 327 | client.logging(on: false); |
|---|
| 269 | 328 | |
|---|
| 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. |
|---|
| 272 | 332 | client.secure = true; |
|---|
| 273 | 333 | client.securityContext = SecurityContext(withTrustedRoots: true); |
|---|
| 274 | | - client.onBadCertificate = (dynamic certificate) => true; |
|---|
| 334 | + client.onBadCertificate = (dynamic certificate) { |
|---|
| 335 | + return _verifyCertTofu(certificate); |
|---|
| 336 | + }; |
|---|
| 275 | 337 | |
|---|
| 276 | 338 | client.onConnected = _onConnected; |
|---|
| 277 | 339 | client.onDisconnected = _onDisconnected; |
|---|