From 1c4a6912f543b4bf9bcd96ab904829bbf5c1153e Mon Sep 17 00:00:00 2001 From: Jasper Date: Sun, 23 Aug 2026 23:08:45 +0000 Subject: [PATCH] fix(desktop): pin renderer ACP certificates (#11485) --- .../src/backendCertificateVerifier.test.ts | 117 ++++++++++++++++++ ui/desktop/src/backendCertificateVerifier.ts | 25 ++++ ui/desktop/src/main.ts | 17 ++- 3 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 ui/desktop/src/backendCertificateVerifier.test.ts create mode 100644 ui/desktop/src/backendCertificateVerifier.ts diff --git a/ui/desktop/src/backendCertificateVerifier.test.ts b/ui/desktop/src/backendCertificateVerifier.test.ts new file mode 100644 index 000000000..14fabe8c4 --- /dev/null +++ b/ui/desktop/src/backendCertificateVerifier.test.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Session } from 'electron'; +import { describe, expect, it, vi } from 'vitest'; +import { + installBackendCertificateVerifiers, + type BackendCertificateTrustVerifier, +} from './backendCertificateVerifier'; + +type CertificateVerifyProc = Exclude[0], null>; + +function createMockSession() { + let verifier: CertificateVerifyProc | null = null; + const setCertificateVerifyProc = vi.fn((nextVerifier: CertificateVerifyProc | null) => { + verifier = nextVerifier; + }); + + return { + session: { setCertificateVerifyProc } as Pick, + setCertificateVerifyProc, + verify(hostname: string, fingerprint: string): number { + if (!verifier) { + throw new Error('Certificate verifier was not installed'); + } + + let result: number | undefined; + verifier( + { + hostname, + certificate: { fingerprint }, + } as Parameters[0], + (verificationResult) => { + result = verificationResult; + } + ); + + if (result === undefined) { + throw new Error('Certificate verifier did not return a result'); + } + return result; + }, + }; +} + +function createTrustVerifier(initialPins: Record) { + const pins = new Map(Object.entries(initialPins)); + const verify = vi.fn((hostname: string, fingerprint: string) => { + const pin = pins.get(hostname); + if (pin === undefined) { + return false; + } + if (pin === null) { + pins.set(hostname, fingerprint); + return true; + } + return pin === fingerprint; + }); + const trustVerifier: BackendCertificateTrustVerifier = { + has: (hostname) => pins.has(hostname), + verify, + }; + + return { trustVerifier, verify }; +} + +function createFixture(initialPins: Record) { + const defaultSession = createMockSession(); + const rendererSession = createMockSession(); + const trust = createTrustVerifier(initialPins); + installBackendCertificateVerifiers( + [defaultSession.session, rendererSession.session], + trust.trustVerifier + ); + + return { defaultSession, rendererSession, trust }; +} + +describe('backend certificate verifier wiring', () => { + it('covers the renderer session that opens ACP WebSockets', () => { + const source = fs.readFileSync(path.resolve('src/main.ts'), 'utf8'); + + expect(source).toMatch( + /installBackendCertificateVerifiers\([\s\S]{0,200}session\.defaultSession[\s\S]{0,200}session\.fromPartition\('persist:goose'\)/ + ); + }); + + it.each(['defaultSession', 'rendererSession'] as const)( + 'enforces explicit pin matches and mismatches on %s', + (sessionName) => { + const fixture = createFixture({ 'backend.example': 'EXPLICIT-PIN' }); + const targetSession = fixture[sessionName]; + + expect(targetSession.verify('backend.example', 'EXPLICIT-PIN')).toBe(0); + expect(targetSession.verify('backend.example', 'OTHER-PIN')).toBe(-2); + } + ); + + it.each(['defaultSession', 'rendererSession'] as const)( + 'enforces learned TOFU pin matches and mismatches on %s', + (sessionName) => { + const fixture = createFixture({ 'backend.example': null }); + const targetSession = fixture[sessionName]; + + expect(targetSession.verify('backend.example', 'LEARNED-PIN')).toBe(0); + expect(targetSession.verify('backend.example', 'LEARNED-PIN')).toBe(0); + expect(targetSession.verify('backend.example', 'OTHER-PIN')).toBe(-2); + } + ); + + it('delegates unrelated hosts to Chromium on both sessions', () => { + const fixture = createFixture({ 'backend.example': 'EXPLICIT-PIN' }); + + expect(fixture.defaultSession.verify('unrelated.example', 'ANY-PIN')).toBe(-3); + expect(fixture.rendererSession.verify('unrelated.example', 'ANY-PIN')).toBe(-3); + expect(fixture.trust.verify).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/desktop/src/backendCertificateVerifier.ts b/ui/desktop/src/backendCertificateVerifier.ts new file mode 100644 index 000000000..010f44d1b --- /dev/null +++ b/ui/desktop/src/backendCertificateVerifier.ts @@ -0,0 +1,25 @@ +import type { Session } from 'electron'; + +export interface BackendCertificateTrustVerifier { + has(hostname: string): boolean; + verify(hostname: string, fingerprint: string): boolean; +} + +type CertificateVerifierSession = Pick; + +export function installBackendCertificateVerifiers( + targetSessions: CertificateVerifierSession[], + trustVerifier: BackendCertificateTrustVerifier +): void { + for (const targetSession of targetSessions) { + targetSession.setCertificateVerifyProc((request, callback) => { + if (!trustVerifier.has(request.hostname)) { + callback(-3); + return; + } + + const match = trustVerifier.verify(request.hostname, request.certificate.fingerprint); + callback(match ? 0 : -2); + }); + } +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 93843d2b4..5835c8e34 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -27,6 +27,7 @@ import os from 'node:os'; import { execFileSync, spawn, execFile } from 'child_process'; import 'dotenv/config'; import { checkBackendStatus } from './backendStatus'; +import { installBackendCertificateVerifiers } from './backendCertificateVerifier'; import { startGooseServe } from './gooseServe'; import { getLoginShellPath } from './loginShellPath'; import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry'; @@ -405,17 +406,15 @@ app.whenReady().then(() => { appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); }); -// Main-process net.fetch: pin to the exact cert once known. +// Main-process net.fetch and renderer WebSockets: pin to the exact cert once known. app.whenReady().then(() => { - session.defaultSession.setCertificateVerifyProc((request, callback) => { - if (!isTrustedHost(request.hostname)) { - callback(-3); - return; + installBackendCertificateVerifiers( + [session.defaultSession, session.fromPartition('persist:goose')], + { + has: isTrustedHost, + verify: verifyBackendCertificate, } - - const match = verifyBackendCertificate(request.hostname, request.certificate.fingerprint); - callback(match ? 0 : -2); - }); + ); }); if (process.env.ENABLE_PLAYWRIGHT) {