Skip to content

Commit b3e0de5

Browse files
committed
add Firefox NSS cert install (best effort)
After the OS trust store install, also try to add the MITM CA to all discovered Firefox profiles via NSS certutil. Silently no-ops if: - NSS certutil is not installed (macOS ships a different certutil; linux needs libnss3-tools; Windows needs NSS binaries) - No Firefox profiles exist - Firefox is currently running (lock on cert.db) Scans profiles in: - macOS: ~/Library/Application Support/Firefox/Profiles - Linux: ~/.mozilla/firefox and ~/snap/firefox/common/.mozilla/firefox - Windows: %APPDATA%\Mozilla\Firefox\Profiles Existing CA-install error path is unchanged; this is purely additive.
1 parent f3e0d92 commit b3e0de5

1 file changed

Lines changed: 145 additions & 0 deletions

File tree

src/cert_installer.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ pub fn install_ca(path: &Path) -> Result<(), InstallError> {
3232
other => return Err(InstallError::Unsupported(other.to_string())),
3333
};
3434

35+
// Best-effort: also try to install into Firefox NSS stores if certutil
36+
// is available. Firefox maintains its own trust store separate from the OS.
37+
install_firefox_nss(&path_s);
38+
3539
if ok {
3640
Ok(())
3741
} else {
@@ -264,3 +268,144 @@ fn install_windows(cert_path: &str) -> bool {
264268
tracing::error!("Windows install failed — run as administrator or install manually.");
265269
false
266270
}
271+
272+
// ---------- Firefox (NSS) ----------
273+
274+
/// Best-effort install of the CA into all discovered Firefox profiles.
275+
/// Silently no-ops if `certutil` (from libnss3-tools) is not available.
276+
/// Firefox must be closed during install for changes to take effect.
277+
fn install_firefox_nss(cert_path: &str) {
278+
// Check if certutil exists at all.
279+
if Command::new("certutil")
280+
.arg("--help")
281+
.output()
282+
.ok()
283+
.map(|o| {
284+
// macOS has a different certutil (built-in) that doesn't support -d.
285+
// Look for NSS-specific flags in the help output.
286+
String::from_utf8_lossy(&o.stderr).contains("-d")
287+
|| String::from_utf8_lossy(&o.stdout).contains("-d")
288+
})
289+
.unwrap_or(false)
290+
== false
291+
{
292+
tracing::debug!(
293+
"NSS certutil not found — Firefox users must import ca.crt manually \
294+
via Settings -> Privacy & Security -> Certificates."
295+
);
296+
return;
297+
}
298+
299+
let profiles = firefox_profile_dirs();
300+
if profiles.is_empty() {
301+
tracing::debug!("no Firefox profiles found");
302+
return;
303+
}
304+
305+
let mut ok = 0;
306+
for p in &profiles {
307+
if install_nss_in_profile(p, cert_path) {
308+
ok += 1;
309+
}
310+
}
311+
if ok > 0 {
312+
tracing::info!("CA installed in {} Firefox profile(s).", ok);
313+
} else {
314+
tracing::debug!(
315+
"No Firefox profiles updated. If Firefox wasn't running, try installing manually."
316+
);
317+
}
318+
}
319+
320+
fn install_nss_in_profile(profile: &Path, cert_path: &str) -> bool {
321+
let prefix = if profile.join("cert9.db").exists() {
322+
"sql:"
323+
} else if profile.join("cert8.db").exists() {
324+
""
325+
} else {
326+
return false;
327+
};
328+
let dir_arg = format!("{}{}", prefix, profile.display());
329+
330+
// Delete any stale entry first (ignore errors).
331+
let _ = Command::new("certutil")
332+
.args(["-D", "-n", CERT_NAME, "-d", &dir_arg])
333+
.output();
334+
335+
let res = Command::new("certutil")
336+
.args([
337+
"-A",
338+
"-n",
339+
CERT_NAME,
340+
"-t",
341+
"C,,",
342+
"-d",
343+
&dir_arg,
344+
"-i",
345+
cert_path,
346+
])
347+
.output();
348+
match res {
349+
Ok(o) if o.status.success() => {
350+
tracing::debug!("NSS install ok: {}", profile.display());
351+
true
352+
}
353+
Ok(o) => {
354+
tracing::debug!(
355+
"NSS install failed for {}: {}",
356+
profile.display(),
357+
String::from_utf8_lossy(&o.stderr).trim()
358+
);
359+
false
360+
}
361+
Err(e) => {
362+
tracing::debug!("NSS certutil exec failed for {}: {}", profile.display(), e);
363+
false
364+
}
365+
}
366+
}
367+
368+
fn firefox_profile_dirs() -> Vec<std::path::PathBuf> {
369+
use std::path::PathBuf;
370+
let mut roots: Vec<PathBuf> = Vec::new();
371+
let home = std::env::var("HOME").unwrap_or_default();
372+
match std::env::consts::OS {
373+
"macos" => {
374+
roots.push(PathBuf::from(format!(
375+
"{}/Library/Application Support/Firefox/Profiles",
376+
home
377+
)));
378+
}
379+
"linux" => {
380+
roots.push(PathBuf::from(format!("{}/.mozilla/firefox", home)));
381+
roots.push(PathBuf::from(format!(
382+
"{}/snap/firefox/common/.mozilla/firefox",
383+
home
384+
)));
385+
}
386+
"windows" => {
387+
if let Ok(appdata) = std::env::var("APPDATA") {
388+
roots.push(PathBuf::from(format!("{}\\Mozilla\\Firefox\\Profiles", appdata)));
389+
}
390+
}
391+
_ => {}
392+
}
393+
394+
let mut out: Vec<PathBuf> = Vec::new();
395+
for root in &roots {
396+
let Ok(entries) = std::fs::read_dir(root) else {
397+
continue;
398+
};
399+
for ent in entries.flatten() {
400+
let p = ent.path();
401+
if !p.is_dir() {
402+
continue;
403+
}
404+
// A profile has cert9.db or cert8.db.
405+
if p.join("cert9.db").exists() || p.join("cert8.db").exists() {
406+
out.push(p);
407+
}
408+
}
409+
}
410+
out
411+
}

0 commit comments

Comments
 (0)