From 52f792f00feafb59d2496f3c7d55f88c1aa132e7 Mon Sep 17 00:00:00 2001 From: yilsem Date: Sat, 12 Sep 2026 15:34:00 +0300 Subject: [PATCH] feat(mobile): verify pinned Android release APK identity before distribution --- apps/mobile/README.md | 18 +++++ .../mobile/scripts/verify-android-release.mjs | 77 +++++++++++++++++++ apps/mobile/tests/android-release.test.mjs | 25 ++++++ 3 files changed, 120 insertions(+) create mode 100644 apps/mobile/scripts/verify-android-release.mjs create mode 100644 apps/mobile/tests/android-release.test.mjs diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 82b18b5..ca86857 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -53,6 +53,24 @@ npm run android:sync --workspaces=false üretim imzası içermez ve kurulabilir sürüm olarak paylaşılmamalıdır. Özel üretim imza anahtarı oluşturulmadı; mağaza hesabı veya ücretli hizmet açılmadı. +### Dağıtım APK'sının doğrulanması + +`scripts/verify-android-release.mjs` dağıtım öncesi salt okunur APK kontrolüdür. +`--apk`, `--java`, `--apksigner` (Android SDK içindeki apksigner.jar), `--aapt2`, +`--certificate` (bağımsız olarak belirlenen üretim sertifikası SHA-256 değeri), +`--version` ve `--version-code` parametrelerinin tamamını ister. Sertifika pinini +kontrol edilen APK'dan otomatik türetmeyin. Bu araç anahtar üretmez veya APK imzalamaz. + +Gerçek apksigner doğrulaması, v2/v3 imza, tek beklenen sertifika, uygulama kimliği +`online.turkservis.aiturk.mobile`, tam sürüm eşleşmesi ve debug bayrağı kontrol edilir. +Dosya doğrulama boyunca değişirse sonuç reddedilir. Debug sertifikası ve `-dev` +sürümü dağıtım için kabul edilmez. Başarılı rapor APK hash'ini verir fakat açıkça +`deviceAcceptanceVerified: false` içerir; fiziksel telefon kabulünün yerine geçmez. + +12 Eylül 2026: mevcut mutual-identity geliştirici APK'sı gerçek SDK araçlarıyla +kontrol edildi ve beklenildiği gibi `debug-signing-certificate` ile reddedildi. +Üretim sertifikalı pozitif APK doğrulaması henüz yapılmadı. + ## Uyumlu IDE ve bağlantı servisi Bu kaynak ağacındaki masaüstü paketini normal masaüstü build süreciyle derleyin. diff --git a/apps/mobile/scripts/verify-android-release.mjs b/apps/mobile/scripts/verify-android-release.mjs new file mode 100644 index 0000000..7aef0d5 --- /dev/null +++ b/apps/mobile/scripts/verify-android-release.mjs @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { lstat } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { parseArgs } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function validateAndroidRelease({ signature, badging, certificate, version, versionCode }) { + const expected = certificate.trim().replaceAll(':', '').toLowerCase() + if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error('certificate-pin-invalid') + if (!/^\d+\.\d+\.\d+(?:-beta\.\d+)?$/.test(version)) throw new Error('release-version-invalid') + if (!Number.isSafeInteger(versionCode) || versionCode <= 0) throw new Error('version-code-invalid') + const signers = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-fA-F0-9]{64})\s*$/gm)] + if (signers.length !== 1 || signers[0][1].toLowerCase() !== expected) throw new Error('signing-certificate-mismatch') + if (/^Signer #\d+ certificate DN:.*\bCN=Android Debug\b/im.test(signature)) throw new Error('debug-signing-certificate') + if (!/^Verified using v[23](?:\.\d+)? scheme[^\r\n]*: true\s*$/m.test(signature)) throw new Error('modern-apk-signature-required') + const packages = [...badging.matchAll(/^package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'/gm)] + if (packages.length !== 1 || packages[0][1] !== 'online.turkservis.aiturk.mobile') throw new Error('application-id-mismatch') + if (packages[0][2] !== String(versionCode) || packages[0][3] !== version) throw new Error('apk-version-mismatch') + if (/^application-debuggable\b/m.test(badging)) throw new Error('debuggable-apk') + return { applicationId: packages[0][1], version, versionCode, certificateSha256: expected } +} + +async function regularFile(path) { + let current = path + while (true) { + const info = await lstat(current) + if (info.isSymbolicLink()) throw new Error('apk-reparse-path') + if (current === path && !info.isFile()) throw new Error('apk-not-regular') + const parent = dirname(current) + if (parent === current) return + current = parent + } +} + +async function digest(path) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('hex') +} + +function tool(executable, args) { + const env = { ...process.env } + delete env.JAVA_TOOL_OPTIONS + delete env._JAVA_OPTIONS + delete env.JDK_JAVA_OPTIONS + const result = spawnSync(executable, args, { env, encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 }) + if (result.error || result.status !== 0) throw new Error('android-verification-tool-failed') + return result.stdout +} + +export async function verifyAndroidRelease(options) { + const apk = resolve(options.apk) + await regularFile(apk) + const before = await lstat(apk) + const sha256 = await digest(apk) + const signature = tool(resolve(options.java), ['-jar', resolve(options.apksigner), 'verify', '--verbose', '--print-certs', apk]) + const badging = tool(resolve(options.aapt2), ['dump', 'badging', apk]) + const identity = validateAndroidRelease({ ...options, signature, badging }) + await regularFile(apk) + const after = await lstat(apk) + if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || await digest(apk) !== sha256) throw new Error('apk-changed-during-verification') + return { verified: true, ...identity, size: after.size, sha256, deviceAcceptanceVerified: false } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const { values } = parseArgs({ options: Object.fromEntries(['apk', 'java', 'apksigner', 'aapt2', 'certificate', 'version', 'version-code'].map(name => [name, { type: 'string' }])) }) + try { + if (Object.keys(values).length !== 7) throw new Error('all-release-verification-arguments-required') + console.log(JSON.stringify(await verifyAndroidRelease({ ...values, versionCode: Number(values['version-code']) }))) + } catch (error) { + const code = /^[a-z][a-z0-9-]{1,95}$/.test(error.message) ? error.message : 'android-release-verification-failed' + console.error(JSON.stringify({ verified: false, code })) + process.exitCode = 1 + } +} diff --git a/apps/mobile/tests/android-release.test.mjs b/apps/mobile/tests/android-release.test.mjs new file mode 100644 index 0000000..5b53edf --- /dev/null +++ b/apps/mobile/tests/android-release.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { validateAndroidRelease } from '../scripts/verify-android-release.mjs' + +const certificate = 'a'.repeat(64) +const fixture = { + certificate, version: '0.1.4-beta.1', versionCode: 6, + signature: `Verified using v2 scheme (APK Signature Scheme v2): true\nSigner #1 certificate DN: CN=AITURK Release\nSigner #1 certificate SHA-256 digest: ${certificate}\n`, + badging: "package: name='online.turkservis.aiturk.mobile' versionCode='6' versionName='0.1.4-beta.1'\n", +} + +test('APK release identity requires a pinned certificate and exact version', () => { + assert.equal(validateAndroidRelease(fixture).certificateSha256, certificate) + assert.throws(() => validateAndroidRelease({ ...fixture, certificate: 'b'.repeat(64) }), /certificate-mismatch/) + assert.throws(() => validateAndroidRelease({ ...fixture, versionCode: 7 }), /version-mismatch/) + assert.throws(() => validateAndroidRelease({ ...fixture, version: '0.1.4-dev' }), /release-version-invalid/) +}) + +test('debug and ambiguous APK identities are never release-ready', () => { + assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature.replace('AITURK Release', 'Android Debug') }), /debug-signing/) + assert.throws(() => validateAndroidRelease({ ...fixture, badging: fixture.badging + 'application-debuggable\n' }), /debuggable-apk/) + assert.throws(() => validateAndroidRelease({ ...fixture, badging: fixture.badging.replace('online.turkservis.aiturk.mobile', 'other.application') }), /application-id/) + assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature + `Signer #2 certificate SHA-256 digest: ${certificate}\n` }), /certificate-mismatch/) + assert.throws(() => validateAndroidRelease({ ...fixture, signature: fixture.signature.replace(': true', ': false') }), /modern-apk-signature/) +})