Matthew Hague: 1 irc: support server certificate pinning 7 files changed, 94 insertions(+), 3 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~emersion/goguma-dev/patches/55145/mbox | git am -3Learn more about email & git
Fixes #87 --- lib/client.dart | 23 ++++++++++++++++++ lib/client_controller.dart | 1 + lib/database.dart | 10 ++++++-- lib/page/connect.dart | 48 +++++++++++++++++++++++++++++++++++++- lib/prefs.dart | 6 +++++ pubspec.lock | 8 +++++++ pubspec.yaml | 1 + 7 files changed, 94 insertions(+), 3 deletions(-) diff --git a/lib/client.dart b/lib/client.dart index 6dd8733..0dab29d 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:hex/hex.dart'; import 'irc.dart'; import 'logging.dart'; @@ -25,6 +26,7 @@ class ConnectParams { final SaslPlainCredentials? saslPlain; final String? bouncerNetId; final String? away; + final String? pinnedCertSHA1; const ConnectParams({ required this.host, @@ -36,6 +38,7 @@ class ConnectParams { this.saslPlain, this.bouncerNetId, this.away, + this.pinnedCertSHA1, }) : realname = realname ?? nick; ConnectParams apply({ @@ -55,10 +58,21 @@ class ConnectParams { saslPlain: saslPlain ?? this.saslPlain, bouncerNetId: bouncerNetId ?? this.bouncerNetId, away: away ?? this.away, + pinnedCertSHA1: this.pinnedCertSHA1, ); } } +class BadCertException implements Exception { + final X509Certificate badCert; + BadCertException(this.badCert); + + @override + String toString() { + return 'Bad certificate. Issued by ' + badCert.issuer + '. SHA1 Fingerprint ' + HEX.encode(badCert.sha1) + '. Valid from: ' + badCert.startValidity.toString() + ' until ' + badCert.endValidity.toString(); + } +} + Set<String> _getDefaultCaps(ConnectParams params) { var caps = { 'away-notify', @@ -107,6 +121,7 @@ class Client { Socket? _socket; String _nick; String _realname; + String? _pinnedCertSHA1; IrcSource? _serverSource; ClientState _state = ClientState.disconnected; bool _registered = false; @@ -128,6 +143,7 @@ class Client { String get nick => _nick; String get realname => _realname; IrcSource? get serverSource => _serverSource; + String? get pinnedCertSHA1 => _pinnedCertSHA1; ClientState get state => _state; bool get registered => _registered; Stream<ClientMessage> get messages => _messagesController.stream; @@ -145,6 +161,7 @@ class Client { _requestCaps = requestCaps ?? _getDefaultCaps(params), _nick = params.nick, _realname = params.realname, + _pinnedCertSHA1 = params.pinnedCertSHA1, _autoReconnect = autoReconnect, isupport = isupport ?? IrcIsupportRegistry(); @@ -174,6 +191,12 @@ class Client { connectionTaskFuture = SecureSocket.startConnect( params.host, params.port, + onBadCertificate: (X509Certificate cert) { + if (params?.pinnedCertSHA1 == HEX.encode(cert.sha1)) { + return true; + } + throw BadCertException(cert); + }, supportedProtocols: ['irc'], ); } else { diff --git a/lib/client_controller.dart b/lib/client_controller.dart index e82ae54..171c1ca 100644 --- a/lib/client_controller.dart +++ b/lib/client_controller.dart @@ -33,6 +33,7 @@ ConnectParams connectParamsFromServerEntry(ServerEntry entry, Prefs prefs) { realname: prefs.realname, pass: entry.pass, saslPlain: saslPlain, + pinnedCertSHA1: entry.pinnedCertSHA1 ); } diff --git a/lib/database.dart b/lib/database.dart index dc2b7c9..2722dc1 100644 --- a/lib/database.dart +++ b/lib/database.dart @@ -19,6 +19,7 @@ class ServerEntry { String? pass; String? saslPlainUsername; String? saslPlainPassword; + String? pinnedCertSHA1; Map<String, Object?> toMap() { return <String, Object?>{ @@ -30,6 +31,7 @@ class ServerEntry { 'pass': pass, 'sasl_plain_username': saslPlainUsername, 'sasl_plain_password': saslPlainPassword, + 'pinned_cert_sha1': pinnedCertSHA1, }; } @@ -41,6 +43,7 @@ class ServerEntry { this.pass, this.saslPlainUsername, this.saslPlainPassword, + this.pinnedCertSHA1, }); ServerEntry.fromMap(Map<String, dynamic> m) : @@ -51,7 +54,8 @@ class ServerEntry { nick = m['nick'] as String?, pass = m['pass'] as String?, saslPlainUsername = m['sasl_plain_username'] as String?, - saslPlainPassword = m['sasl_plain_password'] as String?; + saslPlainPassword = m['sasl_plain_password'] as String?, + pinnedCertSHA1 = m['pinned_cert_sha1'] as String?; } class NetworkEntry { @@ -340,7 +344,8 @@ const _schema = [ nick TEXT, pass TEXT, sasl_plain_username TEXT, - sasl_plain_password TEXT + sasl_plain_password TEXT, + pinned_cert_sha1 TEXT ) ''', ''' @@ -459,6 +464,7 @@ const _migrations = [ 'CREATE INDEX index_message_network_msgid on Message(network_msgid)', 'ALTER TABLE LinkPreview ADD COLUMN image_url TEXT', 'ALTER TABLE Network ADD COLUMN last_delivered_time TEXT', + 'ALTER TABLE Server ADD COLUMN pinned_cert_sha1 TEXT', ]; class DB { diff --git a/lib/page/connect.dart b/lib/page/connect.dart index d661844..a9a5e30 100644 --- a/lib/page/connect.dart +++ b/lib/page/connect.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:hex/hex.dart'; import 'package:provider/provider.dart'; import '../client.dart'; @@ -29,6 +31,7 @@ class _ConnectPageState extends State<ConnectPage> { bool _passwordRequired = false; bool _passwordUnsupported = false; Client? _fetchCapsClient; + String? _pinnedCertSHA1; final formKey = GlobalKey<FormState>(); final serverController = TextEditingController(); @@ -68,6 +71,7 @@ class _ConnectPageState extends State<ConnectPage> { tls: uri.scheme != 'irc+insecure', saslPlainUsername: useSaslPlain ? nicknameController.text : null, saslPlainPassword: useSaslPlain ? passwordController.text : null, + pinnedCertSHA1: _pinnedCertSHA1, ); } @@ -100,7 +104,6 @@ class _ConnectPageState extends State<ConnectPage> { try { await client.connect(); client.dispose(); - await db.storeServer(serverEntry); networkEntry = await db.storeNetwork(NetworkEntry(server: serverEntry.id!)); } on Exception catch (err) { @@ -149,6 +152,12 @@ class _ConnectPageState extends State<ConnectPage> { setState(() { _error = err; }); + + if (err is BadCertException) { + final certErr = err as BadCertException; + askBadCertficate(context, certErr.badCert); + } + return; } @@ -235,6 +244,8 @@ class _ConnectPageState extends State<ConnectPage> { serverErr = ircErr.toString(); break; } + } else if (_error is BadCertException) { + serverErr = 'Bad server certificate'; } else { serverErr = _error?.toString(); } @@ -261,6 +272,7 @@ class _ConnectPageState extends State<ConnectPage> { setState(() { _passwordUnsupported = false; _passwordRequired = false; + _pinnedCertSHA1 = null; }); }, validator: (value) { @@ -313,4 +325,38 @@ class _ConnectPageState extends State<ConnectPage> { ), ); } + + void askBadCertficate(BuildContext context, X509Certificate cert) { + showDialog<void>( + context: context, + builder: (BuildContext context) { + Widget noButton = TextButton( + child: const Text('Reject'), + onPressed: () { Navigator.pop(context); }, + ); + Widget yesButton = TextButton( + child: const Text('Accept Always'), + onPressed: () { + Navigator.pop(context); + setState(() => _pinnedCertSHA1 = HEX.encode(cert.sha1)); + _handleServerFocusChange(false); + }, + ); + return AlertDialog( + title: const Text('Bad Certificate'), + content: SingleChildScrollView( + child: Text( + 'Untrusted server certificate. ' + + 'Only accept this certificate if you know what you\'re doing.\n\n' + + 'Issuer: ' + cert.issuer + '\n' + + 'SHA1 Fingerprint: ' + HEX.encode(cert.sha1) + '\n' + + 'From: ' + cert.startValidity.toString() + '\n' + + 'To: ' + cert.endValidity.toString() + ) + ), + actions: [ noButton, yesButton ], + ); + }, + ); + } } diff --git a/lib/prefs.dart b/lib/prefs.dart index 211eeba..2aef755 100644 --- a/lib/prefs.dart +++ b/lib/prefs.dart @@ -9,6 +9,7 @@ const _nicknameKey = 'nickname'; const _realnameKey = 'realname'; const _pushProviderKey = 'push_provider'; const _linkPreviewKey = 'link_preview'; +const _pinnedCertSHA1Key = 'pinnedCertSHA1'; class Prefs { final SharedPreferences _prefs; @@ -30,6 +31,7 @@ class Prefs { String? get realname => _prefs.getString(_realnameKey); String? get pushProvider => _prefs.getString(_pushProviderKey); bool get linkPreview => _prefs.getBool(_linkPreviewKey) ?? false; + String? get pinnedCertSHA1 => _prefs.getString(_pinnedCertSHA1Key); set bufferCompact(bool enabled) { _prefs.setBool(_bufferCompactKey, enabled); @@ -62,4 +64,8 @@ class Prefs { set linkPreview(bool enabled) { _prefs.setBool(_linkPreviewKey, enabled); } + + set pinnedCertSHA1(String? pinnedCertSHA1) { + _setOptionalString(_pinnedCertSHA1Key, pinnedCertSHA1); + } } diff --git a/pubspec.lock b/pubspec.lock index 0ef865a..c052ca4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -456,6 +456,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hex: + dependency: "direct main" + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" html: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5eeae9b..4defa83 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: flutter_apns_only: ^1.6.0 share_handler: ^0.0.21 dynamic_color: ^1.7.0 + hex: ^0.2.0 dependency_overrides: # TODO: drop once this is released: -- 2.46.0
Pushed with a few minor edits: - Dropped the lib/prefs.dart chanegs which were unused. - Fixed the issues reported by "flutter analyze". Thanks!