Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default [
// Electron Forge build vars
TALK_DESKTOP__WINDOW_AUTHENTICATION_PRELOAD_WEBPACK_ENTRY: 'readonly',
TALK_DESKTOP__WINDOW_CALLBOX_PRELOAD_WEBPACK_ENTRY: 'readonly',
TALK_DESKTOP__WINDOW_CERTIFICATE_PRELOAD_WEBPACK_ENTRY: 'readonly',
TALK_DESKTOP__WINDOW_TALK_PRELOAD_WEBPACK_ENTRY: 'readonly',
TALK_DESKTOP__WINDOW_HELP_PRELOAD_WEBPACK_ENTRY: 'readonly',
TALK_DESKTOP__WINDOW_UPGRADE_PRELOAD_WEBPACK_ENTRY: 'readonly',
Expand Down
20 changes: 14 additions & 6 deletions forge.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

const path = require('node:path')
const fs = require('node:fs')
const semver = require('semver')
const { MakerSquirrel } = require('@electron-forge/maker-squirrel')
const { MakerDMG } = require('@electron-forge/maker-dmg')
const { MakerFlatpak } = require('@electron-forge/maker-flatpak')
const { MakerZIP } = require('@electron-forge/maker-zip')
const { MakerSquirrel } = require('@electron-forge/maker-squirrel')
const { MakerWix } = require('@electron-forge/maker-wix')
const { MakerZIP } = require('@electron-forge/maker-zip')
const fs = require('node:fs')
const path = require('node:path')
const semver = require('semver')
const { resolveConfig } = require('./build/resolveBuildConfig.js')
const packageJSON = require('./package.json')
const { MIN_REQUIRED_BUILT_IN_TALK_VERSION } = require('./src/constants.js')
const { resolveConfig } = require('./build/resolveBuildConfig.js')

require('dotenv').config()

Expand Down Expand Up @@ -436,6 +436,14 @@ module.exports = {
js: './src/preload.js',
},
},
{
name: 'talk_desktop__window_certificate',
html: './src/certificate/renderer/certificate.html',
js: './src/certificate/renderer/certificate.main.ts',
preload: {
js: './src/preload.js',
},
},
],
},
},
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"semver": "^7.7.2",
"unzip-crx-3": "^0.2.0",
"vue": "^2.7.16",
"vue-frag": "^1.4.3",
"vue-material-design-icons": "^5.3.1"
},
"devDependencies": {
Expand Down
98 changes: 98 additions & 0 deletions src/app/certificate.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { BrowserWindow, Request } from 'electron'

import { session } from 'electron'
import { showCertificateTrustDialog } from '../certificate/certificate.window.ts'
import { getAppConfig, setAppConfig } from './AppConfig.ts'

export type UntrustedCertificateDetails = Pick<Request, 'hostname' | 'certificate' | 'verificationResult'>

/**
* Pending showCertificateTrustDialog prompts per fingerprint to prevent duplicated dialogs
*/
const pendingCertificateTrustPrompts: Map<string, Promise<boolean>> = new Map()

/**
* Handle request for untrusted certificate acceptance.
* For unknown certificates, show custom certificate trust dialog.
* Note: dialog.showCertificateTrustDialog is not used
* since application level trusted dialog is needed, not system-level
*
* @param window - Parent window
* @param details - Error details
* @return Whether the certificate is accepted as trusted
*/
export async function promptCertificateTrust(window: BrowserWindow, details: UntrustedCertificateDetails): Promise<boolean> {
const fingerprint = details.certificate.fingerprint
const trustedFingerprints = getAppConfig('trustedFingerprints')

// Already accepted
if (trustedFingerprints.includes(fingerprint)) {
return true
}

// Already in prompt in parallel
const existingPrompt = pendingCertificateTrustPrompts.get(fingerprint)
if (existingPrompt) {
return existingPrompt
}

// Prompt user acceptance
const pendingDialog = showCertificateTrustDialog(window, details)
pendingCertificateTrustPrompts.set(fingerprint, pendingDialog)
const isAccepted = await pendingDialog
pendingCertificateTrustPrompts.delete(fingerprint)

if (isAccepted) {
setAppConfig('trustedFingerprints', [...trustedFingerprints, fingerprint])
}

return isAccepted
}

/**
* Verify certificate on a URL.
* Note: this function only exists due to Electron limitations.
* If a user accepts the certificate later than the request is rejected by timeout,
* Electron considers it rejected for 30 minutes or until the app restart.
* Issue: https://github.com/electron/electron/issues/47267
* And there is no way to reset the cache.
* Issue: https://github.com/electron/electron/issues/41448
* Thus the verification on the defaultSession cannot be used (at least for login).
* This function makes a single request in a new random session, to avoid verification caching.
* The actual result is stored in the application config.
*
* @param window - Parent browser window
* @param url - URL
*/
export async function verifyCertificate(window: BrowserWindow, url: string): Promise<boolean> {
const certificateVerifySession = session.fromPartition(`certificate:verify:${Math.random().toString(36).slice(2, 9)}`)

let verificationResolvers: PromiseWithResolvers<boolean> | undefined

certificateVerifySession.setCertificateVerifyProc(async (request, callback) => {
verificationResolvers = Promise.withResolvers()
// Use original result, failing the request
callback(-3)

const isAccepted = request.errorCode === 0 || await promptCertificateTrust(window, request)
verificationResolvers.resolve(isAccepted)
})

try {
await certificateVerifySession.fetch(url, { bypassCustomProtocolHandlers: true })
// Successful request - no SSL errors
return true
} catch {
// SSL Error - handled by user prompt
if (verificationResolvers) {
return verificationResolvers.promise
}
// Some unexpected network error - not a certificate error
return true
}
}
4 changes: 2 additions & 2 deletions src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,14 @@ export function buildTitle(title?: string) {
return title ? `${title} - ${base}` : base
}

const windows = ['authentication', 'callbox', 'help', 'talk', 'upgrade', 'welcome'] as const
const windows = ['authentication', 'callbox', 'certificate', 'help', 'talk', 'upgrade', 'welcome'] as const

/**
* Get the URL for a window to load
*
* @param window - Window name
*/
export function getWindowUrl(window: typeof windows[]) {
export function getWindowUrl(window: typeof windows[number]) {
if (!windows.includes(window)) {
throw new Error(`Invalid window name: ${window}`)
}
Expand Down
5 changes: 5 additions & 0 deletions src/authentication/renderer/AuthenticationApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ async function login() {
return setError(t('talk_desktop', 'Invalid server address'))
}

// Check the certificate before actually sending a request
if (!await window.TALK_DESKTOP.verifyCertificate(serverUrl.value)) {
return setError(t('talk_desktop', 'SSL certificate error'))
}

// Prepare to request the server
window.TALK_DESKTOP.disableWebRequestInterceptor()
appData.reset()
Expand Down
72 changes: 72 additions & 0 deletions src/certificate/certificate.window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IpcMainEvent } from 'electron'
import type { UntrustedCertificateDetails } from '../app/certificate.service.ts'

import { BrowserWindow, ipcMain } from 'electron'
import { applyContextMenu } from '../app/applyContextMenu.js'
import { applyZoom, buildTitle, getScaledWindowMinSize, getScaledWindowSize, getWindowUrl } from '../app/utils.ts'
import { getBrowserWindowIcon } from '../shared/icons.utils.js'

/**
* Show untrusted certificate dialog window
*
* @param parentWindow - Parent browser window
* @param details - Error details
* @return Whether user accept the certificate
*/
export function showCertificateTrustDialog(parentWindow: BrowserWindow, details: UntrustedCertificateDetails) {
const TITLE = buildTitle('Security warning')
const window = new BrowserWindow({
title: TITLE,
...getScaledWindowSize({
width: 600,
height: 600,
}),
...getScaledWindowMinSize({
minWidth: 320,
minHeight: 256,
}),
parent: parentWindow,
modal: true,
show: false,
maximizable: false,
minimizable: false,
center: true,
fullscreenable: false,
autoHideMenuBar: true,
webPreferences: {
preload: TALK_DESKTOP__WINDOW_CERTIFICATE_PRELOAD_WEBPACK_ENTRY,
},
icon: getBrowserWindowIcon(),
})

applyContextMenu(window)
applyZoom(window)
window.removeMenu()
window.on('ready-to-show', () => window.show())

window.loadURL(getWindowUrl('certificate') + '#' + encodeURIComponent(JSON.stringify(details)))

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

const onCertificateAccept = (event: IpcMainEvent, accepted: boolean) => {
if (event.sender !== window.webContents) {
return
}
isAccepted = accepted
window.close()
}

ipcMain.once('certificate:accept', onCertificateAccept)

window.on('closed', () => {
ipcMain.off('certificate:accept', onCertificateAccept)
resolve(isAccepted)
})
})
}
Loading