Settings gets a Phone link window: when enabled, the IDE main process runs a small TLS proxy on the local network (self-signed certificate, pinned by its fingerprint) in front of the loopback backend and pairs a phone by QR code or an 8-character code. Each phone gets its own revocable token; the backend session token never leaves the computer. The root page, terminal sockets and secret or settings-changing endpoints are not reachable from a phone. The old WebRTC pairing window is replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
/**
|
||
* mobile-lan-cert.ts
|
||
*
|
||
* Self-signed ECDSA P-256 certificate for the "Telefon bağlantısı" LAN proxy
|
||
* (electron/mobile-lan-proxy.ts). No dependency: the X.509 structure is built
|
||
* by hand and signed with node:crypto.
|
||
*
|
||
* The phone never trusts a CA for this server. It pins the certificate by
|
||
* `fp` = SHA-256 of the certificate's DER bytes, base64url without padding
|
||
* (43 characters), which it learns from the pairing QR / rendezvous record.
|
||
*
|
||
* The private key lives only in the injected vault (MobileVault → Electron
|
||
* safeStorage → Windows DPAPI). Existing vault fields are preserved.
|
||
*/
|
||
|
||
import crypto from 'node:crypto'
|
||
|
||
export const MOBILE_LAN_CERT_DAYS = 3650
|
||
/** Regenerate when less than this is left; the phone must then pair again. */
|
||
const RENEW_BEFORE_MS = 30 * 24 * 3600e3
|
||
const FINGERPRINT_RE = /^[A-Za-z0-9_-]{43}$/
|
||
|
||
export interface MobileLanIdentity {
|
||
/** PEM certificate. */
|
||
cert: string
|
||
/** PEM PKCS#8 private key. Never leaves the main process. */
|
||
key: string
|
||
/** SHA-256 of the DER certificate, base64url, 43 characters. */
|
||
fingerprint: string
|
||
}
|
||
|
||
export interface MobileLanVault {
|
||
load: () => Promise<Record<string, any>>
|
||
save: (value: Record<string, any>) => Promise<void>
|
||
}
|
||
|
||
function der(tag: number, ...parts: Buffer[]): Buffer {
|
||
const body = Buffer.concat(parts)
|
||
let length: Buffer
|
||
|
||
if (body.length < 128) {
|
||
length = Buffer.from([body.length])
|
||
} else {
|
||
const bytes: number[] = []
|
||
|
||
for (let n = body.length; n > 0; n >>= 8) {
|
||
bytes.unshift(n & 255)
|
||
}
|
||
|
||
length = Buffer.from([0x80 | bytes.length, ...bytes])
|
||
}
|
||
|
||
return Buffer.concat([Buffer.from([tag]), length, body])
|
||
}
|
||
|
||
const seq = (...parts: Buffer[]) => der(0x30, ...parts)
|
||
const oid = (hex: string) => der(0x06, Buffer.from(hex, 'hex'))
|
||
const int = (value: Buffer) => der(0x02, value[0] & 0x80 ? Buffer.concat([Buffer.from([0]), value]) : value)
|
||
|
||
const utcTime = (date: Date) =>
|
||
der(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`, 'ascii'))
|
||
|
||
const ECDSA_SHA256 = seq(oid('2a8648ce3d040302'))
|
||
const name = (cn: string) => seq(der(0x31, seq(oid('550403'), der(0x0c, Buffer.from(cn, 'utf8')))))
|
||
|
||
export function fingerprintOfDer(certDer: Uint8Array): string {
|
||
return crypto.createHash('sha256').update(certDer).digest('base64url')
|
||
}
|
||
|
||
/** Fingerprint of a PEM certificate; throws when the PEM is not a certificate. */
|
||
export function fingerprintOfPem(certPem: string): string {
|
||
return fingerprintOfDer(new crypto.X509Certificate(certPem).raw)
|
||
}
|
||
|
||
export function createSelfSignedCertificate(
|
||
options: { commonName?: string; days?: number; now?: Date } = {}
|
||
): MobileLanIdentity {
|
||
const commonName = options.commonName ?? 'AITURK IDE'
|
||
const days = options.days ?? MOBILE_LAN_CERT_DAYS
|
||
const now = options.now ?? new Date()
|
||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
|
||
const serial = crypto.randomBytes(16)
|
||
|
||
serial[0] = (serial[0] & 0x7f) | 0x01
|
||
|
||
const notBefore = new Date(now.getTime() - 24 * 3600e3)
|
||
const notAfter = new Date(now.getTime() + days * 24 * 3600e3)
|
||
|
||
if (notAfter.getUTCFullYear() >= 2050 || notBefore.getUTCFullYear() < 1950) {
|
||
// UTCTime cannot carry these years; refuse rather than emit a wrong date.
|
||
throw new Error('mobile-lan-cert:validity-out-of-range')
|
||
}
|
||
|
||
const tbs = seq(
|
||
der(0xa0, int(Buffer.from([2]))), // v3
|
||
int(serial),
|
||
ECDSA_SHA256,
|
||
name(commonName),
|
||
seq(utcTime(notBefore), utcTime(notAfter)),
|
||
name(commonName),
|
||
publicKey.export({ type: 'spki', format: 'der' }) as Buffer
|
||
)
|
||
|
||
const signature = crypto.sign('sha256', tbs, privateKey)
|
||
const certDer = seq(tbs, ECDSA_SHA256, der(0x03, Buffer.from([0]), signature))
|
||
const body = (certDer.toString('base64').match(/.{1,64}/g) ?? []).join('\n')
|
||
|
||
return {
|
||
cert: `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`,
|
||
key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
||
fingerprint: fingerprintOfDer(certDer)
|
||
}
|
||
}
|
||
|
||
/** True when the stored pair is a usable, unexpired, matching identity. */
|
||
export function isUsableIdentity(value: unknown, now: Date = new Date()): value is MobileLanIdentity {
|
||
const row = value as MobileLanIdentity | null
|
||
|
||
if (!row || typeof row.cert !== 'string' || typeof row.key !== 'string' || !FINGERPRINT_RE.test(row.fingerprint)) {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
const x509 = new crypto.X509Certificate(row.cert)
|
||
|
||
return (
|
||
fingerprintOfDer(x509.raw) === row.fingerprint &&
|
||
x509.checkPrivateKey(crypto.createPrivateKey(row.key)) &&
|
||
new Date(x509.validTo).getTime() - now.getTime() > RENEW_BEFORE_MS
|
||
)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load the LAN identity from the vault, or create and store one. Stored under
|
||
* the vault's `lanTls` field; every other field is written back untouched.
|
||
* `created` tells the caller that previously paired phones no longer match.
|
||
*/
|
||
export async function loadOrCreateLanIdentity(
|
||
vault: MobileLanVault,
|
||
options: { now?: () => Date; create?: typeof createSelfSignedCertificate } = {}
|
||
): Promise<MobileLanIdentity & { created: boolean }> {
|
||
const now = options.now ? options.now() : new Date()
|
||
const secrets = (await vault.load()) ?? {}
|
||
|
||
if (isUsableIdentity(secrets.lanTls, now)) {
|
||
const { cert, key, fingerprint } = secrets.lanTls
|
||
|
||
return { cert, key, fingerprint, created: false }
|
||
}
|
||
|
||
const identity = (options.create ?? createSelfSignedCertificate)({ now })
|
||
|
||
await vault.save({ ...secrets, lanTls: identity })
|
||
|
||
return { ...identity, created: true }
|
||
}
|