feat(mobile): bind device identities and bound voice usage in 0.1.4 dev

This commit is contained in:
yilsem committed 2026-09-12 12:11:18 +03:00
1 parent ac6687181e
commit 5f75dcc99a
24 files changed
+909 -47

No files matched your search

+94 -3
View File
@@ -18,6 +18,7 @@ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const browser = await chromium.launch({ channel: 'msedge', headless: true })
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 })
page.setDefaultTimeout(15000)
const errors = []; page.on('pageerror', error => errors.push(error.message))
await page.goto(`http://127.0.0.1:${server.address().port}`)
await page.getByRole('heading', { name: 'Fikrini konuş. Birlikte geliştir.' }).waitFor()
@@ -42,17 +43,106 @@ try {
claimed = true
const body = route.request().postDataJSON(); assert.equal(body.secret, 'a'.repeat(43))
await route.fulfill({ status: 201, contentType: 'application/json', headers: { 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify({ device_id: 'fixture-phone', device_token: 'fixture-device-secret', pairing_id: 'fixture-pair', user_code: 'QRSTUVWX' }) })
body: JSON.stringify({ device_id: '33333333-3333-4333-8333-333333333333', device_token: 'fixture-device-secret', pairing_id: 'fixture-pair', user_code: 'QRSTUVWX' }) })
})
const qrImage = await page.evaluate(async () => {
const { drawQr } = await import('./qr.mjs'), canvas = document.createElement('canvas')
drawQr(canvas, JSON.stringify({ kind: 'aiturk-mobile-invite', version: 1, inviteId: '11111111-1111-4111-8111-111111111111',
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
const desktopFingerprint = window.fixtureDesktopCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value
drawQr(canvas, JSON.stringify({ kind: 'aiturk-mobile-invite', version: 2, desktopFingerprint, inviteId: '11111111-1111-4111-8111-111111111111',
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(Date.now() + 600000).toISOString() }))
return canvas.toDataURL('image/png').split(',')[1]
})
await page.locator('#qr-file').setInputFiles({ name: 'invite.png', mimeType: 'image/png', buffer: Buffer.from(qrImage, 'base64') })
await page.getByText('Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: QRSTUVWX').waitFor()
assert.equal(claimed, true)
const phoneQr = await page.evaluate(() => {
const canvas = document.getElementById('phone-identity-qr')
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height)
return { decoded: jsQR(pixels.data, pixels.width, pixels.height)?.data,
expected: document.getElementById('phone-identity').value, visible: !canvas.hidden }
})
assert.equal(phoneQr.visible, true)
assert.equal(phoneQr.decoded, phoneQr.expected)
// Run the actual app -> AccountClient -> WebRTC -> VoiceSession wiring.
// Portal, IDE responses and Google frames are synthetic; no paid/API traffic.
await page.route('https://turkservis.online/v1/aiturk/mobile/devices', route => route.fulfill({
json: { devices: [{ id: '22222222-2222-4222-8222-222222222222', name: 'Test bilgisayarı', platform: 'windows', self: false }] } }))
await page.route('https://turkservis.online/v1/aiturk/mobile/ice', route => route.fulfill({ json: { iceServers: [] } }))
await page.route('https://turkservis.online/v1/aiturk/mobile/channels**', async route => {
if (route.request().method() === 'POST' && !route.request().url().endsWith('/heartbeat')) {
const { sdp } = route.request().postDataJSON()
await page.evaluate(async sdp => {
const { completeIce, sendFrame } = await import('./transport.mjs')
const { assertRemoteIdentity } = await import('./peer-identity.mjs')
const trustedPhone = JSON.parse(document.getElementById('phone-identity').value)
assertRemoteIdentity(sdp, trustedPhone.fingerprint)
const pc = new RTCPeerConnection({ certificates: [window.fixtureDesktopCertificate] }); window.fixtureDesktop = pc; window.fixtureActions = []
pc.ondatachannel = event => { event.channel.onmessage = incoming => {
const request = JSON.parse(incoming.data); window.fixtureActions.push(request.action)
const results = {
'sessions.list': { sessions: [{ id: 'fixture-session', title: 'Test işi', model: 'fixture-model' }] },
'session.open': { model: 'fixture-model', messages: [], approval: null, running: false },
'voice.token': { token: 'fixture-only', model: 'gemini-3.1-flash-live-preview' },
}
sendFrame(event.channel, { id: request.id, result: results[request.action] || {} })
} }
await pc.setRemoteDescription({ type: 'offer', sdp })
await pc.setLocalDescription(await pc.createAnswer()); await completeIce(pc)
window.fixtureAnswer = pc.localDescription.sdp
}, sdp)
await route.fulfill({ json: {} })
} else await route.fulfill({ json: { sdp: await page.evaluate(() => window.fixtureAnswer) } })
})
await page.evaluate(() => {
window.fixtureVoiceSockets = []
window.WebSocket = class {
constructor(url) {
if (!url.startsWith('wss://generativelanguage.googleapis.com/')) throw new Error('Unexpected test socket')
this.readyState = 1; this.bufferedAmount = 0; window.fixtureVoiceSockets.push(this)
queueMicrotask(() => this.onopen?.())
}
send(raw) {
if (JSON.parse(raw).setup) queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ setupComplete: {} }) }))
}
close() { this.readyState = 3; this.onclose?.() }
}
})
await page.getByRole('button', { name: 'Onaydan sonra bilgisayarları bul' }).click()
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
await page.getByText('Bilgisayar bağlı. IDE konuşmasını seçin.', { exact: true }).waitFor()
await page.getByRole('button', { name: 'Konuşmaları yenile' }).click()
await page.getByRole('button', { name: 'Bu konuşmayı seç', exact: true }).click()
await page.getByText('IDE konuşması açıldı.', { exact: true }).waitFor()
await page.locator('#consent').check()
await page.locator('#voice-budget').fill('0.01')
await page.locator('#voice-minutes').fill('3')
await page.getByRole('button', { name: 'Asistanla yazış', exact: true }).click()
await page.getByText('Google asistanı bağlı.', { exact: true }).waitFor()
assert.match(await page.locator('#usage').textContent(), /henüz bilinmiyor/)
await page.evaluate(() => window.fixtureVoiceSockets[0].onmessage({ data: JSON.stringify({ usageMetadata: {
promptTokenCount: 1000, responseTokenCount: 1000, totalTokenCount: 2000,
promptTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
responseTokensDetails: [{ modality: 'AUDIO', tokenCount: 1000 }],
} }) }))
await page.locator('#voice-status').filter({ hasText: 'maliyet sınırına ulaşıldı' }).waitFor()
assert.match(await page.locator('#usage').textContent(), /0\.0150–0\.0150 USD/)
assert.equal(await page.evaluate(() => window.fixtureVoiceSockets[0].readyState), 3)
assert.deepEqual(await page.evaluate(() => window.fixtureActions), ['sessions.list', 'session.open', 'voice.token'])
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('aiturk.voice-limits'))), { minutes: 3, usd: 0.01 })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
await mkdir('test-results', { recursive: true })
await page.screenshot({ path: 'test-results/android-voice-limit.png', fullPage: true })
await page.evaluate(() => window.fixtureDesktop.close())
// A signaling service returning another machine's valid SDP must not replace
// the identity captured from the user's QR or receive any IDE requests.
await page.evaluate(async () => {
window.fixtureDesktopCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
})
await page.getByRole('button', { name: 'Bilgisayara bağlan', exact: true }).click()
await page.locator('#status').filter({ hasText: 'Cihaz kimliği doğrulanamadı' }).waitFor()
assert.deepEqual(await page.evaluate(() => window.fixtureActions), [])
await page.evaluate(() => window.fixtureDesktop.close())
await page.getByRole('button', { name: 'Bu uygulamadaki hesaptan çık' }).click()
await page.getByText('Bu uygulamanın oturumu kapatıldı.', { exact: false }).waitFor()
assert.deepEqual(loginCalls, ['start', 'poll', 'logout'])
@@ -83,5 +173,6 @@ try {
})
assert.equal(result.length, result.expected); assert.equal(result.dtls, 'connected'); assert.equal(result.action, 'session.history')
assert.deepEqual(errors, [])
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realQrImageWithMockedClaim: 'passed', realWebRTC: result, errors }))
console.log(JSON.stringify({ onboarding: 'passed', mockedLoginUi: 'passed', realQrImageWithMockedClaim: 'passed',
appWebRTCWithSyntheticGoogleUsageLimit: 'passed', qrPinnedDesktopReplacement: 'rejected', realWebRTC: result, errors }))
} finally { await browser.close(); server.close() }
+119
View File
@@ -0,0 +1,119 @@
// Real identity-module + native DTLS test; full app pairing is tested separately.
// Synthetic local peers, isolated browser context, no account or Google requests.
import { chromium } from 'playwright'
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import assert from 'node:assert/strict'
const server = createServer(async (request, response) => {
if (request.url === '/peer-identity.mjs') {
try {
const source = await readFile(new URL('../src/peer-identity.mjs', import.meta.url))
response.writeHead(200, { 'Content-Type': 'text/javascript' }); response.end(source)
} catch { response.writeHead(500); response.end() }
return
}
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end('<!doctype html><title>Local certificate test</title>')
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
let browser
try {
browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage()
page.setDefaultTimeout(20000)
await page.goto(`http://127.0.0.1:${server.address().port}`)
const original = await page.evaluate(async () => {
const { createDeviceIdentity } = await import('/peer-identity.mjs')
const results = await Promise.all(Array.from({ length: 4 }, () => createDeviceIdentity('11111111-1111-4111-8111-111111111111')))
if (new Set(results.map(value => value.fingerprint)).size !== 1) throw new Error('Concurrent identity creation diverged')
const { certificate, fingerprint } = results[0]
return { fingerprints: certificate.getFingerprints(), fingerprint, expires: certificate.expires }
})
await page.reload()
const result = await page.evaluate(async expected => {
const { loadDeviceIdentity, assertRemoteIdentity } = await import('/peer-identity.mjs')
const { certificate } = await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', expected)
let missingRejected = false, wrongPinRejected = false
try { await loadDeviceIdentity('22222222-2222-4222-8222-222222222222', expected) } catch { missingRejected = true }
try { await loadDeviceIdentity('11111111-1111-4111-8111-111111111111', Array(32).fill('00').join(':')) } catch { wrongPinRejected = true }
const peers = []
const complete = async pc => {
if (pc.iceGatheringState === 'complete') return
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error('ICE timeout')) }, 10000)
const check = () => { if (pc.iceGatheringState === 'complete') { cleanup(); resolve() } }
const cleanup = () => { clearTimeout(timeout); pc.removeEventListener('icegatheringstatechange', check) }
pc.addEventListener('icegatheringstatechange', check); check()
})
}
try {
const phone = new RTCPeerConnection({ iceServers: [] })
const desktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
peers.push(phone, desktop)
const channel = phone.createDataChannel('certificate-probe')
desktop.ondatachannel = event => { event.channel.onmessage = message => event.channel.send(message.data) }
await phone.setLocalDescription(await phone.createOffer()); await complete(phone)
await desktop.setRemoteDescription(phone.localDescription)
await desktop.setLocalDescription(await desktop.createAnswer()); await complete(desktop)
const advertised = desktop.localDescription.sdp.split(/\r?\n/).filter(line => line.startsWith('a=fingerprint:'))
assertRemoteIdentity(desktop.localDescription.sdp, expected)
await phone.setRemoteDescription(desktop.localDescription)
const echo = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('DTLS echo timeout')), 15000)
channel.onmessage = event => { clearTimeout(timeout); resolve(event.data) }
channel.onopen = () => channel.send('Türkçe kimlik doğrulama 🟢')
if (channel.readyState === 'open') channel.onopen()
})
const goodDtls = phone.sctp.transport.state
const wrongCertificate = await RTCPeerConnection.generateCertificate({ name: 'ECDSA', namedCurve: 'P-256' })
const wrongFingerprint = wrongCertificate.getFingerprints().find(item => item.algorithm === 'sha-256').value.toUpperCase()
let untrustedPeerRejected = false
try { assertRemoteIdentity(desktop.localDescription.sdp, wrongFingerprint) } catch { untrustedPeerRejected = true }
const badPhone = new RTCPeerConnection({ iceServers: [] })
const badDesktop = new RTCPeerConnection({ iceServers: [], certificates: [certificate] })
peers.push(badPhone, badDesktop)
const badChannel = badPhone.createDataChannel('certificate-mismatch')
let mismatchOpened = false
badChannel.onopen = () => { mismatchOpened = true }
await badPhone.setLocalDescription(await badPhone.createOffer()); await complete(badPhone)
await badDesktop.setRemoteDescription(badPhone.localDescription)
await badDesktop.setLocalDescription(await badDesktop.createAnswer()); await complete(badDesktop)
await badPhone.setRemoteDescription({ type: 'answer', sdp: badDesktop.localDescription.sdp
.split(/\r?\n/).map(line => line.startsWith('a=fingerprint:sha-256 ')
? `a=fingerprint:sha-256 ${wrongFingerprint}` : line).join('\r\n') })
const mismatchState = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error('No definitive certificate rejection')) }, 15000)
const check = () => {
const state = badPhone.sctp?.transport.state
if (state === 'failed' || state === 'connected') { cleanup(); resolve(state) }
}
const cleanup = () => { clearTimeout(timeout); badPhone.removeEventListener('connectionstatechange', check) }
badPhone.addEventListener('connectionstatechange', check); check()
})
return { fingerprints: certificate.getFingerprints(), expires: certificate.expires,
nativeCertificate: certificate instanceof RTCCertificate, advertised, echo,
dtls: goodDtls, mismatchState, mismatchOpened, missingRejected, wrongPinRejected, untrustedPeerRejected }
} finally { for (const peer of peers) peer.close() }
}, original.fingerprint)
assert.equal(result.nativeCertificate, true)
assert.deepEqual(result.fingerprints, original.fingerprints)
assert.equal(result.expires, original.expires)
assert.ok(result.expires > Date.now())
const sha256 = original.fingerprints.find(item => item.algorithm === 'sha-256')
assert.ok(sha256)
assert.ok(result.advertised.some(line => line.toLowerCase() === `a=fingerprint:sha-256 ${sha256.value}`.toLowerCase()))
assert.equal(result.echo, 'Türkçe kimlik doğrulama 🟢')
assert.equal(result.dtls, 'connected')
assert.equal(result.mismatchState, 'failed')
assert.equal(result.mismatchOpened, false)
assert.equal(result.missingRejected, true)
assert.equal(result.wrongPinRejected, true)
assert.equal(result.untrustedPeerRejected, true)
console.log(JSON.stringify({ nativeCertificatePersistence: 'passed', reload: 'passed',
restoredCertificateDtlsEcho: 'passed', nativeFingerprintMismatch: 'rejected',
concurrentCreation: 'passed', missingKeyAndWrongPin: 'rejected', scope: 'identity module and native DTLS; not full app pairing' }))
} finally {
await browser?.close()
server.close()
}
+67 -1
View File
@@ -25,8 +25,74 @@ try {
assert.ok(!blob.includes(Buffer.from('local-fake-account-for-encryption-test')))
const vault = new MobileVault(join(directory, 'mobile'), safeStorage)
assert.equal((await vault.load()).accountToken, 'local-fake-account-for-encryption-test')
await vault.save({ ...await vault.load(), deviceId: '11111111-1111-4111-8111-111111111111' })
const fingerprint = await window.webContents.executeJavaScript(`(async () => {
const { createDeviceIdentity } = await import('./mobile/peer-identity.mjs');
const setup = await mobileHost.call('setup');
const identity = await createDeviceIdentity(setup.deviceId);
await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:identity.fingerprint});
let rejected = false;
try { await mobileHost.call('identity.save', {deviceId:setup.deviceId, fingerprint:Array(32).fill('00').join(':')}); }
catch { rejected = true; }
if (!rejected) throw new Error('Changed identity silently accepted');
return identity.fingerprint;
})()`)
assert.equal((await vault.load()).localFingerprint, fingerprint)
const reloaded = new Promise(resolve => window.webContents.once('did-finish-load', resolve))
window.webContents.reload(); await reloaded
const restored = await window.webContents.executeJavaScript(`(async () => {
const { loadDeviceIdentity } = await import('./mobile/peer-identity.mjs');
const setup = await mobileHost.call('setup');
const identity = await loadDeviceIdentity(setup.deviceId, setup.localFingerprint);
return identity.fingerprint;
})()`)
assert.equal(restored, fingerprint)
const originalFetch = globalThis.fetch, phoneId = '33333333-3333-4333-8333-333333333333'
const pairingId = '44444444-4444-4444-8444-444444444444', phonePin = Array(32).fill('AB').join(':')
let advertisedPin = phonePin, approvals = 0
globalThis.fetch = async (url, options) => {
const path = new URL(url).pathname
if (path === `/v1/aiturk/pairings/${pairingId}/approve` && options.method === 'POST') {
approvals++
return Response.json({ device_id: phoneId, status: 'approved' })
}
if (path === '/v1/aiturk/mobile/channels') return Response.json({ channels: [{ id: 'fixture-channel',
phone_id: phoneId, sdp: `v=0\r\na=fingerprint:sha-256 ${advertisedPin}\r\n` }] })
throw new Error('Unexpected network request in isolated test')
}
try {
const proof = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: phonePin })
const qrImport = await window.webContents.executeJavaScript(`(async () => {
const { drawQr } = await import('./mobile/qr.mjs');
const canvas = document.createElement('canvas'); drawQr(canvas, ${JSON.stringify(proof)});
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'phone-identity.png', {type:'image/png'}));
const input = document.getElementById('phone-identity-file'); input.files = transfer.files;
await input.onchange({target:input});
return {pairingId:document.getElementById('pairing').value, proof:document.getElementById('phone-identity').value,
status:document.getElementById('status').textContent};
})()`)
assert.equal(qrImport.pairingId, pairingId)
assert.equal(qrImport.proof, proof)
assert.match(qrImport.status, /henüz izin verilmedi/)
assert.equal(approvals, 0)
const accepted = await window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: proof })})`)
assert.equal(accepted.device_id, phoneId)
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
const inbox = await window.webContents.executeJavaScript(`mobileHost.call('inbox')`)
assert.equal(inbox.channels[0].trustedPhoneFingerprint, phonePin)
advertisedPin = Array(32).fill('CD').join(':')
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('inbox')`), /kimliği/)
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('channel.answer', {channelId:'fixture-channel',sdp:'unused'})`), /kimliği/)
const changed = JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, pairingId, deviceId: phoneId, fingerprint: advertisedPin })
await assert.rejects(window.webContents.executeJavaScript(`mobileHost.call('phone.approve', ${JSON.stringify({ pairingId, code: 'QRSTUVWX', identity: changed })})`), /kimliği/)
assert.equal(approvals, 1)
assert.equal((await vault.load()).phoneFingerprints[phoneId], phonePin)
} finally { globalThis.fetch = originalFetch }
await vault.save({})
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
const receipt = { realElectronIpc: 'passed', windowsSafeStorage: 'passed', desktopIdentityReload: 'passed',
silentIdentityReplacement: 'rejected', locallyApprovedPhonePin: 'passed',
changedPhoneBeforeAnswer: 'rejected', identityQrImportWithoutApproval: 'passed', heading: result.heading, checkedAt: new Date().toISOString() }
await writeFile('apps/mobile/test-results/electron-smoke.json', JSON.stringify(receipt))
console.log(JSON.stringify(receipt))
window.destroy(); app.exit(0)