Compare commits

..
Author SHA1 Message Date
e21a2dd0ec build.sh: install the newest registry imsd and fingerprintd, verified the way a phone does
Some checks failed
image / image (push) Has been cancelled
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.
2026-09-11 13:21:18 +02:00
e57149f496 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.
2026-09-11 13:10:00 +02:00
4 changed files with 316 additions and 77 deletions

View file

@ -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 <jorijnvdgraaf@catcrafts.net>"
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
"

View file

@ -10,9 +10,15 @@
# processed in sorted order; '#' comments and blank lines ignored:
#
# file <partition[,partition...]> <path-in-partition> <dest> <sha256>
# mbn <partition[,partition...]> <dir-in-partition> <name> <dest> <sha256>
# mbn <partition[,partition...]> <dir-in-partition> <name> <dest> <sha256|->
# rebind <bus> <device>
#
# 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 <path-in-partition> to <dest>, 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 <device> on <bus> 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' </proc/cmdline 2>/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() { # <partition,...>
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 <dir>/<name>.mdt + .b0N into a flat image at <out>. Mirrors
# utilities/ta-analysis/reassemble.py in the fp6 bring-up repo, which is where
@ -130,9 +173,18 @@ reassemble() { # <dir> <name> <out>
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() { # <dir> <name> <out>
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() { # <dir> <name> <out>
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() { # <dir> <name> <out>
return 0
}
extract_mbn() { # <partition,...> <dir-in-partition> <name> <dest> <sha256>
extract_mbn() { # <partition,...> <dir-in-partition> <name> <dest> <sha256|->
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() { # <partition,...> <dir-in-partition> <name> <dest> <sha256>
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() { # <partition,...> <path-in-partition> <dest> <sha256>
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() { # <bus/device ...>
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" </dev/null
extracted=1
;;

View file

@ -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
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
}
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 -)
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)

185
registry-fetch.py Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Fetch the newest published versions of our registry packages, verified.
registry-fetch.py <registry-url> <trusted-key.rsa.pub> <dest-dir> <group>...
<registry-url> is the Alpine repository root the phones carry in
/etc/apk/repositories (.../alpine/edge/fp6); <trusted-key.rsa.pub> is the key
they carry in /etc/apk/keys (aports/device/catcrafts-fp6-repo/); a <group> 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*.<key>, 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
<dest-dir>/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:])