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 } }