Files
holesail-browser/native-host/managers/certificate-authority.js
T
Raven Scott fa9026c481
CI / Build & Test (push) Successful in 3m37s
se pkexec for Linux CA install to show GUI password prompt
- Replace sudo with pkexec so PolicyKit shows a graphical auth dialog
  when the native host is launched by the browser (no terminal).
- Pass DISPLAY and XAUTHORITY into the pkexec env so the polkit
  agent can display the dialog in the current session.
- Keep copy + update-ca-certificates in a single pkexec sh -c for
  one password prompt.
- Update SECURITY.md and JSDoc to describe pkexec / PolicyKit.
2026-03-06 17:59:34 -05:00

498 lines
20 KiB
JavaScript

/**
* Certificate authority for Holesail Browser native host.
* Generates a root CA and per-domain certs for virtual hosts (e.g. *.hs).
* Modeled on P2NS certificate_authority.js; uses node-forge for key/cert generation.
*/
// node-forge expects Node (crypto) or a browser (window/self). Bare has neither.
// Provide window so util.globalScope works; do NOT set process.versions.node so forge
// stays in "browser" mode and uses its pure JS crypto instead of require('crypto').
if (typeof global !== 'undefined' && typeof global.window === 'undefined') {
global.window = global;
}
const path = require('bare-path');
const fs = require('bare-fs');
const forge = require('node-forge');
const platform = typeof process !== 'undefined' && process.platform ? process.platform : '';
let spawn = null;
try {
const cp = require('child_process');
if (cp && typeof cp.spawn === 'function') spawn = cp.spawn;
} catch (_) {}
function runCommand(command, callback) {
if (typeof callback !== 'function') callback = () => {};
if (!spawn) {
callback(new Error('child_process.spawn (bare-subprocess) not available'));
return;
}
const proc = spawn(command, [], { shell: true });
let stdout = '';
let stderr = '';
if (proc.stdout) proc.stdout.on('data', (chunk) => { stdout += (chunk && chunk.toString) ? chunk.toString() : String(chunk); });
if (proc.stderr) proc.stderr.on('data', (chunk) => { stderr += (chunk && chunk.toString) ? chunk.toString() : String(chunk); });
proc.once('close', (code, signal) => {
if (code !== 0 && code != null) {
callback(new Error(stderr || 'Command failed with code ' + code), stdout, stderr);
} else {
callback(null, stdout, stderr);
}
});
proc.once('error', (err) => callback(err, '', ''));
}
/** Run a shell command with macOS GUI sudo prompt (osascript "with administrator privileges"). */
function runWithSudoMacOS(shellCommand, callback) {
if (typeof callback !== 'function') callback = () => {};
const escaped = shellCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const appleScript = 'do shell script "' + escaped + '" with administrator privileges';
const arg = appleScript.replace(/'/g, "'\"'\"'");
runCommand("osascript -e '" + arg + "'", callback);
}
// Resolve certs dir relative to the executable when running as a standalone
// binary (__dirname is bare:/app.bundle/ which is not a real filesystem path).
function resolveCertsBase() {
try {
const os = require('bare-os');
const execPath = os.execPath();
if (execPath && !execPath.startsWith('bare:')) {
return path.dirname(execPath);
}
} catch (_) {}
return path.dirname(__dirname);
}
const certsDir = process.env.CERTS_DIR || process.env.HOLESAIL_BROWSER_CERTS || path.join(resolveCertsBase(), 'holesail-browser-certs');
const caKeyPath = path.join(certsDir, 'ca.key.pem');
const caCertPath = path.join(certsDir, 'ca.cert.pem');
const CA_COMMON_NAME = 'Holesail Browser CA';
function logDebug(tag, msg) {
if (process.stderr) process.stderr.write(`[CA ${tag}] ${msg}\n`);
}
function logWarn(tag, msg) {
if (process.stderr) process.stderr.write(`[CA ${tag}] WARN: ${msg}\n`);
}
function logError(tag, msg) {
if (process.stderr) process.stderr.write(`[CA ${tag}] ERROR: ${msg}\n`);
}
function logInfo(tag, msg) {
if (process.stderr) process.stderr.write(`[CA ${tag}] ${msg}\n`);
}
if (!fs.existsSync(certsDir)) {
try {
fs.mkdirSync(certsDir, { recursive: true });
} catch (e) {
logError('CA', 'Failed to create certs dir: ' + e.message);
}
}
// Determine whether CA generation is needed without doing the expensive RSA work yet.
let _caReadyResolve = null;
let _caReadyReject = null;
const caReady = new Promise((resolve, reject) => {
_caReadyResolve = resolve;
_caReadyReject = reject;
});
function _generateCA() {
try {
logInfo('CA', 'Generating Root CA (2048-bit RSA)...');
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);
const attrs = [
{ name: 'commonName', value: CA_COMMON_NAME },
{ name: 'countryName', value: 'US' },
{ shortName: 'ST', value: 'CA' },
{ name: 'localityName', value: 'San Francisco' },
{ name: 'organizationName', value: 'Holesail Browser' },
{ shortName: 'OU', value: 'Holesail Root CA' }
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
const skid = forge.pki.getPublicKeyFingerprint(cert.publicKey, { md: forge.md.sha1.create() });
cert.setExtensions([
{ name: 'basicConstraints', cA: true, pathLenConstraint: 0 },
{ name: 'keyUsage', keyCertSign: true, cRLSign: true },
{ name: 'subjectKeyIdentifier' },
{ name: 'authorityKeyIdentifier', keyIdentifier: skid.getBytes() }
]);
cert.sign(keys.privateKey, forge.md.sha256.create(), { padding: forge.pki.rsa.PKCS1_v1_5 });
fs.writeFileSync(caKeyPath, forge.pki.privateKeyToPem(keys.privateKey));
fs.writeFileSync(caCertPath, forge.pki.certificateToPem(cert));
logInfo('CA', 'Root CA generated.');
_caReadyResolve();
} catch (e) {
logError('CA', 'Failed to generate Root CA: ' + e.message);
_caReadyReject(e);
}
}
// Defer CA generation to the next event-loop tick so it does not block
// the native host startup (RSA key generation can take several seconds).
let _needsGeneration = false;
if (fs.existsSync(caKeyPath) && fs.existsSync(caCertPath)) {
try {
const caCertPem = fs.readFileSync(caCertPath, 'utf8');
const cert = forge.pki.certificateFromPem(caCertPem);
if (cert.validity.notAfter > new Date()) {
logDebug('CA', 'Existing Root CA is valid.');
_caReadyResolve();
} else {
logWarn('CA', 'Existing Root CA expired, regenerating.');
_needsGeneration = true;
}
} catch (e) {
logWarn('CA', 'Could not read existing CA: ' + e.message + '; regenerating.');
_needsGeneration = true;
}
} else {
_needsGeneration = true;
}
if (_needsGeneration) {
// Use setImmediate so the event loop can process the first native message
// before the blocking RSA generation starts.
if (typeof setImmediate === 'function') {
setImmediate(_generateCA);
} else {
setTimeout(_generateCA, 0);
}
}
/**
* Install the root CA certificate into the OS trust store.
* Uses `security add-trusted-cert` on macOS (with GUI sudo prompt),
* `pkexec` + cp + `update-ca-certificates` on Linux (PolicyKit GUI password prompt),
* and `certutil` on Windows.
* @param {Function} callback - Called as `callback(err)` on completion.
*/
function installRootCA(callback) {
if (typeof callback !== 'function') callback = () => {};
if (!spawn) {
callback(new Error('child_process.spawn (bare-subprocess) not available'));
return;
}
const caPath = path.resolve(caCertPath);
if (platform === 'darwin') {
const systemKeychain = '/Library/Keychains/System.keychain';
const home = process.env.HOME || process.env.USERPROFILE || '';
const loginKc = home ? path.join(home, 'Library', 'Keychains', 'login.keychain-db') : '';
// Check if the *current* CA cert on disk is trusted for SSL (fingerprint match + policy).
// Uses verify-cert -p ssl so we catch stale entries with the same CN but a different key.
function isTrustedCurrent(done) {
runCommand(`security verify-cert -p ssl -c "${caPath}" 2>/dev/null`, (err) => {
if (err) { done(false); return; }
// Also confirm the fingerprint of the installed cert matches the one on disk.
runCommand(`security find-certificate -a -c "${CA_COMMON_NAME}" -Z "${loginKc}" 2>/dev/null`, (errFind, findOut) => {
if (errFind || !findOut) { done(false); return; }
const m = findOut.match(/SHA-1 hash:\s*([0-9A-Fa-f]+)/);
if (!m) { done(false); return; }
const installedSha1 = m[1].toUpperCase();
runCommand(`openssl x509 -in "${caPath}" -noout -fingerprint -sha1 2>/dev/null`, (errFp, fpOut) => {
if (errFp) { done(false); return; }
const currentSha1 = (fpOut.split('=')[1] || '').trim().replace(/:/g, '').toUpperCase();
done(installedSha1 === currentSha1);
});
});
});
}
function removeOldEntries(done) {
runCommand(`security delete-certificate -c "${CA_COMMON_NAME}" "${systemKeychain}" 2>/dev/null; true`, () => {
if (loginKc) {
runCommand(`security delete-certificate -c "${CA_COMMON_NAME}" "${loginKc}" 2>/dev/null; true`, () => done());
} else {
done();
}
});
}
function addToLoginKeychain(done) {
// Install to the user's login keychain with explicit SSL trust policy.
// This requires no sudo/admin password and Chrome respects it because
// it evaluates TLS trust using the SSL policy against all user keychains.
// The -p ssl flag sets the correct policy OID that Chrome checks.
if (!loginKc) {
done(new Error('HOME not set; cannot determine login keychain path'));
return;
}
const cmd = `security add-trusted-cert -r trustRoot -p ssl -k "${loginKc}" "${caPath}"`;
runCommand(cmd, (err, _stdout, stderr) => {
if (!err) {
logInfo('CA', 'Root CA installed to login keychain with SSL trust (trusted by Chrome).');
done(null);
return;
}
const msg = (stderr || err.message || '').trim();
logError('CA', 'add-trusted-cert failed: ' + msg);
done(new Error('Could not install CA: ' + msg));
});
}
isTrustedCurrent((trusted) => {
if (trusted) {
logInfo('CA', 'Root CA already trusted on macOS (fingerprint verified).');
callback(null);
return;
}
// Remove any stale entry (same CN, different key) then install fresh.
removeOldEntries(() => addToLoginKeychain(callback));
});
return;
}
if (platform === 'linux') {
const caTargetPath = '/usr/local/share/ca-certificates/holesail-browser-ca.crt';
// Escape caPath for safe use inside a single-quoted sh -c string (single quotes in path become '"'"').
const escapedPath = caPath.replace(/'/g, "'\"'\"'");
// Pass DISPLAY/XAUTHORITY so pkexec's polkit auth agent can show a GUI password dialog when
// the native host is launched by the browser (no terminal).
const display = (process.env.DISPLAY || '').replace(/'/g, "'\"'\"'");
const xauth = (process.env.XAUTHORITY || '').replace(/'/g, "'\"'\"'");
const pkexecCmd =
`pkexec env DISPLAY='${display}' XAUTHORITY='${xauth}' sh -c 'cp "${escapedPath}" ${caTargetPath} && update-ca-certificates'`;
runCommand(pkexecCmd, (err) => {
if (err) {
logError('CA', 'Error installing CA on Linux (pkexec copy + update-ca-certificates): ' + err.message);
callback(err);
return;
}
logInfo('CA', 'Root CA installed on Linux.');
callback(null);
});
return;
}
if (platform === 'win32') {
// regenerated is always false here — the CA on disk is the authoritative version.
// If the cert was just regenerated the caller should delete the old store entry first.
const regenerated = false;
runCommand(`certutil -store ROOT | findstr "${CA_COMMON_NAME}"`, (err, stdout) => {
const installed = !err && (stdout && stdout.trim() !== '');
if (installed && !regenerated) {
logInfo('CA', 'Root CA already installed on Windows.');
callback(null);
return;
}
function addCA() {
const command = `certutil -addstore -f "ROOT" "${caPath}"`;
runCommand(command, (errAdd, _o, stderr) => {
if (errAdd) {
logError('CA', 'Error installing root CA on Windows: ' + (stderr || errAdd.message));
callback(errAdd);
} else {
logInfo('CA', 'Root CA installed successfully on Windows.');
callback(null);
}
});
}
if (installed) {
runCommand(`certutil -delstore ROOT "${CA_COMMON_NAME}"`, (errDel) => {
if (errDel) logError('CA', 'Error deleting old CA on Windows');
addCA();
});
} else {
addCA();
}
});
return;
}
logWarn('CA', 'Unsupported platform for auto-install CA: ' + platform);
callback(null);
}
/**
* Get or create a TLS certificate for the given domain, signed by the root CA.
* Certificates are cached on disk in `<certsDir>/<domain>/`. Returns `{ cert, key }`
* buffers, or null if the CA is not yet ready.
* @param {string} domain - Certificate common name / directory key.
* @param {Array<{type: number, value: string}>} altNames - Subject Alternative Names.
* @param {boolean} [forceRegenerate=false] - If true, regenerate even if a cached cert exists.
* @returns {{cert: Buffer, key: Buffer}|null}
*/
function getOrCreateDomainCert(domain, altNames, forceRegenerate) {
if (!domain) return null;
const domainDir = path.join(certsDir, domain.replace(/\*/g, 'wildcard'));
if (!fs.existsSync(domainDir)) {
try {
fs.mkdirSync(domainDir, { recursive: true });
} catch (e) {
logError('CERT', 'Failed to create domain dir: ' + e.message);
return null;
}
}
const keyPath = path.join(domainDir, 'key.pem');
const certPath = path.join(domainDir, 'cert.pem');
if (!forceRegenerate && fs.existsSync(keyPath) && fs.existsSync(certPath)) {
try {
const certPem = fs.readFileSync(certPath, 'utf8');
const certMatch = certPem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/);
if (certMatch) {
const cert = forge.pki.certificateFromPem(certMatch[0]);
if (cert.validity.notAfter > new Date()) {
return {
key: fs.readFileSync(keyPath, 'utf8'),
cert: fs.readFileSync(certPath, 'utf8')
};
}
}
} catch (_) {}
}
try {
const caKeyPem = fs.readFileSync(caKeyPath, 'utf8');
const caCertPem = fs.readFileSync(caCertPath, 'utf8');
const caKey = forge.pki.privateKeyFromPem(caKeyPem);
const caCert = forge.pki.certificateFromPem(caCertPem);
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = String(Date.now());
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [{ name: 'commonName', value: domain }];
cert.setSubject(attrs);
cert.setIssuer(caCert.subject.attributes);
const akid = forge.pki.getPublicKeyFingerprint(caCert.publicKey, { md: forge.md.sha1.create() });
const skid = forge.pki.getPublicKeyFingerprint(cert.publicKey, { md: forge.md.sha1.create() });
const finalAltNames = Array.isArray(altNames) && altNames.length ? altNames : [{ type: 2, value: domain }];
cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'extKeyUsage', serverAuth: true },
{ name: 'subjectAltName', altNames: finalAltNames },
{ name: 'authorityKeyIdentifier', keyIdentifier: akid.getBytes() },
{ name: 'subjectKeyIdentifier' }
]);
cert.sign(caKey, forge.md.sha256.create(), { padding: forge.pki.rsa.PKCS1_v1_5 });
const pemKey = forge.pki.privateKeyToPem(keys.privateKey);
const pemCert = forge.pki.certificateToPem(cert);
const pemChain = pemCert + caCertPem;
fs.writeFileSync(keyPath, pemKey);
fs.writeFileSync(certPath, pemChain);
return { key: pemKey, cert: pemChain };
} catch (e) {
logError('CERT', 'Error signing certificate for ' + domain + ': ' + e.message);
return null;
}
}
/**
* Return the absolute path to the root CA certificate PEM file.
* @returns {string}
*/
function getCaCertPath() {
return caCertPath;
}
/**
* Return the absolute path to the directory where all certificates are stored.
* @returns {string}
*/
function getCertsDir() {
return certsDir;
}
/**
* Check whether the root CA is currently trusted by the OS.
* The check is platform-specific and compares SHA-256 fingerprints.
* @param {Function} callback - Called as `callback(isInstalled: boolean)`.
*/
function isRootCAInstalled(callback) {
if (platform === 'darwin') {
// Check SSL trust policy AND that the fingerprint matches the current CA on disk.
// This catches the case where an old CA with the same CN is trusted but the
// current CA (regenerated) is different — which would cause cert errors.
runCommand(`security verify-cert -p ssl -c "${caCertPath}" 2>/dev/null`, (err) => {
if (err) { callback(false); return; }
const home = process.env.HOME || process.env.USERPROFILE || '';
const loginKc = home ? path.join(home, 'Library', 'Keychains', 'login.keychain-db') : '';
runCommand(`security find-certificate -a -c "${CA_COMMON_NAME}" -Z "${loginKc}" 2>/dev/null`, (errFind, findOut) => {
if (errFind || !findOut) { callback(false); return; }
const m = findOut.match(/SHA-1 hash:\s*([0-9A-Fa-f]+)/);
if (!m) { callback(false); return; }
const installedSha1 = m[1].toUpperCase();
runCommand(`openssl x509 -in "${caCertPath}" -noout -fingerprint -sha1 2>/dev/null`, (errFp, fpOut) => {
if (errFp) { callback(false); return; }
const currentSha1 = (fpOut.split('=')[1] || '').trim().replace(/:/g, '').toUpperCase();
callback(installedSha1 === currentSha1);
});
});
});
} else if (platform === 'win32') {
// On Windows, match by the SHA-1 fingerprint of the current CA on disk
// so we detect stale entries with the same CN but a different key.
try {
const caCertPem = fs.readFileSync(caCertPath, 'utf8');
const caCert = forge.pki.certificateFromPem(caCertPem);
const der = forge.asn1.toDer(forge.pki.certificateToAsn1(caCert)).getBytes();
const md = forge.md.sha1.create();
md.update(der);
const thumbprint = md.digest().toHex().toUpperCase().match(/.{2}/g).join(' ');
runCommand(`certutil -store ROOT | findstr /i "${thumbprint.slice(0, 29)}"`, (err, stdout) => {
callback(!err && stdout && stdout.trim() !== '');
});
} catch (_) {
runCommand(`certutil -store ROOT | findstr "${CA_COMMON_NAME}"`, (err, stdout) => {
callback(!err && stdout && stdout.trim() !== '');
});
}
} else if (platform === 'linux') {
const caTargetPath = '/usr/local/share/ca-certificates/holesail-browser-ca.crt';
// Compare fingerprints: installed file vs current CA on disk
runCommand(`openssl x509 -in "${caTargetPath}" -noout -fingerprint -sha256 2>/dev/null`, (errInstalled, installedOut) => {
if (errInstalled) { callback(false); return; }
runCommand(`openssl x509 -in "${caCertPath}" -noout -fingerprint -sha256 2>/dev/null`, (errDisk, diskOut) => {
if (errDisk) { callback(false); return; }
callback(installedOut.trim() === diskOut.trim());
});
});
} else {
callback(false);
}
}
/**
* Get or create a wildcard certificate for a parent domain of any depth.
* e.g. getOrCreateWildcardCert('hole.sail') -> cert covering *.hole.sail
* getOrCreateWildcardCert('love.hole.sail') -> cert covering *.love.hole.sail
*
* This is the preferred API for the SNI-aware HTTPS proxy — one cert per
* wildcard parent, selected at handshake time based on the SNI hostname.
*/
function getOrCreateWildcardCert (parentDomain) {
if (!parentDomain || parentDomain.split('.').length < 2) return null;
const certKey = 'wildcard.' + parentDomain;
const altNames = [
{ type: 2, value: '*.' + parentDomain },
{ type: 2, value: parentDomain }
];
return getOrCreateDomainCert(certKey, altNames);
}
module.exports = {
installRootCA,
isRootCAInstalled,
getOrCreateDomainCert,
getOrCreateWildcardCert,
getCaCertPath,
getCertsDir,
/** Resolves when the CA is ready (either already existed or was generated). */
caReady
};