Skip to content

Commit 31a4d30

Browse files
authored
Merge pull request #1308 from nextcloud/fix/cert
fix: complete untrusted certificate support
2 parents 68f1c2d + 86b0e7f commit 31a4d30

19 files changed

+663
-71
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default [
1818
// Electron Forge build vars
1919
TALK_DESKTOP__WINDOW_AUTHENTICATION_PRELOAD_WEBPACK_ENTRY: 'readonly',
2020
TALK_DESKTOP__WINDOW_CALLBOX_PRELOAD_WEBPACK_ENTRY: 'readonly',
21+
TALK_DESKTOP__WINDOW_CERTIFICATE_PRELOAD_WEBPACK_ENTRY: 'readonly',
2122
TALK_DESKTOP__WINDOW_TALK_PRELOAD_WEBPACK_ENTRY: 'readonly',
2223
TALK_DESKTOP__WINDOW_HELP_PRELOAD_WEBPACK_ENTRY: 'readonly',
2324
TALK_DESKTOP__WINDOW_UPGRADE_PRELOAD_WEBPACK_ENTRY: 'readonly',

forge.config.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
const path = require('node:path')
7-
const fs = require('node:fs')
8-
const semver = require('semver')
9-
const { MakerSquirrel } = require('@electron-forge/maker-squirrel')
106
const { MakerDMG } = require('@electron-forge/maker-dmg')
117
const { MakerFlatpak } = require('@electron-forge/maker-flatpak')
12-
const { MakerZIP } = require('@electron-forge/maker-zip')
8+
const { MakerSquirrel } = require('@electron-forge/maker-squirrel')
139
const { MakerWix } = require('@electron-forge/maker-wix')
10+
const { MakerZIP } = require('@electron-forge/maker-zip')
11+
const fs = require('node:fs')
12+
const path = require('node:path')
13+
const semver = require('semver')
14+
const { resolveConfig } = require('./build/resolveBuildConfig.js')
1415
const packageJSON = require('./package.json')
1516
const { MIN_REQUIRED_BUILT_IN_TALK_VERSION } = require('./src/constants.js')
16-
const { resolveConfig } = require('./build/resolveBuildConfig.js')
1717

1818
require('dotenv').config()
1919

@@ -436,6 +436,14 @@ module.exports = {
436436
js: './src/preload.js',
437437
},
438438
},
439+
{
440+
name: 'talk_desktop__window_certificate',
441+
html: './src/certificate/renderer/certificate.html',
442+
js: './src/certificate/renderer/certificate.main.ts',
443+
preload: {
444+
js: './src/preload.js',
445+
},
446+
},
439447
],
440448
},
441449
},

package-lock.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"semver": "^7.7.2",
6666
"unzip-crx-3": "^0.2.0",
6767
"vue": "^2.7.16",
68+
"vue-frag": "^1.4.3",
6869
"vue-material-design-icons": "^5.3.1"
6970
},
7071
"devDependencies": {

src/app/certificate.service.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { BrowserWindow, Request } from 'electron'
7+
8+
import { session } from 'electron'
9+
import { showCertificateTrustDialog } from '../certificate/certificate.window.ts'
10+
import { getAppConfig, setAppConfig } from './AppConfig.ts'
11+
12+
export type UntrustedCertificateDetails = Pick<Request, 'hostname' | 'certificate' | 'verificationResult'>
13+
14+
/**
15+
* Pending showCertificateTrustDialog prompts per fingerprint to prevent duplicated dialogs
16+
*/
17+
const pendingCertificateTrustPrompts: Map<string, Promise<boolean>> = new Map()
18+
19+
/**
20+
* Handle request for untrusted certificate acceptance.
21+
* For unknown certificates, show custom certificate trust dialog.
22+
* Note: dialog.showCertificateTrustDialog is not used
23+
* since application level trusted dialog is needed, not system-level
24+
*
25+
* @param window - Parent window
26+
* @param details - Error details
27+
* @return Whether the certificate is accepted as trusted
28+
*/
29+
export async function promptCertificateTrust(window: BrowserWindow, details: UntrustedCertificateDetails): Promise<boolean> {
30+
const fingerprint = details.certificate.fingerprint
31+
const trustedFingerprints = getAppConfig('trustedFingerprints')
32+
33+
// Already accepted
34+
if (trustedFingerprints.includes(fingerprint)) {
35+
return true
36+
}
37+
38+
// Already in prompt in parallel
39+
const existingPrompt = pendingCertificateTrustPrompts.get(fingerprint)
40+
if (existingPrompt) {
41+
return existingPrompt
42+
}
43+
44+
// Prompt user acceptance
45+
const pendingDialog = showCertificateTrustDialog(window, details)
46+
pendingCertificateTrustPrompts.set(fingerprint, pendingDialog)
47+
const isAccepted = await pendingDialog
48+
pendingCertificateTrustPrompts.delete(fingerprint)
49+
50+
if (isAccepted) {
51+
setAppConfig('trustedFingerprints', [...trustedFingerprints, fingerprint])
52+
}
53+
54+
return isAccepted
55+
}
56+
57+
/**
58+
* Verify certificate on a URL.
59+
* Note: this function only exists due to Electron limitations.
60+
* If a user accepts the certificate later than the request is rejected by timeout,
61+
* Electron considers it rejected for 30 minutes or until the app restart.
62+
* Issue: https://github.com/electron/electron/issues/47267
63+
* And there is no way to reset the cache.
64+
* Issue: https://github.com/electron/electron/issues/41448
65+
* Thus the verification on the defaultSession cannot be used (at least for login).
66+
* This function makes a single request in a new random session, to avoid verification caching.
67+
* The actual result is stored in the application config.
68+
*
69+
* @param window - Parent browser window
70+
* @param url - URL
71+
*/
72+
export async function verifyCertificate(window: BrowserWindow, url: string): Promise<boolean> {
73+
const certificateVerifySession = session.fromPartition(`certificate:verify:${Math.random().toString(36).slice(2, 9)}`)
74+
75+
let verificationResolvers: PromiseWithResolvers<boolean> | undefined
76+
77+
certificateVerifySession.setCertificateVerifyProc(async (request, callback) => {
78+
verificationResolvers = Promise.withResolvers()
79+
// Use original result, failing the request
80+
callback(-3)
81+
82+
const isAccepted = request.errorCode === 0 || await promptCertificateTrust(window, request)
83+
verificationResolvers.resolve(isAccepted)
84+
})
85+
86+
try {
87+
await certificateVerifySession.fetch(url, { bypassCustomProtocolHandlers: true })
88+
// Successful request - no SSL errors
89+
return true
90+
} catch {
91+
// SSL Error - handled by user prompt
92+
if (verificationResolvers) {
93+
return verificationResolvers.promise
94+
}
95+
// Some unexpected network error - not a certificate error
96+
return true
97+
}
98+
}

src/app/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ export function buildTitle(title?: string) {
8585
return title ? `${title} - ${base}` : base
8686
}
8787

88-
const windows = ['authentication', 'callbox', 'help', 'talk', 'upgrade', 'welcome'] as const
88+
const windows = ['authentication', 'callbox', 'certificate', 'help', 'talk', 'upgrade', 'welcome'] as const
8989

9090
/**
9191
* Get the URL for a window to load
9292
*
9393
* @param window - Window name
9494
*/
95-
export function getWindowUrl(window: typeof windows[]) {
95+
export function getWindowUrl(window: typeof windows[number]) {
9696
if (!windows.includes(window)) {
9797
throw new Error(`Invalid window name: ${window}`)
9898
}

src/authentication/renderer/AuthenticationApp.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ async function login() {
8484
return setError(t('talk_desktop', 'Invalid server address'))
8585
}
8686
87+
// Check the certificate before actually sending a request
88+
if (!await window.TALK_DESKTOP.verifyCertificate(serverUrl.value)) {
89+
return setError(t('talk_desktop', 'SSL certificate error'))
90+
}
91+
8792
// Prepare to request the server
8893
window.TALK_DESKTOP.disableWebRequestInterceptor()
8994
appData.reset()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { IpcMainEvent } from 'electron'
7+
import type { UntrustedCertificateDetails } from '../app/certificate.service.ts'
8+
9+
import { BrowserWindow, ipcMain } from 'electron'
10+
import { applyContextMenu } from '../app/applyContextMenu.js'
11+
import { applyZoom, buildTitle, getScaledWindowMinSize, getScaledWindowSize, getWindowUrl } from '../app/utils.ts'
12+
import { getBrowserWindowIcon } from '../shared/icons.utils.js'
13+
14+
/**
15+
* Show untrusted certificate dialog window
16+
*
17+
* @param parentWindow - Parent browser window
18+
* @param details - Error details
19+
* @return Whether user accept the certificate
20+
*/
21+
export function showCertificateTrustDialog(parentWindow: BrowserWindow, details: UntrustedCertificateDetails) {
22+
const TITLE = buildTitle('Security warning')
23+
const window = new BrowserWindow({
24+
title: TITLE,
25+
...getScaledWindowSize({
26+
width: 600,
27+
height: 600,
28+
}),
29+
...getScaledWindowMinSize({
30+
minWidth: 320,
31+
minHeight: 256,
32+
}),
33+
parent: parentWindow,
34+
modal: true,
35+
show: false,
36+
maximizable: false,
37+
minimizable: false,
38+
center: true,
39+
fullscreenable: false,
40+
autoHideMenuBar: true,
41+
webPreferences: {
42+
preload: TALK_DESKTOP__WINDOW_CERTIFICATE_PRELOAD_WEBPACK_ENTRY,
43+
},
44+
icon: getBrowserWindowIcon(),
45+
})
46+
47+
applyContextMenu(window)
48+
applyZoom(window)
49+
window.removeMenu()
50+
window.on('ready-to-show', () => window.show())
51+
52+
window.loadURL(getWindowUrl('certificate') + '#' + encodeURIComponent(JSON.stringify(details)))
53+
54+
return new Promise<boolean>((resolve) => {
55+
let isAccepted = false
56+
57+
const onCertificateAccept = (event: IpcMainEvent, accepted: boolean) => {
58+
if (event.sender !== window.webContents) {
59+
return
60+
}
61+
isAccepted = accepted
62+
window.close()
63+
}
64+
65+
ipcMain.once('certificate:accept', onCertificateAccept)
66+
67+
window.on('closed', () => {
68+
ipcMain.off('certificate:accept', onCertificateAccept)
69+
resolve(isAccepted)
70+
})
71+
})
72+
}

0 commit comments

Comments
 (0)