Skip to content
19 changes: 15 additions & 4 deletions apps/studio/src/lib/certificate-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() )
);
}

Expand Down Expand Up @@ -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 );
}
Expand Down
88 changes: 87 additions & 1 deletion tools/common/lib/linux-trust-store.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 }` );
}
}
}
62 changes: 62 additions & 0 deletions tools/common/lib/tests/linux-trust-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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( [] );
} );
} );
} );