aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-04-22 00:53:48 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-16 00:55:02 +0200
commit1184eb13f0bbe4768bf3dbd6cd31adf10c6c8dfe (patch)
tree78e6c699d8a232c948b40f643299f7af95a29215
parentfeat(renderer): Insecure connection warning (diff)
downloadsophie-1184eb13f0bbe4768bf3dbd6cd31adf10c6c8dfe.tar.gz
sophie-1184eb13f0bbe4768bf3dbd6cd31adf10c6c8dfe.tar.zst
sophie-1184eb13f0bbe4768bf3dbd6cd31adf10c6c8dfe.zip
feat: Certificate viewer
Show certificates with an interface modeled after firefox's certificate viewer so that they can be inspected before trusting. The current implementation assumes that each certificate has a unique fingerprint (collisions are astronomically unlikely). Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--locales/en/translation.json24
-rw-r--r--packages/main/src/infrastructure/electron/impl/electronShell.ts15
-rw-r--r--packages/main/src/stores/MainEnv.ts2
-rw-r--r--packages/main/src/stores/Service.ts41
-rw-r--r--packages/renderer/src/components/errorPage/CertificateDetails.tsx136
-rw-r--r--packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx369
-rw-r--r--packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx57
-rw-r--r--packages/renderer/src/stores/Service.ts6
-rw-r--r--packages/shared/src/index.ts5
-rw-r--r--packages/shared/src/schemas/ServiceAction.ts4
-rw-r--r--packages/shared/src/stores/Certificate.ts8
11 files changed, 638 insertions, 29 deletions
diff --git a/locales/en/translation.json b/locales/en/translation.json
index a669d28..321e6be 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -91,12 +91,30 @@
91 }, 91 },
92 "details": { 92 "details": {
93 "title": "More info", 93 "title": "More info",
94 "warning": "You should only trust certificates that you've verified to be valid. Trusting invalid certificates allows attackers to steal your passwords and data.", 94 "tabsLabel": "Certificates in the certificate chain",
95 "acceptButton": { 95 "trustWarning": "You should only trust certificates that you've verified to be valid. Trusting invalid certificates allows attackers to steal your passwords and data.",
96 "trustButton": {
96 "pending": "Trust this certificate temporarily", 97 "pending": "Trust this certificate temporarily",
97 "rejected": "You have already rejected this certificate", 98 "rejected": "You have already rejected this certificate",
98 "accepted": "You have already accepted this certificate" 99 "accepted": "You have already accepted this certificate"
99 } 100 },
101 "subject": "Subject",
102 "issuer": "Issuer",
103 "commonName": "Common Name",
104 "country": "Country",
105 "state": "State",
106 "locality": "Locality",
107 "organization": "Organization",
108 "organizationUnit": "Organization Unit",
109 "validity": "Validity",
110 "validStart": "Not Before",
111 "validExpiry": "Not After",
112 "fingerprint": "Fingerprint",
113 "fingerprintUnknown": "Unknown",
114 "miscellaneous": "Miscellaneous",
115 "serialNumber": "Serial Number",
116 "download": "Download",
117 "downloadPEM": "PEM Certificate"
100 } 118 }
101 }, 119 },
102 "crashed": { 120 "crashed": {
diff --git a/packages/main/src/infrastructure/electron/impl/electronShell.ts b/packages/main/src/infrastructure/electron/impl/electronShell.ts
index be1cbe3..f7f7001 100644
--- a/packages/main/src/infrastructure/electron/impl/electronShell.ts
+++ b/packages/main/src/infrastructure/electron/impl/electronShell.ts
@@ -18,7 +18,9 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { app, shell } from 'electron'; 21import { writeFile } from 'node:fs/promises';
22
23import { app, dialog, shell } from 'electron';
22import { getLogger } from 'loglevel'; 24import { getLogger } from 'loglevel';
23 25
24import type MainEnv from '../../../stores/MainEnv'; 26import type MainEnv from '../../../stores/MainEnv';
@@ -34,6 +36,17 @@ const electronShell: MainEnv = {
34 openAboutDialog(): void { 36 openAboutDialog(): void {
35 app.showAboutPanel(); 37 app.showAboutPanel();
36 }, 38 },
39 async saveTextFile(filename: string, value: string): Promise<void> {
40 const result = await dialog.showSaveDialog({
41 defaultPath: filename,
42 });
43 if (result.canceled || result.filePath === undefined) {
44 log.debug('Saving file', filename, 'canceled');
45 return;
46 }
47 await writeFile(result.filePath, value, 'utf8');
48 log.debug('Saved file', filename, 'to', result.filePath);
49 },
37}; 50};
38 51
39export default electronShell; 52export default electronShell;
diff --git a/packages/main/src/stores/MainEnv.ts b/packages/main/src/stores/MainEnv.ts
index 8923322..f9dc209 100644
--- a/packages/main/src/stores/MainEnv.ts
+++ b/packages/main/src/stores/MainEnv.ts
@@ -28,4 +28,6 @@ export default interface MainEnv {
28 openURLInExternalBrowser(url: string): void; 28 openURLInExternalBrowser(url: string): void;
29 29
30 openAboutDialog(): void; 30 openAboutDialog(): void;
31
32 saveTextFile(filename: string, value: string): Promise<void>;
31} 33}
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index d8f3166..1d46dc9 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -20,12 +20,13 @@
20 20
21import type { UnreadCount } from '@sophie/service-shared'; 21import type { UnreadCount } from '@sophie/service-shared';
22import { 22import {
23 CertificateSnapshotIn, 23 type Certificate,
24 type CertificateSnapshotIn,
24 defineServiceModel, 25 defineServiceModel,
25 ServiceAction, 26 ServiceAction,
26 ServiceStateSnapshotIn, 27 type ServiceStateSnapshotIn,
27} from '@sophie/shared'; 28} from '@sophie/shared';
28import { Instance, getSnapshot, cast } from 'mobx-state-tree'; 29import { type Instance, getSnapshot, cast, flow } from 'mobx-state-tree';
29 30
30import type { ServiceView } from '../infrastructure/electron/types'; 31import type { ServiceView } from '../infrastructure/electron/types';
31import { getLogger } from '../utils/log'; 32import { getLogger } from '../utils/log';
@@ -129,6 +130,35 @@ const Service = defineServiceModel(ServiceSettings)
129 toggleDeveloperTools(): void { 130 toggleDeveloperTools(): void {
130 self.serviceView?.toggleDeveloperTools(); 131 self.serviceView?.toggleDeveloperTools();
131 }, 132 },
133 downloadCertificate: flow(function* downloadCertificate(
134 fingerprint: string,
135 ) {
136 const { state } = self;
137 if (state.type !== 'certificateError') {
138 log.warn(
139 'Tried to save certificate',
140 fingerprint,
141 'when there is no certificate error',
142 );
143 return;
144 }
145 let { certificate } = state;
146 while (
147 certificate !== undefined &&
148 certificate.fingerprint !== fingerprint
149 ) {
150 certificate = certificate.issuerCert as Certificate;
151 }
152 if (certificate === undefined) {
153 log.warn(
154 'Tried to save certificate',
155 fingerprint,
156 'which is not part of the current certificate chain',
157 );
158 return;
159 }
160 yield getEnv(self).saveTextFile('certificate.pem', certificate.data);
161 }),
132 })) 162 }))
133 .actions((self) => { 163 .actions((self) => {
134 function setState(state: ServiceStateSnapshotIn): void { 164 function setState(state: ServiceStateSnapshotIn): void {
@@ -272,6 +302,11 @@ const Service = defineServiceModel(ServiceSettings)
272 case 'dismiss-all-popups': 302 case 'dismiss-all-popups':
273 self.dismissAllPopups(); 303 self.dismissAllPopups();
274 break; 304 break;
305 case 'download-certificate':
306 self.downloadCertificate(action.fingerprint).catch((error) => {
307 log.error('Error while saving certificate', error);
308 });
309 break;
275 default: 310 default:
276 log.error('Unknown action to dispatch', action); 311 log.error('Unknown action to dispatch', action);
277 break; 312 break;
diff --git a/packages/renderer/src/components/errorPage/CertificateDetails.tsx b/packages/renderer/src/components/errorPage/CertificateDetails.tsx
index 044cb5c..51e7920 100644
--- a/packages/renderer/src/components/errorPage/CertificateDetails.tsx
+++ b/packages/renderer/src/components/errorPage/CertificateDetails.tsx
@@ -19,22 +19,41 @@
19 */ 19 */
20 20
21import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 21import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
22import WarningAmberIcon from '@mui/icons-material/WarningAmber';
23import Accordion from '@mui/material/Accordion'; 22import Accordion from '@mui/material/Accordion';
24import AccordionDetails from '@mui/material/AccordionDetails'; 23import AccordionDetails from '@mui/material/AccordionDetails';
25import AccordionSummary from '@mui/material/AccordionSummary'; 24import AccordionSummary from '@mui/material/AccordionSummary';
26import Box from '@mui/material/Box'; 25import Box from '@mui/material/Box';
27import Button from '@mui/material/Button'; 26import Tab from '@mui/material/Tab';
27import Tabs from '@mui/material/Tabs';
28import Typography from '@mui/material/Typography'; 28import Typography from '@mui/material/Typography';
29import { styled } from '@mui/material/styles';
30import type { Certificate } from '@sophie/shared';
29import { observer } from 'mobx-react-lite'; 31import { observer } from 'mobx-react-lite';
30import React from 'react'; 32import React, { useState } from 'react';
31import { useTranslation } from 'react-i18next'; 33import { useTranslation } from 'react-i18next';
32 34
33import type Service from '../../stores/Service'; 35import type Service from '../../stores/Service';
34 36
37import SingleCertificateDetails from './SingleCertificateDetails';
38import TrustCertificateDialog from './TrustCertificateDialog';
39
35const SUMMARY_ID = 'Sophie-CertificateDetails-header'; 40const SUMMARY_ID = 'Sophie-CertificateDetails-header';
36const DETAILS_ID = 'Sophie-CertificateDetails-content'; 41const DETAILS_ID = 'Sophie-CertificateDetails-content';
37 42
43function getCertificateChain(endEntityCertificate: Certificate): Certificate[] {
44 const chain: Certificate[] = [];
45 let certificate: Certificate | undefined = endEntityCertificate;
46 while (certificate !== undefined) {
47 chain.push(certificate);
48 certificate = certificate.issuerCert as Certificate;
49 }
50 return chain;
51}
52
53const DetailsBox = styled(Box)(({ theme }) => ({
54 padding: `0 ${theme.spacing(2)}`,
55}));
56
38function CertificateDetails({ 57function CertificateDetails({
39 service, 58 service,
40}: { 59}: {
@@ -43,8 +62,9 @@ function CertificateDetails({
43 const { t } = useTranslation(undefined, { 62 const { t } = useTranslation(undefined, {
44 keyPrefix: 'error.certificateError.details', 63 keyPrefix: 'error.certificateError.details',
45 }); 64 });
65 const [certificateIndex, setCertificateIndex] = useState(0);
46 66
47 const { state } = service; 67 const { id: serviceId, state } = service;
48 const { type: errorType } = state; 68 const { type: errorType } = state;
49 69
50 if (errorType !== 'certificateError') { 70 if (errorType !== 'certificateError') {
@@ -54,6 +74,22 @@ function CertificateDetails({
54 74
55 const { certificate, trust } = state; 75 const { certificate, trust } = state;
56 76
77 const chain = getCertificateChain(certificate);
78
79 if (chain.length === 0) {
80 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
81 return null;
82 }
83
84 const id = `${serviceId}-certificateDetails`;
85
86 const trustCertificateDialog = (
87 <TrustCertificateDialog
88 trust={trust}
89 onClick={() => service.temporarilyTrustCurrentCertificate()}
90 />
91 );
92
57 return ( 93 return (
58 <Accordion 94 <Accordion
59 disableGutters 95 disableGutters
@@ -67,23 +103,81 @@ function CertificateDetails({
67 > 103 >
68 <Typography>{t('title')}</Typography> 104 <Typography>{t('title')}</Typography>
69 </AccordionSummary> 105 </AccordionSummary>
70 <AccordionDetails> 106 <AccordionDetails
71 <Typography sx={{ wordWrap: 'break-word' }}> 107 sx={{
72 {JSON.stringify(certificate)} 108 px: 0,
73 </Typography> 109 pt: 0,
74 <Typography mt={2}> 110 }}
75 <WarningAmberIcon fontSize="small" /> <strong>{t('warning')}</strong> 111 >
76 </Typography> 112 {chain.length >= 2 ? (
77 <Box mt={1}> 113 <>
78 <Button 114 <Tabs
79 disabled={trust !== 'pending'} 115 aria-label={t<string>('tabsLabel')}
80 onClick={() => service.temporarilyTrustCurrentCertificate()} 116 value={certificateIndex < chain.length ? certificateIndex : false}
81 color="error" 117 onChange={(_event, value) => setCertificateIndex(value as number)}
82 variant="outlined" 118 variant="fullWidth"
83 > 119 >
84 {t(`acceptButton.${trust}`)} 120 {chain.map((member, i) => (
85 </Button> 121 <Tab
86 </Box> 122 key={member.fingerprint}
123 value={i}
124 id={`${id}-tab-${i}`}
125 aria-controls={`${id}-tabpanel-${i}`}
126 label={
127 member.subjectName === ''
128 ? member.fingerprint
129 : member.subjectName
130 }
131 />
132 ))}
133 </Tabs>
134 <DetailsBox
135 sx={(theme) => ({
136 paddingTop: 1,
137 borderTop: `1px solid ${theme.palette.divider}`,
138 })}
139 >
140 {chain.map((member, i) => (
141 <div
142 key={member.fingerprint}
143 role="tabpanel"
144 hidden={certificateIndex !== i}
145 id={`${id}-tabpanel-${i}`}
146 aria-labelledby={`${id}-tab-${i}`}
147 >
148 {certificateIndex === i && (
149 <>
150 <SingleCertificateDetails
151 detailsId={id}
152 certificate={member}
153 onIssuerClick={
154 i < chain.length - 1
155 ? () => setCertificateIndex(i + 1)
156 : undefined
157 }
158 onDownloadClick={() =>
159 service.downloadCertificate(member.fingerprint)
160 }
161 />
162 {i === 0 && trustCertificateDialog}
163 </>
164 )}
165 </div>
166 ))}
167 </DetailsBox>
168 </>
169 ) : (
170 <DetailsBox>
171 <SingleCertificateDetails
172 detailsId={id}
173 certificate={chain[0]}
174 onDownloadClick={() =>
175 service.downloadCertificate(chain[0].fingerprint)
176 }
177 />
178 {trustCertificateDialog}
179 </DetailsBox>
180 )}
87 </AccordionDetails> 181 </AccordionDetails>
88 </Accordion> 182 </Accordion>
89 ); 183 );
diff --git a/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx b/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx
new file mode 100644
index 0000000..107d9f1
--- /dev/null
+++ b/packages/renderer/src/components/errorPage/SingleCertificateDetails.tsx
@@ -0,0 +1,369 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import AttachmentIcon from '@mui/icons-material/Attachment';
22import Link from '@mui/material/Link';
23import { styled } from '@mui/material/styles';
24import type { Certificate, CertificatePrincipal } from '@sophie/shared';
25import type { TFunction } from 'i18next';
26import { observer } from 'mobx-react-lite';
27import React, { type ReactNode } from 'react';
28import { useTranslation } from 'react-i18next';
29
30const SingleCertificateDetailsRoot = styled('table')(({ theme }) => ({
31 width: '100%',
32 borderSpacing: `${theme.spacing(2)} 0`,
33}));
34
35const Header = styled('th')({
36 textAlign: 'right',
37 whiteSpace: 'nowrap',
38 verticalAlign: 'top',
39});
40
41const SectionHeader = styled(Header)(({ theme }) => ({
42 padding: `${theme.spacing(2)} 0`,
43}));
44
45const SubHeader = styled(Header)(({ theme }) => ({
46 color: theme.palette.text.secondary,
47}));
48
49const Cell = styled('td')({
50 verticalAlign: 'top',
51});
52
53function Section({ id, label }: { id: string; label: string }): JSX.Element {
54 return (
55 <tr>
56 <SectionHeader id={id} scope="colgroup">
57 {label}
58 </SectionHeader>
59 <Cell aria-hidden="true" />
60 </tr>
61 );
62}
63
64function Row({
65 id,
66 label,
67 headers,
68 children,
69}: {
70 id: string;
71 label: string;
72 headers: string;
73 children: ReactNode;
74}): JSX.Element {
75 return (
76 <tr>
77 <SubHeader id={id} scope="row">
78 {label}
79 </SubHeader>
80 <Cell headers={`${headers} ${id}`}>{children}</Cell>
81 </tr>
82 );
83}
84
85function OptionalRow({
86 id,
87 label,
88 value,
89 headers,
90}: {
91 id: string;
92 label: string;
93 value: string;
94 headers: string;
95}): JSX.Element | null {
96 if (value === '') {
97 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
98 return null;
99 }
100
101 return (
102 <Row id={id} label={label} headers={headers}>
103 {value}
104 </Row>
105 );
106}
107
108function DateTimeRow({
109 id,
110 label,
111 value,
112 locales,
113 headers,
114}: {
115 id: string;
116 label: string;
117 value: number;
118 locales: readonly string[];
119 headers: string;
120}): JSX.Element {
121 // Electron provides `validStart` and `validExpiry` in seconds,
122 // but `Date` expects milliseconds.
123 const date = new Date(value * 1000);
124 const dateStr = new Intl.DateTimeFormat([...locales], {
125 dateStyle: 'long',
126 timeStyle: 'long',
127 }).format(date);
128
129 return (
130 <Row id={id} label={label} headers={headers}>
131 {dateStr}
132 </Row>
133 );
134}
135
136function Principal({
137 id,
138 label,
139 principal: {
140 country,
141 state,
142 locality,
143 organizations,
144 organizationUnits,
145 commonName,
146 },
147 t,
148 onIssuerClick,
149}: {
150 id: string;
151 label: string;
152 principal: CertificatePrincipal;
153 t: TFunction;
154 onIssuerClick?: (() => void) | undefined;
155}): JSX.Element {
156 return (
157 <>
158 <Section id={id} label={label} />
159 <OptionalRow
160 id={`${id}-country`}
161 label={t<string>('country')}
162 value={country}
163 headers={id}
164 />
165 <OptionalRow
166 id={`${id}-state`}
167 label={t<string>('state')}
168 value={state}
169 headers={id}
170 />
171 <OptionalRow
172 id={`${id}-locality`}
173 label={t<string>('locality')}
174 value={locality}
175 headers={id}
176 />
177 {/*
178 eslint-disable react/no-array-index-key --
179 These entries can freely contain repetitions, so we can only tell them apart by index.
180 */}
181 {organizations.map((value, i) => (
182 <OptionalRow
183 key={i}
184 id={`${id}-organization-${i}`}
185 label={t<string>('organization')}
186 value={value}
187 headers={id}
188 />
189 ))}
190 {organizationUnits.map((value, i) => (
191 <OptionalRow
192 key={i}
193 id={`${id}-organizationUnit-${i}`}
194 label={t<string>('organizationUnit')}
195 value={value}
196 headers={id}
197 />
198 ))}
199 {/* eslint-enable react/no-array-index-key */}
200 {commonName !== '' && (
201 <Row
202 id={`${id}-commonName`}
203 label={t<string>('commonName')}
204 headers={id}
205 >
206 {onIssuerClick === undefined ? (
207 commonName
208 ) : (
209 // eslint-disable-next-line jsx-a11y/anchor-is-valid -- This is a `button`.
210 <Link
211 component="button"
212 variant="body1"
213 onClick={onIssuerClick}
214 sx={{ verticalAlign: 'baseline' }}
215 >
216 {commonName}
217 </Link>
218 )}
219 </Row>
220 )}
221 </>
222 );
223}
224
225Principal.defaultProps = {
226 onIssuerClick: undefined,
227};
228
229function parseFingerprint(fingerprint: string): {
230 algorithm: string | undefined;
231 formattedValue: string;
232} {
233 const separatorIndex = fingerprint.indexOf('/');
234 if (separatorIndex <= 0) {
235 return {
236 algorithm: undefined,
237 formattedValue: fingerprint,
238 };
239 }
240 return {
241 algorithm: fingerprint.slice(0, separatorIndex).toUpperCase(),
242 formattedValue: fingerprint.slice(separatorIndex + 1),
243 };
244}
245
246function Fingerprint({
247 id,
248 label,
249 fallbackAlgorithmLabel,
250 value,
251}: {
252 id: string;
253 label: string;
254 fallbackAlgorithmLabel: string;
255 value: string;
256}) {
257 const { algorithm, formattedValue } = parseFingerprint(value);
258 return (
259 <>
260 <Section id={id} label={label} />
261 <OptionalRow
262 id={`${id}-fingerprint`}
263 label={algorithm ?? fallbackAlgorithmLabel}
264 headers={id}
265 value={formattedValue}
266 />
267 </>
268 );
269}
270
271function SingleCertificateDetails({
272 detailsId,
273 certificate: {
274 subject,
275 issuer,
276 validStart,
277 validExpiry,
278 fingerprint,
279 serialNumber,
280 },
281 onIssuerClick,
282 onDownloadClick,
283}: {
284 detailsId: string;
285 certificate: Certificate;
286 onIssuerClick?: (() => void) | undefined;
287 onDownloadClick: () => void;
288}): JSX.Element {
289 const {
290 t,
291 i18n: { languages },
292 } = useTranslation(undefined, {
293 keyPrefix: 'error.certificateError.details',
294 });
295
296 const id = `${detailsId}-${fingerprint}-certificate`;
297
298 return (
299 <SingleCertificateDetailsRoot>
300 <tbody>
301 <Principal
302 id={`${id}-subject`}
303 label={t<string>('subject')}
304 principal={subject}
305 t={t}
306 />
307 <Principal
308 id={`${id}-issuer`}
309 label={t<string>('issuer')}
310 principal={issuer}
311 t={t}
312 onIssuerClick={onIssuerClick}
313 />
314 <Section id={`${id}-validity`} label={t<string>('validity')} />
315 <DateTimeRow
316 id={`${id}-validity-validStart`}
317 label={t<string>('validStart')}
318 value={validStart}
319 locales={languages}
320 headers={`${id}-validity`}
321 />
322 <DateTimeRow
323 id={`${id}-validity-validExpiry`}
324 label={t<string>('validExpiry')}
325 value={validExpiry}
326 locales={languages}
327 headers={`${id}-validity`}
328 />
329 <Section
330 id={`${id}-miscellaneous`}
331 label={t<string>('miscellaneous')}
332 />
333 <OptionalRow
334 id={`${id}-miscellaneous-serialNumber`}
335 label={t<string>('serialNumber')}
336 value={serialNumber}
337 headers={`${id}-miscellaneous`}
338 />
339 <Row
340 id={`${id}-miscellaneous-download`}
341 label={t<string>('download')}
342 headers={`${id}-miscellaneous`}
343 >
344 {/* eslint-disable-next-line jsx-a11y/anchor-is-valid -- This is a `button`. */}
345 <Link
346 component="button"
347 variant="body1"
348 onClick={onDownloadClick}
349 sx={{ verticalAlign: 'baseline' }}
350 >
351 <AttachmentIcon fontSize="inherit" /> {t('downloadPEM')}
352 </Link>
353 </Row>
354 <Fingerprint
355 id={`${id}-fingerprint`}
356 label={t<string>('fingerprint')}
357 fallbackAlgorithmLabel={t<string>('fingerprintUnknown')}
358 value={fingerprint}
359 />
360 </tbody>
361 </SingleCertificateDetailsRoot>
362 );
363}
364
365SingleCertificateDetails.defaultProps = {
366 onIssuerClick: undefined,
367};
368
369export default observer(SingleCertificateDetails);
diff --git a/packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx b/packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx
new file mode 100644
index 0000000..630cbe0
--- /dev/null
+++ b/packages/renderer/src/components/errorPage/TrustCertificateDialog.tsx
@@ -0,0 +1,57 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import WarningAmberIcon from '@mui/icons-material/WarningAmber';
22import Box from '@mui/material/Box';
23import Button from '@mui/material/Button';
24import Typography from '@mui/material/Typography';
25import React from 'react';
26import { useTranslation } from 'react-i18next';
27
28export default function TrustCertificateDialog({
29 trust,
30 onClick,
31}: {
32 trust: 'pending' | 'rejected' | 'accepted';
33 onClick: () => void;
34}): JSX.Element {
35 const { t } = useTranslation(undefined, {
36 keyPrefix: 'error.certificateError.details',
37 });
38
39 return (
40 <>
41 <Typography mt={2}>
42 <WarningAmberIcon fontSize="inherit" />{' '}
43 <strong>{t('trustWarning')}</strong>
44 </Typography>
45 <Box mt={1}>
46 <Button
47 disabled={trust !== 'pending'}
48 onClick={onClick}
49 color="error"
50 variant="outlined"
51 >
52 {t(`trustButton.${trust}`)}
53 </Button>
54 </Box>
55 </>
56 );
57}
diff --git a/packages/renderer/src/stores/Service.ts b/packages/renderer/src/stores/Service.ts
index 4510ec0..dcaf96e 100644
--- a/packages/renderer/src/stores/Service.ts
+++ b/packages/renderer/src/stores/Service.ts
@@ -123,6 +123,12 @@ const Service = defineServiceModel(ServiceSettings)
123 action: 'dismiss-all-popups', 123 action: 'dismiss-all-popups',
124 }); 124 });
125 }, 125 },
126 downloadCertificate(fingerprint: string): void {
127 dispatch({
128 action: 'download-certificate',
129 fingerprint,
130 });
131 },
126 }; 132 };
127 }) 133 })
128 .actions((self) => ({ 134 .actions((self) => ({
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index e3da192..b1d05f8 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -32,7 +32,10 @@ export { ThemeSource } from './schemas/ThemeSource';
32 32
33export { Translation } from './schemas/Translation'; 33export { Translation } from './schemas/Translation';
34 34
35export type { CertificateSnapshotIn } from './stores/Certificate'; 35export type {
36 CertificatePrincipal,
37 CertificateSnapshotIn,
38} from './stores/Certificate';
36export { default as Certificate } from './stores/Certificate'; 39export { default as Certificate } from './stores/Certificate';
37 40
38export type { 41export type {
diff --git a/packages/shared/src/schemas/ServiceAction.ts b/packages/shared/src/schemas/ServiceAction.ts
index cfaba08..c0f07d6 100644
--- a/packages/shared/src/schemas/ServiceAction.ts
+++ b/packages/shared/src/schemas/ServiceAction.ts
@@ -67,6 +67,10 @@ export const ServiceAction = /* @__PURE__ */ (() =>
67 z.object({ 67 z.object({
68 action: z.literal('dismiss-all-popups'), 68 action: z.literal('dismiss-all-popups'),
69 }), 69 }),
70 z.object({
71 action: z.literal('download-certificate'),
72 fingerprint: z.string().nonempty(),
73 }),
70 ]))(); 74 ]))();
71 75
72/* 76/*
diff --git a/packages/shared/src/stores/Certificate.ts b/packages/shared/src/stores/Certificate.ts
index 8b2d007..363406d 100644
--- a/packages/shared/src/stores/Certificate.ts
+++ b/packages/shared/src/stores/Certificate.ts
@@ -30,12 +30,20 @@ const CertificatePrincipal = /* @__PURE__ */ (() =>
30 country: types.string, 30 country: types.string,
31 }))(); 31 }))();
32 32
33/*
34 eslint-disable-next-line @typescript-eslint/no-redeclare --
35 Intentionally naming the type the same as the store definition.
36*/
37export interface CertificatePrincipal
38 extends Instance<typeof CertificatePrincipal> {}
39
33const Certificate = /* @__PURE__ */ (() => 40const Certificate = /* @__PURE__ */ (() =>
34 types.model('Certificate', { 41 types.model('Certificate', {
35 data: types.string, 42 data: types.string,
36 issuer: CertificatePrincipal, 43 issuer: CertificatePrincipal,
37 issuerName: types.string, 44 issuerName: types.string,
38 issuerCert: types.maybe(types.late((): IAnyModelType => Certificate)), 45 issuerCert: types.maybe(types.late((): IAnyModelType => Certificate)),
46 subject: CertificatePrincipal,
39 subjectName: types.string, 47 subjectName: types.string,
40 serialNumber: types.string, 48 serialNumber: types.string,
41 validStart: types.number, 49 validStart: types.number,