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.
This commit is contained in:
Jorijn van der Graaf 2026-09-11 13:03:22 +02:00
commit 8ea35b890a
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 100 additions and 26 deletions

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
;;