137 lines
8.2 KiB
Python
137 lines
8.2 KiB
Python
"""Bind isolated APKs to their exact build inputs and reviewed GPL source. No network or devices."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import zipfile
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
VERSION = '1.0.4'
|
|
LOCKED_BINARY_INPUTS = {
|
|
'app/libs/libv2ray.aar': '3d43b9344723e9c0625527de4f2bea0ee02c21180224e5ecab3384af143ca6d0',
|
|
'app/libs/quickie-foss-1.14.0.aar': '4d90e9cb37e07b57eaa3a839be2abdc422c0ce9ac8e969720a2a71c968cd771f',
|
|
'app/libs/Toasty-1.5.2.aar': '57866e731ebe3ef82328942cd4d96cf940b2406c9440f33857f69777027f36b5',
|
|
'app/libs/arm64-v8a/libhev-socks5-tunnel.so': '4eb0b2353f4fb6d45f43cca39fe72197ce9df8c8f5ba5a82feac87abceb9ea7a',
|
|
'app/libs/armeabi-v7a/libhev-socks5-tunnel.so': '816c48bab1785fcdaaf7fc2f49dc6b0733cf11e68b9379640d22d6a539f306ff',
|
|
'app/libs/x86/libhev-socks5-tunnel.so': '6e42da45387d76630a8ffd52ea45cd5738395da39df31dba1a48806fa79d3572',
|
|
'app/libs/x86_64/libhev-socks5-tunnel.so': 'c61a54f38f61feb73b465d2245fe6e4746c2efba7e9024de8de025ce3fc9206e',
|
|
}
|
|
|
|
|
|
def digest(path):
|
|
with Path(path).open('rb') as stream:
|
|
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
|
|
|
|
def save(path, value):
|
|
with Path(path).open('x', encoding='utf-8', newline='\n') as stream:
|
|
json.dump(value, stream, indent=2, ensure_ascii=False)
|
|
stream.write('\n')
|
|
|
|
|
|
def main(job):
|
|
job = Path(job).resolve()
|
|
if not re.fullmatch(r'ucvl-android-104-[a-f0-9]{32}', job.name):
|
|
raise RuntimeError('Unexpected candidate job')
|
|
candidate = json.loads((job / 'evidence/candidate.json').read_text(encoding='utf-8'))
|
|
if candidate['Version'] != VERSION or candidate['VersionCode'] != 4010004 or candidate['Published']:
|
|
raise RuntimeError('Unexpected candidate contract')
|
|
inputs_path = job / 'evidence/build-inputs.json'
|
|
if digest(inputs_path) != candidate['BuildInputsSha256'].lower():
|
|
raise RuntimeError('Build input manifest changed')
|
|
inputs = json.loads(inputs_path.read_text(encoding='utf-8'))
|
|
public = job / 'public-source-final'
|
|
matched = 0
|
|
binary_inputs = []
|
|
for row in inputs:
|
|
name, expected = row['Path'], row['Sha256'].lower()
|
|
if digest(job / 'project' / name) != expected or digest(REPO / 'V2rayNG' / name) != expected:
|
|
raise RuntimeError('Build/source drift: ' + name)
|
|
if name in LOCKED_BINARY_INPUTS:
|
|
if expected != LOCKED_BINARY_INPUTS[name]:
|
|
raise RuntimeError('Dependency differs from public restoration lock: ' + name)
|
|
binary_inputs.append({'path': name, 'sha256': expected})
|
|
else:
|
|
if digest(public / 'V2rayNG' / name) != expected:
|
|
raise RuntimeError('GPL snapshot does not match compiled input: ' + name)
|
|
matched += 1
|
|
if len(binary_inputs) != len(LOCKED_BINARY_INPUTS):
|
|
raise RuntimeError('Missing locked binary dependencies')
|
|
|
|
inventory = []
|
|
forbidden = {'.jks', '.keystore', '.p12', '.pfx', '.pem', '.apk', '.aar', '.so', '.idsig', '.log'}
|
|
for path in sorted(public.rglob('*')):
|
|
if not path.is_file():
|
|
continue
|
|
name = path.relative_to(public).as_posix()
|
|
if path.is_symlink() or name.casefold().startswith('zero3-helper/') or any(part.casefold() in {'.git', '.tools', 'build', 'releases', '.gradle'} for part in path.relative_to(public).parts):
|
|
raise RuntimeError('Private/generated directory exported: ' + name)
|
|
if path.suffix.casefold() in forbidden or path.name in {'signing.properties', 'local.properties'}:
|
|
raise RuntimeError('Private/binary build input exported: ' + name)
|
|
if re.search(rb'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----', path.read_bytes()):
|
|
raise RuntimeError('Private key marker exported: ' + name)
|
|
inventory.append({'path': name, 'bytes': path.stat().st_size, 'sha256': digest(path)})
|
|
required = ['LICENSE', 'README.md', 'README-UPSTREAM.md', 'SOURCE-PROVENANCE.json', 'Restore-Dependencies.py',
|
|
'V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlAuthorizationGuard.kt',
|
|
'V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlAuthorizationGuardTest.kt']
|
|
if any(not (public / name).is_file() for name in required):
|
|
raise RuntimeError('GPL source snapshot incomplete')
|
|
output = REPO / '.tools/candidates' / ('android-1.0.4-20260924-' + job.name.rsplit('-', 1)[1][:8] + '-final')
|
|
output.mkdir(parents=True, exist_ok=False)
|
|
packages = []
|
|
for package in candidate['Packages']:
|
|
signed = job / 'artifacts' / package['Name']
|
|
if digest(signed) != package['Sha256'].lower():
|
|
raise RuntimeError('Signed APK changed')
|
|
unsigned = job / 'project/app/build/outputs/apk/playstore/release' / package['Name']
|
|
with zipfile.ZipFile(unsigned) as before, zipfile.ZipFile(signed) as after:
|
|
added = set(after.namelist()) - set(before.namelist())
|
|
if before.testzip() is not None or after.testzip() is not None or set(before.namelist()) - set(after.namelist()):
|
|
raise RuntimeError('Signed APK removed or corrupted compiled entries')
|
|
if added != {'META-INF/MANIFEST.MF', 'META-INF/UCVL-ZON.RSA', 'META-INF/UCVL-ZON.SF'}:
|
|
raise RuntimeError('Unexpected entries added during signing')
|
|
for name in before.namelist():
|
|
if before.read(name) != after.read(name):
|
|
raise RuntimeError('APK entry modified after build: ' + name)
|
|
dex = {name: hashlib.sha256(after.read(name)).hexdigest() for name in after.namelist() if re.fullmatch(r'classes\d*\.dex', name)}
|
|
shutil.copy2(signed, output / signed.name)
|
|
packages.append({**package, 'DexSha256': dex, 'AllUnsignedZipEntriesPreserved': True, 'AddedSigningMetadata': sorted(added)})
|
|
|
|
save(output / 'android-1.0.4-source-inventory.json', inventory)
|
|
source_archive = output / 'UCVL-Zonghengjia-1.0.4-source.zip'
|
|
with zipfile.ZipFile(source_archive, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
|
for row in inventory:
|
|
archive.write(public / row['path'], 'UCVL-Zonghengjia-1.0.4-source/' + row['path'])
|
|
with zipfile.ZipFile(source_archive) as archive:
|
|
if archive.testzip() is not None or len(archive.namelist()) != len(inventory):
|
|
raise RuntimeError('Source archive verification failed')
|
|
report = {
|
|
'Version': VERSION, 'BaseVersionCode': 10004, 'ApkVersionCode': 4010004,
|
|
'Packages': packages, 'SourceArchive': source_archive.name, 'SourceArchiveSha256': digest(source_archive),
|
|
'SourceInventorySha256': digest(output / 'android-1.0.4-source-inventory.json'), 'SourceFiles': len(inventory),
|
|
'CompiledPublicSourceInputsMatched': matched, 'LockedRestorableBinaryInputs': binary_inputs,
|
|
'BuildInputManifestSha256': digest(inputs_path), 'UnitTests': 41, 'AuthorizationLifecycleJvmTests': 10,
|
|
'OriginalCompanySignerMatches102And103': True, 'PrivateZero3Excluded': True,
|
|
'PhysicalDeviceTested': False, 'AndroidServiceBroadcastTested': False, 'EmulatorStarted': False,
|
|
'StableReleaseAccepted': False, 'Published': False,
|
|
'RemainingGates': ['Physical Android VPN permission and data path', 'Real Service/broadcast lifecycle for replaced login',
|
|
'Wi-Fi/mobile transitions, sleep/wake and recovery', 'Authenticated sustained business session'],
|
|
}
|
|
save(output / 'android-1.0.4-candidate.json', report)
|
|
shutil.copytree(job / 'evidence', output / 'local-evidence')
|
|
shutil.copytree(job / 'source-evidence-final', output / 'source-export-evidence')
|
|
with (output / 'SHA256SUMS.txt').open('x', encoding='ascii', newline='\n') as stream:
|
|
for path in sorted(output.iterdir()):
|
|
if path.is_file() and path.name != 'SHA256SUMS.txt':
|
|
stream.write(digest(path) + ' ' + path.name + '\n')
|
|
print(json.dumps({'output': str(output), 'source_files': len(inventory), 'source_sha256': digest(source_archive),
|
|
'public_compiled_inputs_matched': matched, 'packages': packages}, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('job')
|
|
main(parser.parse_args().job)
|