~emersion/goguma-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
2 2

[PATCH] irc: support server certificate pinning

Matthew Hague <matthewhague@zoho.com>
Details
Message ID
<20240825195430.9715-1-matthewhague@zoho.com>
DKIM signature
pass
Download raw message
Patch: +103 -9
Fixes #87
---
 lib/client.dart            | 18 +++++++++++++
 lib/client_controller.dart |  3 ++-
 lib/database.dart          | 10 +++++--
 lib/main.dart              |  2 +-
 lib/models.dart            |  7 +++--
 lib/page/connect.dart      | 54 ++++++++++++++++++++++++++++++++++++--
 lib/prefs.dart             |  6 +++++
 lib/push.dart              |  3 ++-
 pubspec.lock               |  8 ++++++
 pubspec.yaml               |  1 +
 10 files changed, 103 insertions(+), 9 deletions(-)

diff --git a/lib/client.dart b/lib/client.dart
index 6dd8733..97f6590 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,16 @@ 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);
}

Set<String> _getDefaultCaps(ConnectParams params) {
	var caps = {
		'away-notify',
@@ -107,6 +116,7 @@ class Client {
	Socket? _socket;
	String _nick;
	String _realname;
	String? _pinnedCertSHA1;
	IrcSource? _serverSource;
	ClientState _state = ClientState.disconnected;
	bool _registered = false;
@@ -128,6 +138,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 +156,7 @@ class Client {
		_requestCaps = requestCaps ?? _getDefaultCaps(params),
		_nick = params.nick,
		_realname = params.realname,
		_pinnedCertSHA1 = params.pinnedCertSHA1,
		_autoReconnect = autoReconnect,
		isupport = isupport ?? IrcIsupportRegistry();

@@ -174,6 +186,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..18d1557 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
	);
}

@@ -921,7 +922,7 @@ class ClientController {
		);
		networkEntry = await _db.storeNetwork(networkEntry);
		var childClient = Client(client.params.apply(bouncerNetId: bouncerNetId));
		childNetwork = NetworkModel(network.serverEntry, networkEntry, childClient.nick, childClient.realname);
		childNetwork = NetworkModel(network.serverEntry, networkEntry, childClient.nick, childClient.realname, childClient.pinnedCertSHA1);
		_networkList.add(childNetwork);
		_provider.add(childClient, childNetwork);
		childClient.connect().ignore();
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/main.dart b/lib/main.dart
index 31fadc9..75ca1ed 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -201,7 +201,7 @@ Future<void> _initModels({
			clientParams = clientParams.apply(bouncerNetId: networkEntry.bouncerId);
		}

		var network = NetworkModel(serverEntry, networkEntry, clientParams.nick, clientParams.realname);
		var network = NetworkModel(serverEntry, networkEntry, clientParams.nick, clientParams.realname, clientParams.pinnedCertSHA1);
		networkList.add(network);

		var client = Client(clientParams, isupport: networkEntry.isupport);
diff --git a/lib/models.dart b/lib/models.dart
index 9f1d309..d3c1968 100644
--- a/lib/models.dart
+++ b/lib/models.dart
@@ -55,10 +55,12 @@ class NetworkModel extends ChangeNotifier {
	String _realname;
	String? _account;
	String? _connectError;
	String? _pinnedCertSHA1;

	NetworkModel(this.serverEntry, this.networkEntry, String nickname, String realname) :
	NetworkModel(this.serverEntry, this.networkEntry, String nickname, String realname, String? pinnedCertSHA1) :
			_nickname = nickname,
			_realname = realname {
			_realname = realname,
			_pinnedCertSHA1 = pinnedCertSHA1 {
		assert(serverEntry.id != null);
		assert(networkEntry.id != null);
		_upstreamName = networkEntry.isupport.network;
@@ -74,6 +76,7 @@ class NetworkModel extends ChangeNotifier {
	String get realname => _realname;
	String? get account => _account;
	String? get connectError => _connectError;
	String? get pinnedCertSHA1 => _pinnedCertSHA1;

	String get displayName {
		// If the user has set a custom bouncer network name, use that
diff --git a/lib/page/connect.dart b/lib/page/connect.dart
index d661844..9889231 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();
@@ -42,6 +45,7 @@ class _ConnectPageState extends State<ConnectPage> {
		if (widget.initialUri != null) {
			_populateFromUri(widget.initialUri!);
		}
		_pinnedCertSHA1 = null;
	}

	void _populateFromUri(IrcUri uri) {
@@ -62,12 +66,14 @@ class _ConnectPageState extends State<ConnectPage> {
	ServerEntry _generateServerEntry() {
		Uri uri = parseServerUri(serverController.text);
		var useSaslPlain = !passwordController.text.isEmpty;
		var prefs = context.read<Prefs>();
		return ServerEntry(
			host: uri.host,
			port: uri.hasPort ? uri.port : null,
			tls: uri.scheme != 'irc+insecure',
			saslPlainUsername: useSaslPlain ? nicknameController.text : null,
			saslPlainPassword: useSaslPlain ? passwordController.text : null,
			pinnedCertSHA1: _pinnedCertSHA1,
		);
	}

@@ -91,6 +97,7 @@ class _ConnectPageState extends State<ConnectPage> {
		var clientProvider = context.read<ClientProvider>();

		prefs.nickname = nicknameController.text;
		prefs.pinnedCertSHA1 = _pinnedCertSHA1;

		// TODO: only connect once (but be careful not to loose messages
		// sent immediately after RPL_WELCOME)
@@ -100,7 +107,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) {
@@ -121,7 +127,7 @@ class _ConnectPageState extends State<ConnectPage> {
		}

		client = Client(clientParams);
		var network = NetworkModel(serverEntry, networkEntry, client.nick, client.realname);
		var network = NetworkModel(serverEntry, networkEntry, client.nick, client.realname, client.pinnedCertSHA1);
		networkList.add(network);
		clientProvider.add(client, network);
		client.connect().ignore();
@@ -149,6 +155,12 @@ class _ConnectPageState extends State<ConnectPage> {
			setState(() {
				_error = err;
			});

			if (err is BadCertException) {
				final certErr = _error as BadCertException;
				askBadCertficate(context, certErr.badCert);
			}

			return;
		}

@@ -235,6 +247,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 +275,7 @@ class _ConnectPageState extends State<ConnectPage> {
							setState(() {
								_passwordUnsupported = false;
								_passwordRequired = false;
								_pinnedCertSHA1 = null;
							});
						},
						validator: (value) {
@@ -313,4 +328,39 @@ 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);
					},
				);
				AlertDialog alert = 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 ],
				);
				return alert;
			},
		);
	}
}
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/lib/push.dart b/lib/push.dart
index 48e1b68..2eb925f 100644
--- a/lib/push.dart
+++ b/lib/push.dart
@@ -60,7 +60,8 @@ Future<void> handlePushMessage(DB db, WebPushSubscriptionEntry sub, List<int> ci

	var nickname = serverEntry.nick ?? prefs.nickname;
	var realname = prefs.realname ?? nickname;
	var network = NetworkModel(serverEntry, networkEntry, nickname, realname);
	var pinnedCertSHA1 = prefs.pinnedCertSHA1;
	var network = NetworkModel(serverEntry, networkEntry, nickname, realname, pinnedCertSHA1);

	var notifController = await NotificationController.init();

diff --git a/pubspec.lock b/pubspec.lock
index 298f6f3..2332764 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
Details
Message ID
<i9rC5t6H0HqfDOgAwbhV8isbZnoquImzKPI2A8muGeZIipHkt3tCY9ZrY9AQmR9Si-KiTfoI9xW7GUXR_2iZFBgtrXLbb8jO03jVDP0HIkI=@emersion.fr>
In-Reply-To
<20240825195430.9715-1-matthewhague@zoho.com> (view parent)
DKIM signature
pass
Download raw message
Thanks for the patch! Overall it looks pretty good, below are a few comments.

On Sunday, August 25th, 2024 at 21:54, Matthew Hague <matthewhague@zoho.com> wrote:

> +class BadCertException implements Exception {
> +	final X509Certificate badCert;
> +	BadCertException(this.badCert);
> +}

Should we override the toString() method to provide a human-readable message?
This would typically happen if the user connects to a server, and the later on
the server certificate changes.

> diff --git a/lib/main.dart b/lib/main.dart
> index 31fadc9..75ca1ed 100644
> --- a/lib/main.dart
> +++ b/lib/main.dart
> @@ -201,7 +201,7 @@ Future<void> _initModels({
>  			clientParams = clientParams.apply(bouncerNetId: networkEntry.bouncerId);
>  		}
> 
> -		var network = NetworkModel(serverEntry, networkEntry, clientParams.nick, clientParams.realname);
> +		var network = NetworkModel(serverEntry, networkEntry, clientParams.nick, clientParams.realname, clientParams.pinnedCertSHA1);
>  		networkList.add(network);
> 
>  		var client = Client(clientParams, isupport: networkEntry.isupport);
> diff --git a/lib/models.dart b/lib/models.dart
> index 9f1d309..d3c1968 100644
> --- a/lib/models.dart
> +++ b/lib/models.dart
> @@ -55,10 +55,12 @@ class NetworkModel extends ChangeNotifier {
>  	String _realname;
>  	String? _account;
>  	String? _connectError;
> +	String? _pinnedCertSHA1;
> 
> -	NetworkModel(this.serverEntry, this.networkEntry, String nickname, String realname) :
> +	NetworkModel(this.serverEntry, this.networkEntry, String nickname, String realname, String? pinnedCertSHA1) :

Do we need to pass the pinnedCertSHA1 as a separate argument here? Isn't it
already passed in serverEntry?

>  			_nickname = nickname,
> -			_realname = realname {
> +			_realname = realname,
> +			_pinnedCertSHA1 = pinnedCertSHA1 {
>  		assert(serverEntry.id != null);
>  		assert(networkEntry.id != null);
>  		_upstreamName = networkEntry.isupport.network;
> @@ -74,6 +76,7 @@ class NetworkModel extends ChangeNotifier {
>  	String get realname => _realname;
>  	String? get account => _account;
>  	String? get connectError => _connectError;
> +	String? get pinnedCertSHA1 => _pinnedCertSHA1;

Callers could use serverEntry.pinnedCertSHA1 instead of this getter.

>  	String get displayName {
>  		// If the user has set a custom bouncer network name, use that
> diff --git a/lib/page/connect.dart b/lib/page/connect.dart
> index d661844..9889231 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();
> @@ -42,6 +45,7 @@ class _ConnectPageState extends State<ConnectPage> {
>  		if (widget.initialUri != null) {
>  			_populateFromUri(widget.initialUri!);
>  		}
> +		_pinnedCertSHA1 = null;

null should be the default value when the state is created, so this shouldn't
be necessary (initState is called at most once per instance).

>  	}
> 
>  	void _populateFromUri(IrcUri uri) {
> @@ -62,12 +66,14 @@ class _ConnectPageState extends State<ConnectPage> {
>  	ServerEntry _generateServerEntry() {
>  		Uri uri = parseServerUri(serverController.text);
>  		var useSaslPlain = !passwordController.text.isEmpty;
> +		var prefs = context.read<Prefs>();

Sounds like this is unused?

>  		return ServerEntry(
>  			host: uri.host,
>  			port: uri.hasPort ? uri.port : null,
>  			tls: uri.scheme != 'irc+insecure',
>  			saslPlainUsername: useSaslPlain ? nicknameController.text : null,
>  			saslPlainPassword: useSaslPlain ? passwordController.text : null,
> +			pinnedCertSHA1: _pinnedCertSHA1,
>  		);
>  	}
> 
> @@ -91,6 +97,7 @@ class _ConnectPageState extends State<ConnectPage> {
>  		var clientProvider = context.read<ClientProvider>();
> 
>  		prefs.nickname = nicknameController.text;
> +		prefs.pinnedCertSHA1 = _pinnedCertSHA1;

Hm, why do we need to store the pinned cert hash in the global preferences?

The nickname is a bit of a special case: in the settings view, it's a global
preference which applies to all servers. I don't think we need to replicate
this for the pinned cert hash.

> 
>  		// TODO: only connect once (but be careful not to loose messages
>  		// sent immediately after RPL_WELCOME)
> @@ -100,7 +107,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) {
> @@ -121,7 +127,7 @@ class _ConnectPageState extends State<ConnectPage> {
>  		}
> 
>  		client = Client(clientParams);
> -		var network = NetworkModel(serverEntry, networkEntry, client.nick, client.realname);
> +		var network = NetworkModel(serverEntry, networkEntry, client.nick, client.realname, client.pinnedCertSHA1);
>  		networkList.add(network);
>  		clientProvider.add(client, network);
>  		client.connect().ignore();
> @@ -149,6 +155,12 @@ class _ConnectPageState extends State<ConnectPage> {
>  			setState(() {
>  				_error = err;
>  			});
> +
> +			if (err is BadCertException) {
> +				final certErr = _error as BadCertException;

Nit: would be a bit more consistent with the line above to use "err" instead of
"_error" here.

> +	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);
> +					},
> +				);
> +				AlertDialog alert = 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 ],
> +				);
> +				return alert;

Nit: the "alert" variable is unnecessary here, the AlertDialog can be directly
returned.
Matthew Hague <matthewhague@zoho.com>
Details
Message ID
<3063852c-bb97-40a7-b4fc-aae53a80e140@zoho.com>
In-Reply-To
<i9rC5t6H0HqfDOgAwbhV8isbZnoquImzKPI2A8muGeZIipHkt3tCY9ZrY9AQmR9Si-KiTfoI9xW7GUXR_2iZFBgtrXLbb8jO03jVDP0HIkI=@emersion.fr> (view parent)
DKIM signature
pass
Download raw message
>below are a few comments..

Thanks for this -- i'll submit a rework when i can.

Best,

Matt
Reply to thread Export thread (mbox)