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
105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
/**
|
||
* Tests for electron/mobile-lan-cert.ts — the dependency-free self-signed
|
||
* certificate of the "Telefon bağlantısı" LAN proxy and its vault storage.
|
||
*/
|
||
|
||
import crypto from 'node:crypto'
|
||
|
||
import { describe, expect, it } from 'vitest'
|
||
|
||
import {
|
||
createSelfSignedCertificate,
|
||
fingerprintOfPem,
|
||
isUsableIdentity,
|
||
loadOrCreateLanIdentity
|
||
} from './mobile-lan-cert'
|
||
|
||
function memoryVault(initial: Record<string, any> = {}) {
|
||
const state = { value: structuredClone(initial), saves: 0 }
|
||
|
||
return {
|
||
state,
|
||
load: async () => structuredClone(state.value),
|
||
save: async (value: Record<string, any>) => {
|
||
state.value = structuredClone(value)
|
||
state.saves++
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('createSelfSignedCertificate', () => {
|
||
it('produces a parseable, self-verifying ECDSA P-256 X.509 certificate', () => {
|
||
const identity = createSelfSignedCertificate()
|
||
const x509 = new crypto.X509Certificate(identity.cert)
|
||
|
||
expect(x509.verify(x509.publicKey)).toBe(true)
|
||
expect(x509.publicKey.asymmetricKeyType).toBe('ec')
|
||
expect(x509.publicKey.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||
expect(x509.checkPrivateKey(crypto.createPrivateKey(identity.key))).toBe(true)
|
||
expect(new Date(x509.validTo).getTime()).toBeGreaterThan(Date.now() + 3000 * 24 * 3600e3)
|
||
expect(new Date(x509.validFrom).getTime()).toBeLessThan(Date.now())
|
||
})
|
||
|
||
it('fingerprint is the base64url SHA-256 of the DER bytes (43 characters, no padding)', () => {
|
||
const identity = createSelfSignedCertificate()
|
||
const der = new crypto.X509Certificate(identity.cert).raw
|
||
|
||
expect(identity.fingerprint).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||
expect(identity.fingerprint).toBe(crypto.createHash('sha256').update(der).digest('base64url'))
|
||
expect(fingerprintOfPem(identity.cert)).toBe(identity.fingerprint)
|
||
expect(createSelfSignedCertificate().fingerprint).not.toBe(identity.fingerprint)
|
||
})
|
||
|
||
it('refuses a validity that UTCTime cannot carry', () => {
|
||
expect(() => createSelfSignedCertificate({ now: new Date('2045-01-01T00:00:00Z') })).toThrow(
|
||
'mobile-lan-cert:validity-out-of-range'
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('loadOrCreateLanIdentity', () => {
|
||
it('creates once, stores the key only in the vault, keeps the other vault fields, then reuses it', async () => {
|
||
const vault = memoryVault({ accountToken: 'old-field', phoneFingerprints: { a: 'b' } })
|
||
const first = await loadOrCreateLanIdentity(vault)
|
||
|
||
expect(first.created).toBe(true)
|
||
expect(vault.state.value.accountToken).toBe('old-field')
|
||
expect(vault.state.value.phoneFingerprints).toEqual({ a: 'b' })
|
||
expect(vault.state.value.lanTls.key).toContain('PRIVATE KEY')
|
||
|
||
const second = await loadOrCreateLanIdentity(vault)
|
||
|
||
expect(second.created).toBe(false)
|
||
expect(second.fingerprint).toBe(first.fingerprint)
|
||
expect(vault.state.saves).toBe(1)
|
||
})
|
||
|
||
it('replaces a corrupt, mismatched or nearly expired identity', async () => {
|
||
const good = createSelfSignedCertificate()
|
||
const other = createSelfSignedCertificate()
|
||
|
||
expect(isUsableIdentity(good)).toBe(true)
|
||
expect(isUsableIdentity({ ...good, key: other.key })).toBe(false)
|
||
expect(isUsableIdentity({ ...good, fingerprint: other.fingerprint })).toBe(false)
|
||
expect(isUsableIdentity({ ...good, cert: 'not a certificate' })).toBe(false)
|
||
expect(isUsableIdentity(good, new Date(Date.now() + 3640 * 24 * 3600e3))).toBe(false)
|
||
|
||
const vault = memoryVault({ lanTls: { ...good, key: other.key } })
|
||
const renewed = await loadOrCreateLanIdentity(vault)
|
||
|
||
expect(renewed.created).toBe(true)
|
||
expect(renewed.fingerprint).not.toBe(good.fingerprint)
|
||
})
|
||
|
||
it('surfaces a vault failure instead of running without a stored key', async () => {
|
||
const vault = {
|
||
load: async () => {
|
||
throw new Error('vault closed')
|
||
},
|
||
save: async () => undefined
|
||
}
|
||
|
||
await expect(loadOrCreateLanIdentity(vault)).rejects.toThrow('vault closed')
|
||
})
|
||
})
|