~emersion/goguma-dev

irc: support server certificate pinning v1 APPLIED

Matthew Hague: 1
 irc: support server certificate pinning

 7 files changed, 94 insertions(+), 3 deletions(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH] irc: support server certificate pinning Export this patch

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!