diff --git a/src/main/index.ts b/src/main/index.ts index e98aedab0c5..a6262753101 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -435,7 +435,8 @@ app.whenReady().then(async () => { }) runtimeRpc = new OrcaRuntimeRpcServer({ runtime, - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + enableWebSocket: true }) registerMobileHandlers(runtimeRpc) diff --git a/src/main/runtime/tls-certificate.ts b/src/main/runtime/tls-certificate.ts index a7b29a8ebb3..1972d13d16e 100644 --- a/src/main/runtime/tls-certificate.ts +++ b/src/main/runtime/tls-certificate.ts @@ -2,8 +2,9 @@ // to prevent passive sniffing of device tokens on shared WiFi networks. The // cert is generated once on first run and reused across restarts. The mobile // app pins the certificate fingerprint received during QR pairing. -import { createHash, generateKeyPairSync, createSign, randomBytes } from 'crypto' -import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs' +import { createHash } from 'crypto' +import { execSync } from 'child_process' +import { existsSync, readFileSync, chmodSync } from 'fs' import { join } from 'path' const TLS_CERT_FILENAME = 'orca-tls-cert.pem' @@ -22,180 +23,41 @@ export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate if (existsSync(certPath) && existsSync(keyPath)) { const cert = readFileSync(certPath, 'utf-8') const key = readFileSync(keyPath, 'utf-8') - return { cert, key, fingerprint: computeFingerprint(cert) } + const fingerprint = computeFingerprint(cert) + if (fingerprint) { + return { cert, key, fingerprint } + } + // Why: if the existing cert is malformed (e.g., from a buggy earlier + // generation), regenerate rather than failing the WebSocket transport. } - const { cert, key } = generateSelfSignedCert() + const keyPath_ = join(userDataPath, TLS_KEY_FILENAME) + const certPath_ = join(userDataPath, TLS_CERT_FILENAME) - writeFileSync(keyPath, key, { mode: 0o600 }) - chmodSync(keyPath, 0o600) - writeFileSync(certPath, cert, { mode: 0o600 }) - chmodSync(certPath, 0o600) + // Why: openssl is available on macOS, Linux, and Windows (via Git Bash). + // Using it avoids hand-rolling ASN.1 DER encoding which is error-prone. + execSync( + `openssl req -new -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` + + `-nodes -days 3650 -subj "/CN=Orca Runtime" ` + + `-keyout "${keyPath_}" -out "${certPath_}" 2>/dev/null` + ) - return { cert, key, fingerprint: computeFingerprint(cert) } + chmodSync(keyPath_, 0o600) + chmodSync(certPath_, 0o600) + + const cert = readFileSync(certPath_, 'utf-8') + const key = readFileSync(keyPath_, 'utf-8') + return { cert, key, fingerprint: computeFingerprint(cert)! } } -function computeFingerprint(certPem: string): string { +function computeFingerprint(certPem: string): string | null { const derMatch = certPem.match( /-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/ ) if (!derMatch?.[1]) { - throw new Error('Invalid PEM certificate') + return null } const der = Buffer.from(derMatch[1].replace(/\n/g, ''), 'base64') const hash = createHash('sha256').update(der).digest('hex') return `sha256:${hash}` } - -// Why: we generate a minimal self-signed X.509v1 certificate using only -// Node.js built-in crypto. This avoids a dependency on `selfsigned` or -// OpenSSL CLI and keeps the cert generation auditable in one place. -function generateSelfSignedCert(): { cert: string; key: string } { - const { publicKey, privateKey } = generateKeyPairSync('ec', { - namedCurve: 'prime256v1' - }) - - const keyPem = privateKey.export({ type: 'sec1', format: 'pem' }) as string - const pubDer = publicKey.export({ type: 'spki', format: 'der' }) - - const serialNumber = randomBytes(16) - serialNumber[0]! &= 0x7f - - const now = new Date() - const notBefore = formatAsn1Time(now) - // Why: 10 years validity — the cert is self-signed and pinned by fingerprint, - // not trusted via a CA chain, so long validity is fine. - const notAfter = formatAsn1Time(new Date(now.getFullYear() + 10, now.getMonth(), now.getDate())) - - const issuerAndSubject = encodeDistinguishedName('Orca Runtime') - - const tbs = encodeTbsCertificate({ - serialNumber, - issuer: issuerAndSubject, - notBefore, - notAfter, - subject: issuerAndSubject, - subjectPublicKeyInfo: pubDer - }) - - const signer = createSign('SHA256') - signer.update(tbs) - const signature = signer.sign(privateKey) - - const cert = encodeX509Certificate(tbs, signature) - const certPem = `-----BEGIN CERTIFICATE-----\n${cert - .toString('base64') - .match(/.{1,64}/g)! - .join('\n')}\n-----END CERTIFICATE-----\n` - - return { cert: certPem, key: keyPem } -} - -function formatAsn1Time(date: Date): string { - const y = date.getUTCFullYear().toString().slice(2) - const m = (date.getUTCMonth() + 1).toString().padStart(2, '0') - const d = date.getUTCDate().toString().padStart(2, '0') - return `${y}${m}${d}000000Z` -} - -function encodeAsn1Length(length: number): Buffer { - if (length < 0x80) { - return Buffer.from([length]) - } - if (length < 0x100) { - return Buffer.from([0x81, length]) - } - return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]) -} - -function encodeAsn1Sequence(...items: Buffer[]): Buffer { - const content = Buffer.concat(items) - return Buffer.concat([Buffer.from([0x30]), encodeAsn1Length(content.length), content]) -} - -function encodeAsn1Integer(value: Buffer): Buffer { - let buf = value - if (buf[0]! & 0x80) { - buf = Buffer.concat([Buffer.from([0x00]), buf]) - } - return Buffer.concat([Buffer.from([0x02]), encodeAsn1Length(buf.length), buf]) -} - -function encodeAsn1Utf8String(str: string): Buffer { - const buf = Buffer.from(str, 'utf-8') - return Buffer.concat([Buffer.from([0x0c]), encodeAsn1Length(buf.length), buf]) -} - -function encodeAsn1UtcTime(timeStr: string): Buffer { - const buf = Buffer.from(timeStr, 'ascii') - return Buffer.concat([Buffer.from([0x17]), encodeAsn1Length(buf.length), buf]) -} - -function encodeAsn1BitString(data: Buffer): Buffer { - const content = Buffer.concat([Buffer.from([0x00]), data]) - return Buffer.concat([Buffer.from([0x03]), encodeAsn1Length(content.length), content]) -} - -function encodeAsn1Oid(oid: number[]): Buffer { - const bytes: number[] = [oid[0]! * 40 + oid[1]!] - for (let i = 2; i < oid.length; i++) { - let value = oid[i]! - if (value < 128) { - bytes.push(value) - } else { - const encoded: number[] = [] - encoded.push(value & 0x7f) - value >>= 7 - while (value > 0) { - encoded.push((value & 0x7f) | 0x80) - value >>= 7 - } - encoded.reverse() - bytes.push(...encoded) - } - } - const buf = Buffer.from(bytes) - return Buffer.concat([Buffer.from([0x06]), encodeAsn1Length(buf.length), buf]) -} - -function encodeDistinguishedName(commonName: string): Buffer { - // CN attribute type OID: 2.5.4.3 - const cnOid = encodeAsn1Oid([2, 5, 4, 3]) - const cnValue = encodeAsn1Utf8String(commonName) - const atv = encodeAsn1Sequence(cnOid, cnValue) - // RDN is a SET of attribute-type-value - const rdnContent = atv - const rdn = Buffer.concat([Buffer.from([0x31]), encodeAsn1Length(rdnContent.length), rdnContent]) - return encodeAsn1Sequence(rdn) -} - -function encodeTbsCertificate(params: { - serialNumber: Buffer - issuer: Buffer - notBefore: string - notAfter: string - subject: Buffer - subjectPublicKeyInfo: Buffer -}): Buffer { - // ecdsaWithSHA256: 1.2.840.10045.4.3.2 - const signatureAlgorithm = encodeAsn1Sequence(encodeAsn1Oid([1, 2, 840, 10045, 4, 3, 2])) - - const validity = encodeAsn1Sequence( - encodeAsn1UtcTime(params.notBefore), - encodeAsn1UtcTime(params.notAfter) - ) - - return encodeAsn1Sequence( - encodeAsn1Integer(params.serialNumber), - signatureAlgorithm, - params.issuer, - validity, - params.subject, - params.subjectPublicKeyInfo - ) -} - -function encodeX509Certificate(tbs: Buffer, signature: Buffer): Buffer { - const signatureAlgorithm = encodeAsn1Sequence(encodeAsn1Oid([1, 2, 840, 10045, 4, 3, 2])) - return encodeAsn1Sequence(tbs, signatureAlgorithm, encodeAsn1BitString(signature)) -}