diff --git a/apps/studio/src/lib/certificate-manager.ts b/apps/studio/src/lib/certificate-manager.ts index c86288b3b2..50b82c2b40 100644 --- a/apps/studio/src/lib/certificate-manager.ts +++ b/apps/studio/src/lib/certificate-manager.ts @@ -6,7 +6,9 @@ import { promisify } from 'node:util'; import * as Sentry from '@sentry/electron/main'; import { CERT_UNTRUSTED_ROOT, SERVER_AUTH_OID } from '@studio/common/constants'; import { + areAllFirefoxProfilesTrustedLinux, buildLinuxTrustInstallCommand, + importCAIntoFirefoxProfilesLinux, importCAIntoUserNssDbsLinux, isCAImportedInUserNssDbsLinux, isCATrustedOnLinux, @@ -52,11 +54,14 @@ export async function isRootCATrusted(): Promise< boolean > { return false; } } else if ( process.platform === 'linux' ) { - // The CA is fully trusted on Linux only when it lives in both the system - // bundle (covers curl/openssl/Node) AND every NSS DB candidate (covers - // Chromium-family browsers, including Snap-Chromium's sandboxed DB). + // The CA is fully trusted on Linux only when it lives in the system + // bundle (curl/openssl/Node), every Chromium-family NSS DB (including + // Snap-Chromium's sandboxed DB), and every existing Firefox profile + // NSS DB. Firefox profiles that don't exist yet are vacuously trusted. return ( - ( await isCATrustedOnLinux( CA_CERT_PATH ) ) && ( await isCAImportedInUserNssDbsLinux() ) + ( await isCATrustedOnLinux( CA_CERT_PATH ) ) && + ( await isCAImportedInUserNssDbsLinux() ) && + ( await areAllFirefoxProfilesTrustedLinux() ) ); } @@ -118,6 +123,12 @@ export async function trustRootCA(): Promise< void > { // Always run NSS imports — they're idempotent (-D before -A) and don't // need sudo, so re-running covers the install-browser-after-trust case. await importCAIntoUserNssDbsLinux( CA_CERT_PATH ); + // Firefox keeps a per-profile NSS DB; the system + Chromium imports + // above don't reach it. Profiles that don't exist yet are skipped — + // when the user later opens Firefox for the first time, the trust + // check flips back to untrusted on refetch and the user clicks Trust + // again to import into the newly created profile. + await importCAIntoFirefoxProfilesLinux( CA_CERT_PATH ); } else { console.error( 'Unsupported platform for automatic certificate trust:', platform ); } diff --git a/tools/common/lib/linux-trust-store.ts b/tools/common/lib/linux-trust-store.ts index 24954f16e5..485387ab8f 100644 --- a/tools/common/lib/linux-trust-store.ts +++ b/tools/common/lib/linux-trust-store.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -83,3 +83,89 @@ export async function importCAIntoUserNssDbsLinux( caPath: string ): Promise< vo } } } + +// Firefox keeps a private NSS DB per profile (cert9.db). The auto-trust +// flow has to walk every install root — apt installs use ~/.mozilla, +// Snap and Flatpak each have their own profile root. +function getFirefoxProfileRootsLinux( homeDir: string ): string[] { + return [ + path.join( homeDir, '.mozilla', 'firefox' ), + path.join( homeDir, 'snap', 'firefox', 'common', '.mozilla', 'firefox' ), + path.join( homeDir, '.var', 'app', 'org.mozilla.firefox', '.mozilla', 'firefox' ), + ]; +} + +// Returns profile dirs that already have a Mozilla NSS DB (cert9.db). +// Profiles without cert9.db are skipped: Firefox creates the DB on first +// launch, so there's nothing to import into until the user has opened the +// browser at least once. +export function getLinuxFirefoxProfileDbDirs( homeDir: string = os.homedir() ): string[] { + const dirs: string[] = []; + for ( const root of getFirefoxProfileRootsLinux( homeDir ) ) { + if ( ! existsSync( root ) ) continue; + let entries: string[]; + try { + entries = readdirSync( root ); + } catch { + continue; + } + for ( const entry of entries ) { + // Firefox profile dirs end with `.default`, `.default-release`, + // `.default-esr`, etc. (per profiles.ini conventions). + if ( ! /\.default(?:-[^/]+)?$/.test( entry ) ) continue; + const profileDir = path.join( root, entry ); + if ( existsSync( path.join( profileDir, 'cert9.db' ) ) ) { + dirs.push( profileDir ); + } + } + } + return dirs; +} + +// Vacuously true when no Firefox profile exists yet (browser installed but +// never opened): there is nothing to import into, so the system+Chromium +// trust state alone is enough to consider the trust flow complete. +export async function areAllFirefoxProfilesTrustedLinux( + homeDir: string = os.homedir() +): Promise< boolean > { + for ( const db of getLinuxFirefoxProfileDbDirs( homeDir ) ) { + try { + await execFilePromise( 'certutil', [ '-d', `sql:${ db }`, '-L', '-n', LINUX_NSS_NICKNAME ] ); + } catch { + return false; + } + } + return true; +} + +// Best-effort: same contract as importCAIntoUserNssDbsLinux. Profiles with +// a locked DB (Firefox is running) or missing certutil log a warning and +// the user falls back to the in-app notice + docs. +export async function importCAIntoFirefoxProfilesLinux( caPath: string ): Promise< void > { + for ( const db of getLinuxFirefoxProfileDbDirs() ) { + try { + await execFilePromise( 'certutil', [ + '-d', + `sql:${ db }`, + '-D', + '-n', + LINUX_NSS_NICKNAME, + ] ).catch( () => undefined ); + await execFilePromise( 'certutil', [ + '-d', + `sql:${ db }`, + '-A', + '-t', + 'C,,', + '-n', + LINUX_NSS_NICKNAME, + '-i', + caPath, + ] ); + console.log( `Imported Studio CA into Firefox profile: ${ db }` ); + } catch ( error ) { + const message = error instanceof Error ? error.message : String( error ); + console.warn( `Could not import Studio CA into Firefox profile ${ db }: ${ message }` ); + } + } +} diff --git a/tools/common/lib/tests/linux-trust-store.test.ts b/tools/common/lib/tests/linux-trust-store.test.ts index d41868501f..c69b9ebd24 100644 --- a/tools/common/lib/tests/linux-trust-store.test.ts +++ b/tools/common/lib/tests/linux-trust-store.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildLinuxTrustInstallCommand, + getLinuxFirefoxProfileDbDirs, getLinuxNssDbCandidates, LINUX_NSS_NICKNAME, LINUX_SYSTEM_CA_BUNDLE, @@ -79,4 +80,65 @@ describe( 'linux-trust-store', () => { ); } ); } ); + + describe( 'getLinuxFirefoxProfileDbDirs', () => { + let tmpHome: string; + + beforeEach( () => { + tmpHome = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-firefox-test-' ) ); + } ); + + afterEach( () => { + fs.rmSync( tmpHome, { recursive: true, force: true } ); + } ); + + it( 'returns an empty list when no Firefox profile root exists', () => { + expect( getLinuxFirefoxProfileDbDirs( tmpHome ) ).toEqual( [] ); + } ); + + it( 'lists profile dirs that already have cert9.db across apt, snap, and flatpak roots', () => { + const apt = path.join( tmpHome, '.mozilla', 'firefox', 'abc.default-release' ); + const snap = path.join( + tmpHome, + 'snap', + 'firefox', + 'common', + '.mozilla', + 'firefox', + 'xyz.default' + ); + const flatpak = path.join( + tmpHome, + '.var', + 'app', + 'org.mozilla.firefox', + '.mozilla', + 'firefox', + 'qqq.default-esr' + ); + for ( const dir of [ apt, snap, flatpak ] ) { + fs.mkdirSync( dir, { recursive: true } ); + fs.writeFileSync( path.join( dir, 'cert9.db' ), '' ); + } + + expect( getLinuxFirefoxProfileDbDirs( tmpHome ).sort() ).toEqual( + [ apt, snap, flatpak ].sort() + ); + } ); + + it( 'skips profile dirs that lack cert9.db (Firefox installed but never opened)', () => { + const fresh = path.join( tmpHome, '.mozilla', 'firefox', 'abc.default-release' ); + fs.mkdirSync( fresh, { recursive: true } ); + + expect( getLinuxFirefoxProfileDbDirs( tmpHome ) ).toEqual( [] ); + } ); + + it( 'skips non-default profile dirs', () => { + const custom = path.join( tmpHome, '.mozilla', 'firefox', 'abc.custom-profile' ); + fs.mkdirSync( custom, { recursive: true } ); + fs.writeFileSync( path.join( custom, 'cert9.db' ), '' ); + + expect( getLinuxFirefoxProfileDbDirs( tmpHome ) ).toEqual( [] ); + } ); + } ); } );