/** * 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 `//`. 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 };