fix(desktop): pin renderer ACP certificates (#11485)
This commit is contained in:
@@ -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<Parameters<Session['setCertificateVerifyProc']>[0], null>;
|
||||
|
||||
function createMockSession() {
|
||||
let verifier: CertificateVerifyProc | null = null;
|
||||
const setCertificateVerifyProc = vi.fn((nextVerifier: CertificateVerifyProc | null) => {
|
||||
verifier = nextVerifier;
|
||||
});
|
||||
|
||||
return {
|
||||
session: { setCertificateVerifyProc } as Pick<Session, 'setCertificateVerifyProc'>,
|
||||
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<CertificateVerifyProc>[0],
|
||||
(verificationResult) => {
|
||||
result = verificationResult;
|
||||
}
|
||||
);
|
||||
|
||||
if (result === undefined) {
|
||||
throw new Error('Certificate verifier did not return a result');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTrustVerifier(initialPins: Record<string, string | null>) {
|
||||
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<string, string | null>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Session, 'setCertificateVerifyProc'>;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user