From e57149f4967c667669faea7df20eba085209020c Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 11 Sep 2026 13:03:22 +0200 Subject: [PATCH 1/2] fp6-vendor-blobs 1-r3: extract from the active slot, and stop pinning a hash on a signed image Two field units got no fingerprint sensor from fingerprintd 0.2.3's manifest: its focal64 line pinned the sha256 of one Android build's trustlet (16.82.0, the dev phone's), and Fairphone re-signs that trustlet every release, so the pin matches exactly one of the six builds seen. A user on 16.100.0 had to edit the manifest by hand; another ended up with a file QTEE refuses. The extractor now tries the active slot's partitions first (androidboot.slot_suffix from the kernel command line): for a signed image only the running TZ's own slot is guaranteed to load. An mbn line may give '-' instead of a hash, which means structural verification only: ELF64 header, every segment present at the size its program header declares, page-aligned offsets, a sane total. 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), so the whole-image hash added fragility and no protection. A real sha256 is still honoured, and the sha256 of what was installed is logged either way. --refresh re-derives mbn dests even when a file exists, replacing it only with an image that verifies; consumers call it from post-install/post-upgrade so a fresh 'apk add' needs no reboot and a hand-placed or wrongly pinned trustlet is replaced on the next upgrade. Verified on the dev phone (busybox): malformed inputs are refused with a reason (missing, truncated or oversize segment; non-ELF, ELF32 or short mdt; a garbage offset), both slots reassemble to the known-good hash, a foreign file survives a plain run and is replaced by --refresh, a failed refresh keeps the old file, pins still work, and the real post-upgrade path re-derived the installed trustlet with the daemon restarting on it. Record: fp6 repo journal/blobs/ and journal/fingerprint/, 2026-09-11. --- aports/device/fp6-vendor-blobs/APKBUILD | 13 +- .../fp6-vendor-blobs/fp6-vendor-blobs-extract | 113 ++++++++++++++---- 2 files changed, 100 insertions(+), 26 deletions(-) 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" Date: Fri, 11 Sep 2026 13:21:18 +0200 Subject: [PATCH 2/2] build.sh: install the newest registry imsd and fingerprintd, verified the way a phone does Until now both were pinned here by version and sha256, so every release of either needed a commit and an image run, in the right order (fingerprintd 0.2.4 would have taken: push, wait for the registry, pin, push). The pin gated fresh installs only: every installed phone already takes the newest registry package on 'apk upgrade'. registry-fetch.py resolves the newest version of each group (imsd + its systemd unit; fingerprintd + systemd + agent, the subpackages at the anchor's version, or it fails) and verifies the way apk does on the phone: the index signature against the key catcrafts-fp6-repo ships -- the signer's name must be that key's too -- each apk's control checksum against the index, and its data segment against the control's datahash. Anything that fails is not written. The resolved versions and sha256s go into the release's build-info.txt, so an image still names its exact packages. Tested against the live registry: it resolves imsd 0.3.3-r0 and fingerprintd 0.2.3-r0 with sha256s identical to the five pins this removes; a wrong key, a key of another name, a tampered control segment, a corrupt or swapped data segment, a truncated file and a missing subpackage are each refused with a reason. --- build.sh | 82 ++++++++------------ registry-fetch.py | 185 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 51 deletions(-) create mode 100755 registry-fetch.py diff --git a/build.sh b/build.sh index a58ca9f..82ae48e 100755 --- a/build.sh +++ b/build.sh @@ -18,37 +18,18 @@ set -eu KERNEL_REPO=https://forgejo.catcrafts.net/Catcrafts/milos-linux.git KERNEL_BRANCH=combined-stable -# imsd is not built here: the image installs the apk the imsd repo's package -# CI publishes to the registry (section 3b), so image and 'apk upgrade' carry -# the same binary. Pinned by version AND by the sha256 of the registry files; -# a bump is these lines (sha256sum the two apks under $IMSD_REGISTRY/aarch64/). -# Bump deliberately, not via tip-chasing. -# 0.3.1: 0.3.0 + the ims-pdn-up hardening (mmcli errors logged verbatim, -# registration gate, configurable ip-type) + README carrier updates. -# 0.3.2: landline callers ring (AMR-NB + G.711 media leg, any playable codec -# accepted, offered codecs named on 488), INVITE validated before the UI is -# told, AMR-NB offered after AMR-WB, CODECS override; built for the A520/A720. -# 0.3.3: 0.3.2 + the media leg resolves the audio user once and thread-safely -# (a getpwnam race could aim both PipeWire helpers at /run/user/0: static). -IMSD_REGISTRY=https://forgejo.catcrafts.net/api/packages/Catcrafts/alpine/edge/fp6 -IMSD_VERSION=0.3.3-r0 -IMSD_SHA256=" -54755ca4aefaa0a7f98a0ce6c6515cf8e0ed1d86734e3f78a3b58f28c38f75b5 imsd-0.3.3-r0.apk -226217f5aeea009462757fc89aa030964ba92ac1a22501bf8f75b92fa693f9f9 imsd-systemd-0.3.3-r0.apk -" -# fingerprintd (fingerprint unlock) comes from the same registry the same way: -# its repo's package CI is the only producer, and the same pinning rule holds. -# Three apks: the daemon, its systemd units, and the session agent (inert -# until a user writes ~/.config/fingerprintd/fingers.conf). Needs the kernel -# aport's CONFIG_QCOMTEE=m (pkgrel 101) and fp6-vendor-blobs >= 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:])