diff --git a/README.md b/README.md index 4843a1f..220a549 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,38 @@ loopback. | `MOSHPIT_PROXY_TOFU` | off | see below | | `MOSHPIT_PROXY_DIR` | `~/.moshpit` | | +## Install + +```sh +curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh +``` + +Installs under `~/.local`, needs no root for the code, and finishes by setting up +your browsers — asking once, in plain language, before it changes anything: + +``` + Moshpit needs to add a security key to this + computer so your browser trusts .moshpit sites. + It only works for .moshpit and cannot affect any + other website. + + Continue? [Y/n] +``` + +`--no-trust` installs the code only. `--uninstall` removes both. + +Already have the code? `moshpit-trust` does the browser setup on its own, and +`moshpit-trust --status` says what is set up without changing anything. It is +idempotent — running it twice is a no-op, not a duplicate. + +> **Nothing in this section applies to [TronBrowser](https://tronbrowser.dev)**, +> which verifies registry pins natively. No setup, no proxy, no local root. + ## Trusting the local root +`moshpit-trust` automates everything below; this section is what it does and why, +for anyone who would rather do it by hand or wants to know what changed. + The proxy generates a root on first run at `~/.moshpit/ca/ca.crt` and prints its fingerprint. Two things make installing it a much smaller ask than a shared CA: @@ -86,15 +116,33 @@ That second property is tested, not asserted — `tests/ca.test.ts` signs a with `permitted subtree violation`. ```sh -# Linux (NSS: Chrome, Firefox) +# Linux (NSS: Chrome, Chromium, Edge) certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "Moshpit Local CA" -i ~/.moshpit/ca/ca.crt # macOS sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.moshpit/ca/ca.crt ``` +`C,,` is server-trust only — not code signing, not mail. + +**Firefox is not covered by either command.** It carries its own NSS database per +profile on every platform, including macOS, so the keychain does nothing for it: + +```sh +certutil -d sql:~/.mozilla/firefox/ -A -t "C,," -n "Moshpit Local CA" -i ~/.moshpit/ca/ca.crt +``` + +On current Ubuntu the default Firefox is the snap, whose profiles live under +`~/snap/firefox/common/.mozilla/firefox/` instead. `moshpit-trust` covers the +snap and flatpak paths as well, which is most of why doing this by hand tends to +half-work. + +`certutil` itself is not installed by default on Debian or Ubuntu — the command +above fails on a machine that *does* have the store it points at. Install +`libnss3-tools` (Debian/Ubuntu), `nss-tools` (Fedora/RHEL) or `nss` (Arch, brew). + Node, Python and Java keep their own trust stores — `NODE_EXTRA_CA_CERTS`, `certifi`, `cacerts` respectively. Installing into the OS store does not cover -them. +them, and neither does `moshpit-trust`. ## Publishing a pin (site operators) diff --git a/bin/moshpit-trust.ts b/bin/moshpit-trust.ts new file mode 100755 index 0000000..b1d23f7 --- /dev/null +++ b/bin/moshpit-trust.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// One command, no vocabulary. The word "certificate" appears nowhere a user reads. +// +// This exists because the honest barrier to Moshpit was never that people +// distrust a locally-generated root — it is that nobody should have to read +// `certutil -d sql:$HOME/.pki/nssdb -A -t "C,,"` to open a web page. The +// mechanism cannot go away for stock browsers; the ceremony can. +// +// There is still exactly one consent moment. Adding a root to someone's trust +// store without telling them is what malware does, and "the user didn't have to +// think about it" is not a reason to skip asking. What gets removed is the +// jargon, not the disclosure. + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { createLocalCa } from "../lib/ca.ts"; +import { loadConfig } from "../lib/config.ts"; +import { + defaultEnv, discoverStores, install, probeTooling, status, uninstall, + type Store, +} from "../lib/trust.ts"; + +const argv = new Set(process.argv.slice(2)); +const wantsHelp = argv.has("--help") || argv.has("-h"); +const assumeYes = argv.has("--yes") || argv.has("-y"); +const wantsRemove = argv.has("--uninstall") || argv.has("--remove"); +const wantsStatus = argv.has("--status"); + +if (wantsHelp) { + console.log(`moshpit-trust — let this computer's browsers open Moshpit sites + + moshpit-trust set it up (asks first) + moshpit-trust --yes set it up without asking + moshpit-trust --status show what is set up, change nothing + moshpit-trust --uninstall undo it + +Run this once. It only affects ${namespaceLabel()} and cannot affect any other website.`); + process.exit(0); +} + +function namespaceLabel(): string { + const tlds = loadConfig().tlds.map((t) => `.${t}`); + return tlds.length === 1 ? tlds[0]! : `${tlds.slice(0, -1).join(", ")} and ${tlds.at(-1)}`; +} + +const config = loadConfig(); +const env = defaultEnv(); +const stores = discoverStores(env); + +if (stores.length === 0) { + console.error("No browsers found on this computer that need setting up."); + console.error("If you use Firefox, launch it once first — it creates its storage on first run."); + process.exit(1); +} + +// ---------------------------------------------------------------- status + +if (wantsStatus) { + const rows = await Promise.all(stores.map((s) => status(s, env))); + for (const row of rows) { + console.log(` ${row.installed ? "✓" : "·"} ${row.store.label} — ${row.detail}`); + } + const ready = rows.filter((r) => r.installed).length; + console.log( + ready === rows.length + ? `\nAll set. Try https://scrambled.${config.tlds[0] ?? "moshpit"}` + : `\n${ready} of ${rows.length} set up. Run \`moshpit-trust\` to finish.`, + ); + process.exit(0); +} + +// ------------------------------------------------------------- uninstall + +if (wantsRemove) { + const results = await Promise.all(stores.map((s) => uninstall(s, env))); + for (const r of results) console.log(` ${r.ok ? "✓" : "✗"} ${r.store.label} — ${r.detail}`); + const failed = results.filter((r) => !r.ok); + console.log(failed.length ? "\nSome entries could not be removed." : "\nRemoved. Moshpit sites will stop opening."); + process.exit(failed.length ? 1 : 0); +} + +// --------------------------------------------------------------- install + +// The tooling check comes before the consent prompt on purpose: asking someone +// to agree to something and *then* failing on a missing package wastes the one +// moment of attention this command gets. +const tooling = await probeTooling(env); +const needsCertutil = stores.some((s) => s.kind === "nss"); +if (needsCertutil && !tooling.certutil) { + console.error(`Missing a small system package that browsers need for this. + + ${tooling.installHint} + +Then run \`moshpit-trust\` again.`); + process.exit(1); +} + +const needsRoot = stores.some((s) => s.needsRoot); + +if (!assumeYes) { + if (!stdin.isTTY) { + console.error("Nothing to read an answer from. Re-run with --yes if you already know what this does."); + process.exit(1); + } + console.log(` + Moshpit needs to add a security key to this + computer so your browser trusts ${namespaceLabel()} sites. + It only works for ${namespaceLabel()} and cannot affect any + other website. +`); + for (const store of stores) console.log(` · ${store.label}`); + if (needsRoot) console.log("\n You will be asked for your password."); + + const rl = createInterface({ input: stdin, output: stdout }); + const answer = (await rl.question("\n Continue? [Y/n] ")).trim().toLowerCase(); + rl.close(); + if (answer && !/^y(es)?$/.test(answer)) { + console.log("\n Cancelled. Nothing was changed."); + process.exit(1); + } +} + +// The root has to exist before it can be trusted. Creating it here means the +// setup command works on a machine that has never started the proxy. +const ca = createLocalCa({ dir: `${config.dir}/ca`, tlds: config.tlds }); +await ca.ensure(); + +// Serially, not in parallel: these prompt for a password, and two prompts +// racing for the same terminal is how a setup step becomes unusable. +const results: Array>> = []; +for (const store of stores) results.push(await install(store, ca.rootCertPath(), env)); + +console.log(""); +for (const r of results) { + const mark = r.ok ? "✓" : "✗"; + console.log(` ${mark} ${r.store.label}${r.changed ? "" : ` — ${r.detail}`}`); +} + +const failed = results.filter((r) => !r.ok); +if (failed.length === results.length) { + console.error("\n Setup did not work. Nothing on this computer was changed."); + process.exit(1); +} + +const sample = `https://scrambled${config.tlds[0] ? `.${config.tlds[0]}` : ""}`; +if (failed.length) { + console.log(`\n Mostly ready — ${failed.length} browser(s) could not be set up.`); +} else { + console.log(`\n ✓ Ready. Try ${sample}`); +} + +// Said last because it is the thing that actually blocks a page from loading, +// and a person who just ran a setup command will read the final line. +console.log(" Moshpit sites need the resolver and proxy running too: `moshpit-proxy`"); + +process.exit(failed.length ? 1 : 0); + +export type { Store }; diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8fefe15 --- /dev/null +++ b/install.sh @@ -0,0 +1,147 @@ +#!/bin/sh +# Install moshpit-proxy, and set this computer up to open Moshpit sites. +# +# curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh +# +# Installs to your home directory and needs no root for the code itself. The +# setup step asks once before changing anything, and on macOS will ask for your +# password because the system store needs it. +# +# If piping a script from the internet into a shell makes you uneasy, good — read +# it first: +# +# curl -fsSL .../install.sh -o install.sh && less install.sh && sh install.sh +# +set -eu + +REPO="profullstack/moshpit-proxy" +REF="${MOSHPIT_REF:-main}" +PREFIX="${MOSHPIT_PREFIX:-${XDG_DATA_HOME:-$HOME/.local/share}/moshpit-proxy}" +BINDIR="${MOSHPIT_BIN:-$HOME/.local/bin}" +ACTION="install" +RUN_TRUST=1 +ASSUME_YES="" + +RED=''; BOLD=''; DIM=''; OFF='' +if [ -t 2 ]; then RED=$(printf '\033[31m'); BOLD=$(printf '\033[1m'); DIM=$(printf '\033[2m'); OFF=$(printf '\033[0m'); fi + +say() { printf '%s\n' "$*" >&2; } +step() { printf '%s==>%s %s\n' "$BOLD" "$OFF" "$*" >&2; } +warn() { printf '%swarning:%s %s\n' "$RED" "$OFF" "$*" >&2; } +die() { printf '%serror:%s %s\n' "$RED" "$OFF" "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +while [ $# -gt 0 ]; do + case "$1" in + --uninstall) ACTION="uninstall" ;; + --no-trust) RUN_TRUST=0 ;; + --yes|-y) ASSUME_YES="--yes" ;; + --prefix) PREFIX="${2:?--prefix needs a path}"; shift ;; + --bin) BINDIR="${2:?--bin needs a path}"; shift ;; + --ref) REF="${2:?--ref needs a git ref}"; shift ;; + -h|--help) + cat >&2 < where the code goes (default: $PREFIX) + --bin where the shims go (default: $BINDIR) + --ref what to install (default: main) + +environment: MOSHPIT_PREFIX, MOSHPIT_BIN, MOSHPIT_REF +EOF + exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac + shift +done + +if [ "$ACTION" = "uninstall" ]; then + if [ -x "$PREFIX/bin/moshpit-trust.ts" ] && have node; then + step "undoing the browser setup" + node "$PREFIX/bin/moshpit-trust.ts" --uninstall || warn "could not undo the browser setup" + fi + step "removing $PREFIX" + rm -rf "$PREFIX" + for shim in moshpit-proxy moshpit-pin moshpit-trust; do rm -f "$BINDIR/$shim"; done + say "Removed." + exit 0 +fi + +# ---------------------------------------------------------------- runtime + +have node || die "node is required (v24 or newer)" + +# Checked by capability rather than by version string, for the same reason +# moshpit-transport does it: a Node built without the pieces we need passes a +# version check and then fails at the first connection. Dynamic SNI and TLS 1.3 +# are the two this proxy cannot work without. +node -e ' + const tls = require("node:tls"); + const [maj] = process.versions.node.split(".").map(Number); + if (maj < 24) { console.error("node " + process.versions.node + " is too old"); process.exit(1); } + if (typeof tls.createSecureContext !== "function") { console.error("node:tls is incomplete"); process.exit(1); } +' || die "this node cannot run the proxy — install Node 24 or newer" + +have openssl || die "openssl is required (the local key is generated with it)" + +# ---------------------------------------------------------------- fetch + +step "installing $REPO@$REF to $PREFIX" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT INT TERM + +url="https://codeload.github.com/$REPO/tar.gz/$REF" +if have curl; then + curl -fsSL "$url" -o "$tmp/src.tgz" || die "download failed: $url" +elif have wget; then + wget -qO "$tmp/src.tgz" "$url" || die "download failed: $url" +else + die "need curl or wget" +fi + +mkdir -p "$tmp/src" +tar -xzf "$tmp/src.tgz" -C "$tmp/src" --strip-components=1 || die "could not unpack the download" + +rm -rf "$PREFIX" +mkdir -p "$(dirname "$PREFIX")" +mv "$tmp/src" "$PREFIX" + +# ---------------------------------------------------------------- shims + +mkdir -p "$BINDIR" +for shim in moshpit-proxy moshpit-pin moshpit-trust; do + [ -f "$PREFIX/bin/$shim.ts" ] || continue + cat > "$BINDIR/$shim" < Promise<{ stdout: string; stderr: string }>; + +export type TrustEnv = { + platform: string; + home: string; + exists: (path: string) => boolean; + listDir: (path: string) => string[]; + run: Runner; +}; + +export function defaultEnv(overrides: Partial = {}): TrustEnv { + return { + platform: osPlatform(), + home: homedir(), + exists: existsSync, + listDir: (path) => { + try { + return readdirSync(path); + } catch { + return []; + } + }, + run: async (file, args) => execFileAsync(file, args, { maxBuffer: 8 * 1024 * 1024 }), + ...overrides, + }; +} + +/** + * Every trust store on this machine that a browser actually reads. + * + * Firefox is the reason this returns a list rather than a path. It ships its + * own NSS database per profile on every platform, so a macOS install that only + * touched the keychain leaves Firefox showing a security warning — which the + * user reasonably reads as "this thing is broken". + */ +export function discoverStores(env: TrustEnv = defaultEnv()): Store[] { + const stores: Store[] = []; + + if (env.platform === "darwin") { + stores.push({ + id: "macos-system", + kind: "macos-keychain", + path: "/Library/Keychains/System.keychain", + label: "Safari, Chrome and anything using the system store", + needsRoot: true, + }); + } else { + // Chrome, Chromium and Edge share this one on Linux. + const nssdb = join(env.home, ".pki", "nssdb"); + if (env.exists(nssdb)) { + stores.push({ + id: "nss-chrome", + kind: "nss", + path: nssdb, + label: "Chrome, Chromium and Edge", + needsRoot: false, + }); + } + } + + for (const profile of firefoxProfiles(env)) { + stores.push({ + id: `nss-firefox-${basename(profile)}`, + kind: "nss", + path: profile, + label: `Firefox (${basename(profile)})`, + needsRoot: false, + }); + } + + return stores; +} + +/** + * Firefox profile directories holding an NSS database. + * + * The snap and flatpak locations are included because on current Ubuntu the + * default Firefox is the snap, and a tool that silently covers only the + * non-snap path looks like it worked and did nothing. + */ +function firefoxProfiles(env: TrustEnv): string[] { + const roots = + env.platform === "darwin" + ? [join(env.home, "Library", "Application Support", "Firefox", "Profiles")] + : [ + join(env.home, ".mozilla", "firefox"), + join(env.home, "snap", "firefox", "common", ".mozilla", "firefox"), + join(env.home, ".var", "app", "org.mozilla.firefox", ".mozilla", "firefox"), + ]; + + const found: string[] = []; + for (const root of roots) { + if (!env.exists(root)) continue; + for (const entry of env.listDir(root)) { + const dir = join(root, entry); + // cert9.db is the modern (sql:) NSS database. A profile without one has + // never been launched, and writing to it would be pointless. + if (env.exists(join(dir, "cert9.db"))) found.push(dir); + } + } + return found; +} + +function basename(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +export type Tooling = { + /** Whether `certutil` is on PATH. Nothing NSS works without it. */ + certutil: boolean; + /** The exact command to get it, for this distribution. */ + installHint: string; + /** The package name alone, for an installer that offers to run it. */ + packageName: string; + packageManager: string | null; +}; + +/** + * Whether the NSS tooling is present, and precisely how to get it if not. + * + * Worth its own function because `certutil` missing is the common case, not the + * edge case: `libnss3-tools` is not installed by default on Debian or Ubuntu, + * so the documented command fails on a machine that *does* have the store it + * points at. Telling someone "certutil: command not found" and stopping is how + * a five-second setup becomes an abandoned one. + */ +export async function probeTooling(env: TrustEnv = defaultEnv()): Promise { + let certutil = false; + try { + await env.run("certutil", ["-H"]); + certutil = true; + } catch (error) { + // certutil exits non-zero for -H on some builds; only ENOENT means absent. + certutil = (error as { code?: string }).code !== "ENOENT"; + } + + const { packageName, packageManager, installHint } = nssPackage(env); + return { certutil, installHint, packageName, packageManager }; +} + +function nssPackage(env: TrustEnv): { packageName: string; packageManager: string | null; installHint: string } { + if (env.platform === "darwin") { + return { packageName: "nss", packageManager: "brew", installHint: "brew install nss" }; + } + + const id = osReleaseId(env); + if (/debian|ubuntu|mint|pop|elementary|raspbian/.test(id)) { + return { + packageName: "libnss3-tools", + packageManager: "apt-get", + installHint: "sudo apt-get install -y libnss3-tools", + }; + } + if (/fedora|rhel|centos|rocky|alma/.test(id)) { + return { packageName: "nss-tools", packageManager: "dnf", installHint: "sudo dnf install -y nss-tools" }; + } + if (/arch|manjaro|endeavour/.test(id)) { + return { packageName: "nss", packageManager: "pacman", installHint: "sudo pacman -S --noconfirm nss" }; + } + if (/opensuse|suse/.test(id)) { + return { packageName: "mozilla-nss-tools", packageManager: "zypper", installHint: "sudo zypper install -y mozilla-nss-tools" }; + } + return { + packageName: "nss-tools", + packageManager: null, + installHint: "install your distribution's NSS tools package (it provides `certutil`)", + }; +} + +function osReleaseId(env: TrustEnv): string { + try { + if (!env.exists("/etc/os-release")) return ""; + const text = readFileSync("/etc/os-release", "utf8"); + const id = /^ID=(.*)$/m.exec(text)?.[1] ?? ""; + const like = /^ID_LIKE=(.*)$/m.exec(text)?.[1] ?? ""; + return `${id} ${like}`.replace(/"/g, "").toLowerCase(); + } catch { + return ""; + } +} + +export type StoreStatus = { store: Store; installed: boolean; detail: string }; + +/** Whether this root is already trusted in `store`. Never writes. */ +export async function status(store: Store, env: TrustEnv = defaultEnv()): Promise { + try { + if (store.kind === "nss") { + await env.run("certutil", ["-d", `sql:${store.path}`, "-L", "-n", NICKNAME]); + return { store, installed: true, detail: "already trusted" }; + } + const { stdout } = await env.run("security", ["find-certificate", "-c", NICKNAME, store.path]); + return { store, installed: stdout.includes(NICKNAME), detail: stdout.includes(NICKNAME) ? "already trusted" : "not present" }; + } catch { + return { store, installed: false, detail: "not present" }; + } +} + +export type InstallResult = { store: Store; ok: boolean; changed: boolean; detail: string }; + +/** + * Trust the root in one store, and prove it afterwards. + * + * The read-back is not ceremony. `certutil` can exit zero having written to a + * database the browser does not read (a profile that was never launched, a + * locked db), and an install that reports success while the browser still shows + * a warning is worse than a clean failure. + */ +export async function install( + store: Store, + certPath: string, + env: TrustEnv = defaultEnv(), +): Promise { + const before = await status(store, env); + if (before.installed) return { store, ok: true, changed: false, detail: "already trusted" }; + + try { + if (store.kind === "nss") { + // "C,," — trusted to issue server certificates, and nothing else. Not + // "CT,c,c" and not a mail or code-signing trust bit; this root has one job. + await env.run("certutil", ["-d", `sql:${store.path}`, "-A", "-t", "C,,", "-n", NICKNAME, "-i", certPath]); + } else { + await env.run("security", [ + "add-trusted-cert", "-d", "-r", "trustRoot", "-k", store.path, certPath, + ]); + } + } catch (error) { + return { store, ok: false, changed: false, detail: reason(error) }; + } + + const after = await status(store, env); + return after.installed + ? { store, ok: true, changed: true, detail: "trusted" } + : { store, ok: false, changed: false, detail: "the store accepted the write but does not show it" }; +} + +/** Remove the root from one store. Absent is success — this has to be re-runnable. */ +export async function uninstall(store: Store, env: TrustEnv = defaultEnv()): Promise { + const before = await status(store, env); + if (!before.installed) return { store, ok: true, changed: false, detail: "was not present" }; + + try { + if (store.kind === "nss") { + await env.run("certutil", ["-d", `sql:${store.path}`, "-D", "-n", NICKNAME]); + } else { + await env.run("security", ["delete-certificate", "-c", NICKNAME, store.path]); + } + } catch (error) { + return { store, ok: false, changed: false, detail: reason(error) }; + } + + const after = await status(store, env); + return after.installed + ? { store, ok: false, changed: false, detail: "still present after removal" } + : { store, ok: true, changed: true, detail: "removed" }; +} + +function reason(error: unknown): string { + const err = error as { code?: string; stderr?: string; message?: string }; + if (err.code === "ENOENT") return "certutil is not installed"; + const stderr = (err.stderr ?? "").trim(); + return stderr || err.message || String(error); +} diff --git a/package.json b/package.json index 611b446..6b5b154 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,13 @@ "description": "Registry-pinned TLS proxy for Moshpit names. Verifies the origin's key against the registry instead of a certificate authority.", "bin": { "moshpit-proxy": "bin/moshpit-proxy.ts", - "moshpit-pin": "bin/moshpit-pin.ts" + "moshpit-pin": "bin/moshpit-pin.ts", + "moshpit-trust": "bin/moshpit-trust.ts" }, "scripts": { "start": "node bin/moshpit-proxy.ts", "pin": "node bin/moshpit-pin.ts", + "trust": "node bin/moshpit-trust.ts", "upstreams": "node scripts/gen-upstreams.ts", "test": "node --test tests/*.test.ts" }, diff --git a/tests/trust.test.ts b/tests/trust.test.ts new file mode 100644 index 0000000..1591339 --- /dev/null +++ b/tests/trust.test.ts @@ -0,0 +1,251 @@ +// Setting up trust stores, without ever touching this machine's trust stores. +// +// Every path and every command runner is injected, so the suite can assert what +// `certutil` would have been asked to do without asking it. That matters more +// than usual here: a test that got this wrong would silently modify the trust +// store of whoever ran it, and a test suite must never be a thing you have to +// undo afterwards. +// +// The one real-`certutil` test is opt-in by availability and skips cleanly when +// the tool is absent — which is the common case, since `libnss3-tools` is not +// installed by default on Debian or Ubuntu. + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { + NICKNAME, defaultEnv, discoverStores, install, probeTooling, status, uninstall, + type Store, type TrustEnv, +} from "../lib/trust.ts"; +import { selfSigned, tempDir } from "./helpers.ts"; + +/** An env whose filesystem is a set of paths and whose exec is a script. */ +function fakeEnv(opts: { + platform?: string; + home?: string; + paths?: string[]; + dirs?: Record; + run?: TrustEnv["run"]; +}): TrustEnv { + const paths = new Set(opts.paths ?? []); + return { + platform: opts.platform ?? "linux", + home: opts.home ?? "/home/tester", + exists: (p) => paths.has(p), + listDir: (p) => opts.dirs?.[p] ?? [], + run: opts.run ?? (async () => ({ stdout: "", stderr: "" })), + }; +} + +const ENOENT = Object.assign(new Error("spawn certutil ENOENT"), { code: "ENOENT" }); + +describe("trust store discovery", () => { + test("finds the Chrome store on Linux when it exists", () => { + const stores = discoverStores(fakeEnv({ paths: ["/home/tester/.pki/nssdb"] })); + assert.equal(stores.length, 1); + assert.equal(stores[0]!.id, "nss-chrome"); + assert.equal(stores[0]!.kind, "nss"); + assert.equal(stores[0]!.needsRoot, false); + }); + + test("returns nothing when no browser store exists", () => { + assert.deepEqual(discoverStores(fakeEnv({})), []); + }); + + test("finds Firefox profiles, and only ones that have been launched", () => { + // `fresh` has no cert9.db: the profile directory exists but Firefox has + // never run, so writing there would look like success and do nothing. + const root = "/home/tester/.mozilla/firefox"; + const stores = discoverStores(fakeEnv({ + paths: [root, join(root, "abc.default"), join(root, "abc.default", "cert9.db"), join(root, "fresh")], + dirs: { [root]: ["abc.default", "fresh"] }, + })); + assert.equal(stores.length, 1); + assert.equal(stores[0]!.id, "nss-firefox-abc.default"); + }); + + test("covers the snap Firefox, which is the default on current Ubuntu", () => { + const snap = "/home/tester/snap/firefox/common/.mozilla/firefox"; + const stores = discoverStores(fakeEnv({ + paths: [snap, join(snap, "x.default"), join(snap, "x.default", "cert9.db")], + dirs: { [snap]: ["x.default"] }, + })); + assert.equal(stores.length, 1); + assert.ok(stores[0]!.label.startsWith("Firefox")); + }); + + test("macOS gets the system keychain, and it needs a password", () => { + const stores = discoverStores(fakeEnv({ platform: "darwin" })); + assert.equal(stores[0]!.kind, "macos-keychain"); + assert.equal(stores[0]!.needsRoot, true); + // The Linux-only Chrome NSS path must not appear on macOS. + assert.ok(!stores.some((s) => s.id === "nss-chrome")); + }); + + test("macOS still picks up Firefox, which never uses the keychain", () => { + const root = "/home/tester/Library/Application Support/Firefox/Profiles"; + const stores = discoverStores(fakeEnv({ + platform: "darwin", + paths: [root, join(root, "p1"), join(root, "p1", "cert9.db")], + dirs: { [root]: ["p1"] }, + })); + assert.equal(stores.length, 2); + assert.ok(stores.some((s) => s.kind === "macos-keychain")); + assert.ok(stores.some((s) => s.id === "nss-firefox-p1")); + }); +}); + +describe("tooling probe", () => { + test("reports certutil missing rather than letting it fail later", async () => { + const tooling = await probeTooling(fakeEnv({ run: async () => { throw ENOENT; } })); + assert.equal(tooling.certutil, false); + }); + + test("a non-zero exit is not the same as absent", async () => { + // Some certutil builds exit non-zero for -H. That is not "not installed". + const tooling = await probeTooling(fakeEnv({ + run: async () => { throw Object.assign(new Error("usage"), { code: 1 }); }, + })); + assert.equal(tooling.certutil, true); + }); + + test("names the package for this distribution", async () => { + const tooling = await probeTooling(defaultEnv({ run: async () => { throw ENOENT; } })); + // Whatever the distro, the hint has to be runnable and mention a manager. + assert.ok(tooling.installHint.length > 0); + assert.ok(tooling.packageName.length > 0); + }); + + test("macOS points at brew, not apt", async () => { + const tooling = await probeTooling(fakeEnv({ platform: "darwin", run: async () => { throw ENOENT; } })); + assert.match(tooling.installHint, /brew/); + }); +}); + +const nssStore: Store = { + id: "nss-test", kind: "nss", path: "/tmp/fake-nssdb", + label: "Test browser", needsRoot: false, +}; + +describe("installing", () => { + /** A store that starts empty and remembers what was added. */ + function stateful() { + const calls: string[][] = []; + let present = false; + const run: TrustEnv["run"] = async (file, args) => { + calls.push([file, ...args]); + if (args.includes("-L")) { + if (!present) throw new Error("PR_FILE_NOT_FOUND_ERROR"); + return { stdout: NICKNAME, stderr: "" }; + } + if (args.includes("-A")) present = true; + if (args.includes("-D")) present = false; + return { stdout: "", stderr: "" }; + }; + return { calls, run, isPresent: () => present }; + } + + test("adds the root and proves it afterwards", async () => { + const s = stateful(); + const result = await install(nssStore, "/tmp/ca.crt", fakeEnv({ run: s.run })); + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(s.isPresent(), true); + }); + + test("asks for server-trust only, not every trust bit", async () => { + const s = stateful(); + await install(nssStore, "/tmp/ca.crt", fakeEnv({ run: s.run })); + const add = s.calls.find((c) => c.includes("-A")); + assert.ok(add, "expected an add call"); + // "C,," — trusted to issue server certificates and nothing else. Not + // "CT,c,c", which would also trust it for mail and code signing. + assert.equal(add![add!.indexOf("-t") + 1], "C,,"); + assert.equal(add![add!.indexOf("-n") + 1], NICKNAME); + assert.ok(add!.includes(`sql:${nssStore.path}`)); + }); + + test("running twice changes nothing the second time", async () => { + const s = stateful(); + const env = fakeEnv({ run: s.run }); + const first = await install(nssStore, "/tmp/ca.crt", env); + const second = await install(nssStore, "/tmp/ca.crt", env); + + assert.equal(first.changed, true); + assert.equal(second.changed, false); + assert.equal(second.ok, true); + // Exactly one write, however many times it is run. + assert.equal(s.calls.filter((c) => c.includes("-A")).length, 1); + }); + + test("a write that the store does not show is a failure, not a success", async () => { + // certutil can exit zero having written somewhere the browser will not + // read. Reporting success there is worse than reporting nothing. + const env = fakeEnv({ + run: async (_file, args) => { + if (args.includes("-L")) throw new Error("PR_FILE_NOT_FOUND_ERROR"); + return { stdout: "", stderr: "" }; + }, + }); + const result = await install(nssStore, "/tmp/ca.crt", env); + assert.equal(result.ok, false); + assert.match(result.detail, /accepted the write but does not show it/); + }); + + test("a missing certutil is reported as such, not as a generic failure", async () => { + const env = fakeEnv({ run: async () => { throw ENOENT; } }); + const result = await install(nssStore, "/tmp/ca.crt", env); + assert.equal(result.ok, false); + assert.match(result.detail, /certutil is not installed/); + }); +}); + +describe("uninstalling", () => { + test("removes it, and removing again is still success", async () => { + let present = true; + const env = fakeEnv({ + run: async (_file, args) => { + if (args.includes("-L")) { + if (!present) throw new Error("not found"); + return { stdout: NICKNAME, stderr: "" }; + } + if (args.includes("-D")) present = false; + return { stdout: "", stderr: "" }; + }, + }); + + const first = await uninstall(nssStore, env); + assert.equal(first.ok, true); + assert.equal(first.changed, true); + + const second = await uninstall(nssStore, env); + assert.equal(second.ok, true); + assert.equal(second.changed, false); + }); +}); + +describe("against real certutil", { skip: await certutilMissing() }, () => { + test("adds and removes a root in a throwaway NSS database", async () => { + const dir = await tempDir("moshpit-nssdb-"); + const env = defaultEnv(); + await env.run("certutil", ["-d", `sql:${dir}`, "-N", "--empty-password"]); + + const ca = await selfSigned(dir, "probe.moshpit"); + const store: Store = { id: "real", kind: "nss", path: dir, label: "throwaway", needsRoot: false }; + + assert.equal((await status(store, env)).installed, false); + + const added = await install(store, ca.certPath, env); + assert.equal(added.ok, true, added.detail); + assert.equal((await status(store, env)).installed, true); + + const removed = await uninstall(store, env); + assert.equal(removed.ok, true, removed.detail); + assert.equal((await status(store, env)).installed, false); + }); +}); + +async function certutilMissing(): Promise { + const { certutil, installHint } = await probeTooling(defaultEnv()); + return certutil ? false : `certutil not installed (${installHint})`; +}