/** * 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> save: (value: Record) => Promise } 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 { 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 } }