d3ro-voice/scripts/ci/verify-android-artifact.mjs
2026-08-29 18:33:45 +09:00

1498 lines
57 KiB
JavaScript

import { createHash, randomBytes } from 'node:crypto'
import {
closeSync,
constants as fsConstants,
existsSync,
fstatSync,
lstatSync,
mkdtempSync,
openSync,
readSync,
readdirSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join, resolve, sep } from 'node:path'
import { spawnSync } from 'node:child_process'
import { inflateRawSync } from 'node:zlib'
const EXPECTED_PACKAGE = 'com.d3ro.voice'
const EXPECTED_MIN_SDK = 24
const EXPECTED_TARGET_SDK = 36
const MODEL_PATH = 'assets/models/ggml-tiny.bin'
const MODEL_BYTES = 77_691_713
const MODEL_SHA256 = 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
const TEST_ADMOB_APP_ID = 'ca-app-pub-3940256099942544~3347511713'
const COMPROMISED_SIGNER_SHA256 = '06eec757722ee7cd3dfbc53202d974aaf0417a7d397f9ef1e5611088ebb2e481'
const DEBUG_PROPS_SYMBOL = Buffer.from('_ZNK8facebook5react5Props13getDebugPropsEv')
const ANDROID_16_KIB_PAGE_BYTES = 16 * 1024
const ELF_PT_LOAD = 1
const VALID_MODES = new Set(['debug', 'e2e', 'release'])
const REQUIRED_NATIVE_LIBRARIES = [
'libappmodules.so',
'libc++_shared.so',
'libfbjni.so',
'libgesturehandler.so',
'libhermesvm.so',
'libjsi.so',
'libNitroIap.so',
'libNitroModules.so',
'libNitroSound.so',
'libreact_codegen_rnscreens.so',
'libreact_codegen_safeareacontext.so',
'libreactnative.so',
'librnscreens.so',
'librnwhisper.so',
]
function fail(message) {
throw new Error(`android_artifact_invalid:${message}`)
}
function normalizeCertificateSha256(value, label) {
if (typeof value !== 'string') fail(`${label}_missing`)
const normalized = value.replaceAll(':', '').trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(normalized)) fail(`${label}_format`)
return normalized
}
function requireAllowedReleaseCertificate(value, label) {
const normalized = normalizeCertificateSha256(value, label)
if (normalized === COMPROMISED_SIGNER_SHA256) fail(`${label}_compromised`)
return normalized
}
function stableStatValue(stat, key, fallbackKey) {
if (key in stat) return stat[key]
return BigInt(Math.trunc(Number(stat[fallbackKey]) * 1_000_000))
}
function sameFileSnapshot(left, right) {
return (process.platform === 'win32' || left.dev === right.dev)
&& left.ino === right.ino
&& left.size === right.size
&& stableStatValue(left, 'mtimeNs', 'mtimeMs') === stableStatValue(right, 'mtimeNs', 'mtimeMs')
&& stableStatValue(left, 'ctimeNs', 'ctimeMs') === stableStatValue(right, 'ctimeNs', 'ctimeMs')
}
function parseArguments(argv) {
const allowed = new Set([
'aab',
'apk',
'bundletool',
'expected-admob-app-id',
'expected-upload-cert-sha256',
'expected-version-code',
'expected-version-name',
'mode',
])
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index]
if (!current.startsWith('--')) fail(`unexpected_argument_${current}`)
const value = argv[index + 1]
if (!value || value.startsWith('--')) fail(`missing_value_${current.slice(2)}`)
const name = current.slice(2)
if (!allowed.has(name) || Object.hasOwn(options, name)) fail(`unexpected_argument_${name}`)
options[name] = value
index += 1
}
return options
}
function readExactly(fd, length, position) {
const buffer = Buffer.alloc(length)
let offset = 0
while (offset < length) {
const bytesRead = readSync(fd, buffer, offset, length - offset, position + offset)
if (bytesRead === 0) fail('unexpected_end_of_zip')
offset += bytesRead
}
return buffer
}
function readZipDirectory(path) {
const absolute = resolve(path)
const pathStat = lstatSync(absolute, { bigint: true })
if (pathStat.isSymbolicLink()) fail(`artifact_symlink_${basename(absolute)}`)
if (realpathSync(absolute) !== absolute) fail(`artifact_reparse_${basename(absolute)}`)
if (!pathStat.isFile()) fail(`artifact_not_regular_${basename(absolute)}`)
if (pathStat.nlink !== 1n) fail(`artifact_hardlink_${basename(absolute)}`)
const fd = openSync(absolute, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0))
try {
const openedStat = fstatSync(fd, { bigint: true })
if (!sameFileSnapshot(pathStat, openedStat)) fail(`artifact_identity_${basename(absolute)}`)
const size = Number(openedStat.size)
const tailLength = Math.min(size, 65_557)
const tail = readExactly(fd, tailLength, size - tailLength)
let eocd = -1
for (let index = tail.length - 22; index >= 0; index -= 1) {
if (tail.readUInt32LE(index) === 0x06054b50) {
eocd = index
break
}
}
if (eocd < 0) fail('zip_eocd_missing')
const entriesCount = tail.readUInt16LE(eocd + 10)
const directorySize = tail.readUInt32LE(eocd + 12)
const directoryOffset = tail.readUInt32LE(eocd + 16)
if (entriesCount === 0xffff || directoryOffset === 0xffffffff) fail('zip64_not_supported')
const directory = readExactly(fd, directorySize, directoryOffset)
const entries = new Map()
let cursor = 0
for (let index = 0; index < entriesCount; index += 1) {
if (directory.readUInt32LE(cursor) !== 0x02014b50) fail('central_directory_corrupt')
const method = directory.readUInt16LE(cursor + 10)
const compressedSize = directory.readUInt32LE(cursor + 20)
const uncompressedSize = directory.readUInt32LE(cursor + 24)
const nameLength = directory.readUInt16LE(cursor + 28)
const extraLength = directory.readUInt16LE(cursor + 30)
const commentLength = directory.readUInt16LE(cursor + 32)
const localOffset = directory.readUInt32LE(cursor + 42)
const nameStart = cursor + 46
const name = directory.subarray(nameStart, nameStart + nameLength).toString('utf8')
if (entries.has(name)) fail(`duplicate_zip_entry_${name}`)
entries.set(name, { method, compressedSize, uncompressedSize, localOffset })
cursor = nameStart + nameLength + extraLength + commentLength
}
return { absolute, fd, entries, keepOpen: true, openedStat }
} catch (error) {
closeSync(fd)
throw error
}
}
function assertArchivePathStable(archive, label) {
const current = lstatSync(archive.absolute, { bigint: true })
if (current.isSymbolicLink() || !current.isFile() || current.nlink !== 1n) {
fail(`${label}_path_not_sealed`)
}
const opened = fstatSync(archive.fd, { bigint: true })
if (!sameFileSnapshot(archive.openedStat, current) || !sameFileSnapshot(archive.openedStat, opened)) {
fail(`${label}_path_changed`)
}
}
function hashArchiveStable(archive, label) {
assertArchivePathStable(archive, label)
const hash = createHash('sha256')
const chunk = Buffer.allocUnsafe(1024 * 1024)
let position = 0
while (position < Number(archive.openedStat.size)) {
const count = readSync(archive.fd, chunk, 0, chunk.length, position)
if (count === 0) fail(`${label}_hash_short_read`)
hash.update(chunk.subarray(0, count))
position += count
}
assertArchivePathStable(archive, label)
return hash.digest('hex')
}
function extractEntry(archive, name) {
const entry = archive.entries.get(name)
if (!entry) fail(`missing_entry_${name}`)
const header = readExactly(archive.fd, 30, entry.localOffset)
if (header.readUInt32LE(0) !== 0x04034b50) fail(`local_header_corrupt_${name}`)
const nameLength = header.readUInt16LE(26)
const extraLength = header.readUInt16LE(28)
const dataOffset = entry.localOffset + 30 + nameLength + extraLength
const compressed = readExactly(archive.fd, entry.compressedSize, dataOffset)
const output = entry.method === 0
? compressed
: entry.method === 8
? inflateRawSync(compressed)
: fail(`unsupported_zip_method_${entry.method}_${name}`)
if (output.length !== entry.uncompressedSize) fail(`entry_size_mismatch_${name}`)
return output
}
function nativeEntryFailureLabel(name) {
return name.replace(/[^a-z0-9_.-]+/gi, '_')
}
function readElfInteger(buffer, offset, byteLength, label) {
if (!Number.isSafeInteger(offset) || offset < 0 || offset + byteLength > buffer.length) {
fail(`${label}_elf_truncated`)
}
if (byteLength === 2) {
return BigInt(buffer.readUInt16LE(offset))
}
if (byteLength === 4) {
return BigInt(buffer.readUInt32LE(offset))
}
if (byteLength === 8) {
return buffer.readBigUInt64LE(offset)
}
fail(`${label}_elf_integer_width_${byteLength}`)
}
function verifyElfLoadSegmentAlignment(buffer, label) {
if (!Buffer.isBuffer(buffer) || buffer.length < 16) fail(`${label}_elf_header_truncated`)
if (
buffer[0] !== 0x7f
|| buffer[1] !== 0x45
|| buffer[2] !== 0x4c
|| buffer[3] !== 0x46
) {
fail(`${label}_elf_magic`)
}
const elfClass = buffer[4]
const dataEncoding = buffer[5]
if (elfClass !== 2) fail(`${label}_elf_class_${elfClass}`)
if (dataEncoding !== 1) fail(`${label}_elf_data_encoding_${dataEncoding}`)
if (buffer[6] !== 1) fail(`${label}_elf_ident_version_${buffer[6]}`)
const headerBytes = 64
const minimumProgramHeaderBytes = 56
if (buffer.length < headerBytes) fail(`${label}_elf_header_truncated`)
const type = readElfInteger(buffer, 16, 2, label)
if (type !== 3n) fail(`${label}_elf_type_${type}`)
const machine = readElfInteger(buffer, 18, 2, label)
if (machine !== 183n) fail(`${label}_elf_machine_${machine}`)
const elfVersion = readElfInteger(buffer, 20, 4, label)
if (elfVersion !== 1n) fail(`${label}_elf_version_${elfVersion}`)
const programHeaderOffset = readElfInteger(buffer, 32, 8, label)
const programHeaderEntryBytes = Number(readElfInteger(buffer, 54, 2, label))
const programHeaderCount = Number(readElfInteger(buffer, 56, 2, label))
if (programHeaderCount === 0xffff) fail(`${label}_elf_extended_program_header_count_unverifiable`)
if (programHeaderCount === 0) fail(`${label}_elf_program_headers_missing`)
if (programHeaderEntryBytes < minimumProgramHeaderBytes) {
fail(`${label}_elf_program_header_size_${programHeaderEntryBytes}`)
}
const programHeaderTableEnd = programHeaderOffset
+ BigInt(programHeaderEntryBytes) * BigInt(programHeaderCount)
if (
programHeaderOffset > BigInt(Number.MAX_SAFE_INTEGER)
|| programHeaderTableEnd > BigInt(buffer.length)
) {
fail(`${label}_elf_program_header_table_truncated`)
}
let loadSegmentCount = 0
let minimumLoadAlignment = null
for (let index = 0; index < programHeaderCount; index += 1) {
const headerOffset = Number(programHeaderOffset) + index * programHeaderEntryBytes
const type = Number(readElfInteger(buffer, headerOffset, 4, label))
if (type !== ELF_PT_LOAD) continue
const fileOffset = readElfInteger(buffer, headerOffset + 8, 8, label)
const virtualAddress = readElfInteger(
buffer,
headerOffset + 16,
8,
label,
)
const fileBytes = readElfInteger(buffer, headerOffset + 32, 8, label)
const memoryBytes = readElfInteger(buffer, headerOffset + 40, 8, label)
const alignment = readElfInteger(buffer, headerOffset + 48, 8, label)
if (fileOffset + fileBytes > BigInt(buffer.length)) {
fail(`${label}_elf_load_${index}_file_range`)
}
if (memoryBytes < fileBytes) fail(`${label}_elf_load_${index}_memory_range`)
if (alignment < BigInt(ANDROID_16_KIB_PAGE_BYTES)) {
fail(`${label}_elf_load_${index}_alignment_${alignment}`)
}
if ((alignment & (alignment - 1n)) !== 0n) {
fail(`${label}_elf_load_${index}_alignment_not_power_of_two_${alignment}`)
}
if (fileOffset % alignment !== virtualAddress % alignment) {
fail(`${label}_elf_load_${index}_offset_address_misaligned`)
}
minimumLoadAlignment = minimumLoadAlignment === null || alignment < minimumLoadAlignment
? alignment
: minimumLoadAlignment
loadSegmentCount += 1
}
if (loadSegmentCount === 0) fail(`${label}_elf_load_segments_missing`)
return {
loadSegmentCount,
minimumLoadAlignmentBytes: minimumLoadAlignment.toString(),
}
}
function listNativeLibraryNames(archive, matchesName) {
return [...archive.entries.keys()].filter(matchesName).sort()
}
function verifyArchiveNativeElfAlignment(archive, matchesName, label) {
const nativeNames = listNativeLibraryNames(archive, matchesName)
if (nativeNames.length === 0) fail(`${label}_native_libraries_missing`)
let loadSegmentCount = 0
let minimumLoadAlignment = null
const librarySha256s = []
for (const name of nativeNames) {
const entryLabel = `${label}_${nativeEntryFailureLabel(name)}`
const library = extractEntry(archive, name)
const evidence = verifyElfLoadSegmentAlignment(library, entryLabel)
librarySha256s.push(createHash('sha256').update(library).digest('hex'))
const alignment = BigInt(evidence.minimumLoadAlignmentBytes)
minimumLoadAlignment = minimumLoadAlignment === null || alignment < minimumLoadAlignment
? alignment
: minimumLoadAlignment
loadSegmentCount += evidence.loadSegmentCount
}
return {
evidence: {
requiredPageSizeBytes: ANDROID_16_KIB_PAGE_BYTES,
elfLibrariesChecked: nativeNames.length,
elfLoadSegmentsChecked: loadSegmentCount,
minimumElfLoadAlignmentBytes: minimumLoadAlignment.toString(),
},
librarySha256s,
}
}
function readLocalEntryMetadata(archive, name) {
const entry = archive.entries.get(name)
if (!entry) fail(`missing_entry_${name}`)
const header = readExactly(archive.fd, 30, entry.localOffset)
if (header.readUInt32LE(0) !== 0x04034b50) fail(`local_header_corrupt_${name}`)
const flags = header.readUInt16LE(6)
const method = header.readUInt16LE(8)
const nameLength = header.readUInt16LE(26)
const extraLength = header.readUInt16LE(28)
const localName = readExactly(archive.fd, nameLength, entry.localOffset + 30).toString('utf8')
if (localName !== name) fail(`local_name_mismatch_${name}`)
if (method !== entry.method) fail(`local_method_mismatch_${name}`)
const dataOffset = entry.localOffset + 30 + nameLength + extraLength
if (BigInt(dataOffset + entry.compressedSize) > archive.openedStat.size) {
fail(`local_data_range_${name}`)
}
return { dataOffset, flags, method }
}
function requireStoredNativeZipAlignment(method, dataOffset, label) {
if (method === 8) return false
if (method !== 0) fail(`${label}_zip_method_${method}_unverifiable`)
if (dataOffset % ANDROID_16_KIB_PAGE_BYTES !== 0) {
fail(`${label}_zip_alignment_${dataOffset % ANDROID_16_KIB_PAGE_BYTES}`)
}
return true
}
function verifyApkNativeZipAlignment(archive, label) {
const nativeNames = listNativeLibraryNames(
archive,
(name) => /^lib\/[^/]+\/[^/]+\.so$/.test(name),
)
if (nativeNames.length === 0) fail(`${label}_zip_native_libraries_missing`)
let storedLibrariesChecked = 0
let compressedLibrariesChecked = 0
for (const name of nativeNames) {
const entryLabel = `${label}_${nativeEntryFailureLabel(name)}`
const metadata = readLocalEntryMetadata(archive, name)
if ((metadata.flags & 0x1) !== 0) fail(`${entryLabel}_zip_encrypted`)
if (requireStoredNativeZipAlignment(metadata.method, metadata.dataOffset, entryLabel)) {
storedLibrariesChecked += 1
} else {
compressedLibrariesChecked += 1
}
}
return {
zipAlignmentBytes: ANDROID_16_KIB_PAGE_BYTES,
zipStoredLibrariesChecked: storedLibrariesChecked,
zipCompressedLibrariesChecked: compressedLibrariesChecked,
}
}
function parseBundlePageAlignment(configOutput) {
let config
try {
config = JSON.parse(configOutput)
} catch {
fail('aab_bundle_config_json_unverifiable')
}
const nativeLibraryConfig = config?.optimizations?.uncompressNativeLibraries
if (
!nativeLibraryConfig
|| typeof nativeLibraryConfig !== 'object'
|| Array.isArray(nativeLibraryConfig)
) {
fail('aab_uncompress_native_libraries_config_missing')
}
if (nativeLibraryConfig.enabled !== true) {
fail('aab_uncompress_native_libraries_not_enabled')
}
const alignment = nativeLibraryConfig.alignment
if (typeof alignment !== 'string') fail('aab_page_alignment_unverifiable')
if (alignment === 'PAGE_ALIGNMENT_UNSPECIFIED' || alignment === 'PAGE_ALIGNMENT_4K') {
fail(`aab_page_alignment_insufficient_${alignment}`)
}
if (alignment !== 'PAGE_ALIGNMENT_16K') {
fail(`aab_page_alignment_unverifiable_${alignment.replace(/[^a-z0-9]+/gi, '_')}`)
}
return {
bundlePageAlignment: alignment,
generatedApkZipAlignmentBytes: ANDROID_16_KIB_PAGE_BYTES,
}
}
function locateAndroidTools() {
const sdk = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME
if (!sdk) fail('android_sdk_not_configured')
const buildToolsRoot = join(sdk, 'build-tools')
const versions = readdirSync(buildToolsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }))
const selected = versions.at(-1)
if (!selected) fail('android_build_tools_missing')
const directory = join(buildToolsRoot, selected)
const aapt = join(directory, process.platform === 'win32' ? 'aapt.exe' : 'aapt')
const zipalign = join(directory, process.platform === 'win32' ? 'zipalign.exe' : 'zipalign')
const apksignerJar = join(directory, 'lib', 'apksigner.jar')
if (!existsSync(aapt) || !existsSync(apksignerJar)) fail('android_verification_tools_missing')
return { aapt, apksignerJar, version: selected, zipalign }
}
function run(command, args, description) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
})
if (result.status !== 0) {
fail(`${description}_failed_${String(result.stderr || result.stdout).trim().replace(/\s+/g, '_')}`)
}
return result.stdout
}
function runArtifact(command, args, description, archive) {
assertArchivePathStable(archive, description)
const output = run(command, args, description)
assertArchivePathStable(archive, description)
return output
}
function runZipalign16KiB(archive, zipalign, description, runArtifactCommand = runArtifact) {
if (!zipalign || !existsSync(zipalign)) fail(`${description}_tool_missing`)
runArtifactCommand(
zipalign,
['-c', '-P', '16', '-v', '4', archive.absolute],
description,
archive,
)
}
function verifyReleaseApkPageSize(archive, zipalign, runArtifactCommand = runArtifact) {
const elfResult = verifyArchiveNativeElfAlignment(
archive,
(name) => /^lib\/[^/]+\/[^/]+\.so$/.test(name),
'release_apk',
)
const zipEvidence = verifyApkNativeZipAlignment(archive, 'release_apk')
runZipalign16KiB(
archive,
zipalign,
'release_zipalign_16kb',
runArtifactCommand,
)
return {
...elfResult.evidence,
...zipEvidence,
zipalign16KiBVerified: true,
}
}
function withVerifiedTemporaryDirectory(prefix, action) {
const trustedTemporaryRoot = realpathSync(tmpdir())
const directory = mkdtempSync(join(trustedTemporaryRoot, prefix))
const resolvedDirectory = realpathSync(directory)
if (!resolvedDirectory.startsWith(`${trustedTemporaryRoot}${sep}`)) {
fail('temporary_directory_outside_system_temp')
}
try {
return action(resolvedDirectory)
} finally {
const cleanupTarget = realpathSync(resolvedDirectory)
if (
cleanupTarget !== resolvedDirectory
|| !cleanupTarget.startsWith(`${trustedTemporaryRoot}${sep}`)
) {
fail('temporary_directory_cleanup_target_changed')
}
rmSync(cleanupTarget, { recursive: true, force: false })
}
}
function verifyAabGeneratedApkSetPageSize(
archive,
bundletoolPath,
zipalign,
sourceLibrarySha256s,
) {
if (!zipalign || !existsSync(zipalign)) fail('aab_zipalign_tool_missing')
return withVerifiedTemporaryDirectory('d3ro-aab-page-size-', (temporaryDirectory) => {
const keyStorePath = join(temporaryDirectory, 'verification-signing.p12')
const apkSetPath = join(temporaryDirectory, 'delivery.apks')
const keyAlias = 'd3ro-16k-verifier'
const password = randomBytes(24).toString('base64url')
run('keytool', [
'-genkeypair',
'-noprompt',
'-keystore', keyStorePath,
'-storetype', 'PKCS12',
'-storepass', password,
'-keypass', password,
'-alias', keyAlias,
'-keyalg', 'RSA',
'-keysize', '2048',
'-validity', '1',
'-dname', 'CN=D3RO 16KiB Artifact Verification',
], 'aab_apk_set_ephemeral_key')
runArtifact('java', [
'-jar', bundletoolPath,
'build-apks',
`--bundle=${archive.absolute}`,
`--output=${apkSetPath}`,
`--ks=${keyStorePath}`,
`--ks-pass=pass:${password}`,
`--ks-key-alias=${keyAlias}`,
`--key-pass=pass:${password}`,
], 'bundletool_build_apks', archive)
const apkSetArchive = readZipDirectory(apkSetPath)
try {
const apkNames = [...apkSetArchive.entries.keys()]
.filter((name) => /(^|\/)[^/]+\.apk$/.test(name))
.sort()
if (apkNames.length === 0) fail('aab_apk_set_delivery_apks_missing')
const deliveryLibrarySha256s = new Set()
let nativeApkFilesChecked = 0
let elfLibrariesChecked = 0
let elfLoadSegmentsChecked = 0
let minimumElfLoadAlignment = null
let zipStoredLibrariesChecked = 0
let zipCompressedLibrariesChecked = 0
for (let index = 0; index < apkNames.length; index += 1) {
const apkBytes = extractEntry(apkSetArchive, apkNames[index])
const deliveryApkPath = join(temporaryDirectory, `delivery-${index}.apk`)
writeFileSync(deliveryApkPath, apkBytes, { flag: 'wx', mode: 0o600 })
const deliveryArchive = readZipDirectory(deliveryApkPath)
try {
const nativeNames = listNativeLibraryNames(
deliveryArchive,
(name) => /^lib\/[^/]+\/[^/]+\.so$/.test(name),
)
if (nativeNames.length === 0) continue
const label = `aab_delivery_apk_${index}`
const elfResult = verifyArchiveNativeElfAlignment(
deliveryArchive,
(name) => /^lib\/[^/]+\/[^/]+\.so$/.test(name),
label,
)
const zipEvidence = verifyApkNativeZipAlignment(deliveryArchive, label)
runZipalign16KiB(
deliveryArchive,
zipalign,
`${label}_zipalign_16kb`,
)
for (const sha256 of elfResult.librarySha256s) deliveryLibrarySha256s.add(sha256)
const alignment = BigInt(elfResult.evidence.minimumElfLoadAlignmentBytes)
minimumElfLoadAlignment = minimumElfLoadAlignment === null
|| alignment < minimumElfLoadAlignment
? alignment
: minimumElfLoadAlignment
nativeApkFilesChecked += 1
elfLibrariesChecked += elfResult.evidence.elfLibrariesChecked
elfLoadSegmentsChecked += elfResult.evidence.elfLoadSegmentsChecked
zipStoredLibrariesChecked += zipEvidence.zipStoredLibrariesChecked
zipCompressedLibrariesChecked += zipEvidence.zipCompressedLibrariesChecked
} finally {
if (deliveryArchive.keepOpen) closeSync(deliveryArchive.fd)
}
}
if (nativeApkFilesChecked === 0) fail('aab_apk_set_native_delivery_apks_missing')
for (const sha256 of new Set(sourceLibrarySha256s)) {
if (!deliveryLibrarySha256s.has(sha256)) {
fail(`aab_apk_set_source_native_missing_${sha256.slice(0, 16)}`)
}
}
return {
apkFilesInspected: apkNames.length,
nativeApkFilesChecked,
elfLibrariesChecked,
elfLoadSegmentsChecked,
minimumElfLoadAlignmentBytes: minimumElfLoadAlignment.toString(),
zipAlignmentBytes: ANDROID_16_KIB_PAGE_BYTES,
zipStoredLibrariesChecked,
zipCompressedLibrariesChecked,
sourceNativeLibrariesRepresented: new Set(sourceLibrarySha256s).size,
zipalign16KiBVerified: true,
}
} finally {
if (apkSetArchive.keepOpen) closeSync(apkSetArchive.fd)
}
})
}
function extractElementBlocks(xmlTree, elementName) {
const lines = xmlTree.split(/\r?\n/)
const blocks = []
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(/^(\s*)E:\s+([^\s(]+)/)
if (!match || match[2] !== elementName) continue
const indentation = match[1].length
let end = index + 1
while (end < lines.length) {
const next = lines[end].match(/^(\s*)E:\s+/)
if (next && next[1].length <= indentation) break
end += 1
}
blocks.push(lines.slice(index, end).join('\n'))
}
return blocks
}
function rawAttribute(block, attribute) {
const escaped = attribute.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return block.match(new RegExp(`android:${escaped}[^\\n]*Raw:\\s*"([^"]+)"`))?.[1] ?? null
}
function filterHasExactData(block, expected) {
const dataBlocks = extractElementBlocks(block, 'data')
if (dataBlocks.length !== 1) return false
const data = dataBlocks[0]
return rawAttribute(data, 'scheme') === expected.scheme
&& rawAttribute(data, 'host') === expected.host
&& rawAttribute(data, 'pathPrefix') === (expected.pathPrefix ?? null)
}
function verifyDeepLinkManifest(manifest) {
if (/android:scheme[^\n]*Raw:\s*"d3ro"/.test(manifest)) fail('legacy_d3ro_scheme_present')
const activities = extractElementBlocks(manifest, 'activity')
const mainActivity = activities.find((block) => (
rawAttribute(block, 'name') === 'com.d3ro.voice.MainActivity'
))
if (!mainActivity || !/android:exported[^\n]*0xffffffff\b/.test(mainActivity)) {
fail('main_activity_exported_contract')
}
const filters = extractElementBlocks(mainActivity, 'intent-filter')
const hasBrowsableView = (block) => (
block.includes('android.intent.action.VIEW')
&& block.includes('android.intent.category.DEFAULT')
&& block.includes('android.intent.category.BROWSABLE')
)
const authCallback = filters.some((block) => (
hasBrowsableView(block)
&& filterHasExactData(block, { scheme: 'd3ro-voice', host: 'auth-callback' })
))
if (!authCallback) fail('missing_deep_link_auth_callback')
const inviteCallback = filters.some((block) => (
hasBrowsableView(block)
&& filterHasExactData(block, { scheme: 'd3ro-voice', host: 'accept-invite' })
))
if (!inviteCallback) fail('missing_deep_link_accept_invite')
const verifiedAppLink = filters.some((block) => (
hasBrowsableView(block)
&& /android:autoVerify[^\n]*0xffffffff\b/.test(block)
&& filterHasExactData(block, {
scheme: 'https',
host: 'd3ro.chanpaca.net',
pathPrefix: '/accept-invite',
})
))
if (!verifiedAppLink) fail('missing_verified_app_link_accept_invite')
}
function verifyManifestContracts(badging, manifest, mode, expectedAdMobAppId) {
const packageInfo = badging.match(/package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']+)'/)
if (!packageInfo || packageInfo[1] !== EXPECTED_PACKAGE) fail('package_name_mismatch')
const versionCode = Number(packageInfo[2])
const versionName = packageInfo[3]
const minSdk = Number(badging.match(/^sdkVersion:'([0-9]+)'$/m)?.[1])
const targetSdk = Number(badging.match(/^targetSdkVersion:'([0-9]+)'$/m)?.[1])
if (minSdk !== EXPECTED_MIN_SDK) fail('min_sdk_mismatch')
if (targetSdk !== EXPECTED_TARGET_SDK) fail('target_sdk_mismatch')
const debuggable = badging.includes('application-debuggable')
if (mode !== 'debug' && debuggable) fail(`${mode}_must_not_be_debuggable`)
if (mode === 'debug' && !debuggable) fail('debug_must_be_debuggable')
if (!/android:usesCleartextTraffic[^\n]*0x0\b/.test(manifest) && mode !== 'debug') {
fail(`${mode}_cleartext_must_be_false`)
}
const requiredPermissions = [
'android.permission.INTERNET',
'android.permission.RECORD_AUDIO',
'android.permission.POST_NOTIFICATIONS',
'android.permission.FOREGROUND_SERVICE_MICROPHONE',
]
for (const permission of requiredPermissions) {
if (!manifest.includes(permission)) fail(`missing_permission_${permission}`)
}
verifyDeepLinkManifest(manifest)
const adMetadata = manifest.match(
/com\.google\.android\.gms\.ads\.APPLICATION_ID[\s\S]{0,600}?android:value[^\n]*Raw:\s*"([^"]+)"/,
)
if (!adMetadata) fail('admob_application_id_missing')
const adMobAppId = adMetadata[1]
if (mode === 'release') {
if (!/^ca-app-pub-\d+~\d+$/.test(adMobAppId)) fail('release_admob_application_id_format')
if (adMobAppId === TEST_ADMOB_APP_ID) fail('release_uses_test_admob_application_id')
} else if (adMobAppId !== TEST_ADMOB_APP_ID) {
fail(`${mode}_must_use_google_test_admob_application_id`)
}
if (expectedAdMobAppId && adMobAppId !== expectedAdMobAppId) fail('admob_application_id_env_mismatch')
return { debuggable, adMobAppId, versionName, versionCode, minSdk, targetSdk }
}
function verifyManifest(aapt, archive, mode, expectedAdMobAppId) {
const apkPath = archive.absolute
const badging = runArtifact(aapt, ['dump', 'badging', apkPath], 'aapt_badging', archive)
const manifest = runArtifact(aapt, ['dump', 'xmltree', apkPath, 'AndroidManifest.xml'], 'aapt_manifest', archive)
return verifyManifestContracts(badging, manifest, mode, expectedAdMobAppId)
}
function verifyArchive(archive, mode) {
const names = [...archive.entries.keys()]
const bundle = mode === 'debug'
? null
: extractEntry(archive, 'assets/index.android.bundle')
if (bundle !== null && bundle.length < 100_000) fail('embedded_bundle_too_small')
const model = extractEntry(archive, MODEL_PATH)
if (model.length !== MODEL_BYTES) fail('whisper_model_size')
const modelSha256 = createHash('sha256').update(model).digest('hex')
if (modelSha256 !== MODEL_SHA256) fail('whisper_model_sha256')
const abiSet = new Set(
names
.map((name) => name.match(/^lib\/([^/]+)\//)?.[1])
.filter(Boolean),
)
const requiredAbis = mode === 'release'
? ['arm64-v8a']
: mode === 'e2e'
? ['arm64-v8a', 'x86_64']
: ['x86_64']
for (const requiredAbi of requiredAbis) {
if (!abiSet.has(requiredAbi)) fail(`missing_abi_${requiredAbi}`)
}
for (const abi of abiSet) {
for (const library of REQUIRED_NATIVE_LIBRARIES) {
if (!archive.entries.has(`lib/${abi}/${library}`)) {
fail(`missing_native_${abi}_${library}`)
}
}
if (abi === 'x86_64' && !archive.entries.has(`lib/${abi}/librnwhisper_x86_64.so`)) {
fail('missing_native_x86_64_librnwhisper_x86_64.so')
}
if (abi === 'arm64-v8a') {
for (const optimizedWhisper of ['librnwhisper_v8.so', 'librnwhisper_v8fp16_va_2.so']) {
if (!archive.entries.has(`lib/${abi}/${optimizedWhisper}`)) {
fail(`missing_native_${abi}_${optimizedWhisper}`)
}
}
}
if (mode !== 'debug') {
const reactNative = extractEntry(archive, `lib/${abi}/libreactnative.so`)
if (reactNative.includes(DEBUG_PROPS_SYMBOL)) {
fail(`${mode}_native_${abi}_uses_debug_props_abi`)
}
}
}
if (mode === 'e2e' && (
abiSet.size !== 2
|| !abiSet.has('arm64-v8a')
|| !abiSet.has('x86_64')
)) {
fail(`e2e_abi_set_${[...abiSet].sort().join('_')}`)
}
if (mode === 'release' && (abiSet.size !== 1 || !abiSet.has('arm64-v8a'))) {
fail(`release_abi_set_${[...abiSet].sort().join('_')}`)
}
return {
bundleBytes: bundle?.length ?? 0,
modelBytes: model.length,
modelSha256,
abis: [...abiSet].sort(),
}
}
function parseAabManifest(manifest) {
const packageName = manifest.match(/\bpackage="([^"]+)"/)?.[1]
const versionName = manifest.match(/\bandroid:versionName="([^"]+)"/)?.[1]
const versionCodeText = manifest.match(/\bandroid:versionCode="([0-9]+)"/)?.[1]
const metadataTag = [...manifest.matchAll(/<meta-data\b[^>]*>/g)]
.map((match) => match[0])
.find((tag) => /android:name="com\.google\.android\.gms\.ads\.APPLICATION_ID"/.test(tag))
const adMobAppId = metadataTag?.match(/\bandroid:value="([^"]+)"/)?.[1]
if (!packageName || !versionName || !versionCodeText || !adMobAppId) {
fail('aab_manifest_metadata_missing')
}
return {
packageName,
versionName,
versionCode: Number(versionCodeText),
adMobAppId,
debuggable: /<application\b[^>]*\bandroid:debuggable="true"/.test(manifest),
}
}
function verifyReleaseBundle(path, bundletoolPath, zipalign, expected) {
const archive = readZipDirectory(path)
try {
const names = [...archive.entries.keys()]
const bundle = extractEntry(archive, 'base/assets/index.android.bundle')
if (bundle.length < 100_000) fail('aab_embedded_bundle_too_small')
const model = extractEntry(archive, `base/${MODEL_PATH}`)
if (model.length !== MODEL_BYTES) fail('aab_whisper_model_size')
const modelSha256 = createHash('sha256').update(model).digest('hex')
if (modelSha256 !== MODEL_SHA256) fail('aab_whisper_model_sha256')
const abiSet = new Set(
names
.map((name) => name.match(/^[^/]+\/lib\/([^/]+)\//)?.[1])
.filter(Boolean),
)
if (abiSet.size !== 1 || !abiSet.has('arm64-v8a')) {
fail(`aab_abi_set_${[...abiSet].sort().join('_')}`)
}
const elfPageSizeResult = verifyArchiveNativeElfAlignment(
archive,
(name) => /^[^/]+\/lib\/[^/]+\/[^/]+\.so$/.test(name),
'aab',
)
for (const library of [
...REQUIRED_NATIVE_LIBRARIES,
'librnwhisper_v8.so',
'librnwhisper_v8fp16_va_2.so',
]) {
if (!archive.entries.has(`base/lib/arm64-v8a/${library}`)) {
fail(`aab_missing_native_arm64-v8a_${library}`)
}
}
const reactNative = extractEntry(archive, 'base/lib/arm64-v8a/libreactnative.so')
if (reactNative.includes(DEBUG_PROPS_SYMBOL)) {
fail('aab_native_arm64-v8a_uses_debug_props_abi')
}
const bundleConfig = runArtifact(
'java',
['-jar', bundletoolPath, 'dump', 'config', `--bundle=${archive.absolute}`],
'bundletool_config',
archive,
)
const bundlePageSizeEvidence = parseBundlePageAlignment(bundleConfig)
const deliveryApkSetPageSizeEvidence = verifyAabGeneratedApkSetPageSize(
archive,
bundletoolPath,
zipalign,
elfPageSizeResult.librarySha256s,
)
const manifest = runArtifact(
'java',
['-jar', bundletoolPath, 'dump', 'manifest', `--bundle=${archive.absolute}`, '--module=base'],
'bundletool_manifest',
archive,
)
const manifestEvidence = parseAabManifest(manifest)
if (manifestEvidence.packageName !== EXPECTED_PACKAGE) fail('aab_package_name_mismatch')
if (manifestEvidence.versionName !== expected.versionName) fail('aab_version_name_mismatch')
if (manifestEvidence.versionCode !== expected.versionCode) fail('aab_version_code_mismatch')
if (manifestEvidence.debuggable) fail('aab_must_not_be_debuggable')
if (manifestEvidence.adMobAppId !== expected.adMobAppId) fail('aab_admob_application_id_mismatch')
if (manifestEvidence.adMobAppId === TEST_ADMOB_APP_ID) fail('aab_uses_test_admob_application_id')
runArtifact('jarsigner', ['-verify', '-strict', archive.absolute], 'jarsigner', archive)
const certificate = runArtifact(
'keytool', ['-printcert', '-jarfile', archive.absolute], 'aab_certificate', archive,
)
const signerText = certificate.match(/SHA-?256:\s*([0-9A-F:]+)/i)?.[1]
if (!signerText) fail('aab_signer_sha256_missing')
const signerSha256 = requireAllowedReleaseCertificate(signerText, 'aab_signer_sha256')
if (signerSha256 !== expected.signerSha256) fail('aab_signer_sha256_mismatch')
return {
artifact: basename(archive.absolute),
sha256: hashArchiveStable(archive, 'aab'),
...manifestEvidence,
signerSha256,
bundleBytes: bundle.length,
modelBytes: model.length,
modelSha256,
abis: [...abiSet],
pageSize: {
...elfPageSizeResult.evidence,
...bundlePageSizeEvidence,
deliveryApkSet: deliveryApkSetPageSizeEvidence,
},
}
} finally {
if (archive.keepOpen) closeSync(archive.fd)
}
}
function createElf64SelfTestFixture(alignments, types = alignments.map(() => ELF_PT_LOAD)) {
const programHeaderBytes = 56
const buffer = Buffer.alloc(64 + alignments.length * programHeaderBytes)
buffer.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1], 0)
buffer.writeUInt16LE(3, 16)
buffer.writeUInt16LE(183, 18)
buffer.writeUInt32LE(1, 20)
buffer.writeBigUInt64LE(64n, 32)
buffer.writeUInt16LE(64, 52)
buffer.writeUInt16LE(programHeaderBytes, 54)
buffer.writeUInt16LE(alignments.length, 56)
for (let index = 0; index < alignments.length; index += 1) {
const offset = 64 + index * programHeaderBytes
buffer.writeUInt32LE(types[index], offset)
buffer.writeBigUInt64LE(BigInt(alignments[index]), offset + 48)
}
return buffer
}
function createStoredZipSelfTestFixture({
data,
alignment,
centralName = 'lib/arm64-v8a/libfixture.so',
localName = centralName,
flags = 0,
centralMethod = 0,
localMethod = centralMethod,
}) {
const localNameBytes = Buffer.from(localName)
const centralNameBytes = Buffer.from(centralName)
const extraLength = (
alignment - ((30 + localNameBytes.length) % alignment)
) % alignment
const localHeader = Buffer.alloc(30 + localNameBytes.length + extraLength)
localHeader.writeUInt32LE(0x04034b50, 0)
localHeader.writeUInt16LE(20, 4)
localHeader.writeUInt16LE(flags, 6)
localHeader.writeUInt16LE(localMethod, 8)
localHeader.writeUInt32LE(data.length, 18)
localHeader.writeUInt32LE(data.length, 22)
localHeader.writeUInt16LE(localNameBytes.length, 26)
localHeader.writeUInt16LE(extraLength, 28)
localNameBytes.copy(localHeader, 30)
const centralOffset = localHeader.length + data.length
const centralHeader = Buffer.alloc(46 + centralNameBytes.length)
centralHeader.writeUInt32LE(0x02014b50, 0)
centralHeader.writeUInt16LE(20, 4)
centralHeader.writeUInt16LE(20, 6)
centralHeader.writeUInt16LE(flags, 8)
centralHeader.writeUInt16LE(centralMethod, 10)
centralHeader.writeUInt32LE(data.length, 20)
centralHeader.writeUInt32LE(data.length, 24)
centralHeader.writeUInt16LE(centralNameBytes.length, 28)
centralHeader.writeUInt32LE(0, 42)
centralNameBytes.copy(centralHeader, 46)
const endOfDirectory = Buffer.alloc(22)
endOfDirectory.writeUInt32LE(0x06054b50, 0)
endOfDirectory.writeUInt16LE(1, 8)
endOfDirectory.writeUInt16LE(1, 10)
endOfDirectory.writeUInt32LE(centralHeader.length, 12)
endOfDirectory.writeUInt32LE(centralOffset, 16)
return Buffer.concat([localHeader, data, centralHeader, endOfDirectory])
}
function withZipSelfTestArchive(buffer, action) {
return withVerifiedTemporaryDirectory('d3ro-zip-self-test-', (temporaryDirectory) => {
const path = join(temporaryDirectory, 'fixture.apk')
writeFileSync(path, buffer, { flag: 'wx', mode: 0o600 })
const archive = readZipDirectory(path)
try {
return action(archive)
} finally {
if (archive.keepOpen) closeSync(archive.fd)
}
})
}
function expectSelfTestFailure(label, action, expectedMessage) {
let rejected = false
try {
action()
} catch (error) {
rejected = error instanceof Error && error.message.includes(expectedMessage)
}
if (!rejected) fail(`self_test_${label}_not_rejected`)
}
function createManifestContractSelfTestFixture() {
const badging = [
"package: name='com.d3ro.voice' versionCode='1000001' versionName='1.0.1'",
"sdkVersion:'24'",
"targetSdkVersion:'36'",
].join('\n')
const manifest = [
'E: manifest',
' E: uses-permission',
' A: android:name="android.permission.INTERNET" (Raw: "android.permission.INTERNET")',
' E: uses-permission',
' A: android:name="android.permission.RECORD_AUDIO" (Raw: "android.permission.RECORD_AUDIO")',
' E: uses-permission',
' A: android:name="android.permission.POST_NOTIFICATIONS" (Raw: "android.permission.POST_NOTIFICATIONS")',
' E: uses-permission',
' A: android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" (Raw: "android.permission.FOREGROUND_SERVICE_MICROPHONE")',
' E: application',
' A: android:usesCleartextTraffic=(type 0x12)0x0',
' E: meta-data',
' A: android:name="com.google.android.gms.ads.APPLICATION_ID" (Raw: "com.google.android.gms.ads.APPLICATION_ID")',
' A: android:value="ca-app-pub-1234567890123456~1234567890" (Raw: "ca-app-pub-1234567890123456~1234567890")',
' E: activity',
' A: android:name="com.d3ro.voice.MainActivity" (Raw: "com.d3ro.voice.MainActivity")',
' A: android:exported=(type 0x12)0xffffffff',
' E: intent-filter',
' E: action',
' A: android:name="android.intent.action.VIEW" (Raw: "android.intent.action.VIEW")',
' E: category',
' A: android:name="android.intent.category.DEFAULT" (Raw: "android.intent.category.DEFAULT")',
' E: category',
' A: android:name="android.intent.category.BROWSABLE" (Raw: "android.intent.category.BROWSABLE")',
' E: data',
' A: android:scheme="d3ro-voice" (Raw: "d3ro-voice")',
' A: android:host="auth-callback" (Raw: "auth-callback")',
' E: intent-filter',
' E: action',
' A: android:name="android.intent.action.VIEW" (Raw: "android.intent.action.VIEW")',
' E: category',
' A: android:name="android.intent.category.DEFAULT" (Raw: "android.intent.category.DEFAULT")',
' E: category',
' A: android:name="android.intent.category.BROWSABLE" (Raw: "android.intent.category.BROWSABLE")',
' E: data',
' A: android:scheme="d3ro-voice" (Raw: "d3ro-voice")',
' A: android:host="accept-invite" (Raw: "accept-invite")',
' E: intent-filter',
' A: android:autoVerify=(type 0x12)0xffffffff',
' E: action',
' A: android:name="android.intent.action.VIEW" (Raw: "android.intent.action.VIEW")',
' E: category',
' A: android:name="android.intent.category.DEFAULT" (Raw: "android.intent.category.DEFAULT")',
' E: category',
' A: android:name="android.intent.category.BROWSABLE" (Raw: "android.intent.category.BROWSABLE")',
' E: data',
' A: android:scheme="https" (Raw: "https")',
' A: android:host="d3ro.chanpaca.net" (Raw: "d3ro.chanpaca.net")',
' A: android:pathPrefix="/accept-invite" (Raw: "/accept-invite")',
].join('\n')
return { badging, manifest }
}
function runSigningPolicySelfTest() {
const safe = '4F:AC:69:24:82:1C:50:DA:AB:ED:76:49:32:A5:3C:48:6F:8C:6C:5F:34:B9:F1:8D:B9:20:AA:40:99:15:2B:54'
if (requireAllowedReleaseCertificate(safe, 'self_test_safe') !== safe.replaceAll(':', '').toLowerCase()) {
fail('self_test_safe_certificate_mismatch')
}
let compromisedRejected = false
try {
requireAllowedReleaseCertificate(COMPROMISED_SIGNER_SHA256, 'self_test')
} catch (error) {
compromisedRejected = error instanceof Error
&& error.message.includes('self_test_compromised')
}
if (!compromisedRejected) fail('self_test_compromised_certificate_not_rejected')
let legacyArgumentRejected = false
try {
parseArguments(['--expected-cert-sha256', safe])
} catch (error) {
legacyArgumentRejected = error instanceof Error
&& error.message.includes('unexpected_argument_expected-cert-sha256')
}
if (!legacyArgumentRejected) fail('self_test_legacy_certificate_argument_not_rejected')
const manifestFixture = createManifestContractSelfTestFixture()
const manifestEvidence = verifyManifestContracts(
manifestFixture.badging,
manifestFixture.manifest,
'release',
'ca-app-pub-1234567890123456~1234567890',
)
if (manifestEvidence.minSdk !== EXPECTED_MIN_SDK || manifestEvidence.targetSdk !== EXPECTED_TARGET_SDK) {
fail('self_test_manifest_sdk_evidence')
}
expectSelfTestFailure(
'manifest_min_sdk',
() => verifyManifestContracts(
manifestFixture.badging.replace("sdkVersion:'24'", "sdkVersion:'23'"),
manifestFixture.manifest,
'release',
'ca-app-pub-1234567890123456~1234567890',
),
'min_sdk_mismatch',
)
expectSelfTestFailure(
'manifest_decoy_auth_host',
() => verifyManifestContracts(
manifestFixture.badging,
`${manifestFixture.manifest.replace(
'android:host="auth-callback" (Raw: "auth-callback")',
'android:host="decoy" (Raw: "decoy")',
)}\n E: meta-data\n A: android:value="auth-callback" (Raw: "auth-callback")`,
'release',
'ca-app-pub-1234567890123456~1234567890',
),
'missing_deep_link_auth_callback',
)
expectSelfTestFailure(
'manifest_legacy_scheme',
() => verifyManifestContracts(
manifestFixture.badging,
manifestFixture.manifest.replace(
'android:scheme="d3ro-voice" (Raw: "d3ro-voice")',
'android:scheme="d3ro" (Raw: "d3ro")',
),
'release',
'ca-app-pub-1234567890123456~1234567890',
),
'legacy_d3ro_scheme_present',
)
expectSelfTestFailure(
'manifest_app_link_not_verified',
() => verifyManifestContracts(
manifestFixture.badging,
manifestFixture.manifest.replace(
'android:autoVerify=(type 0x12)0xffffffff',
'android:autoVerify=(type 0x12)0x0',
),
'release',
'ca-app-pub-1234567890123456~1234567890',
),
'missing_verified_app_link_accept_invite',
)
const alignedElf = verifyElfLoadSegmentAlignment(
createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES, 64 * 1024]),
'self_test_aligned',
)
if (
alignedElf.loadSegmentCount !== 2
|| alignedElf.minimumLoadAlignmentBytes !== String(ANDROID_16_KIB_PAGE_BYTES)
) {
fail('self_test_aligned_elf_evidence')
}
const elf32 = createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES])
elf32[4] = 1
expectSelfTestFailure(
'elf_32_unverifiable',
() => verifyElfLoadSegmentAlignment(elf32, 'self_test_elf32'),
'self_test_elf32_elf_class_1',
)
const bigEndianElf = createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES])
bigEndianElf[5] = 2
expectSelfTestFailure(
'elf_big_endian_unverifiable',
() => verifyElfLoadSegmentAlignment(bigEndianElf, 'self_test_big_endian'),
'self_test_big_endian_elf_data_encoding_2',
)
const wrongMachineElf = createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES])
wrongMachineElf.writeUInt16LE(62, 18)
expectSelfTestFailure(
'elf_wrong_machine',
() => verifyElfLoadSegmentAlignment(wrongMachineElf, 'self_test_machine'),
'self_test_machine_elf_machine_62',
)
const wrongTypeElf = createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES])
wrongTypeElf.writeUInt16LE(0, 16)
expectSelfTestFailure(
'elf_wrong_type',
() => verifyElfLoadSegmentAlignment(wrongTypeElf, 'self_test_type'),
'self_test_type_elf_type_0',
)
expectSelfTestFailure(
'elf_4k_only',
() => verifyElfLoadSegmentAlignment(createElf64SelfTestFixture([4 * 1024]), 'self_test_4k'),
'self_test_4k_elf_load_0_alignment_4096',
)
expectSelfTestFailure(
'elf_non_load',
() => verifyElfLoadSegmentAlignment(
createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES], [0]),
'self_test_non_load',
),
'self_test_non_load_elf_load_segments_missing',
)
const misalignedElf = createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES])
misalignedElf.writeBigUInt64LE(1n, 64 + 16)
expectSelfTestFailure(
'elf_offset_address',
() => verifyElfLoadSegmentAlignment(misalignedElf, 'self_test_offset_address'),
'self_test_offset_address_elf_load_0_offset_address_misaligned',
)
expectSelfTestFailure(
'elf_truncated',
() => verifyElfLoadSegmentAlignment(
createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES]).subarray(0, 80),
'self_test_truncated',
),
'self_test_truncated_elf_program_header_table_truncated',
)
const bundle16KiB = parseBundlePageAlignment(JSON.stringify({
optimizations: {
uncompressNativeLibraries: { enabled: true, alignment: 'PAGE_ALIGNMENT_16K' },
},
}))
if (bundle16KiB.generatedApkZipAlignmentBytes !== ANDROID_16_KIB_PAGE_BYTES) {
fail('self_test_bundle_16k_evidence')
}
expectSelfTestFailure(
'bundle_64k_not_exact_contract',
() => parseBundlePageAlignment(JSON.stringify({
optimizations: {
uncompressNativeLibraries: { enabled: true, alignment: 'PAGE_ALIGNMENT_64K' },
},
})),
'aab_page_alignment_unverifiable_PAGE_ALIGNMENT_64K',
)
expectSelfTestFailure(
'bundle_4k_only',
() => parseBundlePageAlignment(JSON.stringify({
optimizations: {
uncompressNativeLibraries: { enabled: true, alignment: 'PAGE_ALIGNMENT_4K' },
},
})),
'aab_page_alignment_insufficient_PAGE_ALIGNMENT_4K',
)
expectSelfTestFailure(
'bundle_unspecified',
() => parseBundlePageAlignment(JSON.stringify({
optimizations: {
uncompressNativeLibraries: { enabled: true, alignment: 'PAGE_ALIGNMENT_UNSPECIFIED' },
},
})),
'aab_page_alignment_insufficient_PAGE_ALIGNMENT_UNSPECIFIED',
)
expectSelfTestFailure(
'bundle_alignment_missing',
() => parseBundlePageAlignment(JSON.stringify({
compression: { uncompressedGlob: ['PAGE_ALIGNMENT_16K'] },
optimizations: { uncompressNativeLibraries: { enabled: true } },
})),
'aab_page_alignment_unverifiable',
)
expectSelfTestFailure(
'bundle_alignment_wrong_field',
() => parseBundlePageAlignment(JSON.stringify({
alignment: 'PAGE_ALIGNMENT_16K',
optimizations: { uncompressNativeLibraries: { enabled: true } },
})),
'aab_page_alignment_unverifiable',
)
expectSelfTestFailure(
'bundle_native_libraries_disabled',
() => parseBundlePageAlignment(JSON.stringify({
optimizations: {
uncompressNativeLibraries: { enabled: false, alignment: 'PAGE_ALIGNMENT_16K' },
},
})),
'aab_uncompress_native_libraries_not_enabled',
)
expectSelfTestFailure(
'bundle_config_non_json',
() => parseBundlePageAlignment('alignment: PAGE_ALIGNMENT_16K'),
'aab_bundle_config_json_unverifiable',
)
if (!requireStoredNativeZipAlignment(0, ANDROID_16_KIB_PAGE_BYTES, 'self_test_zip_aligned')) {
fail('self_test_zip_aligned_evidence')
}
if (requireStoredNativeZipAlignment(8, 1, 'self_test_zip_compressed')) {
fail('self_test_zip_compressed_evidence')
}
expectSelfTestFailure(
'zip_4k_only',
() => requireStoredNativeZipAlignment(0, 4 * 1024, 'self_test_zip_4k'),
'self_test_zip_4k_zip_alignment_4096',
)
expectSelfTestFailure(
'zip_method_unverifiable',
() => requireStoredNativeZipAlignment(12, ANDROID_16_KIB_PAGE_BYTES, 'self_test_zip_method'),
'self_test_zip_method_zip_method_12_unverifiable',
)
const elfFixture = createElf64SelfTestFixture([ANDROID_16_KIB_PAGE_BYTES])
let zipalignInvocation = null
const archivePageSizeEvidence = withZipSelfTestArchive(
createStoredZipSelfTestFixture({
data: elfFixture,
alignment: ANDROID_16_KIB_PAGE_BYTES,
}),
(archive) => verifyReleaseApkPageSize(
archive,
process.execPath,
(command, args, description, invokedArchive) => {
zipalignInvocation = { command, args, description, invokedArchive }
return ''
},
),
)
if (
archivePageSizeEvidence.elfLibrariesChecked !== 1
|| archivePageSizeEvidence.zipStoredLibrariesChecked !== 1
|| archivePageSizeEvidence.zipCompressedLibrariesChecked !== 0
) {
fail('self_test_zip_archive_evidence')
}
if (
zipalignInvocation?.command !== process.execPath
|| zipalignInvocation.description !== 'release_zipalign_16kb'
|| zipalignInvocation.args.length !== 6
|| zipalignInvocation.args[0] !== '-c'
|| zipalignInvocation.args[1] !== '-P'
|| zipalignInvocation.args[2] !== '16'
|| zipalignInvocation.args[3] !== '-v'
|| zipalignInvocation.args[4] !== '4'
|| zipalignInvocation.args[5] !== zipalignInvocation.invokedArchive.absolute
) {
fail('self_test_zipalign_invocation')
}
let fourKiBZipalignInvoked = false
expectSelfTestFailure(
'zip_archive_4k_only',
() => withZipSelfTestArchive(
createStoredZipSelfTestFixture({ data: elfFixture, alignment: 4 * 1024 }),
(archive) => verifyReleaseApkPageSize(
archive,
process.execPath,
() => {
fourKiBZipalignInvoked = true
return ''
},
),
),
'zip_alignment_4096',
)
if (fourKiBZipalignInvoked) fail('self_test_zip_archive_4k_reached_zipalign')
expectSelfTestFailure(
'zip_archive_local_name_mismatch',
() => withZipSelfTestArchive(
createStoredZipSelfTestFixture({
data: elfFixture,
alignment: ANDROID_16_KIB_PAGE_BYTES,
localName: 'lib/arm64-v8a/libfixturE.so',
}),
(archive) => verifyReleaseApkPageSize(archive, process.execPath, () => ''),
),
'local_name_mismatch_lib/arm64-v8a/libfixture.so',
)
expectSelfTestFailure(
'zip_archive_encrypted',
() => withZipSelfTestArchive(
createStoredZipSelfTestFixture({
data: elfFixture,
alignment: ANDROID_16_KIB_PAGE_BYTES,
flags: 1,
}),
(archive) => verifyReleaseApkPageSize(archive, process.execPath, () => ''),
),
'zip_encrypted',
)
expectSelfTestFailure(
'zip_archive_method_mismatch',
() => withZipSelfTestArchive(
createStoredZipSelfTestFixture({
data: elfFixture,
alignment: ANDROID_16_KIB_PAGE_BYTES,
centralMethod: 0,
localMethod: 8,
}),
(archive) => verifyReleaseApkPageSize(archive, process.execPath, () => ''),
),
'local_method_mismatch_lib/arm64-v8a/libfixture.so',
)
process.stdout.write(`${JSON.stringify({
ok: true,
compromisedCertificateRejected: true,
manifestContractVerified: true,
elf16KiBVerified: true,
bundleAlignment16KiBVerified: true,
zipAlignment16KiBVerified: true,
})}\n`)
}
const cliArguments = process.argv.slice(2)
if (cliArguments.length === 1 && cliArguments[0] === '--self-test') {
runSigningPolicySelfTest()
process.exit(0)
}
const options = parseArguments(cliArguments)
const mode = options.mode
if (!VALID_MODES.has(mode)) fail(`unknown_mode_${mode}`)
if (!options.apk) fail('apk_path_required')
const apkPath = resolve(options.apk)
if (!existsSync(apkPath)) fail(`apk_missing_${apkPath}`)
if (options.aab && mode !== 'release') fail('aab_only_valid_for_release')
const aabPath = options.aab ? resolve(options.aab) : null
if (aabPath && !existsSync(aabPath)) fail(`aab_missing_${aabPath}`)
const tools = locateAndroidTools()
const bundletoolPath = options.bundletool ? resolve(options.bundletool) : null
if (mode === 'release' && aabPath && !bundletoolPath) fail('bundletool_path_required')
if (bundletoolPath) {
const bundletoolStat = lstatSync(bundletoolPath, { bigint: true })
if (!bundletoolStat.isFile() || bundletoolStat.isSymbolicLink() || bundletoolStat.nlink !== 1n) {
fail('bundletool_path_invalid')
}
if (realpathSync(bundletoolPath) !== bundletoolPath) fail('bundletool_reparse_rejected')
}
const archive = readZipDirectory(apkPath)
try {
const archiveEvidence = verifyArchive(archive, mode)
const pageSizeEvidence = mode === 'release'
? verifyReleaseApkPageSize(archive, tools.zipalign)
: null
const manifestEvidence = verifyManifest(
tools.aapt,
archive,
mode,
options['expected-admob-app-id'],
)
if (mode !== 'debug') {
const expectedVersionName = options['expected-version-name']
const expectedVersionCode = Number(options['expected-version-code'])
if (!expectedVersionName) fail(`${mode}_expected_version_name_missing`)
if (!Number.isSafeInteger(expectedVersionCode) || expectedVersionCode <= 0) {
fail(`${mode}_expected_version_code_invalid`)
}
if (manifestEvidence.versionName !== expectedVersionName) fail(`${mode}_version_name_mismatch`)
if (manifestEvidence.versionCode !== expectedVersionCode) fail(`${mode}_version_code_mismatch`)
}
const signature = runArtifact(
'java',
['-jar', tools.apksignerJar, 'verify', '--verbose', '--print-certs', apkPath],
'apksigner',
archive,
)
if (mode === 'release' && /CN=Android Debug/i.test(signature)) fail('release_debug_certificate')
const expectedCertificate = mode === 'release'
? requireAllowedReleaseCertificate(options['expected-upload-cert-sha256'], 'release_expected_upload_certificate')
: options['expected-upload-cert-sha256']
? normalizeCertificateSha256(options['expected-upload-cert-sha256'], 'expected_upload_certificate')
: null
const actualCertificateText = signature
.match(/Signer #1 certificate SHA-256 digest:\s*([0-9a-f:]+)/i)?.[1]
if (!actualCertificateText) fail('signer_sha256_missing')
const actualCertificate = mode === 'release'
? requireAllowedReleaseCertificate(actualCertificateText, 'release_signer_sha256')
: normalizeCertificateSha256(actualCertificateText, 'signer_sha256')
if (expectedCertificate && actualCertificate !== expectedCertificate) fail('signer_sha256_mismatch')
const apkSha256 = hashArchiveStable(archive, 'apk')
const aabEvidence = aabPath ? verifyReleaseBundle(aabPath, bundletoolPath, tools.zipalign, {
versionName: manifestEvidence.versionName,
versionCode: manifestEvidence.versionCode,
adMobAppId: manifestEvidence.adMobAppId,
signerSha256: actualCertificate,
}) : null
process.stdout.write(`${JSON.stringify({
artifact: basename(apkPath),
mode,
packageName: EXPECTED_PACKAGE,
buildTools: tools.version,
apkSha256,
signerSha256: actualCertificate,
...manifestEvidence,
...archiveEvidence,
...(pageSizeEvidence ? { pageSize: pageSizeEvidence } : {}),
aab: aabEvidence,
}, null, 2)}\n`)
} finally {
if (archive.keepOpen) closeSync(archive.fd)
}