Skip to content

Commit 86b0e7f

Browse files
committed
fix(certificate): Electron rejects cert 30s timeout during user prompt
Signed-off-by: Grigorii K. Shartsev <me@shgk.me>
1 parent bfaa5ed commit 86b0e7f

File tree

4 files changed

+59
-1
lines changed

4 files changed

+59
-1
lines changed

src/app/certificate.service.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { BrowserWindow, Request } from 'electron'
77

8+
import { session } from 'electron'
89
import { showCertificateTrustDialog } from '../certificate/certificate.window.ts'
910
import { getAppConfig, setAppConfig } from './AppConfig.ts'
1011

@@ -52,3 +53,46 @@ export async function promptCertificateTrust(window: BrowserWindow, details: Unt
5253

5354
return isAccepted
5455
}
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/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()

src/main.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const { setupMenu } = require('./app/app.menu.js')
1111
const { loadAppConfig, getAppConfig, setAppConfig } = require('./app/AppConfig.ts')
1212
const { appData } = require('./app/AppData.js')
1313
const { registerAppProtocolHandler } = require('./app/appProtocol.ts')
14-
const { promptCertificateTrust } = require('./app/certificate.service.ts')
14+
const { verifyCertificate, promptCertificateTrust } = require('./app/certificate.service.ts')
1515
const { openChromeWebRtcInternals } = require('./app/dev.utils.ts')
1616
const { triggerDownloadUrl } = require('./app/downloads.ts')
1717
const { setupReleaseNotificationScheduler } = require('./app/githubReleaseNotification.service.js')
@@ -350,6 +350,8 @@ app.whenReady().then(async () => {
350350

351351
ipcMain.on('app:downloadURL', (event, url, filename) => triggerDownloadUrl(mainWindow, url, filename))
352352

353+
ipcMain.handle('certificate:verify', (event, url) => verifyCertificate(mainWindow, url))
354+
353355
// Click on the dock icon on macOS
354356
app.on('activate', () => {
355357
if (mainWindow && !mainWindow.isDestroyed()) {

src/preload.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ const TALK_DESKTOP = {
191191
* @param {boolean} isAccepted - Is the certificate accepted as trusted
192192
*/
193193
acceptCertificate: (isAccepted) => ipcRenderer.send('certificate:accept', isAccepted),
194+
/**
195+
* Verify certificate on a URL
196+
*
197+
* @param {string} url - URL
198+
* @return {Promise<boolean>}
199+
*/
200+
verifyCertificate: (url) => ipcRenderer.invoke('certificate:verify', url),
194201
}
195202

196203
// Set global window.TALK_DESKTOP

0 commit comments

Comments
 (0)