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 ++++++++++++++------- packages/renderer/src/components/App.tsx | 64 +++++--- .../src/components/BrowserViewPlaceholder.tsx | 26 +-- packages/renderer/src/stores/Service.ts | 84 +++++----- packages/shared/src/index.ts | 6 + packages/shared/src/schemas/ServiceAction.ts | 4 + packages/shared/src/stores/Certificate.ts | 54 +++++++ packages/shared/src/stores/Profile.ts | 21 ++- packages/shared/src/stores/ServiceBase.ts | 47 +++--- packages/shared/src/stores/ServiceState.ts | 72 +++++++++ 12 files changed, 434 insertions(+), 157 deletions(-) create mode 100644 packages/shared/src/stores/Certificate.ts create mode 100644 packages/shared/src/stores/ServiceState.ts 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); diff --git a/packages/renderer/src/components/App.tsx b/packages/renderer/src/components/App.tsx index af2e4ec..b647a80 100644 --- a/packages/renderer/src/components/App.tsx +++ b/packages/renderer/src/components/App.tsx @@ -19,38 +19,46 @@ */ import Box from '@mui/material/Box'; -import React from 'react'; +import Button from '@mui/material/Button'; +import { observer } from 'mobx-react-lite'; +import React, { useCallback } from 'react'; import BrowserViewPlaceholder from './BrowserViewPlaceholder'; import { useStore } from './StoreProvider'; import LocationBar from './locationBar/LocationBar'; import Sidebar from './sidebar/Sidebar'; -export default function App(): JSX.Element { - const store = useStore(); +function App(): JSX.Element { + const { + settings: { selectedService }, + } = useStore(); - function onClick(event: React.MouseEvent): void { - switch (event.button) { - case 3: - store.settings.selectedService?.goBack(); - break; - case 4: - store.settings.selectedService?.goForward(); - break; - default: - // Allow the event to propagate. - return; - } - event.preventDefault(); - event.stopPropagation(); - } + const handleBackForwardMouseButtons = useCallback( + (event: React.MouseEvent) => { + switch (event.button) { + case 3: + selectedService?.goBack(); + break; + case 4: + selectedService?.goForward(); + break; + default: + // Allow the event to propagate. + return; + } + event.preventDefault(); + event.stopPropagation(); + }, + [selectedService], + ); return ( onClick(event)} - onAuxClick={(event) => onClick(event)} + onClick={handleBackForwardMouseButtons} + onAuxClick={handleBackForwardMouseButtons} sx={{ display: 'flex', + overflow: 'hidden', flexDirection: 'row', alignItems: 'stretch', height: '100vh', @@ -69,8 +77,22 @@ export default function App(): JSX.Element { }} > - + +

{JSON.stringify(selectedService?.state)}

+ {selectedService?.state.type === 'certificateError' && ( + + )} +
); } + +export default observer(App); diff --git a/packages/renderer/src/components/BrowserViewPlaceholder.tsx b/packages/renderer/src/components/BrowserViewPlaceholder.tsx index c07ed15..1f5f9f4 100644 --- a/packages/renderer/src/components/BrowserViewPlaceholder.tsx +++ b/packages/renderer/src/components/BrowserViewPlaceholder.tsx @@ -20,12 +20,15 @@ import Box from '@mui/material/Box'; import throttle from 'lodash-es/throttle'; -import { observer } from 'mobx-react-lite'; -import React, { useCallback, useRef } from 'react'; +import React, { ReactNode, useCallback, useRef } from 'react'; import { useStore } from './StoreProvider'; -export default observer(() => { +function BrowserViewPlaceholder({ + children, +}: { + children?: ReactNode; +}): JSX.Element { const store = useStore(); // eslint-disable-next-line react-hooks/exhaustive-deps -- react-hooks doesn't support `throttle`. @@ -62,11 +65,14 @@ export default observer(() => { ); return ( - + + {children} + ); -}); +} + +BrowserViewPlaceholder.defaultProps = { + children: undefined, +}; + +export default BrowserViewPlaceholder; diff --git a/packages/renderer/src/stores/Service.ts b/packages/renderer/src/stores/Service.ts index 7878ea0..695cff4 100644 --- a/packages/renderer/src/stores/Service.ts +++ b/packages/renderer/src/stores/Service.ts @@ -25,47 +25,59 @@ import getEnv from '../env/getEnv'; import ServiceSettings from './ServiceSettings'; -const Service = defineServiceModel(ServiceSettings).actions((self) => ({ - dispatch(serviceAction: ServiceAction): void { +const Service = defineServiceModel(ServiceSettings).actions((self) => { + function dispatch(serviceAction: ServiceAction): void { getEnv(self).dispatchMainAction({ action: 'dispatch-service-action', serviceId: self.id, serviceAction, }); - }, - goBack(): void { - this.dispatch({ - action: 'back', - }); - }, - goForward(): void { - this.dispatch({ - action: 'forward', - }); - }, - reload(ignoreCache = false): void { - this.dispatch({ - action: 'reload', - ignoreCache, - }); - }, - stop(): void { - this.dispatch({ - action: 'stop', - }); - }, - go(url: string): void { - this.dispatch({ - action: 'go', - url, - }); - }, - goHome(): void { - this.dispatch({ - action: 'go-home', - }); - }, -})); + } + + return { + goBack(): void { + dispatch({ + action: 'back', + }); + }, + goForward(): void { + dispatch({ + action: 'forward', + }); + }, + reload(ignoreCache = false): void { + dispatch({ + action: 'reload', + ignoreCache, + }); + }, + stop(): void { + dispatch({ + action: 'stop', + }); + }, + go(url: string): void { + dispatch({ + action: 'go', + url, + }); + }, + goHome(): void { + dispatch({ + action: 'go-home', + }); + }, + temporarilyTrustCurrentCertificate(): void { + if (self.state.type !== 'certificateError') { + throw new Error('No certificate to accept'); + } + dispatch({ + action: 'temporarily-trust-current-certificate', + fingerprint: self.state.certificate.fingerprint, + }); + }, + }; +}); /* eslint-disable-next-line @typescript-eslint/no-redeclare -- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fa3fbfd..f7c5bcf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,6 +30,9 @@ export { ServiceAction } from './schemas/ServiceAction'; export { ThemeSource } from './schemas/ThemeSource'; +export type { CertificateSnapshotIn } from './stores/Certificate'; +export { default as Certificate } from './stores/Certificate'; + export type { GlobalSettingsSnapshotIn, GlobalSettingsSnapshotOut, @@ -61,6 +64,9 @@ export { defineServiceSettingsModel, } from './stores/ServiceSettingsBase'; +export type { ServiceStateSnapshotIn } from './stores/ServiceState'; +export { default as ServiceState } from './stores/ServiceState'; + export type { SharedStoreListener, SharedStoreSnapshotIn, diff --git a/packages/shared/src/schemas/ServiceAction.ts b/packages/shared/src/schemas/ServiceAction.ts index a4a7049..8961bfe 100644 --- a/packages/shared/src/schemas/ServiceAction.ts +++ b/packages/shared/src/schemas/ServiceAction.ts @@ -42,6 +42,10 @@ export const ServiceAction = /* @__PURE__ */ (() => action: z.literal('go'), url: z.string(), }), + z.object({ + action: z.literal('temporarily-trust-current-certificate'), + fingerprint: z.string(), + }), ]))(); /* diff --git a/packages/shared/src/stores/Certificate.ts b/packages/shared/src/stores/Certificate.ts new file mode 100644 index 0000000..8b2d007 --- /dev/null +++ b/packages/shared/src/stores/Certificate.ts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IAnyModelType, Instance, SnapshotIn, types } from 'mobx-state-tree'; + +const CertificatePrincipal = /* @__PURE__ */ (() => + types.model('CertificatePrincipal', { + commonName: types.string, + organizations: types.array(types.string), + organizationUnits: types.array(types.string), + locality: types.string, + state: types.string, + country: types.string, + }))(); + +const Certificate = /* @__PURE__ */ (() => + types.model('Certificate', { + data: types.string, + issuer: CertificatePrincipal, + issuerName: types.string, + issuerCert: types.maybe(types.late((): IAnyModelType => Certificate)), + subjectName: types.string, + serialNumber: types.string, + validStart: types.number, + validExpiry: types.number, + fingerprint: types.string, + }))(); + +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +interface Certificate extends Instance {} + +export default Certificate; + +export interface CertificateSnapshotIn extends SnapshotIn {} diff --git a/packages/shared/src/stores/Profile.ts b/packages/shared/src/stores/Profile.ts index 49c5195..611ca6f 100644 --- a/packages/shared/src/stores/Profile.ts +++ b/packages/shared/src/stores/Profile.ts @@ -20,13 +20,26 @@ import { Instance, types } from 'mobx-state-tree'; +import Certificate, { CertificateSnapshotIn } from './Certificate'; import ProfileSettings from './ProfileSettings'; const Profile = /* @__PURE__ */ (() => - types.model('Profile', { - id: types.identifier, - settings: ProfileSettings, - }))(); + types + .model('Profile', { + id: types.identifier, + settings: ProfileSettings, + temporarilyTrustedCertificates: types.array(Certificate), + }) + .views((self) => ({ + isCertificateTemporarilyTrusted( + certificate: CertificateSnapshotIn, + ): boolean { + return self.temporarilyTrustedCertificates.some( + (trustedCertificate) => + trustedCertificate.fingerprint === certificate.fingerprint, + ); + }, + })))(); /* eslint-disable-next-line @typescript-eslint/no-redeclare -- diff --git a/packages/shared/src/stores/ServiceBase.ts b/packages/shared/src/stores/ServiceBase.ts index 4a17bc5..c69f339 100644 --- a/packages/shared/src/stores/ServiceBase.ts +++ b/packages/shared/src/stores/ServiceBase.ts @@ -20,7 +20,10 @@ import { IAnyModelType, Instance, types } from 'mobx-state-tree'; +import type { CertificateSnapshotIn } from './Certificate'; +import type Profile from './Profile'; import ServiceSettingsBase from './ServiceSettingsBase'; +import ServiceState from './ServiceState'; export function defineServiceModel(settings: TS) { return types @@ -31,30 +34,7 @@ export function defineServiceModel(settings: TS) { canGoBack: false, canGoForward: false, title: types.maybe(types.string), - state: types.optional( - types.union( - types.model({ - type: types.literal('initializing'), - }), - types.model({ - type: types.literal('loading'), - }), - types.model({ - type: types.literal('loaded'), - }), - types.model({ - type: types.literal('failed'), - errorCode: types.integer, - errorDesc: types.string, - }), - types.model({ - type: types.literal('crashed'), - reason: types.string, - exitCode: types.integer, - }), - ), - { type: 'initializing' }, - ), + state: ServiceState, directMessageCount: 0, indirectMessageCount: 0, }) @@ -64,12 +44,25 @@ export function defineServiceModel(settings: TS) { self.state.type === 'initializing' || self.state.type === 'loading' ); }, - get failed(): boolean { - return self.state.type === 'failed'; - }, get crashed(): boolean { return self.state.type === 'crashed'; }, + })) + .views((self) => ({ + get hasError(): boolean { + return ( + self.crashed || + self.state.type === 'failed' || + self.state.type === 'certificateError' + ); + }, + isCertificateTemporarilyTrusted( + certificate: CertificateSnapshotIn, + ): boolean { + return ( + self.settings.profile as Profile + ).isCertificateTemporarilyTrusted(certificate); + }, })); } diff --git a/packages/shared/src/stores/ServiceState.ts b/packages/shared/src/stores/ServiceState.ts new file mode 100644 index 0000000..ad49321 --- /dev/null +++ b/packages/shared/src/stores/ServiceState.ts @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Instance, SnapshotIn, types } from 'mobx-state-tree'; + +import Certificate from './Certificate'; + +const ServiceState = /* @__PURE__ */ (() => + types.optional( + types.union( + types.model('ServiceInitializingState', { + type: types.literal('initializing'), + }), + types.model('ServiceLoadingState', { + type: types.literal('loading'), + }), + types.model('ServiceLoadedState', { + type: types.literal('loaded'), + }), + types.model('ServiceFailedState', { + type: types.literal('failed'), + errorCode: types.integer, + errorDesc: types.string, + }), + types.model('ServiceCertificateErrorState', { + type: types.literal('certificateError'), + errorCode: types.string, + certificate: Certificate, + trust: types.optional( + types.union( + types.literal('pending'), + types.literal('rejected'), + types.literal('accepted'), + ), + 'pending', + ), + }), + types.model({ + type: types.literal('crashed'), + reason: types.string, + exitCode: types.integer, + }), + ), + { type: 'initializing' }, + ))(); + +/* + eslint-disable-next-line @typescript-eslint/no-redeclare -- + Intentionally naming the type the same as the store definition. +*/ +type ServiceState = Instance; + +export default ServiceState; + +export type ServiceStateSnapshotIn = SnapshotIn; -- cgit v1.2.3-54-g00ecf