check-native-provenance.py

5.1 KB · raw

1#!/usr/bin/env python32"""Check the native libraries a release ships against a checked-in allow-list.34The inventory is keyed by PACKAGED path — `lib/<abi>/<name>` — so it covers every5native library in the artifact, not only the ones committed under jniLibs. A6library that arrives through a dependency (AAR/prefab) never appears in the source7tree, which is why the packaged artifact is checked directly.89Controls:10  1. Allow-list. Every native library in the source set and in the packaged APK11     must have an inventory record, and every record for a shipped ABI must be12     present. A newly added .so is refused until it is recorded and reviewed.13  2. Integrity. Each record's digest must match the bytes: the committed source14     file where one exists, otherwise the packaged entry.15  3. Provenance. The Noise core records must match the authoritative cohort in16     docs/NOISE-CORE-PROVENANCE.md.1718Usage: check-native-provenance.py [release.apk]19"""2021from hashlib import sha25622from pathlib import Path23import re24import sys25import zipfile2627ROOT = Path(__file__).resolve().parents[1]28INVENTORY = ROOT / "docs" / "android-native-provenance-inventory.txt"29PROVENANCE = ROOT / "docs" / "NOISE-CORE-PROVENANCE.md"30JNI_LIBS = ROOT / "app" / "src" / "main" / "jniLibs"31NOISE_CORE = "libpeach_noise_core.so"323334def digest_bytes(data: bytes) -> str:35    return sha256(data).hexdigest()363738def digest_file(path: Path) -> str:39    hasher = sha256()40    with path.open("rb") as stream:41        for chunk in iter(lambda: stream.read(1024 * 1024), b""):42            hasher.update(chunk)43    return hasher.hexdigest()444546def inventory_records() -> dict[str, str]:47    if not INVENTORY.is_file():48        raise SystemExit(f"checked-in native inventory not found: {INVENTORY}")49    records = {}50    for line in INVENTORY.read_text().splitlines():51        if not line.strip() or line.startswith("#"):52            continue53        try:54            path, expected = line.split("|", 1)55        except ValueError:56            raise SystemExit(f"invalid native inventory line: {line}")57        records[path.strip()] = expected.strip()58    return records596061def abi_of(packaged_path: str) -> str:62    return Path(packaged_path).parent.name636465records = inventory_records()66if not records:67    raise SystemExit("native inventory is empty")6869# ── 1a. Source set: every committed library must be recorded, and match. ─────70if not JNI_LIBS.is_dir():71    raise SystemExit(f"native source set not found: {JNI_LIBS}")72source_set = {}73for path in sorted(JNI_LIBS.rglob("*.so")):74    if not path.is_file():75        continue76    packaged = f"lib/{path.parent.name}/{path.name}"77    source_set[packaged] = digest_file(path)7879unrecorded_source = sorted(set(source_set) - set(records))80if unrecorded_source:81    raise SystemExit(82        "native source set contains unrecorded libraries: " + ", ".join(unrecorded_source)83    )84for packaged, actual in source_set.items():85    if records[packaged] != actual:86        raise SystemExit(87            f"committed native library digest mismatch for {packaged}: "88            f"recorded {records[packaged]}, actual {actual}"89        )9091# ── 1b/2b. Packaged artifact: the shipped set for each ABI must equal the92# recorded set for that ABI, and every packaged entry must match its record. ──93if len(sys.argv) > 1:94    apk = Path(sys.argv[1])95    if not apk.is_file():96        raise SystemExit(f"release APK not found: {apk}")97    with zipfile.ZipFile(apk) as archive:98        packaged = {99            name: digest_bytes(archive.read(name))100            for name in archive.namelist()101            if name.startswith("lib/") and name.endswith(".so")102        }103    if not packaged:104        raise SystemExit("release APK contains no native libraries")105    shipped_abis = {abi_of(name) for name in packaged}106    expected = {name for name in records if abi_of(name) in shipped_abis}107    unrecorded = sorted(set(packaged) - set(records))108    missing = sorted(expected - set(packaged))109    if unrecorded or missing:110        raise SystemExit(111            "packaged native libraries do not match the inventory: "112            f"packaged but unrecorded {unrecorded}; recorded but absent {missing}"113        )114    for name, actual in packaged.items():115        if records[name] != actual:116            raise SystemExit(117                f"packaged native library digest mismatch for {name}: "118                f"recorded {records[name]}, actual {actual}"119            )120121# ── 3. Provenance document agrees with the Noise core records. ──────────────122provenance = PROVENANCE.read_text()123for packaged, expected in records.items():124    if Path(packaged).name != NOISE_CORE:125        continue126    abi = abi_of(packaged)127    match = re.search(rf"^- `{re.escape(abi)}`: `([0-9a-f]+)`$", provenance, re.MULTILINE)128    if match is None:129        raise SystemExit(f"provenance document has no authoritative hash for {abi}")130    if match.group(1) != expected:131        raise SystemExit(132            f"provenance digest mismatch for {packaged}: "133            f"recorded {expected}, documented {match.group(1)}"134        )135136print(f"PASS: native provenance verified ({len(records)} packaged libraries)")