diff --git a/apps/desktop/electron/fixtures/windows-system-ca.ts b/apps/desktop/electron/fixtures/windows-system-ca.ts new file mode 100644 index 0000000000..3f5d636c2f --- /dev/null +++ b/apps/desktop/electron/fixtures/windows-system-ca.ts @@ -0,0 +1,37 @@ +// Synthetic self-signed CAs; no private keys are retained. Tests freeze time +// between the expired root's 2025 expiry and the other roots' 2035 expiry. +export const bundledRoot = `-----BEGIN CERTIFICATE----- +MIIBWDCB/6ADAgECAhRU6TyqBZA4z6kjw80UZ4JJcTGTezAKBggqhkjOPQQDAjAi +MSAwHgYDVQQDDBdIZXJtZXMgdGVzdCBidW5kbGVkUm9vdDAeFw0yMDAxMDEwMDAw +MDBaFw0zNTAxMDEwMDAwMDBaMCIxIDAeBgNVBAMMF0hlcm1lcyB0ZXN0IGJ1bmRs +ZWRSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEzYsepxCA6/L5jOtyaK7c +tonILmwaEH7S3SaXZkCPMPPn7ciN08fFIQvEK2xmexibsIW/07/y+EQsCsytdXD7 +TqMTMBEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiAoOvvNIWtl +56kb5jeS67rndvpGdvRVfA8hu/d7qPUXHgIhAOVWB0FmymX7/ptbzS9os2DB7S8M +bsTP81ca6H4QAVMO +-----END CERTIFICATE----- +` + +export const privateRoot = `-----BEGIN CERTIFICATE----- +MIIBWDCB/6ADAgECAhQpNQF0JRsR2+tIXdZrGV71uQLqnjAKBggqhkjOPQQDAjAi +MSAwHgYDVQQDDBdIZXJtZXMgdGVzdCBwcml2YXRlUm9vdDAeFw0yMDAxMDEwMDAw +MDBaFw0zNTAxMDEwMDAwMDBaMCIxIDAeBgNVBAMMF0hlcm1lcyB0ZXN0IHByaXZh +dGVSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbWaJLnFzm5gFQFK4u1Sg ++ZSXFpfKOuKVLiLU9K2xfg9glPZQY+25Eh+7eMkeOWbOOgXbQE9gmQ2Lsjs+G57C +fqMTMBEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiAuMyPaHvKJ +BTNIxn3kTlG/7rQ8iSBJ/iTYSxC/7sLzcwIhAJZno/XUb+l9XQfNgADj7jHRorx7 +Hfp7kgJ01+X1LAKU +-----END CERTIFICATE----- +` + +export const expiredRoot = `-----BEGIN CERTIFICATE----- +MIIBWDCB/6ADAgECAhQpDur4nw/PAq6RuWUbTzDvuICiojAKBggqhkjOPQQDAjAi +MSAwHgYDVQQDDBdIZXJtZXMgdGVzdCBleHBpcmVkUm9vdDAeFw0yMDAxMDEwMDAw +MDBaFw0yNTAxMDEwMDAwMDBaMCIxIDAeBgNVBAMMF0hlcm1lcyB0ZXN0IGV4cGly +ZWRSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEe+50enZvxfDciM9yMPBo +BHYtfdV4eSv017W1GgC3OyDQpypA7Usio1jSy6VXGSGTKpKVPTeMhTPtQLSpbf2z +eKMTMBEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiAduJa3QWjS ++lF7cmuGdgUezYQLEIUqAPsPhJxgzGNmiwIhAKIyQ1uxv4Mzl0Mou7AiUGpqy8Xr +CKxqXEB0ODeqpB0N +-----END CERTIFICATE----- +` diff --git a/apps/desktop/electron/windows-system-ca.test.ts b/apps/desktop/electron/windows-system-ca.test.ts index d7e408bd24..164d931c8f 100644 --- a/apps/desktop/electron/windows-system-ca.test.ts +++ b/apps/desktop/electron/windows-system-ca.test.ts @@ -1,9 +1,36 @@ import assert from 'node:assert/strict' +import { X509Certificate } from 'node:crypto' -import { test } from 'vitest' +import { afterEach, test, vi } from 'vitest' +import { bundledRoot, expiredRoot, privateRoot } from './fixtures/windows-system-ca' import { installWindowsSystemCaTrust, type NodeTlsCaApi } from './windows-system-ca' +afterEach(() => vi.restoreAllMocks()) + +test('excludes expired roots and deduplicates real certificates with defaults first', () => { + vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-01-01T00:00:00Z').getTime()) + const tlsApi = fakeTlsApi( + [expiredRoot, bundledRoot, bundledRoot, 'unparseable-default'], + [expiredRoot, bundledRoot.replaceAll('\n', '\r\n'), privateRoot, privateRoot, 'unparseable-system'] + ) + + const result = installWindowsSystemCaTrust(tlsApi, 'win32') + + assert.deepEqual(tlsApi.installed, [[bundledRoot, 'unparseable-default', privateRoot, 'unparseable-system']]) + assert.deepEqual(result, { applied: true, systemCertificateCount: 2, totalCertificateCount: 4 }) +}) + +test('excludes a root at its exact expiry while retaining valid defaults', () => { + vi.spyOn(Date, 'now').mockReturnValue(new X509Certificate(expiredRoot).validToDate.getTime()) + const tlsApi = fakeTlsApi([bundledRoot], [expiredRoot]) + + const result = installWindowsSystemCaTrust(tlsApi, 'win32') + + assert.deepEqual(tlsApi.installed, [[bundledRoot]]) + assert.deepEqual(result, { applied: true, systemCertificateCount: 0, totalCertificateCount: 1 }) +}) + function fakeTlsApi( defaults: string[] = ['bundled-ca', 'extra-ca'], system: string[] = ['windows-root-ca'] @@ -34,7 +61,7 @@ test('installs Windows system CAs without dropping existing defaults', () => { }) }) -test('does not inspect or replace CAs outside Windows', () => { +test.each(['darwin', 'linux'] as const)('does not inspect or replace CAs on %s', platform => { let reads = 0 const tlsApi: NodeTlsCaApi = { @@ -48,7 +75,7 @@ test('does not inspect or replace CAs outside Windows', () => { } } - const result = installWindowsSystemCaTrust(tlsApi, 'darwin') + const result = installWindowsSystemCaTrust(tlsApi, platform) assert.equal(reads, 0) assert.deepEqual(result, { diff --git a/apps/desktop/electron/windows-system-ca.ts b/apps/desktop/electron/windows-system-ca.ts index f4406fdd42..a2cecce08b 100644 --- a/apps/desktop/electron/windows-system-ca.ts +++ b/apps/desktop/electron/windows-system-ca.ts @@ -1,3 +1,5 @@ +import { X509Certificate } from 'node:crypto' + interface NodeTlsCaApi { getCACertificates(type?: 'default' | 'system'): string[] setDefaultCACertificates(certificates: string[]): void @@ -31,12 +33,35 @@ function installWindowsSystemCaTrust(tlsApi: NodeTlsCaApi, platform = process.pl } } - const certificates = [...defaultCertificates, ...systemCertificates] + // Prefer existing defaults. Expired Windows roots can divert OpenSSL onto + // an expired chain even when a valid bundled trust path exists. + const seen = new Set() + const now = Date.now() + + const keepCertificate = (pem: string): boolean => { + try { + const certificate = new X509Certificate(pem) + + if (certificate.validToDate.getTime() <= now || seen.has(certificate.fingerprint256)) { + return false + } + seen.add(certificate.fingerprint256) + } catch { + // Leave PEM acceptability to Node if its X.509 parser cannot inspect it. + } + + return true + } + + const filteredDefaults = defaultCertificates.filter(keepCertificate) + const filteredSystem = systemCertificates.filter(keepCertificate) + const certificates = [...filteredDefaults, ...filteredSystem] + tlsApi.setDefaultCACertificates(certificates) return { applied: true, - systemCertificateCount: systemCertificates.length, + systemCertificateCount: filteredSystem.length, totalCertificateCount: certificates.length } } catch (error) { diff --git a/contributors/emails/215755014+Stoltemberg@users.noreply.github.com b/contributors/emails/215755014+Stoltemberg@users.noreply.github.com new file mode 100644 index 0000000000..38b203b47c --- /dev/null +++ b/contributors/emails/215755014+Stoltemberg@users.noreply.github.com @@ -0,0 +1 @@ +Stoltemberg diff --git a/contributors/emails/249166551+wliu-dev@users.noreply.github.com b/contributors/emails/249166551+wliu-dev@users.noreply.github.com new file mode 100644 index 0000000000..250b3c2fb7 --- /dev/null +++ b/contributors/emails/249166551+wliu-dev@users.noreply.github.com @@ -0,0 +1 @@ +wliu-dev