From d2213e7eba2ec8b478c879397dc0de64d293f367 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 14 Mar 2022 17:59:22 +0100 Subject: feat: Temporary certificate acceptance backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We use the 'certificate-error' event of webContents to detect certificate verification errors and display a message to manually trust the certificate. Certificates are trusted per profile and only until Sophie is restarted. We still need to build the associated UI, the current one is just a rough prototype for debugging. Signed-off-by: Kristóf Marussy --- .../electron/impl/ElectronServiceView.ts | 27 ++++ packages/main/src/stores/Profile.ts | 8 +- packages/main/src/stores/Service.ts | 178 ++++++++++++++------- 3 files changed, 154 insertions(+), 59 deletions(-) (limited to 'packages/main') diff --git a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts index d90ff19..edcf758 100644 --- a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts +++ b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts @@ -93,6 +93,33 @@ export default class ElectronServiceView implements ServiceView { }, ); + /** + * We use the `'certificate-error'` event instead of `session.setCertificateVerifyProc` + * because: + * + * 1. `'certificate-error'` is bound to the `webContents`, so we can display the certificate + * in the place of the correct service. Note that chromium still manages certificate trust + * per session, so we can't have different trusted certificates for each service of a + * profile. + * 2. The results of `'certificate-error'` are _not_ cached, so we can initially reject + * the certificate but we can still accept it once the user trusts it temporarily. + */ + webContents.on( + 'certificate-error', + (event, url, error, certificate, callback, isMainFrame) => { + if (service.isCertificateTemporarilyTrusted(certificate)) { + event.preventDefault(); + callback(true); + return; + } + if (isMainFrame) { + setLocation(url); + service.setCertificateError(error, certificate); + } + callback(false); + }, + ); + webContents.on('page-title-updated', (_event, title) => { service.setTitle(title); }); diff --git a/packages/main/src/stores/Profile.ts b/packages/main/src/stores/Profile.ts index 836f4a8..405a5d4 100644 --- a/packages/main/src/stores/Profile.ts +++ b/packages/main/src/stores/Profile.ts @@ -18,8 +18,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Profile as ProfileBase } from '@sophie/shared'; -import { getSnapshot, Instance } from 'mobx-state-tree'; +import { Certificate, Profile as ProfileBase } from '@sophie/shared'; +import { clone, getSnapshot, Instance } from 'mobx-state-tree'; import type ProfileConfig from './config/ProfileConfig'; @@ -28,6 +28,10 @@ const Profile = ProfileBase.views((self) => ({ const { id, settings } = self; return { ...getSnapshot(settings), id }; }, +})).actions((self) => ({ + temporarilyTrustCertificate(certificate: Certificate): void { + self.temporarilyTrustedCertificates.push(clone(certificate)); + }, })); /* diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts index d98e52e..9b2bf1e 100644 --- a/packages/main/src/stores/Service.ts +++ b/packages/main/src/stores/Service.ts @@ -19,8 +19,13 @@ */ import type { UnreadCount } from '@sophie/service-shared'; -import { defineServiceModel, ServiceAction } from '@sophie/shared'; -import { Instance, getSnapshot } from 'mobx-state-tree'; +import { + CertificateSnapshotIn, + defineServiceModel, + ServiceAction, + ServiceStateSnapshotIn, +} from '@sophie/shared'; +import { Instance, getSnapshot, cast } from 'mobx-state-tree'; import type { ServiceView } from '../infrastructure/electron/types'; import { getLogger } from '../utils/log'; @@ -31,6 +36,13 @@ import type ServiceConfig from './config/ServiceConfig'; const log = getLogger('Service'); const Service = defineServiceModel(ServiceSettings) + .volatile( + (): { + serviceView: ServiceView | undefined; + } => ({ + serviceView: undefined, + }), + ) .views((self) => ({ get config(): ServiceConfig { const { id, settings } = self; @@ -42,18 +54,16 @@ const Service = defineServiceModel(ServiceSettings) get shouldBeLoaded(): boolean { return !self.crashed; }, + })) + .views((self) => ({ get shouldBeVisible(): boolean { - return this.shouldBeLoaded && !self.failed; + return self.shouldBeLoaded && !self.hasError; }, })) - .volatile( - (): { - serviceView: ServiceView | undefined; - } => ({ - serviceView: undefined, - }), - ) .actions((self) => ({ + setServiceView(serviceView: ServiceView | undefined): void { + self.serviceView = serviceView; + }, setLocation({ url, canGoBack, @@ -70,21 +80,6 @@ const Service = defineServiceModel(ServiceSettings) setTitle(title: string): void { self.title = title; }, - startLoading(): void { - self.state = { type: 'loading' }; - }, - finishLoading(): void { - if (self.loading) { - // Do not overwrite crashed state if the service haven't been reloaded yet. - self.state = { type: 'loaded' }; - } - }, - setFailed(errorCode: number, errorDesc: string): void { - self.state = { type: 'failed', errorCode, errorDesc }; - }, - setCrashed(reason: string, exitCode: number): void { - self.state = { type: 'crashed', reason, exitCode }; - }, setUnreadCount({ direct, indirect }: UnreadCount): void { if (direct !== undefined) { self.directMessageCount = direct; @@ -93,55 +88,124 @@ const Service = defineServiceModel(ServiceSettings) self.indirectMessageCount = indirect; } }, - setServiceView(serviceView: ServiceView | undefined): void { - self.serviceView = serviceView; - }, - goBack(): void { - self.serviceView?.goBack(); - }, - goForward(): void { - self.serviceView?.goForward(); + })) + .actions((self) => { + function setState(state: ServiceStateSnapshotIn): void { + self.state = cast(state); + } + + return { + startLoading(): void { + setState({ type: 'loading' }); + }, + finishLoading(): void { + if (self.loading) { + // Do not overwrite any error state state if the service haven't been reloaded yet. + setState({ type: 'loaded' }); + } + }, + setFailed(errorCode: number, errorDesc: string): void { + if (!self.hasError) { + setState({ + type: 'failed', + errorCode, + errorDesc, + }); + } + }, + setCertificateError( + errorCode: string, + certificate: CertificateSnapshotIn, + ): void { + if (!self.crashed && self.state.type !== 'certificateError') { + setState({ + type: 'certificateError', + errorCode, + certificate, + }); + } + }, + setCrashed(reason: string, exitCode: number): void { + if (!self.crashed) { + setState({ + type: 'crashed', + reason, + exitCode, + }); + } + }, + goBack(): void { + self.serviceView?.goBack(); + }, + goForward(): void { + self.serviceView?.goForward(); + }, + reload(ignoreCache = false): void { + if (self.serviceView === undefined) { + setState({ type: 'initializing' }); + } else { + self.serviceView?.reload(ignoreCache); + } + }, + stop(): void { + self.serviceView?.stop(); + }, + go(url: string): void { + if (self.serviceView === undefined) { + self.currentUrl = url; + setState({ type: 'initializing' }); + } else { + self.serviceView?.loadURL(url); + } + }, + }; + }) + .actions((self) => ({ + goHome(): void { + self.go(self.settings.url); }, - reload(ignoreCache = false): void { - if (self.serviceView === undefined) { - self.state = { type: 'initializing' }; - } else { - self.serviceView?.reload(ignoreCache); + temporarilyTrustCurrentCertificate(fingerprint: string): void { + if (self.state.type !== 'certificateError') { + log.error('Tried to trust certificate without any certificate error'); + return; } - }, - stop(): void { - self.serviceView?.stop(); - }, - go(url: string): void { - if (self.serviceView === undefined) { - self.currentUrl = url; - self.state = { type: 'initializing' }; - } else { - self.serviceView?.loadURL(url); + if (self.state.certificate.fingerprint !== fingerprint) { + log.error( + 'Tried to trust certificate', + fingerprint, + 'but the currently pending fingerprint is', + self.state.certificate.fingerprint, + ); + return; } + self.settings.profile.temporarilyTrustCertificate(self.state.certificate); + self.state.trust = 'accepted'; + self.reload(); }, - goHome(): void { - this.go(self.settings.url); - }, + })) + .actions((self) => ({ dispatch(action: ServiceAction): void { switch (action.action) { case 'back': - this.goBack(); + self.goBack(); break; case 'forward': - this.goForward(); + self.goForward(); break; case 'reload': - this.reload(action.ignoreCache); + self.reload(action.ignoreCache); break; case 'stop': - this.stop(); + self.stop(); break; case 'go-home': - this.goHome(); + self.goHome(); break; case 'go': - this.go(action.url); + self.go(action.url); + break; + case 'temporarily-trust-current-certificate': + self.temporarilyTrustCurrentCertificate(action.fingerprint); break; default: log.error('Unknown action to dispatch', action); -- cgit v1.2.3-54-g00ecf