Linux: trust root CA for HTTPS custom domains#3268
Conversation
The previous title 'no shell redirect' was misleading — the asserted command still uses < and > /dev/null. The actual change is removing the redirect into the privileged /etc/hosts file.
…e-custom-domains-over-http
Wires up the 'Trust Certificate' button on Linux to install the Studio root CA into the system trust store via @vscode/sudo-prompt. Mirrors the existing macOS/Windows shape; system store only (per scope). - isRootCATrusted() and trustRootCA() gain a linux branch in both the CLI and renderer-side certificate-manager, delegating to a shared helper in tools/common/lib/linux-trust-store.ts - trustCertificate IPC handler routes Linux to trustRootCA() instead of falling through to openCertificateDialog() - DEB postrm cleans up /usr/local/share/ca-certificates/studio-ca.crt on purge and refreshes the system bundle - MakerDeb depends on ca-certificates
…ains-over-http' into rsm-1299-linux-trust-root-ca-for-https-custom-domains
System trust alone isn't enough on stock Ubuntu desktops because the default Chromium ships as a Snap with sandboxed NSS, and Firefox uses its own per-profile NSS. After the sudo-prompt system install succeeds, trustRootCA now best-effort imports the CA into: - ~/.pki/nssdb (apt-installed Chromium-family browsers) - ~/snap/chromium/current/.pki/nssdb (Snap-Chromium, when present) Each import runs as the user (no sudo), uses certutil, and swallows errors with a console.warn. System trust install is still authoritative - NSS imports are an additive convenience for browser UX. - Adds importCAIntoUserNssDbsLinux + getLinuxNssDbCandidates to the shared helper - Wires the call into both CLI and renderer trustRootCA Linux branches - MakerDeb depends on libnss3-tools (provides certutil) - Helper test asserts path discovery (always returns ~/.pki/nssdb; Snap-Chromium path only when ~/snap/chromium exists) - CLI test asserts NSS import runs after sudo install succeeds, and is skipped when sudo install fails Firefox per-profile NSS remains out of scope (profile discovery is messy; users can import via about:preferences).
…-root-ca-for-https-custom-domains # Conflicts: # apps/studio/forge.config.ts
📊 Performance Test ResultsComparing ce693a9 vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
ivan-ottinger
left a comment
There was a problem hiding this comment.
Great work, Rahul!
This PR took a bit longer to fully test and review. The changes look good and they work as expected. 👍🏼 On Firefox, the mentioned workaround also works.
Before workaround:
CleanShot.2026-04-29.at.12.22.56.mp4
After workaround:
CleanShot.2026-04-29.at.12.32.39.mp4
The only issue I noticed is the scenario where the user creates their site before installing a Chromium-family browser. In my case it was Snap-Chromium.
I let Claude summarize the issue in the text below - as it did much better job in explaining the details clearly.
What happens :
- User creates their first site →
ensureRootCA()triggerstrustRootCA()→ polkit prompt → system bundle gets the CA,~/.pki/nssdbgets it too. So far so good. - At this point
~/snap/chromium/doesn't exist, sogetLinuxNssDbCandidates()doesn't include the snap NSS path. The snap DB is never touched. - Later, the user installs Snap-Chromium and visits their site → Chromium shows the "your connection is not private" warning because Snap-Chromium reads from
~/snap/chromium/current/.pki/nssdb, which is empty. - The user opens Studio site settings looking for the Trust Certificate button — but it's hidden, because
isRootCATrusted()only checks the system bundle (isCATrustedOnLinux), which is still happy. There's no UI affordance to repair the gap; they'd have to know aboutcertutilto fix it manually.
Reproduction
Starting from a clean Ubuntu VM:
- Make sure Snap-Chromium is not installed yet:
sudo snap remove chromium(if present). - Create a site in Studio with a custom domain + HTTPS. Approve the polkit prompt.
- Verify the system bundle has the cert:
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt ~/.studio/certificates/studio-ca.crt→OK. - Verify only the standard NSS DB has the cert:
certutil -dsql:$HOME/.pki/nssdb -L | grep StudioshowsWordPress Studio CA. - Now install Snap-Chromium:
sudo snap install chromium. - Open the site in Chromium → privacy warning.
- Open Studio site settings → Trust Certificate button is
missing.
Here's the diff with proposed fix:
diff --git a/apps/cli/lib/certificate-manager.ts b/apps/cli/lib/certificate-manager.ts
index d87b0978..7653e243 100644
--- a/apps/cli/lib/certificate-manager.ts
+++ b/apps/cli/lib/certificate-manager.ts
@@ -8,6 +8,7 @@ import { CERT_UNTRUSTED_ROOT, SERVER_AUTH_OID } from '@studio/common/constants';
import {
buildLinuxTrustInstallCommand,
importCAIntoUserNssDbsLinux,
+ isCAImportedInUserNssDbsLinux,
isCATrustedOnLinux,
} from '@studio/common/lib/linux-trust-store';
import { getCertificatesPath } from '@studio/common/lib/well-known-paths';
@@ -183,7 +184,12 @@ export async function isRootCATrusted(): Promise< boolean > {
return false;
}
} else if ( process.platform === 'linux' ) {
- return isCATrustedOnLinux( CA_CERT_PATH );
+ // 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).
+ return (
+ ( await isCATrustedOnLinux( CA_CERT_PATH ) ) && ( await isCAImportedInUserNssDbsLinux() )
+ );
}
return false;
@@ -220,22 +226,29 @@ export async function trustRootCA(): Promise< void > {
);
} );
} else if ( platform === 'linux' ) {
- await new Promise< void >( ( resolve, reject ) => {
- sudo.exec(
- buildLinuxTrustInstallCommand( CA_CERT_PATH ),
- { name: 'WordPress Studio' },
- ( error ) => {
- if ( error ) {
- console.error( 'Error adding certificate to system trust store:', error );
- reject( error );
- } else {
- console.log( 'Root CA trusted in Linux system trust store' );
- resolve();
+ // Skip the sudo install when the system bundle is already trusted —
+ // otherwise we'd reprompt for the polkit password just to re-sync NSS
+ // (the common case when a Chromium-family browser is installed after
+ // the initial trust flow).
+ if ( ! ( await isCATrustedOnLinux( CA_CERT_PATH ) ) ) {
+ await new Promise< void >( ( resolve, reject ) => {
+ sudo.exec(
+ buildLinuxTrustInstallCommand( CA_CERT_PATH ),
+ { name: 'WordPress Studio' },
+ ( error ) => {
+ if ( error ) {
+ console.error( 'Error adding certificate to system trust store:', error );
+ reject( error );
+ } else {
+ console.log( 'Root CA trusted in Linux system trust store' );
+ resolve();
+ }
}
- }
- );
- } );
- // Chromium-family browsers don't consult the system bundle on Linux.
+ );
+ } );
+ }
+ // 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 );
} else {
console.error( 'Unsupported platform for automatic certificate trust:', platform );
diff --git a/apps/cli/lib/tests/certificate-manager.test.ts b/apps/cli/lib/tests/certificate-manager.test.ts
index 7aed2bda..e527151e 100644
--- a/apps/cli/lib/tests/certificate-manager.test.ts
+++ b/apps/cli/lib/tests/certificate-manager.test.ts
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import {
buildLinuxTrustInstallCommand,
importCAIntoUserNssDbsLinux,
+ isCAImportedInUserNssDbsLinux,
isCATrustedOnLinux,
} from '@studio/common/lib/linux-trust-store';
import sudo from '@vscode/sudo-prompt';
@@ -12,6 +13,7 @@ vi.mock( '@studio/common/lib/linux-trust-store', () => ( {
LINUX_TRUST_STORE_PATH: '/usr/local/share/ca-certificates/studio-ca.crt',
LINUX_NSS_NICKNAME: 'WordPress Studio CA',
isCATrustedOnLinux: vi.fn(),
+ isCAImportedInUserNssDbsLinux: vi.fn(),
buildLinuxTrustInstallCommand: vi.fn(),
importCAIntoUserNssDbsLinux: vi.fn(),
} ) );
@@ -23,6 +25,7 @@ vi.mock( '@vscode/sudo-prompt', () => ( {
} ) );
const mockedIsCATrustedOnLinux = vi.mocked( isCATrustedOnLinux );
+const mockedIsCAImportedInUserNssDbsLinux = vi.mocked( isCAImportedInUserNssDbsLinux );
const mockedBuildLinuxTrustInstallCommand = vi.mocked( buildLinuxTrustInstallCommand );
const mockedImportCAIntoUserNssDbsLinux = vi.mocked( importCAIntoUserNssDbsLinux );
const mockedSudoExec = vi.mocked( sudo.exec );
@@ -46,6 +49,7 @@ describe( 'certificate-manager (Linux)', () => {
beforeEach( () => {
mockedIsCATrustedOnLinux.mockReset();
+ mockedIsCAImportedInUserNssDbsLinux.mockReset();
mockedBuildLinuxTrustInstallCommand.mockReset();
mockedImportCAIntoUserNssDbsLinux.mockReset();
mockedSudoExec.mockReset();
@@ -53,6 +57,9 @@ describe( 'certificate-manager (Linux)', () => {
'install -m 0644 "/home/user/.studio/certificates/studio-ca.crt" "/usr/local/share/ca-certificates/studio-ca.crt" && update-ca-certificates'
);
mockedImportCAIntoUserNssDbsLinux.mockResolvedValue( undefined );
+ // Default: NSS DBs are populated. Override per-test for the
+ // install-browser-after-trust scenario.
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( true );
existsSpy = vi.spyOn( fs, 'existsSync' ).mockReturnValue( true );
} );
@@ -62,21 +69,33 @@ describe( 'certificate-manager (Linux)', () => {
} );
describe( 'isRootCATrusted', () => {
- it( 'delegates to isCATrustedOnLinux on Linux and returns its result', async () => {
+ it( 'returns true on Linux only when both system bundle AND NSS DBs are populated', async () => {
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( true );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( true );
- const result = await isRootCATrusted();
- expect( result ).toBe( true );
+ await expect( isRootCATrusted() ).resolves.toBe( true );
expect( mockedIsCATrustedOnLinux ).toHaveBeenCalledWith(
expect.stringContaining( 'studio-ca.crt' )
);
+ expect( mockedIsCAImportedInUserNssDbsLinux ).toHaveBeenCalled();
} );
- it( 'returns false on Linux when isCATrustedOnLinux returns false', async () => {
+ it( 'returns false on Linux when the system bundle is missing the CA', async () => {
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( false );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( true );
+
+ await expect( isRootCATrusted() ).resolves.toBe( false );
+ } );
+
+ it( 'returns false on Linux when an NSS DB is missing the CA (install-browser-after-trust)', async () => {
+ // Repro: Snap-Chromium installed *after* the initial trust — system
+ // bundle is happy, but the new browser's NSS DB is empty.
+ setPlatform( 'linux' );
+ mockedIsCATrustedOnLinux.mockResolvedValue( true );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( false );
await expect( isRootCATrusted() ).resolves.toBe( false );
} );
@@ -87,6 +106,7 @@ describe( 'certificate-manager (Linux)', () => {
await expect( isRootCATrusted() ).resolves.toBe( false );
expect( mockedIsCATrustedOnLinux ).not.toHaveBeenCalled();
+ expect( mockedIsCAImportedInUserNssDbsLinux ).not.toHaveBeenCalled();
} );
} );
@@ -94,6 +114,7 @@ describe( 'certificate-manager (Linux)', () => {
it( 'invokes sudo.exec with the install command on Linux when not yet trusted', async () => {
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( false );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( false );
stubSudoExec();
await expect( trustRootCA() ).resolves.toBeUndefined();
@@ -111,6 +132,7 @@ describe( 'certificate-manager (Linux)', () => {
it( 'imports the CA into per-user NSS DBs after the system install succeeds', async () => {
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( false );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( false );
stubSudoExec();
await trustRootCA();
@@ -127,6 +149,7 @@ describe( 'certificate-manager (Linux)', () => {
it( 'does not import into NSS DBs when the system install fails', async () => {
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( false );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( false );
stubSudoExec( new Error( 'pkexec dismissed' ) );
await expect( trustRootCA() ).rejects.toThrow();
@@ -137,17 +160,37 @@ describe( 'certificate-manager (Linux)', () => {
it( 'rejects when sudo.exec reports an error', async () => {
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( false );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( false );
stubSudoExec( new Error( 'user dismissed pkexec prompt' ) );
await expect( trustRootCA() ).rejects.toThrow( 'user dismissed pkexec prompt' );
} );
- it( 'short-circuits when the CA is already trusted', async () => {
+ it( 'short-circuits when the CA is fully trusted (system bundle + every NSS DB)', async () => {
+ setPlatform( 'linux' );
+ mockedIsCATrustedOnLinux.mockResolvedValue( true );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( true );
+
+ await expect( trustRootCA() ).resolves.toBeUndefined();
+ expect( mockedSudoExec ).not.toHaveBeenCalled();
+ expect( mockedImportCAIntoUserNssDbsLinux ).not.toHaveBeenCalled();
+ } );
+
+ it( 'skips sudo but still imports into NSS when system bundle is already trusted (install-browser-after-trust)', async () => {
+ // Repro: user clicks **Trust Certificate** after installing
+ // Snap-Chromium. System bundle is already populated, so polkit
+ // shouldn't reprompt — but NSS DBs need to be re-synced because the
+ // new browser's sandboxed DB started empty.
setPlatform( 'linux' );
mockedIsCATrustedOnLinux.mockResolvedValue( true );
+ mockedIsCAImportedInUserNssDbsLinux.mockResolvedValue( false );
await expect( trustRootCA() ).resolves.toBeUndefined();
+
expect( mockedSudoExec ).not.toHaveBeenCalled();
+ expect( mockedImportCAIntoUserNssDbsLinux ).toHaveBeenCalledWith(
+ expect.stringContaining( 'studio-ca.crt' )
+ );
} );
} );
} );
diff --git a/apps/studio/src/lib/certificate-manager.ts b/apps/studio/src/lib/certificate-manager.ts
index 60e21256..c86288b3 100644
--- a/apps/studio/src/lib/certificate-manager.ts
+++ b/apps/studio/src/lib/certificate-manager.ts
@@ -8,6 +8,7 @@ import { CERT_UNTRUSTED_ROOT, SERVER_AUTH_OID } from '@studio/common/constants';
import {
buildLinuxTrustInstallCommand,
importCAIntoUserNssDbsLinux,
+ isCAImportedInUserNssDbsLinux,
isCATrustedOnLinux,
} from '@studio/common/lib/linux-trust-store';
import { getCertificatesPath } from '@studio/common/lib/well-known-paths';
@@ -51,7 +52,12 @@ export async function isRootCATrusted(): Promise< boolean > {
return false;
}
} else if ( process.platform === 'linux' ) {
- return isCATrustedOnLinux( CA_CERT_PATH );
+ // 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).
+ return (
+ ( await isCATrustedOnLinux( CA_CERT_PATH ) ) && ( await isCAImportedInUserNssDbsLinux() )
+ );
}
return false;
@@ -88,22 +94,29 @@ export async function trustRootCA(): Promise< void > {
);
} );
} else if ( platform === 'linux' ) {
- await new Promise< void >( ( resolve, reject ) => {
- sudo.exec(
- buildLinuxTrustInstallCommand( CA_CERT_PATH ),
- { name: 'WordPress Studio' },
- ( error ) => {
- if ( error ) {
- console.error( 'Error adding certificate to system trust store:', error );
- reject( error );
- } else {
- console.log( 'Root CA trusted in Linux system trust store' );
- resolve();
+ // Skip the sudo install when the system bundle is already trusted —
+ // otherwise we'd reprompt for the polkit password just to re-sync NSS
+ // (the common case when a Chromium-family browser is installed after
+ // the initial trust flow).
+ if ( ! ( await isCATrustedOnLinux( CA_CERT_PATH ) ) ) {
+ await new Promise< void >( ( resolve, reject ) => {
+ sudo.exec(
+ buildLinuxTrustInstallCommand( CA_CERT_PATH ),
+ { name: 'WordPress Studio' },
+ ( error ) => {
+ if ( error ) {
+ console.error( 'Error adding certificate to system trust store:', error );
+ reject( error );
+ } else {
+ console.log( 'Root CA trusted in Linux system trust store' );
+ resolve();
+ }
}
- }
- );
- } );
- // Chromium-family browsers don't consult the system bundle on Linux.
+ );
+ } );
+ }
+ // 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 );
} 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 d138732c..24954f16 100644
--- a/tools/common/lib/linux-trust-store.ts
+++ b/tools/common/lib/linux-trust-store.ts
@@ -19,6 +19,24 @@ export async function isCATrustedOnLinux( caPath: string ): Promise< boolean > {
}
}
+// Returns true only when every expected NSS DB contains the Studio CA. Used by
+// isRootCATrusted() so the **Trust Certificate** button reappears if a
+// Chromium-family browser (notably Snap-Chromium) is installed *after* the
+// initial system-bundle trust — that browser's sandboxed NSS DB starts empty
+// and only this check surfaces the gap.
+export async function isCAImportedInUserNssDbsLinux(
+ homeDir: string = os.homedir()
+): Promise< boolean > {
+ for ( const db of getLinuxNssDbCandidates( homeDir ) ) {
+ try {
+ await execFilePromise( 'certutil', [ '-d', `sql:${ db }`, '-L', '-n', LINUX_NSS_NICKNAME ] );
+ } catch {
+ return false;
+ }
+ }
+ return true;
+}
+
export function buildLinuxTrustInstallCommand( caPath: string ): string {
return `install -m 0644 "${ caPath }" "${ LINUX_TRUST_STORE_PATH }" && update-ca-certificates`;
}| @@ -0,0 +1,18 @@ | |||
| #!/bin/sh | |||
There was a problem hiding this comment.
Great we are cleaning up the certificate on Studio app removal.
Related issues
How AI was used in this PR
The plan was generated by Claude. We started with minimal scope, then expanded after manual testing surfaced gaps. All code reviewed and manually tested by the author before pushing.
Proposed Changes
Wires up Trust Certificate on Linux. Clicking the button now installs the Studio root CA into the system trust store via
@vscode/sudo-prompt(single polkit prompt), then best-effort imports it into per-user NSS DBs so Chromium-family browsers (incl. Snap-Chromium) trust it too.tools/common/lib/linux-trust-store.ts:isCATrustedOnLinux()—openssl verifyagainst/etc/ssl/certs/ca-certificates.crt.buildLinuxTrustInstallCommand()—install -m 0644 … && update-ca-certificates.getLinuxNssDbCandidates()/importCAIntoUserNssDbsLinux()—certutilimport into~/.pki/nssdb(always) and~/snap/chromium/current/.pki/nssdb(when Snap-Chromium is present). Best-effort, no sudo, swallows errors.certificate-manager.tsfiles (CLI + renderer) gain a Linux branch inisRootCATrusted()andtrustRootCA().ipc-handlers.tstrustCertificateroutes Linux totrustRootCA()instead of opening the file manager.postrm.shcleans the system CA onpurge;MakerDebdependsaddsca-certificatesandlibnss3-tools.Out of scope
~/.studio/certificates/studio-ca.crt→ trust for websites. Tracked in RSM-1615 for docs coverage.developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/— RSM-1615.Testing Instructions
Fresh Ubuntu 22.04/24.04 VM. PR 1 (STU-1647) must be merged first so the proxy can bind 443.
npm run make→sudo apt install -y ./out/make/deb/*/studio_*_amd64.deb.mysite.wp.local.https://mysite.wp.localin Chromium → confirm cert error (the bug).openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt ~/.studio/certificates/studio-ca.crt→ok.certutil -d sql:$HOME/.pki/nssdb -L | grep Studio→ present.certutil -d sql:$HOME/snap/chromium/current/.pki/nssdb -L | grep Studio→ present (when Snap-Chromium is installed).https://mysite.wp.localin Chromium → green padlock.curl -v https://mysite.wp.local→ handshakes without--insecure.sudo apt purge studio→/usr/local/share/ca-certificates/studio-ca.crtremoved;openssl verifyagainst the bundle now fails. (NSS entries persist by design — user-owned data.)CleanShot.2026-04-28.at.22.22.00.mp4
Pre-merge Checklist