diff --git a/aports/device/fp6-vendor-blobs/APKBUILD b/aports/device/fp6-vendor-blobs/APKBUILD index 0a6d8c8..a03b09b 100644 --- a/aports/device/fp6-vendor-blobs/APKBUILD +++ b/aports/device/fp6-vendor-blobs/APKBUILD @@ -10,11 +10,18 @@ # their post-install/post-upgrade should also run # /usr/lib/fp6-vendor-blobs/extract --if-device so a package upgrade that # drops a previously-shipped blob restores the file immediately instead of -# at the next boot. First consumer: soc-fairphone-fp6-audio (aw88261 acf). +# at the next boot (--refresh in addition for an mbn consumer: it re-derives +# the trustlet from the active slot even if a file is present). Consumers: +# soc-fairphone-fp6-audio (aw88261 acf), fingerprintd (focal64 trustlet). +# +# 1-r3 (2026-09-11): active slot first, an unpinned ('-') structurally +# verified mode for mbn, --refresh -- after two field units got no +# fingerprint sensor from a whole-image hash pin that can only match one +# Android build (fp6 repo journal/blobs/, journal/fingerprint/). maintainer="Jorijn van der Graaf " pkgname=fp6-vendor-blobs pkgver=1 -pkgrel=2 +pkgrel=3 pkgdesc="On-device extraction of vendor blobs from the stock Android partitions" url="https://forgejo.catcrafts.net/Catcrafts/fp6-img" arch="noarch" @@ -55,7 +62,7 @@ package() { } sha512sums=" -2caafdedf93e103516834a1f815dd828ecee66c82d569e4a925ccc6bd6ac75d6778290adb02db69538af3bb6ad36cee5c13c8afba2c722a4c4550efe761ba8a0 fp6-vendor-blobs-extract +e9618b4a1ccbe0913a608b32a1a9e87337e3da79a3d7ccc7f49292ae9aaa9406d2ea9817d63b3cfff2c4346b80dd2476c10725f4f0f95c859acd17b22ec03a16 fp6-vendor-blobs-extract b4c290095d9f39515378dfef08de720ce49324210342aa13c131dfce1103785e796e6f821f0c659671a4c44b46f466ce0e03f11f216fdcdee2a99db5e7970800 fp6-vendor-blobs.service 9e79dd0aed13f11a71282aa24b2a26331e85c105e25ab0c0fed6189b8c300769a5f4308b18b91d9855868d658ad3a57c03e26c9b11bd27fd5e03f9a5decbbd6a fp6-vendor-blobs.preset " diff --git a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract index 1289b21..7f5b940 100644 --- a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract +++ b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract @@ -10,9 +10,15 @@ # processed in sorted order; '#' comments and blank lines ignored: # # file -# mbn +# mbn # rebind # +# Partition lists are tried in the order written, except that on an A/B +# device the ACTIVE slot's partitions (androidboot.slot_suffix in +# /proc/cmdline) come first: the other slot may hold a different Android +# build, and for a signed image only the active slot's copy is guaranteed to +# match the TZ that is running. +# # file: mount the first available listed partition READ-ONLY (ext4 also # gets -o noload - never a byte written to the stock partitions, not # even a journal replay), copy to , verify the @@ -28,9 +34,19 @@ # Reassembly is therefore not a concatenation: segments are page aligned # but not contiguous, gaps stay zero, and two segments may share an offset # (focal64 has two such pairs), so they are written in index order and the -# later one wins. Same guarantees as file: the sha256 is of the reassembled -# image, a mismatch tries the next partition, and an unverified image is -# never installed. +# later one wins. With a real sha256 the guarantees are file's: the hash is +# of the reassembled image, a mismatch tries the next partition, an +# unverified image is never installed. With '-' the image is verified +# STRUCTURALLY instead - ELF64 header, every segment present at the size +# its program header declares, page-aligned offsets, sane total - and not +# against a pinned hash. That is the right mode for an OEM-signed trustlet: +# the OEM re-signs it every Android release, so one whole-image hash matches +# exactly one build (six builds, six hashes, one trustlet: fp6 repo +# journal/fingerprint/ 2026-09-07..11, two field units failed on the pin), +# while the loader in TZ verifies the signature and the per-segment hashes +# itself and refuses a damaged or foreign image (one flipped byte -> +# ERROR_ELF_SIGNATURE_ERROR, measured 2026-09-03). The sha256 of what was +# installed is logged either way. # rebind: if this fragment's run extracted at least one file, unbind and # re-probe on so the consuming driver picks the file up # in the same boot. Unconditional on purpose: a still-bound consumer may @@ -48,6 +64,13 @@ # --if-device: exit 0 quietly when no stock super partition is visible # (apk post-install scripts run inside build/CI chroots too; on images # built there the first-boot service does the real extraction). +# --refresh: re-derive every mbn dest even if it exists, replacing it only +# with an image that verifies (a failed refresh leaves the old file). For +# the consumer's post-install/post-upgrade: a fresh 'apk add' gets its +# trustlet without a reboot, and a trustlet that was hand-placed or pinned +# to another build is replaced by the active slot's on the next upgrade. +# file dests are still left alone: re-copying the acf would rebind the +# sound card on every upgrade for nothing. MANIFEST_DIR=/usr/share/fp6-vendor-blobs/manifest.d SUPER=/dev/disk/by-partlabel/super @@ -55,6 +78,10 @@ MNT= MNT_PART= CREATED= TRIED_MAPPING= +IF_DEVICE= +REFRESH= +# "a" or "b" on an A/B device (androidboot.slot_suffix=_a), else empty +ACTIVE_SLOT=$(tr ' ' '\n' /dev/null | sed -n 's/^androidboot\.slot_suffix=_\([ab]\)$/\1/p' | head -n1) log() { echo "fp6-vendor-blobs: $*"; } @@ -114,10 +141,26 @@ mount_part() { MNT_PART=$1 } +# The listed partitions, space separated, the active slot's first. +order_parts() { # + first= rest= + for p in $(echo "$1" | tr ',' ' '); do + if [ -n "$ACTIVE_SLOT" ] && [ "${p%_$ACTIVE_SLOT}" != "$p" ]; then + first="$first $p" + else + rest="$rest $p" + fi + done + echo "$first $rest" +} + # Little-endian scalars out of an ELF header. aarch64 is little endian and so # is the image, so od's host order is the right one. u64() { od -An -tu8 -j "$2" -N 8 "$1" | tr -d ' '; } u16() { od -An -tu2 -j "$2" -N 2 "$1" | tr -d ' '; } +u8() { od -An -tu1 -j "$2" -N 1 "$1" | tr -d ' '; } +hex4() { od -An -tx1 -N 4 "$1" | tr -d ' \n'; } +fsize() { stat -c %s "$1"; } # Reassemble /.mdt + .b0N into a flat image at . Mirrors # utilities/ta-analysis/reassemble.py in the fp6 bring-up repo, which is where @@ -130,9 +173,18 @@ reassemble() { # mdir=$1 mname=$2 mout=$3 mdt="$mdir/$mname.mdt" [ -f "$mdt" ] || return 1 + # Structure first, before anything is written: an ELF64 header whose + # program header table fits in the .mdt, and for every segment with + # contents a .b0N file of exactly the declared size at a page-aligned + # offset. This is the whole verification when the manifest pins no hash; + # the loader's own signature check does the rest. + [ "$(hex4 "$mdt")" = 7f454c46 ] || { log "$mname.mdt: not an ELF image"; return 1; } + [ "$(u8 "$mdt" 4)" = 2 ] || { log "$mname.mdt: not ELF64"; return 1; } phoff=$(u64 "$mdt" 32) phentsize=$(u16 "$mdt" 54) phnum=$(u16 "$mdt" 56) [ -n "$phoff" ] && [ -n "$phentsize" ] && [ -n "$phnum" ] || return 1 - [ "$phnum" -gt 0 ] 2>/dev/null || return 1 + [ "$phentsize" -eq 56 ] 2>/dev/null || { log "$mname.mdt: phentsize $phentsize"; return 1; } + [ "$phnum" -gt 0 ] 2>/dev/null && [ "$phnum" -le 64 ] || { log "$mname.mdt: phnum $phnum"; return 1; } + [ "$(fsize "$mdt")" -ge $((phoff + phnum * phentsize)) ] || { log "$mname.mdt: shorter than its program header table"; return 1; } # The image is as long as the furthest segment reaches; everything no # segment covers stays zero. @@ -142,11 +194,21 @@ reassemble() { # pfsz=$(u64 "$mdt" $((o + 32))) if [ "$pfsz" -gt 0 ]; then poff=$(u64 "$mdt" $((o + 8))) + seg=$(printf '%s/%s.b%02d' "$mdir" "$mname" "$i") + [ -f "$seg" ] || { log "$mname: segment $i missing"; return 1; } + [ "$(fsize "$seg")" -eq "$pfsz" ] || { log "$mname: segment $i is $(fsize "$seg") bytes, header says $pfsz"; return 1; } + # dd seeks in whole blocks, which is only correct because + # every p_offset in these images is page aligned. Refuse + # rather than silently misplace a segment if that changes. + [ $((poff % 4096)) -eq 0 ] || { log "$mname: segment $i offset $poff is not page aligned"; return 1; } [ $((poff + pfsz)) -gt "$total" ] && total=$((poff + pfsz)) fi i=$((i + 1)) done - [ "$total" -gt 0 ] || return 1 + [ "$total" -gt 0 ] || { log "$mname: no segment has contents"; return 1; } + # an order of magnitude above any TA; a garbage p_offset would otherwise + # make a sparse multi-GiB file that then gets hashed + [ "$total" -le $((64 * 1024 * 1024)) ] || { log "$mname: image would be $total bytes"; return 1; } : > "$mout" || return 1 truncate -s "$total" "$mout" || return 1 @@ -157,14 +219,6 @@ reassemble() { # if [ "$pfsz" -gt 0 ]; then poff=$(u64 "$mdt" $((o + 8))) seg=$(printf '%s/%s.b%02d' "$mdir" "$mname" "$i") - [ -f "$seg" ] || { log "$mname: segment $i missing"; return 1; } - # dd seeks in whole blocks, which is only correct because - # every p_offset in these images is page aligned. Refuse - # rather than silently misplace a segment if that changes. - [ $((poff % 4096)) -eq 0 ] || { - log "$mname: segment $i offset $poff is not page aligned" - return 1 - } dd if="$seg" of="$mout" bs=4096 seek=$((poff / 4096)) \ conv=notrunc 2>/dev/null || return 1 fi @@ -173,9 +227,9 @@ reassemble() { # return 0 } -extract_mbn() { # +extract_mbn() { # parts=$1 rdir=$2 rname=$3 dest=$4 want=$5 - for part in $(echo "$parts" | tr ',' ' '); do + for part in $(order_parts "$parts"); do mount_part "$part" || { log "$part: not mountable, trying next"; continue; } [ -f "$MNT/$rdir/$rname.mdt" ] || { log "$part: no $rdir/$rname.mdt, trying next"; continue; } tmp="$dest.fp6-extract.$$" @@ -186,21 +240,26 @@ extract_mbn() { # continue fi got=$(sha256sum "$tmp" | awk '{print $1}') - if [ "$got" != "$want" ]; then + if [ "$want" != - ] && [ "$got" != "$want" ]; then rm -f "$tmp" log "$part:$rdir/$rname sha256 $got != expected, trying next" continue fi chmod 644 "$tmp" && mv "$tmp" "$dest" || { rm -f "$tmp"; fail "installing $dest failed"; } - log "reassembled $part:$rdir/$rname.{mdt,b0N} -> $dest" + log "reassembled $part:$rdir/$rname.{mdt,b0N} -> $dest (sha256 $got)" return 0 done - fail "no listed partition ($parts) yields $rname with sha256 $want - $dest NOT installed" + kept= + [ -e "$dest" ] && kept=" (the existing file is left in place)" + if [ "$want" = - ]; then + fail "no listed partition ($parts) yields a well-formed $rname - $dest NOT installed$kept" + fi + fail "no listed partition ($parts) yields $rname with sha256 $want - $dest NOT installed$kept" } extract() { # parts=$1 src=$2 dest=$3 want=$4 - for part in $(echo "$parts" | tr ',' ' '); do + for part in $(order_parts "$parts"); do mount_part "$part" || { log "$part: not mountable, trying next"; continue; } [ -f "$MNT/$src" ] || { log "$part: no $src, trying next"; continue; } tmp="$dest.fp6-extract.$$" @@ -239,7 +298,14 @@ rebind_all() { # done } -if [ "${1:-}" = --if-device ] && [ ! -b "$SUPER" ]; then +for arg in "$@"; do + case "$arg" in + --if-device) IF_DEVICE=1 ;; + --refresh) REFRESH=1 ;; + *) fail "unknown option '$arg'" ;; + esac +done +if [ -n "$IF_DEVICE" ] && [ ! -b "$SUPER" ]; then log "no stock super partition visible (build chroot?), nothing to do" exit 0 fi @@ -253,11 +319,12 @@ for f in "$MANIFEST_DIR"/*.manifest; do while read -r kind a b c d e; do case "$kind" in file) [ -e "$c" ] || missing=1 ;; - mbn) [ -e "$d" ] || missing=1 ;; + mbn) [ -e "$d" ] && [ -z "$REFRESH" ] || missing=1 ;; esac done < "$f" done [ -z "$missing" ] && exit 0 +[ -n "$ACTIVE_SLOT" ] && log "active slot $ACTIVE_SLOT" for f in "$MANIFEST_DIR"/*.manifest; do [ -e "$f" ] || continue @@ -274,7 +341,7 @@ for f in "$MANIFEST_DIR"/*.manifest; do ;; mbn) [ -n "$e" ] || fail "$f: malformed mbn line" - [ -e "$d" ] && continue + [ -e "$d" ] && [ -z "$REFRESH" ] && continue extract_mbn "$a" "$b" "$c" "$d" "$e" = 1-r2, both -# built in this run. 0.2.3: 0.2.2 (enrol, unlock, agent, actions) + the -# versioned blobs dependency + a post-upgrade daemon restart. -FPD_VERSION=0.2.3-r0 -FPD_SHA256=" -3e28f0c1a9a844592ab6878b2dfc0d8f91674549e44bdc1652e7d7d029de1765 fingerprintd-0.2.3-r0.apk -0cc46eba5c6c77d5bb54cd9f0e2902f7644720f9c98153062ffa33e19ca36889 fingerprintd-systemd-0.2.3-r0.apk -6dfdbc6f971ba4b8f811f828e5868869c7d71fea6c7045e2bffd51bf2736c040 fingerprintd-agent-0.2.3-r0.apk -" +# imsd and fingerprintd are not built here: each repo's package CI publishes +# its apk to the registry, and the image installs the NEWEST published +# version (section 3b), so a fresh install carries the same binary every +# installed phone gets from 'apk upgrade', and a release of either needs no +# commit here. Integrity comes from the same place as on the phones: the +# registry index is verified against the key catcrafts-fp6-repo ships +# (aports/device/, the phones' /etc/apk/keys) and each apk against that +# index, by registry-fetch.py. The resolved versions and sha256s are in the +# build summary. Until 2026-09-11 both were pinned here by version and +# sha256; that gated fresh installs only, never upgrades, and cost a commit +# and an image run per release. +REGISTRY=https://forgejo.catcrafts.net/api/packages/Catcrafts/alpine/edge/fp6 PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git cd "$(dirname "$0")" @@ -270,31 +251,29 @@ retry "build modemmanager" pmbootstrap $NOCROSS build --arch aarch64 modemmanage retry "build libcamera" pmbootstrap $NOCROSS build --arch aarch64 libcamera # --- 3b. imsd + fingerprintd: the published apks, not local builds ----------- # Each repo's package CI is the only producer of its apk; the image installs -# the exact registry package users later get via 'apk upgrade'. +# the newest registry package, the one users get via 'apk upgrade'. # pmbootstrap has no knob for a third-party repository, and after the main # 'apk add' it re-adds every package found in its local packages dir BY FILE # PATH — which makes apk verify the package's own signature, and registry # packages are signed with per-run keys nobody keeps (phones trust the -# registry-signed index instead). So: fetch, check against the sha256 pin, -# re-sign the envelope with this run's abuild key (control and data streams -# stay byte-identical, so the identity checksum equals the registry's), drop -# into the local packages dir, re-index. The abuild key exists because the -# builds above initialized the buildroot. +# registry-signed index instead). So: fetch the newest of each, verified the +# way a phone verifies them (registry-fetch.py: index signature against the +# shipped key, control checksum and data hash against the index), re-sign +# the envelope with this run's abuild key (control and data streams stay +# byte-identical, so the identity checksum equals the registry's), drop into +# the local packages dir, re-index. The abuild key exists because the builds +# above initialized the buildroot. Each group's first name decides the +# version; its subpackages must exist at that same version. REG_DL="$WORK/registry-apks" rm -rf "$REG_DL" mkdir -p "$REG_DL" -for _f in "imsd-$IMSD_VERSION.apk" "imsd-systemd-$IMSD_VERSION.apk" \ - "fingerprintd-$FPD_VERSION.apk" "fingerprintd-systemd-$FPD_VERSION.apk" \ - "fingerprintd-agent-$FPD_VERSION.apk"; do - # every fetched file must have a pin: 'grep .' below drops empty lines, - # so an empty pin list would otherwise pass the check with nothing checked - printf '%s\n' "$IMSD_SHA256" "$FPD_SHA256" | grep -q " $_f\$" || { - echo "no sha256 pin for $_f - add it to IMSD_SHA256/FPD_SHA256" >&2 - exit 1 - } - retry "fetch $_f" curl -fsSL -o "$REG_DL/$_f" "$IMSD_REGISTRY/aarch64/$_f" -done -(cd "$REG_DL" && printf '%s\n' "$IMSD_SHA256" "$FPD_SHA256" | grep . | sha256sum -c -) +REGISTRY_KEY=$(echo aports/device/catcrafts-fp6-repo/*.rsa.pub) +if [ ! -f "$REGISTRY_KEY" ]; then + echo "expected exactly one registry key in aports/device/catcrafts-fp6-repo" >&2 + exit 1 +fi +retry "fetch registry packages" python3 ./registry-fetch.py "$REGISTRY" "$REGISTRY_KEY" "$REG_DL" \ + imsd,imsd-systemd fingerprintd,fingerprintd-systemd,fingerprintd-agent ABUILD_KEY=$(echo "$WORKDIR"/config_abuild/*.rsa) if [ ! -f "$ABUILD_KEY" ]; then echo "expected exactly one abuild key in $WORKDIR/config_abuild" >&2 @@ -354,8 +333,9 @@ cp README.md install.sh "$STAGE/fp6-img/" echo "kernel: $KERNEL_REPO $KERNEL_BRANCH @ $COMMIT" echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "default login: user / 147147 (same as official postmarketOS images)" - echo "imsd: $IMSD_REGISTRY imsd-$IMSD_VERSION (registry package, sha256-pinned)" - echo "fingerprintd: $IMSD_REGISTRY fingerprintd-$FPD_VERSION (registry package, sha256-pinned)" + while read -r _n _v _s; do + printf '%-14s %s %s-%s (newest registry package, sha256 %s)\n' "$_n:" "$REGISTRY" "$_n" "$_v" "$_s" + done < "$REG_DL/manifest" } > "$STAGE/fp6-img/build-info.txt" # sums of the extracted contents (cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt) diff --git a/registry-fetch.py b/registry-fetch.py new file mode 100755 index 0000000..9ebf4dd --- /dev/null +++ b/registry-fetch.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Fetch the newest published versions of our registry packages, verified. + + registry-fetch.py ... + + is the Alpine repository root the phones carry in +/etc/apk/repositories (.../alpine/edge/fp6); is the key +they carry in /etc/apk/keys (aports/device/catcrafts-fp6-repo/); a is +a comma-separated list of package names whose FIRST member decides the +version: "imsd,imsd-systemd" fetches the newest imsd and the imsd-systemd of +that same version, and fails if the registry lacks it. + +Verification mirrors apk's own, so the image trusts exactly what an installed +phone trusts: the index signature (.SIGN.RSA*., over the index's +compressed tar) against the trusted key, and the signing key's NAME against +the trusted key's; each package's control segment against the index's C: +checksum ("Q1" + base64 sha1); each data segment against the control +segment's datahash (sha256). A package that fails any step is not written. +Prints one "name version sha256" line per apk and writes the same lines to +/manifest. + +Version order: apk's rules for the shapes our own packages use +(X.Y.Z[-rN], numeric components); a suffix like _git is compared as text. +""" +import base64 +import gzip +import hashlib +import os +import re +import subprocess +import sys +import tempfile +import time +import urllib.request +import zlib + +DIGEST = {"RSA": "sha1", "RSA256": "sha256", "RSA512": "sha512"} + + +def die(msg): + sys.exit(f"registry-fetch: {msg}") + + +def gzip_members(data): + off = 0 + while off < len(data): + d = zlib.decompressobj(31) + d.decompress(data[off:]) + end = len(data) - len(d.unused_data) + if end <= off: + raise ValueError("gzip stream did not advance") + yield data[off:end] + off = end + + +def tar_files(tar): + """(name, bytes) for each regular file in a tar image; pax headers skipped.""" + off = 0 + while off + 512 <= len(tar): + hdr = tar[off:off + 512] + if hdr == b"\0" * 512: + return + size = int(hdr[124:136].split(b"\0")[0].strip() or b"0", 8) + name = hdr[:100].rstrip(b"\0").decode() + if hdr[156:157] not in (b"x", b"g"): + yield name, tar[off + 512:off + 512 + size] + off += 512 + (size + 511) // 512 * 512 + + +def fetch(url): + last = None + for attempt in range(3): + try: + with urllib.request.urlopen(url, timeout=120) as r: + return r.read() + except Exception as e: # noqa: BLE001 - any transport failure retries + last = e + time.sleep(10) + die(f"cannot fetch {url}: {last}") + + +def verify_index(index_tgz, keyfile): + """Returns the APKINDEX text after checking the signature against keyfile.""" + try: + members = list(gzip_members(index_tgz)) + except (zlib.error, ValueError) as e: + die(f"index: corrupt gzip stream ({e})") + if len(members) != 2: + die(f"index: expected 2 gzip streams, found {len(members)}") + sig_entries = list(tar_files(gzip.decompress(members[0]))) + if not sig_entries: + die("index: no signature entry") + name, sig = sig_entries[0] + m = re.fullmatch(r"\.SIGN\.(RSA\d*)\.(.+)", name) + if not m or m.group(1) not in DIGEST: + die(f"index: unexpected signature entry {name!r}") + kind, signer = m.groups() + if signer != os.path.basename(keyfile): + die(f"index: signed by {signer!r}, phones trust {os.path.basename(keyfile)!r}") + with tempfile.TemporaryDirectory() as t: + sigf, dataf = os.path.join(t, "sig"), os.path.join(t, "data") + open(sigf, "wb").write(sig) + open(dataf, "wb").write(members[1]) + r = subprocess.run(["openssl", "dgst", f"-{DIGEST[kind]}", "-verify", keyfile, + "-signature", sigf, dataf], capture_output=True, text=True) + if r.returncode != 0 or "Verified OK" not in r.stdout: + die(f"index: signature does NOT verify against {keyfile}: {r.stdout.strip()} {r.stderr.strip()}") + files = dict(tar_files(gzip.decompress(members[1]))) + if "APKINDEX" not in files: + die("index: no APKINDEX entry") + return files["APKINDEX"].decode() + + +def parse_index(text): + """{name: {version: fields}} for aarch64 entries.""" + out = {} + for block in text.split("\n\n"): + f = dict(line.split(":", 1) for line in block.splitlines() if ":" in line) + if f.get("A", "aarch64") != "aarch64" or "P" not in f or "V" not in f: + continue + out.setdefault(f["P"], {})[f["V"]] = f + return out + + +def version_key(v): + ver, _, rel = v.partition("-r") + parts = tuple((0, int(t)) if t.isdigit() else (1, t) for t in re.split(r"[._]", ver)) + return parts, int(rel) if rel.isdigit() else 0 + + +def verify_apk(blob, fields, name): + try: + members = list(gzip_members(blob)) + except (zlib.error, ValueError) as e: + die(f"{name}: corrupt gzip stream ({e})") + if len(members) != 3: + die(f"{name}: expected 3 gzip streams, found {len(members)}") + want = fields.get("C", "") + if not want.startswith("Q1"): + die(f"{name}: index has no Q1 checksum") + got = "Q1" + base64.b64encode(hashlib.sha1(members[1]).digest()).decode() + if got != want: + die(f"{name}: control checksum {got} != index {want}") + pkginfo = dict(tar_files(gzip.decompress(members[1]))).get(".PKGINFO", b"").decode() + datahash = next((l.split("=", 1)[1].strip() for l in pkginfo.splitlines() + if l.startswith("datahash")), None) + if not datahash: + die(f"{name}: .PKGINFO has no datahash") + if hashlib.sha256(members[2]).hexdigest() != datahash: + die(f"{name}: data segment does not match its datahash") + if "S" in fields and int(fields["S"]) != len(blob): + die(f"{name}: size {len(blob)} != index {fields['S']}") + + +def main(registry, keyfile, dest, groups): + registry = registry.rstrip("/") + if not os.path.isfile(keyfile): + die(f"trusted key {keyfile} not found") + os.makedirs(dest, exist_ok=True) + index = parse_index(verify_index(fetch(f"{registry}/aarch64/APKINDEX.tar.gz"), keyfile)) + lines = [] + for group in groups: + names = group.split(",") + anchor = names[0] + if anchor not in index: + die(f"{anchor}: not in the registry index") + version = max(index[anchor], key=version_key) + for n in names: + fields = index.get(n, {}).get(version) + if fields is None: + die(f"{n}-{version}: not in the registry (newest {anchor} is {version})") + fname = f"{n}-{version}.apk" + blob = fetch(f"{registry}/aarch64/{fname}") + verify_apk(blob, fields, fname) + open(os.path.join(dest, fname), "wb").write(blob) + lines.append(f"{n} {version} {hashlib.sha256(blob).hexdigest()}") + with open(os.path.join(dest, "manifest"), "w") as f: + f.write("\n".join(lines) + "\n") + print("\n".join(lines)) + + +if __name__ == "__main__": + if len(sys.argv) < 5: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:])