Skip to content

Commit 444820c

Browse files
committed
fix(certificate): redesign untrusted certificate dialog
Signed-off-by: Grigorii K. Shartsev <me@shgk.me>
1 parent 77db73e commit 444820c

File tree

4 files changed

+96
-31
lines changed

4 files changed

+96
-31
lines changed

src/app/certificate.service.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import type { BrowserWindow, Certificate } from 'electron'
88
import { showCertificateTrustDialog } from '../certificate/certificate.window.ts'
99
import { getAppConfig, setAppConfig } from './AppConfig.ts'
1010

11+
export type UntrustedCertificateDetails = {
12+
host: string
13+
certificate: Certificate
14+
error: string
15+
}
16+
1117
/**
1218
* Pending showCertificateTrustDialog prompts per fingerprint to prevent duplicated dialogs
1319
*/
@@ -20,31 +26,32 @@ const pendingCertificateTrustPrompts: Map<string, Promise<boolean>> = new Map()
2026
* since application level trusted dialog is needed, not system-level
2127
*
2228
* @param window - Parent window
23-
* @param certificate - Certificate
29+
* @param details - Error details
2430
* @return Whether the certificate is accepted as trusted
2531
*/
26-
export async function promptCertificateTrust(window: BrowserWindow, certificate: Certificate): Promise<boolean> {
32+
export async function promptCertificateTrust(window: BrowserWindow, details: UntrustedCertificateDetails): Promise<boolean> {
33+
const fingerprint = details.certificate.fingerprint
2734
const trustedFingerprints = getAppConfig('trustedFingerprints')
2835

2936
// Already accepted
30-
if (trustedFingerprints.includes(certificate.fingerprint)) {
37+
if (trustedFingerprints.includes(fingerprint)) {
3138
return true
3239
}
3340

3441
// Already in prompt in parallel
35-
const existingPrompt = pendingCertificateTrustPrompts.get(certificate.fingerprint)
42+
const existingPrompt = pendingCertificateTrustPrompts.get(fingerprint)
3643
if (existingPrompt) {
3744
return existingPrompt
3845
}
3946

4047
// Prompt user acceptance
41-
const pendingDialog = showCertificateTrustDialog(window, certificate)
42-
pendingCertificateTrustPrompts.set(certificate.fingerprint, pendingDialog)
48+
const pendingDialog = showCertificateTrustDialog(window, details)
49+
pendingCertificateTrustPrompts.set(fingerprint, pendingDialog)
4350
const isAccepted = await pendingDialog
44-
pendingCertificateTrustPrompts.delete(certificate.fingerprint)
51+
pendingCertificateTrustPrompts.delete(fingerprint)
4552

4653
if (isAccepted) {
47-
setAppConfig('trustedFingerprints', [...trustedFingerprints, certificate.fingerprint])
54+
setAppConfig('trustedFingerprints', [...trustedFingerprints, fingerprint])
4855
}
4956

5057
return isAccepted

src/certificate/certificate.window.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import type { Certificate, IpcMainEvent } from 'electron'
7+
import type { UntrustedCertificateDetails } from '../app/certificate.service.ts'
78

89
import { BrowserWindow, ipcMain } from 'electron'
910
import { applyContextMenu } from '../app/applyContextMenu.js'
@@ -14,16 +15,16 @@ import { getBrowserWindowIcon } from '../shared/icons.utils.js'
1415
* Show untrusted certificate dialog window
1516
*
1617
* @param parentWindow - Parent browser window
17-
* @param certificate - Certificate
18+
* @param details - Error details
1819
* @return Whether user accept the certificate
1920
*/
20-
export function showCertificateTrustDialog(parentWindow: BrowserWindow, certificate: Certificate) {
21-
const TITLE = buildTitle('Untrusted certificate')
21+
export function showCertificateTrustDialog(parentWindow: BrowserWindow, details: UntrustedCertificateDetails) {
22+
const TITLE = buildTitle('Security warning')
2223
const window = new BrowserWindow({
2324
title: TITLE,
2425
...getScaledWindowSize({
25-
width: 650,
26-
height: 900,
26+
width: 600,
27+
height: 600,
2728
}),
2829
...getScaledWindowMinSize({
2930
minWidth: 320,
@@ -48,7 +49,7 @@ export function showCertificateTrustDialog(parentWindow: BrowserWindow, certific
4849
window.removeMenu()
4950
window.on('ready-to-show', () => window.show())
5051

51-
window.loadURL(getWindowUrl('certificate') + '#' + encodeURIComponent(JSON.stringify(certificate)))
52+
window.loadURL(getWindowUrl('certificate') + '#' + encodeURIComponent(JSON.stringify(details)))
5253

5354
return new Promise<boolean>((resolve) => {
5455
let isAccepted = false

src/certificate/renderer/CertificateApp.vue

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,81 @@
44
-->
55

66
<script setup lang="ts">
7-
import type { Certificate } from 'electron'
7+
import type { UntrustedCertificateDetails } from '../../app/certificate.service.ts'
88
99
import { t } from '@nextcloud/l10n'
1010
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
11+
import { ref } from 'vue'
1112
import NcButton from '@nextcloud/vue/components/NcButton'
1213
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
14+
import IconAlert from 'vue-material-design-icons/Alert.vue'
15+
import IconShieldOffOutline from 'vue-material-design-icons/ShieldOffOutline.vue'
1316
import CertificateInfo from './components/CertificateInfo.vue'
1417
1518
useHotKey('Escape', () => window.close())
1619
17-
const certificate = JSON.parse(decodeURIComponent(location.hash.slice(1))) as Certificate
20+
const { host, certificate, error } = JSON.parse(decodeURIComponent(location.hash.slice(1))) as UntrustedCertificateDetails
21+
const urlParam = {
22+
value: `<strong>${host}</strong>`,
23+
escape: false,
24+
}
1825
1926
const acceptCertificate = window.TALK_DESKTOP.acceptCertificate
27+
28+
const isAdvanced = ref(false)
2029
</script>
2130

2231
<template>
2332
<div class="certificate">
2433
<h2 class="certificate__heading">
25-
{{ t('talk_desktop', 'Untrusted certificate') }}
34+
<IconAlert :size="30" class="certificate__alert-icon" />
35+
{{ t('talk_desktop', 'Warning: potential security risk') }}
2636
</h2>
2737

28-
<div class="certificate__content">
29-
<NcNoteCard type="warning" class="certificate__note">
30-
{{ t('talk_desktop', 'The server\'s security certificate is not trusted. Your connection may not be secure. Proceed only if you trust this certificate.') }}
31-
</NcNoteCard>
38+
<NcNoteCard type="error" class="certificate__note">
39+
<template #icon>
40+
<IconShieldOffOutline :size="24" class="certificate__alert-icon" />
41+
</template>
42+
<!-- eslint-disable-next-line -->
43+
<p v-html="t('talk_desktop', 'Connection to {url} is not private.', { url: urlParam })"/>
44+
<p>{{ t('talk-desktop', 'If you are unsure to proceed contact your system administrator.') }}</p>
45+
</NcNoteCard>
46+
47+
<NcButton
48+
v-if="!isAdvanced"
49+
aria-expanded="false"
50+
variant="error"
51+
@click="isAdvanced = true">
52+
{{ t('talk_desktop', 'Advanced') }}
53+
</NcButton>
3254

33-
<CertificateInfo :certificate="certificate" />
55+
<div class="certificate__content">
56+
<div v-if="isAdvanced" class="certificate__advanced">
57+
<!-- eslint-disable-next-line -->
58+
<p v-html="t('talk_desktop', 'This server could not prove that it is {url}.', { url: urlParam })"/>
59+
<p>
60+
{{ t('talk_desktop', 'It has untrusted SSL certificate. This might be caused by an attacker intercepting your connection or server misconfiguration.') }}
61+
</p>
62+
<p>
63+
<code>{{ error }}</code>
64+
</p>
65+
<p>
66+
<NcButton variant="error" @click="acceptCertificate(true)">
67+
{{ t('talk_desktop', 'Proceed') }}
68+
</NcButton>
69+
</p>
70+
<details>
71+
<summary>
72+
{{ t('talk_desktop', 'View certificate') }}
73+
</summary>
74+
<CertificateInfo :certificate="certificate" />
75+
</details>
76+
</div>
3477
</div>
3578

3679
<div class="certificate__actions">
37-
<NcButton variant="secondary" wide @click="acceptCertificate(false)">
38-
{{ t('talk_desktop', 'Reject') }}
39-
</NcButton>
40-
<NcButton variant="primary" wide @click="acceptCertificate(true)">
41-
{{ t('talk_desktop', 'Accept') }}
80+
<NcButton variant="primary" wide @click="acceptCertificate(false)">
81+
{{ t('talk_desktop', 'Cancel') }}
4282
</NcButton>
4383
</div>
4484
</div>
@@ -63,11 +103,18 @@ const acceptCertificate = window.TALK_DESKTOP.acceptCertificate
63103
64104
.certificate__heading {
65105
margin-block: 0;
106+
display: flex;
107+
align-items: baseline;
108+
justify-content: center;
109+
gap: 1ch;
66110
font-size: 1.5em;
67-
text-align: center;
68111
text-transform: capitalize;
69112
}
70113
114+
.certificate__alert-icon {
115+
color: var(--color-error);
116+
}
117+
71118
.certificate__content {
72119
flex: 1;
73120
margin-inline: calc(-4 * var(--default-grid-baseline));
@@ -78,14 +125,24 @@ const acceptCertificate = window.TALK_DESKTOP.acceptCertificate
78125
overflow: auto;
79126
}
80127
128+
.certificate__advanced {
129+
border: 1px solid var(--color-border);
130+
border-radius: var(--border-radius-small);
131+
padding: calc(2 * var(--default-grid-baseline));
132+
display: flex;
133+
flex-direction: column;
134+
gap: 1em;
135+
background-color: var(--color-background-dark);
136+
}
137+
81138
.certificate__note {
82139
/* Override default component styles */
83140
margin: 0;
84-
margin-block-end: calc(3 * var(--default-grid-baseline));
85141
}
86142
87143
.certificate__actions {
88144
display: flex;
89145
gap: calc(2 * var(--default-grid-baseline));
146+
align-items: flex-end;
90147
}
91148
</style>

src/main.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ app.whenReady().then(async () => {
234234
// Note: the result of this verification is cached by domain in Electron
235235
// There is no way to clean the cache except by restarting the app
236236
session.defaultSession.setCertificateVerifyProc(async (request, callback) => {
237-
const isAccepted = await promptCertificateTrust(mainWindow, request.certificate)
237+
const isAccepted = await promptCertificateTrust(mainWindow, { host: request.hostname, certificate: request.certificate, error: request.verificationResult })
238238
// See: https://source.chromium.org/chromium/chromium/src/+/main:net/base/net_error_list.h
239239
const SSL_PROTOCOL_ERROR_CODE = -107
240240
callback(isAccepted ? 0 : SSL_PROTOCOL_ERROR_CODE)
@@ -243,7 +243,7 @@ app.whenReady().then(async () => {
243243
// Allow web-view with accepted untrusted certificate (Login Flow)
244244
app.on('certificate-error', async (event, webContents, url, error, certificate, callback) => {
245245
event.preventDefault()
246-
const isAccepted = await promptCertificateTrust(mainWindow, certificate)
246+
const isAccepted = await promptCertificateTrust(mainWindow, { host: new URL(url).host, certificate, error })
247247
callback(isAccepted)
248248
})
249249

0 commit comments

Comments
 (0)