1498 lines
57 KiB
JavaScript
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)
|
|
}
|