import { execFile } from 'node:child_process' import { access, chmod, mkdir, rm, writeFile } from 'node:fs/promises' import { constants } from 'node:fs' import { homedir, platform } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { configPath } from './connector-config.mjs' const run = promisify(execFile) export function certificateDirectory() { return process.env.SPINE_CONNECTOR_TLS_DIR || join(dirname(configPath()), 'tls') } export function certificatePaths(directory = certificateDirectory()) { return { directory, caKey: join(directory, 'ca-key.pem'), ca: join(directory, 'ca.pem'), serverKey: join(directory, 'server-key.pem'), serverCertificate: join(directory, 'server.pem'), identity: join(directory, 'identity.p12'), request: join(directory, 'server.csr'), extensions: join(directory, 'server.ext'), } } async function certificateIsUsable(path) { try { await access(path, constants.R_OK) await run('/usr/bin/openssl', ['x509', '-checkend', '2592000', '-noout', '-in', path]) return true } catch { return false } } async function identityIsUsable(path) { try { await access(path, constants.R_OK) return true } catch { return false } } async function runOpenSSL(args) { try { await run('/usr/bin/openssl', args) } catch (error) { const detail = String(error?.stderr || error?.message || error).trim() throw new Error(`无法生成本机 HTTPS 证书:${detail}`) } } export async function ensureLocalCertificate() { if (platform() !== 'darwin') throw new Error('Safari 本机 HTTPS 证书仅支持 macOS') const paths = certificatePaths() if (await certificateIsUsable(paths.serverCertificate) && await identityIsUsable(paths.identity)) return paths await mkdir(paths.directory, { recursive: true, mode: 0o700 }) await runOpenSSL(['genrsa', '-out', paths.caKey, '2048']) await runOpenSSL([ 'req', '-x509', '-new', '-key', paths.caKey, '-sha256', '-days', '3650', '-subj', '/CN=Spine Particle Connector Local CA', '-out', paths.ca, ]) await runOpenSSL(['genrsa', '-out', paths.serverKey, '2048']) await runOpenSSL(['req', '-new', '-key', paths.serverKey, '-subj', '/CN=127.0.0.1', '-out', paths.request]) await writeFile(paths.extensions, 'subjectAltName=IP:127.0.0.1,DNS:localhost\nextendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n', 'utf8') await runOpenSSL([ 'x509', '-req', '-in', paths.request, '-CA', paths.ca, '-CAkey', paths.caKey, '-CAcreateserial', '-out', paths.serverCertificate, '-days', '825', '-sha256', '-extfile', paths.extensions, ]) await runOpenSSL([ 'pkcs12', '-export', '-out', paths.identity, '-inkey', paths.serverKey, '-in', paths.serverCertificate, '-passout', 'pass:spine-connector-local', ]) await Promise.all([paths.serverKey, paths.identity].map((path) => chmod(path, 0o600))) await rm(paths.caKey, { force: true }) return paths } export async function localCertificateTrusted() { const paths = certificatePaths() try { await run('/usr/bin/security', [ 'verify-cert', '-c', paths.serverCertificate, '-n', '127.0.0.1', '-p', 'ssl', '-L', '-q', ]) return true } catch { return false } } export async function trustLocalCertificate() { if (platform() !== 'darwin') throw new Error('Safari 本机 HTTPS 证书仅支持 macOS') const paths = await ensureLocalCertificate() const loginKeychain = join(homedir(), 'Library', 'Keychains', 'login.keychain-db') try { await run('/usr/bin/security', [ 'add-trusted-cert', '-r', 'trustRoot', '-p', 'ssl', '-k', loginKeychain, paths.ca, ]) } catch (error) { const detail = String(error?.stderr || error?.message || error).trim() throw new Error(detail || '无法把本机证书加入信任列表') } if (!(await localCertificateTrusted())) throw new Error('证书已安装,但系统仍未信任它') return paths }