From 5f75dcc99a32b93285accbbf4fb54433c6596f70 Mon Sep 17 00:00:00 2001 From: yilsem Date: Sat, 12 Sep 2026 12:11:18 +0300 Subject: [PATCH] feat(mobile): bind device identities and bound voice usage in 0.1.4 dev --- apps/desktop/electron/mobile-host-ui.mjs | 39 +++++- apps/desktop/electron/mobile-host.html | 5 +- apps/desktop/electron/mobile-host.mjs | 32 ++++- apps/desktop/scripts/bundle-electron-main.mjs | 4 +- apps/mobile/README.md | 74 ++++++++++- apps/mobile/android/app/build.gradle | 4 +- apps/mobile/package-lock.json | 4 +- apps/mobile/package.json | 2 +- apps/mobile/scripts/browser-smoke.mjs | 97 +++++++++++++- apps/mobile/scripts/certificate-smoke.mjs | 119 ++++++++++++++++++ apps/mobile/scripts/electron-smoke.mjs | 68 +++++++++- apps/mobile/src/app.mjs | 41 ++++-- apps/mobile/src/index.html | 12 +- apps/mobile/src/invite.mjs | 7 +- apps/mobile/src/peer-identity.mjs | 99 +++++++++++++++ apps/mobile/src/privacy.html | 5 +- apps/mobile/src/style.css | 1 + apps/mobile/src/transport.mjs | 22 +++- apps/mobile/src/voice-usage.mjs | 87 +++++++++++++ apps/mobile/src/voice.mjs | 37 ++++-- apps/mobile/tests/invite.test.mjs | 6 +- apps/mobile/tests/peer-identity.test.mjs | 40 ++++++ .../tests/voice-start-cancellation.test.mjs | 51 ++++++++ apps/mobile/tests/voice-usage.test.mjs | 100 +++++++++++++++ 24 files changed, 909 insertions(+), 47 deletions(-) create mode 100644 apps/mobile/scripts/certificate-smoke.mjs create mode 100644 apps/mobile/src/peer-identity.mjs create mode 100644 apps/mobile/src/voice-usage.mjs create mode 100644 apps/mobile/tests/peer-identity.test.mjs create mode 100644 apps/mobile/tests/voice-start-cancellation.test.mjs create mode 100644 apps/mobile/tests/voice-usage.test.mjs diff --git a/apps/desktop/electron/mobile-host-ui.mjs b/apps/desktop/electron/mobile-host-ui.mjs index 23b1ff3..84b2b87 100644 --- a/apps/desktop/electron/mobile-host-ui.mjs +++ b/apps/desktop/electron/mobile-host-ui.mjs @@ -1,16 +1,45 @@ import { completeIce, rtcConfiguration, sendFrame } from './mobile/transport.mjs' -import { drawQr } from './mobile/qr.mjs' +import { drawQr, InviteScanner } from './mobile/qr.mjs' +import { createDeviceIdentity, loadDeviceIdentity, assertRemoteIdentity, parsePhoneIdentity } from './mobile/peer-identity.mjs' const host = window.mobileHost, $ = id => document.getElementById(id), peers = new Map() const status = text => { $('status').textContent = text } function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } } +const identityScanner = new InviteScanner(document.createElement('video'), async raw => { + if (raw.length > 700) throw new Error('Telefon kimlik belgesi çok büyük.') + const value = JSON.parse(raw) + const pairingId = $('pairing').value.trim() || value.pairingId + parsePhoneIdentity(raw, pairingId) + if (!/^[0-9a-f-]{36}$/.test(pairingId)) throw new Error('Eşleştirme kimliği geçersiz.') + $('pairing').value = pairingId; $('phone-identity').value = raw + status('Telefon kimliği QR resminden alındı. Telefondaki kısa kodu girin ve bu telefonu ayrıca onaylayın; henüz izin verilmedi.') +}) +$('phone-identity-file').onchange = async event => { + try { await identityScanner.image(event.target.files[0]) } + catch (error) { status(error.message) } + finally { event.target.value = '' } +} action('login-start', async () => { const result = await host.call('account.login.start'); $('login-code').textContent = `Açılan hesap sayfasında yalnız bu kodu onaylayın: ${result.code}` }) action('login-check', async () => { const result = await host.call('account.login.check'); status(result.approved ? 'Hesaba giriş yapıldı. Bu bilgisayarı eşleştirebilirsiniz.' : 'Tarayıcıdaki kod onayı bekleniyor.') }) action('logout', async () => { await host.call('account.logout'); listening = false; $('login-code').textContent = ''; status('Mobil oturum kapatıldı; hesap ve Google anahtarı bu bilgisayar kasasından temizlendi.') }) -action('invite', async () => { const invite = await host.call('invite.create'); drawQr($('invite-qr'), JSON.stringify(invite)); $('invite-qr').hidden = false; $('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}` }) +async function desktopIdentity(create = false) { + const setup = await host.call('setup') + if (!setup.deviceId) throw new Error('Önce bu bilgisayarı hesapla eşleştirin.') + if (setup.localFingerprint) return loadDeviceIdentity(setup.deviceId, setup.localFingerprint) + if (!create) throw new Error('Önce bu bilgisayarda yeni telefon QR kodu oluşturun.') + const identity = await createDeviceIdentity(setup.deviceId) + await host.call('identity.save', { deviceId: setup.deviceId, fingerprint: identity.fingerprint }) + return identity +} +action('invite', async () => { + const identity = await desktopIdentity(true), invite = await host.call('invite.create') + drawQr($('invite-qr'), JSON.stringify({ ...invite, version: 2, desktopFingerprint: identity.fingerprint })) + $('invite-qr').hidden = false + $('invite-status').textContent = `Telefonda aynı hesaba giriş yapıp bu QR kodunu tarayın. Son geçerlilik: ${invite.expiresAt}` +}) action('invite-check', async () => { const result = await host.call('invite.check'); if (!result.pending) { status('QR ile gelen telefon henüz yok.'); return }; $('pairing').value = result.pending.pairingId; status(`${result.pending.name} bulundu. Telefondaki kısa kodu girip “Bu telefonu onayla” seçin.`) }) action('save', async () => { const token = $('account').value; $('account').value = ''; await host.call('account.save', { token }); status('Hesap anahtarı Windows kasasına kaydedildi.') }) action('pair', async () => { const pair = await host.call('desktop.pair'); $('code').textContent = `Açılan hesap sayfasında bu kodu onaylayın: ${pair.code}. Süre: ${pair.expiresAt}` }) -action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase() }); $('phone-code').value = ''; status('Telefon bu bilgisayarda onaylandı.') }) +action('approve', async () => { await host.call('phone.approve', { pairingId: $('pairing').value.trim(), code: $('phone-code').value.trim().toUpperCase(), identity: $('phone-identity').value.trim() }); $('phone-code').value = ''; $('phone-identity').value = ''; status('Telefon bu bilgisayarda kimliğiyle onaylandı.') }) action('refresh', async () => { const data = await host.call('devices'); $('devices').replaceChildren() for (const device of data.devices) { @@ -24,8 +53,10 @@ action('refresh', async () => { function close(id) { const entry = peers.get(id); if (!entry) return; peers.delete(id); clearInterval(entry.timer); entry.pc.close(); void host.call('channel.close', { channelId: id }) } host.onEvent(event => { if (event.close) { close(event.channelId); return }; const entry = peers.get(event.channelId); if (entry?.channel?.readyState === 'open') sendFrame(entry.channel, event.frame) }) async function connect(offer) { + assertRemoteIdentity(offer.sdp, offer.trustedPhoneFingerprint) const ice = await host.call('ice') - const pc = new RTCPeerConnection(rtcConfiguration(ice.iceServers)), entry = { pc }; peers.set(offer.id, entry) + const identity = await desktopIdentity() + const pc = new RTCPeerConnection({ ...rtcConfiguration(ice.iceServers), certificates: [identity.certificate] }), entry = { pc }; peers.set(offer.id, entry) pc.ondatachannel = event => { const channel = event.channel; entry.channel = channel if (channel.label !== 'aiturk-mobile-v1') { close(offer.id); return } diff --git a/apps/desktop/electron/mobile-host.html b/apps/desktop/electron/mobile-host.html index 0918aea..b2ba6ea 100644 --- a/apps/desktop/electron/mobile-host.html +++ b/apps/desktop/electron/mobile-host.html @@ -15,7 +15,10 @@

Telefona izin ver

Telefondaki eşleştirme kimliğini ve 8 karakterli kodu buraya girin. Bu onay yalnız bu telefon ile bu bilgisayarı bağlar.

+ + +

Yalnız kendi telefonunuzun gösterdiği belgenin tamamını buraya aktarın. Onay, bu telefonun sertifikasını bilgisayarınıza kaydeder; farklı sertifika yeniden açık eşleştirme gerektirir.

Cihazlar

Farklı ağlarda bağlantı kurulması ağınıza ve TURN hizmeti yapılandırmasına bağlıdır. Bu geliştirme sürümünde genel ağ erişimi henüz doğrulanmadı.

- + diff --git a/apps/desktop/electron/mobile-host.mjs b/apps/desktop/electron/mobile-host.mjs index b9b229a..662bdb0 100644 --- a/apps/desktop/electron/mobile-host.mjs +++ b/apps/desktop/electron/mobile-host.mjs @@ -4,6 +4,7 @@ import { BrowserWindow, ipcMain, safeStorage, shell } from 'electron' import { MobileVault, LiveTokenIssuer } from './mobile-secrets.mjs' import { DeliveryJournal, MobileGatewayClient } from './mobile-adapter.mjs' import { AccountClient } from '../../mobile/src/transport.mjs' +import { normalizeFingerprint, parsePhoneIdentity, assertRemoteIdentity } from '../../mobile/src/peer-identity.mjs' export function createMobileWindow({ directory, assets, resolveBackend, show = true }) { const vault = new MobileVault(join(directory, 'mobile'), safeStorage) @@ -23,7 +24,16 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error('Geçersiz mobil pencere.') if (action === 'setup') { const secrets = await vault.load() - return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey) } + return { paired: Boolean(secrets.deviceId), hasAccount: Boolean(secrets.accountToken), hasGoogle: Boolean(secrets.googleKey), + deviceId: secrets.deviceId, localFingerprint: secrets.localFingerprint } + } + if (action === 'identity.save') { + const previous = await vault.load(), fingerprint = normalizeFingerprint(params.fingerprint) + if (!previous.deviceId || previous.deviceId !== params.deviceId || + (previous.localFingerprint && previous.localFingerprint !== fingerprint)) + throw new Error('Cihaz kimliği sessizce değiştirilemez. Yeniden eşleştirin.') + await vault.save({ ...previous, localFingerprint: fingerprint }) + return { saved: true } } if (action === 'account.save') { if (typeof params.token !== 'string' || params.token.length < 12 || params.token.length > 512) throw new Error('AITURK hesap anahtarı geçersiz.') @@ -68,7 +78,15 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t } if (action === 'phone.approve') { if (!/^[0-9a-f-]{36}$/.test(params.pairingId) || !/^[A-Z2-9]{8}$/.test(params.code)) throw new Error('Telefondaki eşleştirme bilgileri geçersiz.') - return call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code }) + const identity = parsePhoneIdentity(params.identity, params.pairingId) + const previous = await vault.load(), existing = previous.phoneFingerprints?.[identity.deviceId] + if (existing && existing !== identity.fingerprint) throw new Error('Kayıtlı telefon kimliği değiştirilemez. Cihazı iptal edip yeniden eşleştirin.') + const result = await call(`/v1/aiturk/pairings/${params.pairingId}/approve`, { user_code: params.code }) + if (result.device_id !== identity.deviceId || result.status !== 'approved') throw new Error('Onaylanan telefon kimlik belgesiyle eşleşmiyor; bağlantı açılmadı.') + const current = await vault.load() + if (current.deviceId !== previous.deviceId || current.accountToken !== previous.accountToken) throw new Error('Onay sırasında hesap değişti; yeniden eşleştirin.') + await vault.save({ ...current, phoneFingerprints: { ...current.phoneFingerprints, [identity.deviceId]: identity.fingerprint } }) + return result } if (action === 'devices') return call('/v1/aiturk/mobile/devices') if (action === 'invite.create') return call('/v1/aiturk/mobile/invites', {}) @@ -82,12 +100,20 @@ export function createMobileWindow({ directory, assets, resolveBackend, show = t if (result.self_revoked) await vault.save({}) return result } - if (action === 'inbox') return call('/v1/aiturk/mobile/channels') + if (action === 'inbox') { + const data = await call('/v1/aiturk/mobile/channels'), secrets = await vault.load() + return { ...data, channels: data.channels.map(offer => { + const fingerprint = secrets.phoneFingerprints?.[offer.phone_id] + assertRemoteIdentity(offer.sdp, fingerprint) + return { ...offer, trustedPhoneFingerprint: fingerprint } + }) } + } if (action === 'ice') return call('/v1/aiturk/mobile/ice') if (action === 'channel.answer') { const inbox = await call('/v1/aiturk/mobile/channels') const offer = inbox.channels.find(row => row.id === params.channelId) if (!offer || clients.has(offer.id) || clients.size >= 4) throw new Error('Bağlantı isteği artık geçerli değil.') + assertRemoteIdentity(offer.sdp, (await vault.load()).phoneFingerprints?.[offer.phone_id]) const authorize = () => call(`/v1/aiturk/mobile/channels/${offer.id}/heartbeat`, {}) const client = new MobileGatewayClient(resolveBackend, frame => send({ channelId: offer.id, frame }), { deviceId: offer.phone_id, journal, authorize }) diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs index 83c168e..135843f 100644 --- a/apps/desktop/scripts/bundle-electron-main.mjs +++ b/apps/desktop/scripts/bundle-electron-main.mjs @@ -67,8 +67,10 @@ for (const name of ['mobile-host.html', 'mobile-host-ui.mjs', 'mobile-preload.cj cpSync(resolve(root, 'electron', name), resolve(distDir, name)) } mkdirSync(resolve(distDir, 'mobile'), { recursive: true }) -for (const name of ['transport.mjs', 'style.css', 'qr.mjs']) { +for (const name of ['transport.mjs', 'peer-identity.mjs', 'style.css', 'qr.mjs']) { cpSync(resolve(root, '../mobile/src', name), resolve(distDir, 'mobile', name)) } mkdirSync(resolve(distDir, 'mobile/vendor'), { recursive: true }) cpSync(resolve(root, '../mobile/node_modules/qrcode-generator/dist/qrcode.mjs'), resolve(distDir, 'mobile/vendor/qrcode.mjs')) +cpSync(resolve(root, '../mobile/node_modules/jsqr/dist/jsQR.js'), resolve(distDir, 'mobile/vendor/jsQR.js')) +cpSync(resolve(root, '../mobile/node_modules/jsqr/LICENSE'), resolve(distDir, 'mobile/vendor/jsQR-LICENSE.txt')) diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 1e957c6..4f78e0d 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,4 +1,4 @@ -# AITURK Mobil — Android geliştirme sürümü 0.1.2 +# AITURK Mobil — Android geliştirme sürümü 0.1.4 Bu bir **geliştirme paketi**; planın tamamlanmış ürün/kapalı beta kabulü değildir. Android uygulaması, IDE adaptörü ve hesap doğrulamalı bağlantı servisi kodlandı. @@ -25,6 +25,14 @@ turkservis.online veya kurulu IDE bu kaynak değişiklikleriyle güncellenmedi. üretme kodu; doğrudan Google WebSocket, PCM mikrofon/oynatma ve kapatma kodu. - Ses görüşmesini kapatma IDE'ye interrupt göndermez. Görünür görev gönder düğmesi ve ayrı IDE durdur düğmesi vardır. +- Google kullanım bildirimleri ücretli liste tarifesiyle tahmin edilir; her + isteğin tekrar faturalandırılan geçmişi de sayılır. Ayrıntısı eksik tokenlar + için fiyat aralığı gösterilir. Bu fatura, kredi veya ücretsiz kota sorgusu değildir. +- Kullanıcı her görüşme için 1–10 dakika ve 0,01–10 USD tahmini durdurma sınırı + seçer (varsayılan 10 dakika / 0,25 USD). Üst tahmin sınıra ulaşırsa, süre + dolarsa veya 120 saniye kullanım bildirimi gelmezse yalnız Google bağlantısı + kapanır. Otomatik yeniden bağlanılmaz. Her yeni oturumda sayaç sıfırlanır; + bu günlük/aylık bütçe veya Google tarafında kesin harcama tavanı değildir. ## Geliştirme kurulumu @@ -70,12 +78,65 @@ deposunun `docs/aiturk-mobile-operations.md` dosyasında açıklanır. ## Doğrulama ve sınırlar +0.1.4-dev (Android versionCode 5), 12 Eylül 2026: 41 mobil test geçti; +tarayıcı uygulama testi, yerel sertifika/DTLS testi ve Electron IPC/Windows kasa +testi geçti. Telefon kimliği QR resminin onaysız içe alınması ve değiştirilmiş +cihaz kimliklerinin reddi test edildi. `assembleDebug lintDebug` başarılı, +uygulama lint sonucu 0 hata / 17 uyarı. Debug APK imzası doğrulandı ve kimlik, +taşıma, uygulama, davet, gizlilik ve ana sayfa dosyaları kaynakla karşılaştırıldı. +APK SHA-256: `94835E223B71BE36AFB26686D3EBC79D8976514FFDB4E7CDFC189ACE7E2F12A5`. +Bu APK geliştirici imzalıdır; üretim/gerçek Android kabulü değildir. + +12 Eylül 2026 yerel doğrulaması: `npm test` 37/37 geçti. Ses başlangıcı +beklerken iptal ve kapatılmış oturumu yeniden açma regresyonları önce başarısız +testlerle gösterildi, sonra düzeltildi. `npm run android:sync` ve +`gradlew.bat assembleDebug lintDebug` başarılı; uygulama lint sonucu 0 hata, +17 uyarı. `scripts/browser-smoke.mjs` gerçek DTLS veri kanalında 240.000 +karakter aktarımını ve sentetik Google kullanım sınırını doğruladı; Google'a +gerçek çağrı yapmadı. APK içindeki `voice.mjs` kaynakla birebir karşılaştırıldı. +Debug imzası `apksigner verify` ile doğrulandı; bu üretim imzası değildir. +APK SHA-256: `F5DB098DF0F1C59B92F274969FCAD926BCB2DD25AF823533A983085A18BFB966`. +Bu kontrolde `adb devices -l` bağlı cihaz göstermedi; fiziksel telefon kabulü +ve canlı yayın hâlâ tamamlanmadı. + `npm test`: davranış, tekrar gönderim, onay sınırı, ses temizliği ve token kısıtları. `scripts/browser-smoke.mjs`: yeni test tarayıcısında gerçek DTLS veri kanalı, 240.000 karakter Unicode aktarımı, dar ekran taşması ve dokunma hedefi. `scripts/electron-smoke.mjs`: gerçek Electron IPC ve Windows safeStorage turu; yalnız sentetik bir test anahtarı kullanır. +`src/peer-identity.mjs` cihaz kimliğine göre kalıcı standart RTCCertificate +kaydını, atomik ilk oluşturmayı, kayıtlı parmak iziyle yüklemeyi ve SDP kimlik +kontrolünü uygular. Anahtar eksik veya geçersizse yükleme yeni sertifika üretmez. +Bilgisayar QR daveti sürüm 2 ile sertifika parmak izini taşır; telefon bunu +eşleştirme kaydına alır ve SDP uygulanmadan önce kontrol eder. Bilgisayar +bağlantıda kayıtlı sertifikasını kullanır. Eski sürüm 1 QR ve kimliği olmayan +elle eşleştirmeler veri bağlantısı açamaz; yeni QR ile yeniden eşleştirme gerekir. +Telefon da eşleştirmede kendi sertifikasını oluşturur ve bağlantıda aynı +sertifikayı yükler. Kullanıcı telefondaki kimlik belgesini bilgisayardaki +ilgili alana aktarır; `phone.approve` belgeyi eşleştirme kimliği ve sunucunun +onayladığı cihaz kimliğiyle karşılaştırıp Windows kasasına kaydeder. IDE hem +gelen bağlantı listesinde hem `channel.answer` yolunda kayıtlı telefon +parmak izini kontrol eder; renderer SDP'yi uygulamadan önce tekrar doğrular. +İlk kayıt için yerel açık onay gerekir; mevcut telefon kimliği sessizce +değiştirilemez. Telefon kimliğini QR olarak da gösterir. Kullanıcı ekran +görüntüsünü kendi bilgisayarına aktararak “Telefon kimliği QR resmi” alanından +okutabilir; eşleştirme kimliği ve belge doldurulur, fakat otomatik onay verilmez. +Kısa kod ve ayrı onay düğmesi gereklidir; elle belge aktarma yolu da korunur. + +`node scripts/certificate-smoke.mjs`: 12 Eylül 2026'da izole Edge bağlamında +yerel WebRTC sertifikasının IndexedDB'ye yazılıp sayfa yenilendikten sonra +aynı parmak iziyle geri alınması, geri alınan sertifikayla gerçek DTLS veri +aktarımı ve yanlış SDP parmak izinde DTLS'nin `failed` durumuna geçmesi +doğrulandı. Test artık gerçek `peer-identity.mjs` modülünü kullanıyor; eşzamanlı +dört oluşturma çağrısının tek kimlikte birleşmesi, kayıp kaydın ve yanlış +parmak izinin reddi de geçti. Tarayıcı uygulama testi ayrıca QR ile eşlenen +bilgisayar yerine farklı sertifikalı SDP geldiğinde görünür kimlik hatası +verildiğini ve hiçbir IDE isteği gönderilmediğini doğruladı. Electron IPC testi +Windows kasasındaki parmak iziyle sertifikanın pencere yenilemesinden sonra +yüklenmesini ve sessiz kimlik değişiminin reddini doğruladı. Bu testlerde portal +ve Google gerçek servisleri kullanılmadı; canlı karşılıklı kimlik kabulü değildir. + Gerçek IDE yolunun testi, kök dizinden zorunlu test sarmalayıcısıyla çalışır: ```bash @@ -90,6 +151,12 @@ ve maliyet testi olarak sayılmaz. Henüz tamamlanmayanlar: +- Karşılıklı cihaz kimliğinin gerçek Android ve paketlenmiş IDE arasında, + yeniden başlatma/iptal/anahtar kaybı senaryolarıyla uçtan uca kabulü. + İki yönün kodu var; tarayıcı ve Electron testleri gerçek cihaz kabulünün + yerine geçmez. Telefon QR çıktısının pikselden geri okunması ve Electron'da + QR resminin açık onay vermeden alanlara aktarılması test edildi; gerçek + telefon ekran görüntüsü aktarımı henüz denenmedi. - Fiziksel Android'de kurulum, mikrofon, kesme, Keystore ve arka plan testi. - Kullanıcıya ait test anahtarıyla Google Live doğrulaması ve Türkçe konuşma senaryoları. Sıfır gerçek Google çağrısı yapıldı; ücret oluştuğu iddia edilmiyor. @@ -103,8 +170,9 @@ Henüz tamamlanmayanlar: - Yeniden bağlantı bounded IDE olay tamponunu okuyarak kaçırılan görev sonucunu alır. Tampon kaybı/backend yeniden başlaması durumunda sonuç bilinmiyor kalabilir; iş otomatik tekrar çalıştırılmaz. -- Doğrulanmış maliyet tahmini/ayarlanabilir bütçe. Yerel görüşme süresi ve - 10 dakikalık oturum sınırı Google kotası veya fatura üst sınırı değildir. +- Maliyet tahmini/ayarlanabilir oturum sınırı uygulandı: 35 Node testi ve + derlenmiş uygulamanın gerçek WebRTC, sentetik IDE/Google yanıtlı sınır akışı + geçti. Gerçek Google faturasına karşı karşılaştırma henüz yapılmadı. - Bağımsız güvenlik incelemesi ve sinyalleşme sunucusuna karşı doğrulanmış kalıcı eş parmak izi. WebRTC standart şifrelemesi vardır; eş kimliğinin sinyalleşme hizmetine güven sınırı gizlenmez. diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 425387d..0e4c602 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "online.turkservis.aiturk.mobile" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 3 - versionName "0.1.2-dev" + versionCode 5 + versionName "0.1.4-dev" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index c6373cf..ab28c21 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "aiturk-mobile", - "version": "0.1.2", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aiturk-mobile", - "version": "0.1.2", + "version": "0.1.4", "dependencies": { "@capacitor/android": "8.5.1", "@capacitor/core": "8.5.1", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d9975f2..a587b7a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "aiturk-mobile", - "version": "0.1.2", + "version": "0.1.4", "private": true, "type": "module", "scripts": { diff --git a/apps/mobile/scripts/browser-smoke.mjs b/apps/mobile/scripts/browser-smoke.mjs index fe7bf44..6082945 100644 --- a/apps/mobile/scripts/browser-smoke.mjs +++ b/apps/mobile/scripts/browser-smoke.mjs @@ -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() } diff --git a/apps/mobile/scripts/certificate-smoke.mjs b/apps/mobile/scripts/certificate-smoke.mjs new file mode 100644 index 0000000..36e9168 --- /dev/null +++ b/apps/mobile/scripts/certificate-smoke.mjs @@ -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('Local certificate test') +}) +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() +} diff --git a/apps/mobile/scripts/electron-smoke.mjs b/apps/mobile/scripts/electron-smoke.mjs index 182c643..7a96b0f 100644 --- a/apps/mobile/scripts/electron-smoke.mjs +++ b/apps/mobile/scripts/electron-smoke.mjs @@ -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) diff --git a/apps/mobile/src/app.mjs b/apps/mobile/src/app.mjs index 67fcd65..860df0a 100644 --- a/apps/mobile/src/app.mjs +++ b/apps/mobile/src/app.mjs @@ -1,11 +1,13 @@ import { AccountClient, openPhoneConnection } from './transport.mjs' import { VoiceSession } from './voice.mjs' +import { DEFAULT_VOICE_LIMITS, voiceLimits, usageLabel } from './voice-usage.mjs' import { taskState } from './behavior.mjs' import { parseInvite } from './invite.mjs' -import { InviteScanner } from './qr.mjs' +import { InviteScanner, drawQr } from './qr.mjs' +import { createDeviceIdentity } from './peer-identity.mjs' const $ = id => document.getElementById(id) if (globalThis.Capacitor?.isNativePlatform()) globalThis.Capacitor.registerPlugin('MobileSecrets') -let credentials = {}, account, connection, selected, approval, voice, pendingTask, usageTimer, voiceGeneration = 0 +let credentials = {}, account, connection, selected, approval, voice, pendingTask, voiceGeneration = 0 let connectedDesktopId = null let connectionEpoch = 0, selectionEpoch = 0 const status = text => { $('status').textContent = text } @@ -17,9 +19,13 @@ const scanner = new InviteScanner($('qr-video'), async raw => { const result = await account.call(`/v1/aiturk/mobile/invites/${invite.inviteId}/claim`, { secret: invite.secret, display_name: $('device-name').value.trim() }) if (account !== targetAccount || epoch !== connectionEpoch) return + const identity = await createDeviceIdentity(result.device_id) + if (account !== targetAccount || epoch !== connectionEpoch) return Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, - pairingId: result.pairing_id, preferredDesktopId: invite.desktopId }) + pairingId: result.pairing_id, preferredDesktopId: invite.desktopId, + desktopFingerprints: { [invite.desktopId]: invite.desktopFingerprint }, localFingerprint: identity.fingerprint }) await saveCredentials() + showPhoneIdentity() $('pair-code').textContent = `Bilgisayarda bekleyen telefonu getirip şu kodu onaylayın: ${result.user_code}` status('QR okundu. Bilgisayardaki açık telefon onayı bekleniyor.') }) @@ -28,6 +34,12 @@ action('scan-invite', async () => { if (!account || credentials.deviceId) throw action('stop-scan', async () => scanner.stop()) $('qr-file').onchange = async event => { try { await scanner.image(event.target.files[0]) } catch (error) { status(error.message) } finally { event.target.value = '' } } async function saveCredentials() { if (native()) await native().save({ value: JSON.stringify(credentials) }) } +function showPhoneIdentity() { + $('phone-identity').value = credentials.localFingerprint ? JSON.stringify({ kind: 'aiturk-phone-identity', version: 1, + deviceId: credentials.deviceId, pairingId: credentials.pairingId, fingerprint: credentials.localFingerprint }) : '' + $('phone-identity-qr').hidden = !$('phone-identity').value + if ($('phone-identity').value) drawQr($('phone-identity-qr'), $('phone-identity').value) +} function addText(container, role, text) { const p = document.createElement('p'); p.textContent = `${role}: ${text}`; $(container).append(p); while ($(container).children.length > 150) $(container).firstChild.remove() } function action(id, work) { $(id).onclick = async () => { $(id).disabled = true; try { await work() } catch (error) { status(error.message) } finally { $(id).disabled = false } } } function rpc(action, params) { if (!connection) throw new Error('Önce bilgisayara bağlanın.'); return connection.requests.call(action, params) } @@ -76,6 +88,7 @@ action('logout', async () => { if (token?.startsWith('tsml_')) await new AccountClient({ accountToken: token }).call('/v1/aiturk/mobile-auth/logout', {}) connectionEpoch++; stopVoice(); connection?.close(); connection = null; selected = null; account = null credentials = {}; pendingTask = null; await saveCredentials() + showPhoneIdentity() $('work').hidden = true; $('login-link').hidden = true; $('login-code').textContent = ''; $('pair-code').textContent = '' status('Bu uygulamanın oturumu kapatıldı. IDE’de başlamış iş kendi çalışma kurallarıyla devam edebilir.') }) @@ -87,8 +100,11 @@ action('pair', async () => { if (!credentials.accountToken) throw new Error('Önce AITURK hesabınıza giriş yapın.') account = new AccountClient(credentials) const result = await account.call('/v1/aiturk/pairings', { display_name: $('device-name').value.trim(), platform: 'android' }) - Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id }) + const identity = await createDeviceIdentity(result.device_id) + Object.assign(credentials, { deviceId: result.device_id, deviceToken: result.device_token, pairingId: result.pairing_id, + localFingerprint: identity.fingerprint }) await saveCredentials() + showPhoneIdentity() $('pair-code').textContent = `Eşleştirme kimliği:\n${result.pairing_id}\n\nKod: ${result.user_code}\nSon geçerlilik: ${result.expires_at}` status('Bilgisayardaki açık onay bekleniyor.') }) @@ -130,14 +146,18 @@ action('select-session', async () => { for (const [id, choice] of [['allow', 'once'], ['deny', 'deny']]) action(id, async () => { const target = requireSession(); await rpc('approval.respond', { sessionId: target.id, requestId: approval?.requestId, choice }); setApproval(null); status('Yanıt gerçek IDE onayına iletildi.') }) action('save-google', async () => { const key = $('google-key').value.trim(); $('google-key').value = ''; await rpc('google.key.save', { key }); status('Google anahtarı bilgisayarın güvenli kasasına kaydedildi.') }) action('delete-google', async () => { stopVoice(); await rpc('google.key.delete'); status('Google anahtarı bilgisayar kasasından silindi.') }) -function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; clearInterval(usageTimer); $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' } +function stopVoice() { voiceGeneration++; voice?.stop(); voice = null; $('voice-status').textContent = 'Mikrofon kapalı. IDE işi devam edebilir.' } async function startVoice(microphone) { if (!$('consent').checked) throw new Error('Önce Google veri ve ücret bilgisini okuyup kabul edin.') + const limits = voiceLimits({ minutes: $('voice-minutes').value, usd: $('voice-budget').value }) + // Only non-secret preferences; account and API credentials remain in the + // native vault. No usage history or transcripts are stored here. + try { localStorage.setItem('aiturk.voice-limits', JSON.stringify(limits)) } catch {} const target = { ...requireSession() }, targetConnection = connection, desktopId = connectedDesktopId stopVoice(); const generation = voiceGeneration; const token = await rpc('voice.token') if (generation !== voiceGeneration || document.hidden) return - const started = Date.now() - voice = new VoiceSession({ ...token, + voice = new VoiceSession({ ...token, limits, + onUsage: usage => { if (generation === voiceGeneration) $('usage').textContent = usageLabel(usage, usage.seconds) }, onText: (role, text) => addText('discussion', role, text), onDraft: (text, intent) => { $('task').value = text; $('intent').value = intent; updatePreview(); status('Asistan görev taslağını hazırladı. Henüz IDE’ye gönderilmedi.') }, onSubmit: async (text, intent) => { @@ -148,8 +168,7 @@ async function startVoice(microphone) { pendingTask = { taskId: crypto.randomUUID(), sessionId: target.id, model: target.model, desktopId, intent, text } return deliver() }, - onError: status, onState: text => { $('voice-status').textContent = text; if (voice?.stopped) clearInterval(usageTimer) } }) - usageTimer = setInterval(() => { $('usage').textContent = `Bu uygulamanın ölçtüğü görüşme süresi: ${Math.floor((Date.now() - started) / 1000)} sn. Google kotası/faturası değildir.` }, 1000) + onError: status, onState: text => { $('voice-status').textContent = text } }) await voice.start(microphone) } action('voice-start', () => startVoice(true)); action('voice-text', () => startVoice(false)) @@ -189,4 +208,8 @@ action('disconnect', async () => { connectionEpoch++; stopVoice(); connection?.c action('revoke', async () => { const data = await account.call('/v1/aiturk/mobile/devices'); const self = data.devices.find(d => d.self); await account.call(`/v1/aiturk/devices/${self.id}/revoke`, { expected_version: self.version }); stopVoice(); connection?.close(); connection = null; credentials = {}; await saveCredentials(); account = null; $('work').hidden = true; status('Bu telefonun erişimi iptal edildi.') }) document.addEventListener('visibilitychange', () => { if (document.hidden) { stopVoice(); scanner.stop() } }) window.addEventListener('pagehide', () => { stopVoice(); scanner.stop(); connection?.close() }) +let savedLimits = DEFAULT_VOICE_LIMITS +try { const raw = localStorage.getItem('aiturk.voice-limits'); if (raw) savedLimits = voiceLimits(JSON.parse(raw)) } catch {} +$('voice-minutes').value = savedLimits.minutes; $('voice-budget').value = savedLimits.usd try { const saved = await native()?.load(); if (saved?.value) { credentials = JSON.parse(saved.value); if (credentials.accountToken) { account = new AccountClient(credentials); status('Kayıtlı cihaz bulundu. Bilgisayarları yenileyerek bağlanın.') } } } catch { status('Android güvenli kasası açılamadı. Hesabınızı yeniden eşleştirin.') } +showPhoneIdentity() diff --git a/apps/mobile/src/index.html b/apps/mobile/src/index.html index f24f5ea..903d29a 100644 --- a/apps/mobile/src/index.html +++ b/apps/mobile/src/index.html @@ -18,6 +18,10 @@

Kodu bilgisayardaki AITURK Mobil bağlantı penceresinde onaylayın. Eşleştirme kimliğini kopyalayabilirsiniz.

+ + +

Kimlik QR kodunun ekran görüntüsünü kendi bilgisayarınıza aktarabilir ve bilgisayarda “Telefon kimliği QR resmi” alanından açabilirsiniz. Onayı ayrıca bilgisayarda verin.

+

Bu belgenin tamamını kendi bilgisayarınızdaki “Telefondan alınan kimlik belgesi” alanına aktarın. Bu, özel anahtar değil; onaylayacağınız telefonun sertifika kimliğidir. Başkasının gönderdiği belgeyi onaylamayın.