2026-02-08 04:28:13 -05:00
|
|
|
const fs = require("fs");
|
|
|
|
|
const crypto = require("crypto");
|
|
|
|
|
const { exec } = require("child_process");
|
2026-03-22 22:23:14 -04:00
|
|
|
const { execWithPassword, isSudoAvailable } = require("../dns/dnsConfig.js");
|
2026-04-29 06:28:38 -04:00
|
|
|
const { runElevatedPowerShell, quotePs } = require("../winElevated.js");
|
2026-03-14 12:59:22 -04:00
|
|
|
const { log, err } = require("../logger");
|
2026-02-08 04:28:13 -05:00
|
|
|
|
|
|
|
|
const IS_WIN = process.platform === "win32";
|
2026-02-24 22:40:15 -05:00
|
|
|
const IS_MAC = process.platform === "darwin";
|
2026-05-11 05:05:34 -04:00
|
|
|
const LINUX_CERT_PATHS = [
|
|
|
|
|
// Debian / Ubuntu
|
|
|
|
|
{ dir: "/usr/local/share/ca-certificates", cmd: "update-ca-certificates" },
|
|
|
|
|
// Arch Linux / CachyOS / Manjaro
|
|
|
|
|
{ dir: "/etc/ca-certificates/trust-source/anchors", cmd: "update-ca-trust" },
|
|
|
|
|
// Fedora / RHEL / CentOS
|
|
|
|
|
{ dir: "/etc/pki/ca-trust/source/anchors", cmd: "update-ca-trust" },
|
|
|
|
|
// openSUSE
|
|
|
|
|
{ dir: "/etc/pki/trust/anchors", cmd: "update-ca-certificates" }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function getLinuxCertConfig() {
|
|
|
|
|
for (const config of LINUX_CERT_PATHS) {
|
|
|
|
|
if (fs.existsSync(config.dir)) {
|
|
|
|
|
return config;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Fallback to Debian default if none exist
|
|
|
|
|
return LINUX_CERT_PATHS[0];
|
|
|
|
|
}
|
2026-04-29 06:28:38 -04:00
|
|
|
const ROOT_CA_CN = "9Router MITM Root CA";
|
2026-02-08 04:28:13 -05:00
|
|
|
|
|
|
|
|
// Get SHA1 fingerprint from cert file using Node.js crypto
|
|
|
|
|
function getCertFingerprint(certPath) {
|
|
|
|
|
const pem = fs.readFileSync(certPath, "utf-8");
|
|
|
|
|
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64");
|
|
|
|
|
return crypto.createHash("sha1").update(der).digest("hex").toUpperCase().match(/.{2}/g).join(":");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if certificate is already installed in system store
|
|
|
|
|
*/
|
|
|
|
|
async function checkCertInstalled(certPath) {
|
2026-02-24 22:40:15 -05:00
|
|
|
if (IS_WIN) return checkCertInstalledWindows(certPath);
|
|
|
|
|
if (IS_MAC) return checkCertInstalledMac(certPath);
|
|
|
|
|
return checkCertInstalledLinux();
|
2026-02-08 04:28:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkCertInstalledMac(certPath) {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
try {
|
2026-03-03 04:19:44 -05:00
|
|
|
const fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
|
2026-03-04 23:25:03 -05:00
|
|
|
// security verify-cert returns 0 only if cert is trusted by system policy
|
2026-04-14 00:48:59 -04:00
|
|
|
exec(`security verify-cert -c "${certPath}" -p ssl -k /Library/Keychains/System.keychain 2>/dev/null`, { windowsHide: true }, (error) => {
|
2026-03-04 23:25:03 -05:00
|
|
|
if (!error) return resolve(true);
|
|
|
|
|
// Fallback: check if fingerprint appears in System keychain with trust
|
2026-04-14 00:48:59 -04:00
|
|
|
exec(`security dump-trust-settings -d 2>/dev/null | grep -i "${fingerprint}"`, { windowsHide: true }, (err2, stdout2) => {
|
2026-03-04 23:25:03 -05:00
|
|
|
resolve(!err2 && !!stdout2?.trim());
|
|
|
|
|
});
|
2026-02-08 04:28:13 -05:00
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
resolve(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkCertInstalledWindows(certPath) {
|
|
|
|
|
return new Promise((resolve) => {
|
2026-04-29 06:28:38 -04:00
|
|
|
// Check by SHA1 fingerprint — detects stale cert with same CN but different key
|
|
|
|
|
let fingerprint;
|
|
|
|
|
try {
|
|
|
|
|
fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
|
|
|
|
|
} catch {
|
|
|
|
|
return resolve(false);
|
|
|
|
|
}
|
|
|
|
|
exec(`certutil -store Root ${fingerprint}`, { windowsHide: true }, (error) => {
|
2026-02-08 04:28:13 -05:00
|
|
|
resolve(!error);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install SSL certificate to system trust store
|
|
|
|
|
*/
|
|
|
|
|
async function installCert(sudoPassword, certPath) {
|
|
|
|
|
if (!fs.existsSync(certPath)) {
|
|
|
|
|
throw new Error(`Certificate file not found: ${certPath}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isInstalled = await checkCertInstalled(certPath);
|
|
|
|
|
if (isInstalled) {
|
2026-03-14 12:59:22 -04:00
|
|
|
log("🔐 Cert: already trusted ✅");
|
2026-02-08 04:28:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (IS_WIN) {
|
|
|
|
|
await installCertWindows(certPath);
|
2026-02-24 22:40:15 -05:00
|
|
|
} else if (IS_MAC) {
|
2026-02-08 04:28:13 -05:00
|
|
|
await installCertMac(sudoPassword, certPath);
|
2026-02-24 22:40:15 -05:00
|
|
|
} else {
|
|
|
|
|
await installCertLinux(sudoPassword, certPath);
|
2026-02-08 04:28:13 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function installCertMac(sudoPassword, certPath) {
|
2026-03-14 05:38:44 -04:00
|
|
|
// Remove all old certs with same name first to avoid duplicate/stale cert conflict
|
|
|
|
|
const deleteOld = `security delete-certificate -c "9Router MITM Root CA" /Library/Keychains/System.keychain 2>/dev/null || true`;
|
|
|
|
|
const install = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`;
|
2026-02-08 04:28:13 -05:00
|
|
|
try {
|
2026-03-14 05:38:44 -04:00
|
|
|
await execWithPassword(`${deleteOld} && ${install}`, sudoPassword);
|
2026-03-14 12:59:22 -04:00
|
|
|
log("🔐 Cert: ✅ installed to system keychain");
|
2026-02-08 04:28:13 -05:00
|
|
|
} catch (error) {
|
|
|
|
|
const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed";
|
|
|
|
|
throw new Error(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function installCertWindows(certPath) {
|
2026-04-29 06:28:38 -04:00
|
|
|
// Auto-elevate via UAC popup if not admin (zero popup if already admin).
|
|
|
|
|
// Delete any stale cert with same CN before adding to avoid duplicates.
|
|
|
|
|
const script = `
|
|
|
|
|
certutil -delstore Root ${quotePs(ROOT_CA_CN)} 2>$null | Out-Null
|
|
|
|
|
$exit = & certutil -addstore Root ${quotePs(certPath)} 2>&1
|
|
|
|
|
if ($LASTEXITCODE -ne 0) { throw "certutil exit $LASTEXITCODE" }
|
|
|
|
|
`;
|
|
|
|
|
try {
|
|
|
|
|
await runElevatedPowerShell(script);
|
|
|
|
|
log("🔐 Cert: ✅ installed to Windows Root store");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw new Error(`Failed to install certificate: ${e.message}`);
|
|
|
|
|
}
|
2026-02-08 04:28:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Uninstall SSL certificate from system store
|
|
|
|
|
*/
|
|
|
|
|
async function uninstallCert(sudoPassword, certPath) {
|
|
|
|
|
const isInstalled = await checkCertInstalled(certPath);
|
|
|
|
|
if (!isInstalled) {
|
2026-03-14 12:59:22 -04:00
|
|
|
log("🔐 Cert: not found in system store");
|
2026-02-08 04:28:13 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (IS_WIN) {
|
|
|
|
|
await uninstallCertWindows();
|
2026-02-24 22:40:15 -05:00
|
|
|
} else if (IS_MAC) {
|
2026-02-08 04:28:13 -05:00
|
|
|
await uninstallCertMac(sudoPassword, certPath);
|
2026-02-24 22:40:15 -05:00
|
|
|
} else {
|
|
|
|
|
await uninstallCertLinux(sudoPassword);
|
2026-02-08 04:28:13 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uninstallCertMac(sudoPassword, certPath) {
|
|
|
|
|
const fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
|
2026-02-08 12:15:31 -05:00
|
|
|
const command = `security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`;
|
2026-02-08 04:28:13 -05:00
|
|
|
try {
|
|
|
|
|
await execWithPassword(command, sudoPassword);
|
2026-03-14 12:59:22 -04:00
|
|
|
log("🔐 Cert: ✅ uninstalled from system keychain");
|
2026-02-08 04:28:13 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
throw new Error("Failed to uninstall certificate");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uninstallCertWindows() {
|
2026-04-29 06:28:38 -04:00
|
|
|
// Auto-elevate via UAC popup if not admin
|
|
|
|
|
const script = `certutil -delstore Root ${quotePs(ROOT_CA_CN)}`;
|
|
|
|
|
try {
|
|
|
|
|
await runElevatedPowerShell(script);
|
|
|
|
|
log("🔐 Cert: ✅ uninstalled from Windows Root store");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw new Error(`Failed to uninstall certificate: ${e.message}`);
|
|
|
|
|
}
|
2026-02-08 04:28:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-02-24 22:40:15 -05:00
|
|
|
function checkCertInstalledLinux() {
|
2026-05-11 05:05:34 -04:00
|
|
|
const config = getLinuxCertConfig();
|
|
|
|
|
const certFile = `${config.dir}/9router-root-ca.crt`;
|
2026-02-24 22:40:15 -05:00
|
|
|
return Promise.resolve(fs.existsSync(certFile));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 05:05:34 -04:00
|
|
|
async function updateNssDatabases(certPath, action = 'add') {
|
|
|
|
|
const certName = "9Router MITM Root CA";
|
|
|
|
|
|
|
|
|
|
const script = `
|
|
|
|
|
if ! command -v certutil &> /dev/null; then
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
DIRS="$HOME/.pki/nssdb $HOME/snap/chromium/current/.pki/nssdb"
|
|
|
|
|
|
|
|
|
|
if [ -d "$HOME/.mozilla/firefox" ]; then
|
|
|
|
|
for profile in "$HOME"/.mozilla/firefox/*/; do
|
|
|
|
|
if [ -f "\${profile}cert9.db" ] || [ -f "\${profile}cert8.db" ]; then
|
|
|
|
|
DIRS="$DIRS $profile"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ -d "$HOME/snap/firefox/common/.mozilla/firefox" ]; then
|
|
|
|
|
for profile in "$HOME"/snap/firefox/common/.mozilla/firefox/*/; do
|
|
|
|
|
if [ -f "\${profile}cert9.db" ] || [ -f "\${profile}cert8.db" ]; then
|
|
|
|
|
DIRS="$DIRS $profile"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
for db in $DIRS; do
|
|
|
|
|
if [ -d "$db" ]; then
|
|
|
|
|
if [ "${action}" = "add" ]; then
|
|
|
|
|
certutil -d sql:"$db" -A -t "C,," -n "${certName}" -i "${certPath}" 2>/dev/null || \\
|
|
|
|
|
certutil -d "$db" -A -t "C,," -n "${certName}" -i "${certPath}" 2>/dev/null || true
|
|
|
|
|
else
|
|
|
|
|
certutil -d sql:"$db" -D -n "${certName}" 2>/dev/null || \\
|
|
|
|
|
certutil -d "$db" -D -n "${certName}" 2>/dev/null || true
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
exec(script, { shell: "/bin/bash" }, () => resolve());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 22:40:15 -05:00
|
|
|
async function installCertLinux(sudoPassword, certPath) {
|
2026-03-22 22:23:14 -04:00
|
|
|
if (!isSudoAvailable()) {
|
|
|
|
|
log(`🔐 Cert: cannot install to system store without sudo — trust this file on clients: ${certPath}`);
|
2026-05-11 05:05:34 -04:00
|
|
|
// Still try to update user NSS DBs even if no sudo!
|
|
|
|
|
await updateNssDatabases(certPath, 'add');
|
2026-03-22 22:23:14 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-11 05:05:34 -04:00
|
|
|
|
|
|
|
|
const config = getLinuxCertConfig();
|
|
|
|
|
const destFile = `${config.dir}/9router-root-ca.crt`;
|
|
|
|
|
|
|
|
|
|
// Copy to the discovered directory and execute the specific update command
|
|
|
|
|
const cmd = `cp "${certPath}" "${destFile}" && (${config.cmd} 2>/dev/null || true)`;
|
|
|
|
|
|
2026-02-24 22:40:15 -05:00
|
|
|
try {
|
|
|
|
|
await execWithPassword(cmd, sudoPassword);
|
2026-05-11 05:05:34 -04:00
|
|
|
await updateNssDatabases(certPath, 'add');
|
|
|
|
|
log(`🔐 Cert: ✅ installed to Linux trust store (${config.dir}) and user browser databases`);
|
2026-02-24 22:40:15 -05:00
|
|
|
} catch (error) {
|
2026-05-11 05:05:34 -04:00
|
|
|
throw new Error(`Certificate install failed: ${error.message}`);
|
2026-02-24 22:40:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uninstallCertLinux(sudoPassword) {
|
2026-05-11 05:05:34 -04:00
|
|
|
// Always try to uninstall from user DBs even without sudo
|
|
|
|
|
await updateNssDatabases(null, 'delete');
|
|
|
|
|
|
2026-03-22 22:23:14 -04:00
|
|
|
if (!isSudoAvailable()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-11 05:05:34 -04:00
|
|
|
|
|
|
|
|
const config = getLinuxCertConfig();
|
|
|
|
|
const destFile = `${config.dir}/9router-root-ca.crt`;
|
|
|
|
|
const cmd = `rm -f "${destFile}" && (${config.cmd} 2>/dev/null || true)`;
|
|
|
|
|
|
2026-02-24 22:40:15 -05:00
|
|
|
try {
|
|
|
|
|
await execWithPassword(cmd, sudoPassword);
|
2026-05-11 05:05:34 -04:00
|
|
|
log("🔐 Cert: ✅ uninstalled from Linux trust store and user browser databases");
|
2026-02-24 22:40:15 -05:00
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error("Failed to uninstall certificate");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 04:28:13 -05:00
|
|
|
module.exports = { installCert, uninstallCert, checkCertInstalled };
|