feat(mobile): verify pinned Android release APK identity before distribution
This commit is contained in:
1 parent
9a1d831d4b
commit
52f792f00f
3 files changed
+120
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user