Compare commits
4 changed files with 77 additions and 316 deletions
|
|
@ -10,18 +10,11 @@
|
|||
# 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 (--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/).
|
||||
# at the next boot. First consumer: soc-fairphone-fp6-audio (aw88261 acf).
|
||||
maintainer="Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>"
|
||||
pkgname=fp6-vendor-blobs
|
||||
pkgver=1
|
||||
pkgrel=3
|
||||
pkgrel=2
|
||||
pkgdesc="On-device extraction of vendor blobs from the stock Android partitions"
|
||||
url="https://forgejo.catcrafts.net/Catcrafts/fp6-img"
|
||||
arch="noarch"
|
||||
|
|
@ -62,7 +55,7 @@ package() {
|
|||
}
|
||||
|
||||
sha512sums="
|
||||
e9618b4a1ccbe0913a608b32a1a9e87337e3da79a3d7ccc7f49292ae9aaa9406d2ea9817d63b3cfff2c4346b80dd2476c10725f4f0f95c859acd17b22ec03a16 fp6-vendor-blobs-extract
|
||||
2caafdedf93e103516834a1f815dd828ecee66c82d569e4a925ccc6bd6ac75d6778290adb02db69538af3bb6ad36cee5c13c8afba2c722a4c4550efe761ba8a0 fp6-vendor-blobs-extract
|
||||
b4c290095d9f39515378dfef08de720ce49324210342aa13c131dfce1103785e796e6f821f0c659671a4c44b46f466ce0e03f11f216fdcdee2a99db5e7970800 fp6-vendor-blobs.service
|
||||
9e79dd0aed13f11a71282aa24b2a26331e85c105e25ab0c0fed6189b8c300769a5f4308b18b91d9855868d658ad3a57c03e26c9b11bd27fd5e03f9a5decbbd6a fp6-vendor-blobs.preset
|
||||
"
|
||||
|
|
|
|||
|
|
@ -10,15 +10,9 @@
|
|||
# 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
|
||||
|
|
@ -34,19 +28,9 @@
|
|||
# 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. 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.
|
||||
# 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.
|
||||
# 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
|
||||
|
|
@ -64,13 +48,6 @@
|
|||
# --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
|
||||
|
|
@ -78,10 +55,6 @@ 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: $*"; }
|
||||
|
||||
|
|
@ -141,26 +114,10 @@ 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
|
||||
|
|
@ -173,18 +130,9 @@ 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
|
||||
[ "$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; }
|
||||
[ "$phnum" -gt 0 ] 2>/dev/null || return 1
|
||||
|
||||
# The image is as long as the furthest segment reaches; everything no
|
||||
# segment covers stays zero.
|
||||
|
|
@ -194,21 +142,11 @@ 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 ] || { 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; }
|
||||
[ "$total" -gt 0 ] || return 1
|
||||
: > "$mout" || return 1
|
||||
truncate -s "$total" "$mout" || return 1
|
||||
|
||||
|
|
@ -219,6 +157,14 @@ 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
|
||||
|
|
@ -227,9 +173,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 $(order_parts "$parts"); do
|
||||
for part in $(echo "$parts" | tr ',' ' '); 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.$$"
|
||||
|
|
@ -240,26 +186,21 @@ extract_mbn() { # <partition,...> <dir-in-partition> <name> <dest> <sha256|->
|
|||
continue
|
||||
fi
|
||||
got=$(sha256sum "$tmp" | awk '{print $1}')
|
||||
if [ "$want" != - ] && [ "$got" != "$want" ]; then
|
||||
if [ "$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 (sha256 $got)"
|
||||
log "reassembled $part:$rdir/$rname.{mdt,b0N} -> $dest"
|
||||
return 0
|
||||
done
|
||||
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"
|
||||
fail "no listed partition ($parts) yields $rname with sha256 $want - $dest NOT installed"
|
||||
}
|
||||
|
||||
extract() { # <partition,...> <path-in-partition> <dest> <sha256>
|
||||
parts=$1 src=$2 dest=$3 want=$4
|
||||
for part in $(order_parts "$parts"); do
|
||||
for part in $(echo "$parts" | tr ',' ' '); 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.$$"
|
||||
|
|
@ -298,14 +239,7 @@ rebind_all() { # <bus/device ...>
|
|||
done
|
||||
}
|
||||
|
||||
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
|
||||
if [ "${1:-}" = --if-device ] && [ ! -b "$SUPER" ]; then
|
||||
log "no stock super partition visible (build chroot?), nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -319,12 +253,11 @@ 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" ] && [ -z "$REFRESH" ] || missing=1 ;;
|
||||
mbn) [ -e "$d" ] || 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
|
||||
|
|
@ -341,7 +274,7 @@ for f in "$MANIFEST_DIR"/*.manifest; do
|
|||
;;
|
||||
mbn)
|
||||
[ -n "$e" ] || fail "$f: malformed mbn line"
|
||||
[ -e "$d" ] && [ -z "$REFRESH" ] && continue
|
||||
[ -e "$d" ] && continue
|
||||
extract_mbn "$a" "$b" "$c" "$d" "$e" </dev/null
|
||||
extracted=1
|
||||
;;
|
||||
|
|
|
|||
80
build.sh
80
build.sh
|
|
@ -18,18 +18,37 @@ set -eu
|
|||
|
||||
KERNEL_REPO=https://forgejo.catcrafts.net/Catcrafts/milos-linux.git
|
||||
KERNEL_BRANCH=combined-stable
|
||||
# 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
|
||||
# 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
|
||||
"
|
||||
PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
|
@ -251,29 +270,31 @@ 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 newest registry package, the one users get via 'apk upgrade'.
|
||||
# the exact registry package users later 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 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.
|
||||
# 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.
|
||||
REG_DL="$WORK/registry-apks"
|
||||
rm -rf "$REG_DL"
|
||||
mkdir -p "$REG_DL"
|
||||
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
|
||||
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
|
||||
fi
|
||||
retry "fetch registry packages" python3 ./registry-fetch.py "$REGISTRY" "$REGISTRY_KEY" "$REG_DL" \
|
||||
imsd,imsd-systemd fingerprintd,fingerprintd-systemd,fingerprintd-agent
|
||||
}
|
||||
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 -)
|
||||
ABUILD_KEY=$(echo "$WORKDIR"/config_abuild/*.rsa)
|
||||
if [ ! -f "$ABUILD_KEY" ]; then
|
||||
echo "expected exactly one abuild key in $WORKDIR/config_abuild" >&2
|
||||
|
|
@ -333,9 +354,8 @@ 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)"
|
||||
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"
|
||||
echo "imsd: $IMSD_REGISTRY imsd-$IMSD_VERSION (registry package, sha256-pinned)"
|
||||
echo "fingerprintd: $IMSD_REGISTRY fingerprintd-$FPD_VERSION (registry package, sha256-pinned)"
|
||||
} > "$STAGE/fp6-img/build-info.txt"
|
||||
# sums of the extracted contents
|
||||
(cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt)
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
#!/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:])
|
||||
Loading…
Reference in a new issue