From 6ecc1dc87b596c19aa93b42dfe31a72729f7af0c Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 28 Aug 2026 00:57:56 +0200 Subject: [PATCH 01/19] fp6-vendor-blobs: ship a systemd preset so the unit stays enabled The package installs a multi-user.target.wants symlink, but that alone does not survive image build: pmbootstrap runs systemctl preset-all, which removes manually-installed .wants links for any unit whose preset does not enable it. The 2026-08-24 image therefore shipped the service as 'disabled; preset: disabled' and it never ran, so aw88261_acf.bin was never extracted from the stock vendor partition and a fresh install came up with no sound card at all. Reproduced and verified on the dev phone: with no preset file installed, systemctl preset leaves the unit disabled; with it, the unit comes back enabled and stays enabled across a full preset-all. Investigation and evidence: fp6 repo journal/blobs/, 2026-08-28. Assisted-by: Claude:claude-fable-5 --- aports/device/fp6-vendor-blobs/APKBUILD | 9 +++++++++ aports/device/fp6-vendor-blobs/fp6-vendor-blobs.preset | 7 +++++++ 2 files changed, 16 insertions(+) create mode 100644 aports/device/fp6-vendor-blobs/fp6-vendor-blobs.preset diff --git a/aports/device/fp6-vendor-blobs/APKBUILD b/aports/device/fp6-vendor-blobs/APKBUILD index b1e6d74..d0d793c 100644 --- a/aports/device/fp6-vendor-blobs/APKBUILD +++ b/aports/device/fp6-vendor-blobs/APKBUILD @@ -25,6 +25,7 @@ options="!check" source=" fp6-vendor-blobs-extract fp6-vendor-blobs.service + fp6-vendor-blobs.preset " package() { @@ -38,10 +39,18 @@ package() { mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants ln -s /usr/lib/systemd/system/fp6-vendor-blobs.service \ "$pkgdir"/etc/systemd/system/multi-user.target.wants/fp6-vendor-blobs.service + # ...and a preset, because the symlink alone does NOT survive: image + # build runs `systemctl preset-all`, which removes .wants links for any + # unit not enabled by a preset. That is what shipped the 2026-08-24 + # image with the service `disabled; preset: disabled` -- no blob + # extracted, no sound card. See journal/blobs/ 2026-08-28. + install -Dm644 "$srcdir"/fp6-vendor-blobs.preset \ + "$pkgdir"/usr/lib/systemd/system-preset/50-fp6-vendor-blobs.preset mkdir -p "$pkgdir"/usr/share/fp6-vendor-blobs/manifest.d } sha512sums=" a71b2c86f980734d0aae6e135b26272fe52ae5603050bea52f55f7e74c8bd98c62b6194047711248d4fef40851792adc47d77c40016d99a0d68ecd2459894b80 fp6-vendor-blobs-extract e2c03e5016f1848c57e6160ce432530b181ff5d34a5aaf1822f0ffa5fe71d16691657ada4a5789c4d4209e14e83f43aab072d01f644b07b93648f91e2a7bb0ca fp6-vendor-blobs.service +9e79dd0aed13f11a71282aa24b2a26331e85c105e25ab0c0fed6189b8c300769a5f4308b18b91d9855868d658ad3a57c03e26c9b11bd27fd5e03f9a5decbbd6a fp6-vendor-blobs.preset " diff --git a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.preset b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.preset new file mode 100644 index 0000000..b935173 --- /dev/null +++ b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.preset @@ -0,0 +1,7 @@ +# The package also ships the multi-user.target.wants symlink, but a +# `systemctl preset-all` (pmbootstrap runs one while building the image) +# strips manually-installed .wants links for any unit whose preset does not +# enable it -- which left the service `disabled; preset: disabled` in the +# 2026-08-24 image and so no aw88261_acf.bin and no sound card on a fresh +# install. This preset is what makes the enable survive that. +enable fp6-vendor-blobs.service From d1a673940541c3f5b8fcbaf467254fe55e15132a Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 28 Aug 2026 00:59:45 +0200 Subject: [PATCH 02/19] readme change --- README.md | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c42aa32..fdd08a3 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,7 @@ Maintained by [Jorijn van der Graaf](https://catcrafts.net/about) Everything on the `combined-stable` branch: display, touch, wifi, cellular data, NFC (reader), speaker audio, microphone, IMU, magnetometer, barometer, -ambient light/proximity — plus VoLTE calls (both directions) through imsd. - -Verified on exactly one device / one carrier (KPN NL). Reports from other -carriers are very welcome. +ambient light/proximity plus VoLTE calls (both directions) through imsd. ## Flashing @@ -52,23 +49,4 @@ Find it in a stock-firmware capture or your carrier's IMS documentation, then `systemctl restart imsd` (the service is enabled at boot and waits for this file to exist). See the [imsd README](https://forgejo.catcrafts.net/Catcrafts/imsd) for the full -variable reference and carrier assumptions. - -## How it builds - -`build.sh` drives [pmbootstrap](https://gitlab.postmarketos.org/postmarketOS/pmbootstrap) -with the `aports/` overlay in this repository: - -- `device/linux-postmarketos-qcom-milos` — the pmaports kernel aport, - repointed at `combined-stable` with the exact kernel config the development FP6 runs. -- `modem/imsd` — imsd, packaged from source. Its `provides=81voltd` keeps the - conflicting modem-firmware IMS helper off the image (two IMS stacks cannot - share one PDN). -- `main/postmarketos-config-nftables` — adds the `-imsd` subpackage accepting - imsd's IPsec-protected SIP ports on the IMS PDN (auto-installed alongside - imsd). -- `device/catcrafts-fp6-repo` — subscribes the installed system to the - [Catcrafts apk registry](https://forgejo.catcrafts.net/Catcrafts/-/packages) - (signing key + `/etc/apk/repositories` entry). CI publishes every built - package there, so `apk upgrade` delivers kernel/imsd/GNSS updates between - image releases. \ No newline at end of file +variable reference and carrier assumptions. \ No newline at end of file From 77574494524cb7bd7031d336bfbb6e5269828291 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 29 Aug 2026 00:40:38 +0200 Subject: [PATCH 03/19] debian fix --- install.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 4da7406..1e20126 100755 --- a/install.sh +++ b/install.sh @@ -76,10 +76,15 @@ say "waiting for the phone to come up on the USB network ($PHONE)" # machine's side of the USB link: the gadget shows up as a new network # interface here, and either nothing configures an address on it, or a # network manager grabs it with the wrong profile (we have seen -# NetworkManager hand it an unrelated LAN address). The phone runs a DHCP -# server on the link, but host managers don't always ask. +# NetworkManager hand it an unrelated LAN address). Whether the host gets +# an address automatically depends on its network manager - don't count +# on it. +# The probe must be ssh-keyscan, not bash's /dev/tcp: this script runs +# under /bin/sh, which is dash on Debian, where /dev/tcp is a literal +# nonexistent path and the loop never succeeds (field report, 2026-08-28). +# ssh-keyscan ships with the ssh client we already require. i=0 -until (exec 3<>"/dev/tcp/$PHONE/22") 2>/dev/null; do +until ssh-keyscan -T 5 "$PHONE" >/dev/null 2>&1; do i=$((i+1)) if [ $i -gt 40 ]; then cat >&2 <<'EOF' @@ -98,12 +103,14 @@ its side of the USB network link. Fix it by hand: If a network manager keeps reclaiming the interface, tell it to ignore it, e.g.: sudo nmcli device set managed no + +If ping 172.16.42.1 already works, this machine's side is fine - the phone's +ssh service just isn't answering yet. Wait a minute and re-run. EOF exit 1 fi sleep 10 done -exec 3<&- 3>&- 2>/dev/null || true sleep 8 # --- 4: write boot from Linux --------------------------------------------------- From 1cb5099add90db83cbacc232ce9ab0b6a84e85f3 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 29 Aug 2026 21:53:02 +0200 Subject: [PATCH 04/19] aports: extract vendor blobs before udev coldplug so first-boot audio fully works The preset fix (6ecc1dc) makes the unit run, but multi-user is too late: the sound card probes at ~21 s, hard-fails (-2, no retry) on the missing ACF, and the extractor's amp rebind at ~60 s cannot re-form a card whose machine driver already failed. Re-probing the machine driver does re-form it, but a late-formed card has a dead capture path - the wcd9378 TX sequencer refuses PS0 (act_ps 0x3) until the next boot. So rebinding after the fact can never fully win; extraction has to happen before the drivers probe. - fp6-vendor-blobs.service: DefaultDependencies=no, run after remount-fs and before systemd-udev-trigger, so blobs exist before module coldplug. multi-user stays a second Wants as a post-udev retry if the early run failed (by-partlabel/make-dynpart-mappings fallbacks need udev). - aw88261-acf.manifest: add 'rebind platform sound' so the fallback path at least recovers playback the same boot (capture then heals on reboot). - fp6-vendor-blobs.post-upgrade (pkgrel 1): systemctl preset + immediate extract, so installs of the 2026-08-24 image (unit shipped disabled, no version bump to deliver the preset) finally heal via plain apk upgrade. - soc-fairphone-fp6-audio pkgrel 1: carries the manifest change. Verified on the dev phone with the exact staged bytes (sha256-compared): simulated fresh install (ACF removed, reboot) extracts at 13.3 s, coldplug probes the amps at 15.1 s, card init at 20.9 s loads the ACF cleanly, and a 1 kHz speaker-to-mic loopback passes on that same first boot with zero TX sequencer warnings; next boot takes the all-dests-present fast path in 25 ms. Evidence: fp6 repo journal/blobs/, 2026-08-29. Assisted-by: Claude:claude-fable-5 --- aports/device/fp6-vendor-blobs/APKBUILD | 13 +++++++--- .../fp6-vendor-blobs.post-upgrade | 10 +++++++ .../fp6-vendor-blobs/fp6-vendor-blobs.service | 26 ++++++++++++++----- .../device/soc-fairphone-fp6-audio/APKBUILD | 4 +-- .../aw88261-acf.manifest | 12 ++++++--- 5 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 aports/device/fp6-vendor-blobs/fp6-vendor-blobs.post-upgrade diff --git a/aports/device/fp6-vendor-blobs/APKBUILD b/aports/device/fp6-vendor-blobs/APKBUILD index d0d793c..6cd43ae 100644 --- a/aports/device/fp6-vendor-blobs/APKBUILD +++ b/aports/device/fp6-vendor-blobs/APKBUILD @@ -14,13 +14,14 @@ maintainer="Jorijn van der Graaf " pkgname=fp6-vendor-blobs pkgver=1 -pkgrel=0 +pkgrel=1 pkgdesc="On-device extraction of vendor blobs from the stock Android partitions" url="https://forgejo.catcrafts.net/Catcrafts/fp6-img" arch="noarch" license="MIT" # fallback mapper for when the initramfs didn't map the dynamic partitions depends="make-dynpart-mappings" +install="$pkgname.post-upgrade" options="!check" source=" fp6-vendor-blobs-extract @@ -35,8 +36,12 @@ package() { "$pkgdir"/usr/lib/systemd/system/fp6-vendor-blobs.service # enabled unconditionally: the unit is a fast no-op once every manifest # dest exists, and blobs appearing on first boot must not depend on a - # manual systemctl enable - mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants + # manual systemctl enable. sysinit = the real (pre-coldplug) run; + # multi-user = the post-udev retry if the early run failed (see unit) + mkdir -p "$pkgdir"/etc/systemd/system/sysinit.target.wants \ + "$pkgdir"/etc/systemd/system/multi-user.target.wants + ln -s /usr/lib/systemd/system/fp6-vendor-blobs.service \ + "$pkgdir"/etc/systemd/system/sysinit.target.wants/fp6-vendor-blobs.service ln -s /usr/lib/systemd/system/fp6-vendor-blobs.service \ "$pkgdir"/etc/systemd/system/multi-user.target.wants/fp6-vendor-blobs.service # ...and a preset, because the symlink alone does NOT survive: image @@ -51,6 +56,6 @@ package() { sha512sums=" a71b2c86f980734d0aae6e135b26272fe52ae5603050bea52f55f7e74c8bd98c62b6194047711248d4fef40851792adc47d77c40016d99a0d68ecd2459894b80 fp6-vendor-blobs-extract -e2c03e5016f1848c57e6160ce432530b181ff5d34a5aaf1822f0ffa5fe71d16691657ada4a5789c4d4209e14e83f43aab072d01f644b07b93648f91e2a7bb0ca fp6-vendor-blobs.service +b4c290095d9f39515378dfef08de720ce49324210342aa13c131dfce1103785e796e6f821f0c659671a4c44b46f466ce0e03f11f216fdcdee2a99db5e7970800 fp6-vendor-blobs.service 9e79dd0aed13f11a71282aa24b2a26331e85c105e25ab0c0fed6189b8c300769a5f4308b18b91d9855868d658ad3a57c03e26c9b11bd27fd5e03f9a5decbbd6a fp6-vendor-blobs.preset " diff --git a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.post-upgrade b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.post-upgrade new file mode 100644 index 0000000..c495e57 --- /dev/null +++ b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.post-upgrade @@ -0,0 +1,10 @@ +#!/bin/sh +# Re-apply the preset: images built before the preset file existed (the +# 2026-08-24 image) shipped the unit `disabled; preset: disabled`, so an +# upgrade must enable it or those installs never extract anything. Then +# extract immediately rather than at the next boot (--if-device makes that +# a quiet no-op inside build/CI chroots). A card that already hard-failed +# this boot recovers playback via the manifest's rebind lines; capture +# needs the next boot (journal/blobs/ 2026-08-29). +systemctl preset fp6-vendor-blobs.service 2>/dev/null || : +exec /usr/lib/fp6-vendor-blobs/extract --if-device diff --git a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.service b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.service index 200326e..73bcae7 100644 --- a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.service +++ b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs.service @@ -1,10 +1,24 @@ [Unit] Description=Extract vendor blobs from the stock Android partitions -# The dynamic-partition mappings come from the initramfs and the dests live -# on the rootfs, so local-fs ordering is enough. Deliberately not ordered -# against driver/audio bringup: the extractor re-probes consumers itself -# (manifest rebind lines), so it never has to win a race. -After=local-fs.target +# Must run BEFORE udev coldplug loads the consuming drivers: a sound card +# that probes without its firmware hard-fails (-2, never retried), and while +# a later re-probe of the machine driver re-forms the card, that late-formed +# card has a dead capture path (wcd9378 TX sequencer refuses PS0 - fp6 repo +# journal/blobs/ 2026-08-29). Only extraction-before-probe yields a fully +# working card, so this runs pre-coldplug; the manifest rebind lines remain +# as the fallback for consumers that probed anyway (playback recovers same +# boot, capture needs the next boot) and for on-device re-extraction after a +# package upgrade. +# The dynamic-partition mappings come from the initramfs (device nodes exist +# pre-udev) and the dests live on the rootfs, so remount-fs ordering is +# enough. multi-user is a deliberate second Wants: if the early run failed +# (e.g. no mappings pre-udev), the multi-user start job retries it after +# udev, where the by-partlabel/make-dynpart-mappings fallbacks work. +DefaultDependencies=no +After=systemd-remount-fs.service +Before=systemd-udev-trigger.service sysinit.target shutdown.target +Conflicts=shutdown.target +RequiresMountsFor=/usr/lib/firmware [Service] Type=oneshot @@ -12,4 +26,4 @@ RemainAfterExit=yes ExecStart=/usr/lib/fp6-vendor-blobs/extract [Install] -WantedBy=multi-user.target +WantedBy=sysinit.target multi-user.target diff --git a/aports/device/soc-fairphone-fp6-audio/APKBUILD b/aports/device/soc-fairphone-fp6-audio/APKBUILD index 6393c1f..0478cce 100644 --- a/aports/device/soc-fairphone-fp6-audio/APKBUILD +++ b/aports/device/soc-fairphone-fp6-audio/APKBUILD @@ -11,7 +11,7 @@ maintainer="Jorijn van der Graaf " pkgname=soc-fairphone-fp6-audio pkgver=4 -pkgrel=0 +pkgrel=1 pkgdesc="Fairphone 6 audio topology and UCM profile" url="https://forgejo.catcrafts.net/Catcrafts/audioreach-topology" arch="noarch" @@ -47,7 +47,7 @@ package() { sha512sums=" 51071c574b9f5899c78ba9fe1b6f3bbda2195e6d274014a05869ae56ed0ff18e15678bd7d78cd68846900d3c579556328e229842152cffd943186d43344ed33c fp6-tplg.bin -bdfe413494b6815cf96843d83c6c9e8deb757b083d7577c93eab460de49d1cd00b8615c115c25d71f6488de4e65f04d5f02bca816b2c31747880a22613e547a3 aw88261-acf.manifest +0240a36e6fae76f6df576c5ffc93241f6d59e03db429811d61c746561743ddb4690cb925028546a6c3d69dcc867aead5ddf1c85b12a0065195ea9c6621fa847f aw88261-acf.manifest 3bb268f9fcb8d1c0b42741b67da42b6361bab259e7211d9930919995ebe7dcf67826e6d4f00ff5e4fe5b52067fe90d00212158c3b4879d1bee481e87f0623206 ucm-milos-fp6.conf 2a189c3141fedc43f8eb19f4df1e43487223d524e27da3f948556d224cca1f493f6815ada191953fb79a19d5c5abadb21de7b3ece7b74b0929338c216e21e84a HiFi.conf ec63fa5363738a43c9eee4b1be70201e2d2076ec817031fda087e61d781e81dc67351be44faf8b056a2939875a139cd909f7c311115bb1150e17ad4493846606 50-echo-cancel.conf diff --git a/aports/device/soc-fairphone-fp6-audio/aw88261-acf.manifest b/aports/device/soc-fairphone-fp6-audio/aw88261-acf.manifest index 7fc47cd..88fbc16 100644 --- a/aports/device/soc-fairphone-fp6-audio/aw88261-acf.manifest +++ b/aports/device/soc-fairphone-fp6-audio/aw88261-acf.manifest @@ -3,8 +3,14 @@ # journal/blobs/, 2026-08-24). Slot A first, slot B as fallback; the copy # must hash-match the blob the audio stack was tested with. file vendor_a,vendor_b firmware/aw882xx_acf.bin /usr/lib/firmware/qcom/milos/fairphone/fp6/aw88261_acf.bin af723973655ba5901948d4a22212323e39ce53ff0156021185720af4af6ab0b3 -# the amps bind on i2c probe but request the ACF only at card init, which -# races the extractor on first boot and is never retried on failure - a -# forced fresh probe of both amps makes audio work that same boot either way +# Rebinds are the FALLBACK for a card that probed before extraction (the +# service normally runs pre-coldplug, so they no-op). The amps bind on i2c +# probe but request the ACF only at card init, never retried on failure; and +# when that failed card init already took the machine driver down (-2, not a +# defer), re-probing the amps alone cannot re-form the card - the platform +# device needs a fresh probe too. Note the recovered card plays back fine +# but its capture path is dead until the next boot (wcd9378 TX sequencer +# refuses PS0 on a late-formed card - journal/blobs/ 2026-08-29). rebind i2c 6-0034 rebind i2c 6-0035 +rebind platform sound From 6d32d10a8af67f33bb257328341b75595b85fe14 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 1 Sep 2026 18:26:33 +0200 Subject: [PATCH 05/19] imsd 0.3.1: aport ownership moves to the imsd repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The imsd repo now owns its apk packaging (packaging/aport/, imsd commit 17e0f6b); build.sh copies the aport out of the same pinned checkout it archives, so daemon and packaging can never skew. The local aports/modem/imsd copy is gone. 0.3.1 ships the ims-pdn-up hardening (mmcli errors logged verbatim per attempt, connect attempts gated on network registration, IMS_IP_TYPE configurable) — the field-debugging fix for undecodable bearer failures (journal/users/tu11ebukk), tested on the dev phone. build-info.txt now derives the imsd version from the aport instead of hardcoding it. Co-Authored-By: Claude Fable 5 --- aports/modem/imsd/80-imsd.preset | 1 - aports/modem/imsd/APKBUILD | 74 ------------------- .../modem/imsd/org.kde.modem.daemon.desktop | 2 - build.sh | 20 +++-- 4 files changed, 15 insertions(+), 82 deletions(-) delete mode 100644 aports/modem/imsd/80-imsd.preset delete mode 100644 aports/modem/imsd/APKBUILD delete mode 100644 aports/modem/imsd/org.kde.modem.daemon.desktop diff --git a/aports/modem/imsd/80-imsd.preset b/aports/modem/imsd/80-imsd.preset deleted file mode 100644 index 9ee121f..0000000 --- a/aports/modem/imsd/80-imsd.preset +++ /dev/null @@ -1 +0,0 @@ -enable imsd.service diff --git a/aports/modem/imsd/APKBUILD b/aports/modem/imsd/APKBUILD deleted file mode 100644 index 8314201..0000000 --- a/aports/modem/imsd/APKBUILD +++ /dev/null @@ -1,74 +0,0 @@ -maintainer="Jorijn van der Graaf " -pkgname=imsd -pkgver=0.3.0 -pkgrel=0 -pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" -url="https://forgejo.catcrafts.net/Catcrafts/imsd" -# other arches: nothing wrong known, just never run there -arch="aarch64 x86_64" -license="GPL-3.0-only" -# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire — -# none of which abuild's .so auto-scan can see -depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools" -# clang/libc++ C++26-modules build (upstream Makefile); llvm-runtimes ships -# the libc++ std module sources the build precompiles -makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgconf" -# the versioned provides both satisfies soc-qcom-modem's 81voltd dependency -# and excludes the real package: 81voltd serves the modem firmware's own -# ims-PDN requests, which races imsd for the PDN and flaps it (a new prefix -# every ~2.5 min) — two IMS stacks cannot share one PDN. Installing imsd is -# an explicit choice to hand the IMS PDN to userspace. -provides="81voltd=$pkgver-r$pkgrel" -# no OpenRC service yet: the unit's PDN-bring-up/env-file sequencing is only -# tested under systemd; an initd is welcome once someone can verify one -subpackages="$pkgname-systemd" -# Pinned by CI (see build.sh at the repository root): the Forgejo instance -# serves no source archives, so the tarball is generated with git-archive -# (prefix imsd/) and placed next to this APKBUILD. The skel override hides -# kde-telephony's modem daemon autostart for the account created at install — -# imsd-dialerd owns those session D-Bus names instead. -_commit="REPLACED_BY_CI" -source=" - imsd-$_commit.tar.gz - org.kde.modem.daemon.desktop - 80-imsd.preset -" -builddir="$srcdir/$pkgname" - -build() { - make -} - -check() { - make check -} - -package() { - make install DESTDIR="$pkgdir" - install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ - "$pkgdir"/etc/skel/.config/autostart/org.kde.modem.daemon.desktop - # enabled by preset: the unit is a no-op until /etc/imsd.env exists, and - # VoLTE surviving reboots must not depend on a manual systemctl enable - install -Dm644 "$srcdir"/80-imsd.preset \ - "$pkgdir"/usr/lib/systemd/system-preset/80-imsd.preset - mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants - ln -s /usr/lib/systemd/system/imsd.service \ - "$pkgdir"/etc/systemd/system/multi-user.target.wants/imsd.service - # ...but only actually start once the carrier config exists, so - # unconfigured systems don't boot into a failing unit - mkdir -p "$pkgdir"/usr/lib/systemd/system/imsd.service.d - printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \ - > "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf -} - -systemd() { - install_if="$pkgname=$pkgver-r$pkgrel systemd" - - amove usr/lib/systemd/system -} - -sha512sums=" -REPLACED_BY_CI imsd-REPLACED_BY_CI.tar.gz -REPLACED_BY_CI org.kde.modem.daemon.desktop -REPLACED_BY_CI 80-imsd.preset -" diff --git a/aports/modem/imsd/org.kde.modem.daemon.desktop b/aports/modem/imsd/org.kde.modem.daemon.desktop deleted file mode 100644 index e1e3e17..0000000 --- a/aports/modem/imsd/org.kde.modem.daemon.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/build.sh b/build.sh index 9c5cd0b..6b70485 100755 --- a/build.sh +++ b/build.sh @@ -18,9 +18,12 @@ set -eu KERNEL_REPO=https://forgejo.catcrafts.net/Catcrafts/milos-linux.git KERNEL_BRANCH=combined-stable IMSD_REPO=https://forgejo.catcrafts.net/Catcrafts/imsd.git -# imsd 0.3.0: what the dev phone runs (0.2.7) + emergency-calling stage 1 + -# the make build path the aport needs. Bump deliberately, not via tip-chasing. -IMSD_COMMIT=1037958 +# imsd 0.3.1: 0.3.0 + the ims-pdn-up hardening (mmcli errors logged +# verbatim, registration gate, configurable ip-type) + README carrier +# updates. The aport now lives IN the imsd repo (packaging/aport/, +# transferred 2026-09-01) and is copied out of this checkout below. +# Bump deliberately, not via tip-chasing. +IMSD_COMMIT=17e0f6b PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git cd "$(dirname "$0")" @@ -135,7 +138,8 @@ cp -r aports/device/fp6-device-tweaks "$WORK/pmaports/device/" cp -r aports/device/fp6-charging-mode "$WORK/pmaports/device/" cp -r aports/device/catcrafts-fp6-repo "$WORK/pmaports/device/" cp -r aports/main/postmarketos-config-nftables "$WORK/pmaports/main/" -cp -r aports/modem/imsd "$WORK/pmaports/modem/" +# imsd's aport is NOT carried here: the imsd repo owns it (packaging/aport/) +# and section 2 copies it out of the pinned checkout. # Alpine forks carrying the GNSS patches (libqmi !470 unreleased; MM !1463 # draft) - deps of modemmanager/imsd, built from aports because r100 > repo. cp -r aports/temp/libqmi "$WORK/pmaports/temp/" @@ -162,10 +166,16 @@ KDATE=$(git -C "$WORK/milos-src" log -1 --format=%cd --date=format:%Y%m%d) sed -i "s/^pkgver=\([0-9.]*\)\$/pkgver=\1_git$KDATE/" "$KAPORT/APKBUILD" # Same dance for imsd, pinned to a reviewed commit rather than branch tip. +# The aport itself comes from the same pinned checkout (packaging/aport/, +# owned by the imsd repo since 2026-09-01), so daemon and packaging can +# never skew. clone_retry "$WORK/imsd-src" -q "$IMSD_REPO" git -C "$WORK/imsd-src" checkout -q "$IMSD_COMMIT" IAPORT="$WORK/pmaports/modem/imsd" +mkdir -p "$WORK/pmaports/modem" +rm -rf "$IAPORT" +cp -r "$WORK/imsd-src/packaging/aport" "$IAPORT" git -C "$WORK/imsd-src" archive --prefix=imsd/ \ -o "$IAPORT/imsd-$IMSD_COMMIT.tar.gz" HEAD sed -i "s/^_commit=.*/_commit=\"$IMSD_COMMIT\"/" "$IAPORT/APKBUILD" @@ -281,7 +291,7 @@ 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_REPO @ $IMSD_COMMIT (0.3.0)" + echo "imsd: $IMSD_REPO @ $IMSD_COMMIT ($(apkbuild_var "$IAPORT" pkgver))" } > "$STAGE/fp6-img/build-info.txt" # sums of the extracted contents (cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt) From e7a1825b5050157022113825b0589a3ed624b2fb Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 2 Sep 2026 16:11:50 +0200 Subject: [PATCH 06/19] imsd: install the published apk instead of building it The imsd repo's package CI is now the only producer of the imsd apk. The image installs the exact registry package users get via 'apk upgrade' (pinned version + sha256 of the registry files), so the two can no longer diverge and the payload-parity rule between two packagings is gone. pmbootstrap has no knob for a third-party repository, and after the main 'apk add' it re-adds every package in its local packages dir by file path, which makes apk verify the package's own signature. Registry packages are signed with per-run keys nobody keeps (phones trust the registry-signed index), so apk-resign.py replaces the signature stream with one from this run's abuild key; control and data streams stay byte-identical and the identity checksum equals the registry's. Verified on the host with apk 3.0.8: originals UNTRUSTED, re-signed OK, checksums equal. The publish step skips imsd-*: those files came from the registry. --- .forgejo/workflows/build.yml | 7 ++- apk-resign.py | 76 +++++++++++++++++++++++++++++++++ build.sh | 83 +++++++++++++++++++++--------------- 3 files changed, 130 insertions(+), 36 deletions(-) create mode 100755 apk-resign.py diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 60f5d86..fa00071 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -44,12 +44,14 @@ jobs: path: dist/* if-no-files-found: error - # Ship every locally built apk (kernel, modemmanager, libqmi, imsd, + # Ship every locally built apk (kernel, modemmanager, libqmi, # callaudioshim, audio files, ...) to the Forgejo Alpine registry, so # installed systems get updates via 'apk upgrade' instead of losing # the FP6 patches to the next upstream version bump. Requires the # PACKAGE_TOKEN repo secret (catbot account, package:write scope); # skips quietly until it exists. 409 = same version already published. + # imsd is skipped: build.sh 3b took it FROM the registry (re-signed + # for the chroot), so it is not ours to publish. - name: Publish packages to the apk registry env: PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} @@ -62,6 +64,9 @@ jobs: found=0 for f in /home/build/.local/var/pmbootstrap/packages/*/aarch64/*.apk; do [ -e "$f" ] || continue + case "$(basename "$f")" in + imsd-*) echo "registry-sourced, not republished: $(basename "$f")"; continue ;; + esac found=1 code=$(curl -s -o /dev/null -w '%{http_code}' \ --user "catbot:$PACKAGE_TOKEN" --upload-file "$f" \ diff --git a/apk-resign.py b/apk-resign.py new file mode 100755 index 0000000..023707d --- /dev/null +++ b/apk-resign.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Re-sign an apk (v2 format) with another RSA key, in place. + + apk-resign.py + +An apk is three concatenated gzip streams: the signature tar, the control +tar (.PKGINFO) and the data tar. The signature covers the control stream +only, so replacing the first stream re-signs the package while its identity +checksum (over the control stream) and its contents stay byte-identical. +This is what abuild-sign does to the control segment when abuild packages. +""" +import gzip +import io +import subprocess +import sys +import tarfile +import zlib + +DIGEST = {"RSA": "sha1", "RSA256": "sha256", "RSA512": "sha512"} + + +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 first_entry_name(tar): + """Name of the first regular file in a tar stream, skipping pax headers.""" + off = 0 + while off + 512 <= len(tar): + hdr = tar[off:off + 512] + size = int(hdr[124:136].split(b"\0")[0].strip() or b"0", 8) + if hdr[156:157] not in (b"x", b"g"): # not a pax extended/global header + return hdr[:100].rstrip(b"\0").decode() + off += 512 + (size + 511) // 512 * 512 + raise ValueError("no file entry in signature tar") + + +def main(path, privkey, pubname): + members = list(gzip_members(open(path, "rb").read())) + if len(members) != 3: + sys.exit(f"{path}: expected 3 gzip streams, found {len(members)}") + old_sig, control, payload = members + # keep the original digest type: the entry is .SIGN.. + name = first_entry_name(gzip.decompress(old_sig)) + kind = name.split(".")[2] if name.startswith(".SIGN.") else "" + if kind not in DIGEST: + sys.exit(f"{path}: unexpected signature entry {name!r}") + sig = subprocess.run( + ["openssl", "dgst", f"-{DIGEST[kind]}", "-sign", privkey], + input=control, capture_output=True, check=True, + ).stdout + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tar: + info = tarfile.TarInfo(f".SIGN.{kind}.{pubname}") + info.size = len(sig) + info.mode = 0o644 + tar.addfile(info, io.BytesIO(sig)) + # like abuild-tar --cut: header + data blocks, no end-of-archive marker + cut = 512 + (len(sig) + 511) // 512 * 512 + new_sig = gzip.compress(buf.getvalue()[:cut], mtime=0) + with open(path, "wb") as f: + f.write(new_sig + control + payload) + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit(__doc__) + main(*sys.argv[1:]) diff --git a/build.sh b/build.sh index 6b70485..311f29c 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/bin/sh -eu # fp6-img pipeline: build a flashable postmarketOS image for the Fairphone 6 -# with the Catcrafts kernel (milos-linux combined-stable) and, once its tag -# is published, imsd (VoLTE). +# with the Catcrafts kernel (milos-linux combined-stable) and imsd (VoLTE) +# installed from the Catcrafts apk registry. # # Runs in CI inside an Alpine container on the privileged "pmos" runner # (pmbootstrap needs loop devices; the aarch64 chroots need the qemu-user @@ -17,13 +17,19 @@ set -eu KERNEL_REPO=https://forgejo.catcrafts.net/Catcrafts/milos-linux.git KERNEL_BRANCH=combined-stable -IMSD_REPO=https://forgejo.catcrafts.net/Catcrafts/imsd.git -# imsd 0.3.1: 0.3.0 + the ims-pdn-up hardening (mmcli errors logged -# verbatim, registration gate, configurable ip-type) + README carrier -# updates. The aport now lives IN the imsd repo (packaging/aport/, -# transferred 2026-09-01) and is copied out of this checkout below. +# 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. -IMSD_COMMIT=17e0f6b +# 0.3.1: 0.3.0 + the ims-pdn-up hardening (mmcli errors logged verbatim, +# registration gate, configurable ip-type) + README carrier updates. +IMSD_REGISTRY=https://forgejo.catcrafts.net/api/packages/Catcrafts/alpine/edge/fp6 +IMSD_VERSION=0.3.1-r0 +IMSD_SHA256=" +f1c317d7ff9448c05df068d683d31da08e4bfc074704d96cf52cb8acbdee6304 imsd-0.3.1-r0.apk +a78ef31fc2943ac02e120c46df26353d515ac9840d959cf5193b2afe1c665fa6 imsd-systemd-0.3.1-r0.apk +" PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git cd "$(dirname "$0")" @@ -38,7 +44,7 @@ if [ "$(id -u)" = 0 ]; then # multipath-tools: kpartx; util-linux: losetup with --json support # (pmbootstrap's host-tool checks + image mounting need both) apk add -q git sudo openssl python3 py3-pip multipath-tools util-linux \ - tar xz + tar xz curl # the pmOS gitlab hiccups under crawler load and truncates clones # ("early EOF"); that should cost a retry, not the run — same reasoning # as clone_retry below, which isn't defined yet in this root branch @@ -138,8 +144,7 @@ cp -r aports/device/fp6-device-tweaks "$WORK/pmaports/device/" cp -r aports/device/fp6-charging-mode "$WORK/pmaports/device/" cp -r aports/device/catcrafts-fp6-repo "$WORK/pmaports/device/" cp -r aports/main/postmarketos-config-nftables "$WORK/pmaports/main/" -# imsd's aport is NOT carried here: the imsd repo owns it (packaging/aport/) -# and section 2 copies it out of the pinned checkout. +# imsd has no aport here at all: section 3b installs the published apk. # Alpine forks carrying the GNSS patches (libqmi !470 unreleased; MM !1463 # draft) - deps of modemmanager/imsd, built from aports because r100 > repo. cp -r aports/temp/libqmi "$WORK/pmaports/temp/" @@ -165,22 +170,7 @@ sed -i "s/^_commit=.*/_commit=\"$COMMIT\"/" "$KAPORT/APKBUILD" KDATE=$(git -C "$WORK/milos-src" log -1 --format=%cd --date=format:%Y%m%d) sed -i "s/^pkgver=\([0-9.]*\)\$/pkgver=\1_git$KDATE/" "$KAPORT/APKBUILD" -# Same dance for imsd, pinned to a reviewed commit rather than branch tip. -# The aport itself comes from the same pinned checkout (packaging/aport/, -# owned by the imsd repo since 2026-09-01), so daemon and packaging can -# never skew. - -clone_retry "$WORK/imsd-src" -q "$IMSD_REPO" -git -C "$WORK/imsd-src" checkout -q "$IMSD_COMMIT" -IAPORT="$WORK/pmaports/modem/imsd" -mkdir -p "$WORK/pmaports/modem" -rm -rf "$IAPORT" -cp -r "$WORK/imsd-src/packaging/aport" "$IAPORT" -git -C "$WORK/imsd-src" archive --prefix=imsd/ \ - -o "$IAPORT/imsd-$IMSD_COMMIT.tar.gz" HEAD -sed -i "s/^_commit=.*/_commit=\"$IMSD_COMMIT\"/" "$IAPORT/APKBUILD" - -# Same again for the gitlab.freedesktop.org packages (libqmi, modemmanager, +# Same dance for the gitlab.freedesktop.org packages (libqmi, modemmanager, # libcamera): their pinned tarballs came from fd.o's on-demand archive # endpoint, which 503/504s for hours at a stretch — runs #25, #27, #30 and # #31 all died there, outlasting any in-run retry. git clone is served from @@ -228,12 +218,11 @@ systemd = always extra_packages = soc-fairphone-fp6-audio,callaudioshim,imsd,fp6-device-tweaks,fp6-charging-mode,catcrafts-fp6-repo,postmarketos-base-ui-audio-backend-pipewire,pipewire-pulse,pipewire-echo-cancel EOF -# All five source tarballs are generated locally above, so every checksum +# All four source tarballs are generated locally above, so every checksum # step is offline. libcamera needs a checksum step now too: its committed # sums were for the fd.o-served tarball, and git-archive output is not # byte-identical to it (verified: sha512 differs). pmbootstrap checksum linux-postmarketos-qcom-milos -pmbootstrap checksum imsd pmbootstrap checksum libqmi pmbootstrap checksum modemmanager pmbootstrap checksum libcamera @@ -255,12 +244,36 @@ retry "build modemmanager" pmbootstrap $NOCROSS build --arch aarch64 modemmanage # patched -r2 exists for the publish step even if the install set resolves # it before the overlay is considered. retry "build libcamera" pmbootstrap $NOCROSS build --arch aarch64 libcamera -# imsd is the one crossdirect build left to the install phase, which has no -# per-package flag — pre-build it here in no-crossdirect mode so install -# finds it current. -if [ -n "$NOCROSS" ]; then - retry "build imsd" pmbootstrap $NOCROSS build --arch aarch64 imsd +# --- 3b. imsd: the published apk, not a local build -------------------------- +# The imsd repo's package CI is the only producer of the imsd apk; the image +# installs 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, 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. +IMSD_DL="$WORK/imsd-apk" +rm -rf "$IMSD_DL" +mkdir -p "$IMSD_DL" +for _f in "imsd-$IMSD_VERSION.apk" "imsd-systemd-$IMSD_VERSION.apk"; do + retry "fetch $_f" curl -fsSL -o "$IMSD_DL/$_f" "$IMSD_REGISTRY/aarch64/$_f" +done +(cd "$IMSD_DL" && printf '%s\n' "$IMSD_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 + exit 1 fi +for _f in "$IMSD_DL"/*.apk; do + python3 ./apk-resign.py "$_f" "$ABUILD_KEY" "$(basename "$ABUILD_KEY").pub" +done +mkdir -p "$WORKDIR/packages/edge/aarch64" +mv "$IMSD_DL"/*.apk "$WORKDIR/packages/edge/aarch64/" +pmbootstrap index # --- 4. build the image ------------------------------------------------------- # Same default credentials as the official postmarketOS images. @@ -291,7 +304,7 @@ 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_REPO @ $IMSD_COMMIT ($(apkbuild_var "$IAPORT" pkgver))" + echo "imsd: $IMSD_REGISTRY imsd-$IMSD_VERSION (registry package, sha256-pinned)" } > "$STAGE/fp6-img/build-info.txt" # sums of the extracted contents (cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt) From 877fe92a4b622ca9e5329d0c818e360db22efdc2 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 2 Sep 2026 17:21:30 +0200 Subject: [PATCH 07/19] imsd: sign from a readable copy of the abuild key Run 49 died in section 3b: pmbootstrap's abuild-keygen runs inside the chroot as its own user (uid 12345), so the key in config_abuild/ is 0600 to that uid and the build user cannot read it; openssl dgst -sign exited 1 and apk-resign.py swallowed its stderr. Take a private copy via sudo for the duration of the re-sign, and make the script name an unreadable key and let openssl's stderr through instead of hiding it. --- apk-resign.py | 16 ++++++++++++---- build.sh | 8 +++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apk-resign.py b/apk-resign.py index 023707d..af386fe 100755 --- a/apk-resign.py +++ b/apk-resign.py @@ -11,6 +11,7 @@ This is what abuild-sign does to the control segment when abuild packages. """ import gzip import io +import os import subprocess import sys import tarfile @@ -53,10 +54,17 @@ def main(path, privkey, pubname): kind = name.split(".")[2] if name.startswith(".SIGN.") else "" if kind not in DIGEST: sys.exit(f"{path}: unexpected signature entry {name!r}") - sig = subprocess.run( - ["openssl", "dgst", f"-{DIGEST[kind]}", "-sign", privkey], - input=control, capture_output=True, check=True, - ).stdout + if not os.access(privkey, os.R_OK): + sys.exit(f"{privkey}: not readable by uid {os.getuid()} (pmbootstrap's " + "abuild-keygen runs as the chroot user, uid 12345, and leaves " + "the key 0600 to it - sign from a readable copy)") + try: + sig = subprocess.run( + ["openssl", "dgst", f"-{DIGEST[kind]}", "-sign", privkey], + input=control, stdout=subprocess.PIPE, check=True, + ).stdout + except subprocess.CalledProcessError as e: + sys.exit(f"openssl dgst -sign exited {e.returncode} signing {path}") buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tar: info = tarfile.TarInfo(f".SIGN.{kind}.{pubname}") diff --git a/build.sh b/build.sh index 311f29c..7d3519f 100755 --- a/build.sh +++ b/build.sh @@ -268,9 +268,15 @@ if [ ! -f "$ABUILD_KEY" ]; then echo "expected exactly one abuild key in $WORKDIR/config_abuild" >&2 exit 1 fi +# abuild-keygen ran inside the chroot as pmbootstrap's user (uid 12345), so +# the key is 0600 to that uid and unreadable here (run 49 died on exactly +# this); sign from a private copy taken via sudo, then drop it. +KEYCOPY="$IMSD_DL/abuild-key.rsa" +sudo install -m 0600 -o "$(id -un)" "$ABUILD_KEY" "$KEYCOPY" for _f in "$IMSD_DL"/*.apk; do - python3 ./apk-resign.py "$_f" "$ABUILD_KEY" "$(basename "$ABUILD_KEY").pub" + python3 ./apk-resign.py "$_f" "$KEYCOPY" "$(basename "$ABUILD_KEY").pub" done +rm -f "$KEYCOPY" mkdir -p "$WORKDIR/packages/edge/aarch64" mv "$IMSD_DL"/*.apk "$WORKDIR/packages/edge/aarch64/" pmbootstrap index From 57517217e441e9e02962d81377e6389f841bd157 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 2 Sep 2026 19:35:20 +0200 Subject: [PATCH 08/19] imsd: place the re-signed apks with the packages dir's owner Run 50 got past the key copy and the re-sign, then died on the move into pmbootstrap's packages dir: abuild created that directory from inside the chroot, so it belongs to the chroot user (uid 12345) and the build user cannot write there. Install the files via sudo with the directory's own owner and mode 0644, as abuild would have left them, and fail with a clear message if the directory is missing instead of creating a wrongly-owned one. --- build.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 7d3519f..64bd9f7 100755 --- a/build.sh +++ b/build.sh @@ -277,8 +277,20 @@ for _f in "$IMSD_DL"/*.apk; do python3 ./apk-resign.py "$_f" "$KEYCOPY" "$(basename "$ABUILD_KEY").pub" done rm -f "$KEYCOPY" -mkdir -p "$WORKDIR/packages/edge/aarch64" -mv "$IMSD_DL"/*.apk "$WORKDIR/packages/edge/aarch64/" +# The packages dir belongs to pmbootstrap's chroot user as well (abuild wrote +# it from inside the chroot), so the build user cannot write there either +# (run 50): install the files with the directory's own owner, as abuild would +# have left them. The dir exists because the builds above populated it. +PKGDIR="$WORKDIR/packages/edge/aarch64" +if [ ! -d "$PKGDIR" ]; then + echo "$PKGDIR missing - the package builds above should have created it" >&2 + exit 1 +fi +for _f in "$IMSD_DL"/*.apk; do + sudo install -m 0644 -o "$(stat -c %u "$PKGDIR")" -g "$(stat -c %g "$PKGDIR")" \ + "$_f" "$PKGDIR/$(basename "$_f")" +done +rm -f "$IMSD_DL"/*.apk pmbootstrap index # --- 4. build the image ------------------------------------------------------- From 9575e5517d7bb9741c82ff9d13abd833d13c3482 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 5 Sep 2026 03:56:13 +0200 Subject: [PATCH 09/19] fp6-vendor-blobs: reassemble a Qualcomm trustlet, not just copy a file The fingerprint matcher is a proprietary OEM-signed trustlet, and it is not shipped as one file: QTEE images live in the modem partition's image/ as an ELF header+hashes file plus one payload per program header, and the loader wants each payload written back at its segment's p_offset. So the existing file directive cannot reach it. An mbn directive does, with the same guarantees file has: the sha256 is of the reassembled image, a mismatch tries the next partition, and an unverified image is never installed. Reassembly is not a concatenation -- segments are page aligned but not contiguous, gaps stay zero, and two pairs of focal64's nine segments share an offset, so they are written in index order and the later one wins, exactly as the bring-up repo's reassemble.py does. Verified on the dev phone against the hash QTEE has actually accepted since August: 3600472 bytes, sha256 1930c490..., reassembled from the phone's own modem_a. The retry path was verified too, with a deliberately wrong first partition -- which is how the variable clobber got caught: POSIX sh has no locals, and reassemble() taking rdir= rewrote its caller's copy to the mount path, so the second partition would have been searched at $MNT/$MNT/... The fast path needed teaching as well: mbn's dest is the fifth field, and a first boot would otherwise have exited early and extracted nothing. Not pushed. The consumer fragment lives in the fingerprintd package. --- aports/device/fp6-vendor-blobs/APKBUILD | 2 +- .../fp6-vendor-blobs/fp6-vendor-blobs-extract | 110 +++++++++++++++++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/aports/device/fp6-vendor-blobs/APKBUILD b/aports/device/fp6-vendor-blobs/APKBUILD index 6cd43ae..8dc7a5b 100644 --- a/aports/device/fp6-vendor-blobs/APKBUILD +++ b/aports/device/fp6-vendor-blobs/APKBUILD @@ -14,7 +14,7 @@ maintainer="Jorijn van der Graaf " pkgname=fp6-vendor-blobs pkgver=1 -pkgrel=1 +pkgrel=2 pkgdesc="On-device extraction of vendor blobs from the stock Android partitions" url="https://forgejo.catcrafts.net/Catcrafts/fp6-img" arch="noarch" diff --git a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract index 4239697..1289b21 100644 --- a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract +++ b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract @@ -10,6 +10,7 @@ # processed in sorted order; '#' comments and blank lines ignored: # # file +# mbn # rebind # # file: mount the first available listed partition READ-ONLY (ext4 also @@ -20,6 +21,16 @@ # unverified blob is never installed and a missing one never silently # skipped. Dests that already exist are left alone (no hashing: a # deliberately replaced file stays). +# mbn: the same, for a Qualcomm trustlet, which is not shipped as one file. +# QTEE images live in the modem partition's image/ as an ELF header+hashes +# file (.mdt) plus one payload per program header (.b00, .b01, +# ...), and the loader wants them written back at each segment's p_offset. +# 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. # 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 @@ -103,6 +114,90 @@ mount_part() { MNT_PART=$1 } +# 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 ' '; } + +# Reassemble /.mdt + .b0N into a flat image at . Mirrors +# utilities/ta-analysis/reassemble.py in the fp6 bring-up repo, which is where +# the format was worked out and where the known-good hash comes from. +# POSIX sh has no locals, so these names are deliberately distinct from +# extract_mbn's: reassemble() taking rdir= would rewrite its CALLER's copy to +# the mount path, and the next partition in the retry loop would then be +# searched at $MNT/$MNT/... +reassemble() { # + mdir=$1 mname=$2 mout=$3 + mdt="$mdir/$mname.mdt" + [ -f "$mdt" ] || 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 + + # The image is as long as the furthest segment reaches; everything no + # segment covers stays zero. + total=0 i=0 + while [ "$i" -lt "$phnum" ]; do + o=$((phoff + i * phentsize)) + pfsz=$(u64 "$mdt" $((o + 32))) + if [ "$pfsz" -gt 0 ]; then + poff=$(u64 "$mdt" $((o + 8))) + [ $((poff + pfsz)) -gt "$total" ] && total=$((poff + pfsz)) + fi + i=$((i + 1)) + done + [ "$total" -gt 0 ] || return 1 + : > "$mout" || return 1 + truncate -s "$total" "$mout" || return 1 + + i=0 + while [ "$i" -lt "$phnum" ]; do + o=$((phoff + i * phentsize)) + 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; } + # 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 + i=$((i + 1)) + done + return 0 +} + +extract_mbn() { # + parts=$1 rdir=$2 rname=$3 dest=$4 want=$5 + 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.$$" + mkdir -p "${dest%/*}" || fail "cannot create ${dest%/*}" + if ! reassemble "$MNT/$rdir" "$rname" "$tmp"; then + rm -f "$tmp" + log "$part: reassembling $rname failed, trying next" + continue + fi + got=$(sha256sum "$tmp" | awk '{print $1}') + 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" + return 0 + done + fail "no listed partition ($parts) yields $rname with sha256 $want - $dest NOT installed" +} + extract() { # parts=$1 src=$2 dest=$3 want=$4 for part in $(echo "$parts" | tr ',' ' '); do @@ -155,8 +250,11 @@ fi missing= for f in "$MANIFEST_DIR"/*.manifest; do [ -e "$f" ] || continue - while read -r kind _ _ dest _; do - [ "$kind" = file ] && [ ! -e "$dest" ] && missing=1 + while read -r kind a b c d e; do + case "$kind" in + file) [ -e "$c" ] || missing=1 ;; + mbn) [ -e "$d" ] || missing=1 ;; + esac done < "$f" done [ -z "$missing" ] && exit 0 @@ -165,7 +263,7 @@ for f in "$MANIFEST_DIR"/*.manifest; do [ -e "$f" ] || continue extracted= rebinds= - while read -r kind a b c d; do + while read -r kind a b c d e; do case "$kind" in ''|'#'*) ;; file) @@ -174,6 +272,12 @@ for f in "$MANIFEST_DIR"/*.manifest; do extract "$a" "$b" "$c" "$d" Date: Sat, 5 Sep 2026 04:25:51 +0200 Subject: [PATCH 10/19] kernel: build qcomtee, which is one line and not the coupled change we recorded The fingerprint stack needs /dev/tee0, and the pmOS config has never built the driver -- so every session so far has loaded an out-of-tree module. That has been recorded since 2 September as the release blocker, on the reading that drivers/tee/qcomtee/Kconfig's `select QCOM_TZMEM_MODE_SHMBRIDGE` would flip tzmem out of Generic mode and drag a boot+selftest round with it. It does not. QCOM_TZMEM_MODE_SHMBRIDGE is a member of a choice block, and Kconfig cannot select a choice member -- the statement is inert, and silently so. Setting CONFIG_QCOMTEE=m and running olddefconfig against the tree produces exactly one line of difference and leaves CONFIG_QCOM_TZMEM_MODE_ GENERIC=y, which is the mode every fingerprint measurement was taken on. The driver is already in the base tree; the only reason it was out-of-tree here was that nothing enabled it. Verified on the dev phone: the module built from this config carries ZERO module parameters against the research build's 95, matching vermagic, and the phone boots on it with modules-load.d bringing it up, /dev/tee0 present, fingerprintd ready and the enrolled template loading. So the shipped module is upstream code on the tested tzmem mode. Not pushed. Whether to ship QCOMTEE with tzmem in Generic mode is a separate question with a real answer -- journal/tee warns about that mode -- and it is now sequenceable on its own instead of forced by a select that does nothing. --- aports/device/linux-postmarketos-qcom-milos/APKBUILD | 2 +- .../config-postmarketos-qcom-milos.aarch64 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aports/device/linux-postmarketos-qcom-milos/APKBUILD b/aports/device/linux-postmarketos-qcom-milos/APKBUILD index 94fde64..c7c3224 100644 --- a/aports/device/linux-postmarketos-qcom-milos/APKBUILD +++ b/aports/device/linux-postmarketos-qcom-milos/APKBUILD @@ -8,7 +8,7 @@ _flavor="postmarketos-qcom-milos" pkgname=linux-$_flavor pkgver=7.2.0 # always sorts above the upstream aport (r0..r99) -pkgrel=100 +pkgrel=101 pkgdesc="Milos mainline kernel + Catcrafts FP6 bring-up carries (combined-stable)" arch="aarch64" _carch="arm64" diff --git a/aports/device/linux-postmarketos-qcom-milos/config-postmarketos-qcom-milos.aarch64 b/aports/device/linux-postmarketos-qcom-milos/config-postmarketos-qcom-milos.aarch64 index 9c26c1c..e0aedd3 100644 --- a/aports/device/linux-postmarketos-qcom-milos/config-postmarketos-qcom-milos.aarch64 +++ b/aports/device/linux-postmarketos-qcom-milos/config-postmarketos-qcom-milos.aarch64 @@ -7224,7 +7224,7 @@ CONFIG_TEE_DMABUF_HEAPS=y CONFIG_OPTEE=y # CONFIG_OPTEE_INSECURE_LOAD_IMAGE is not set CONFIG_OPTEE_STATIC_PROTMEM_POOL=y -# CONFIG_QCOMTEE is not set +CONFIG_QCOMTEE=m # CONFIG_MUX_CORE is not set CONFIG_PM_OPP=y # CONFIG_SIOX is not set From 2a4427919e592ec0d0f552baaea132174de84b73 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 5 Sep 2026 20:31:37 +0200 Subject: [PATCH 11/19] Install fingerprintd from the registry, so the image unlocks with a finger The daemon's own package CI publishes it to the registry the same way imsd's does, so the image takes it from there: the exact apk a user later gets via apk upgrade, sha256-pinned, re-signed for the chroot. Section 3b now fetches both sets, and every fetched file must have a pin -- the check used to be `grep . | sha256sum -c`, which an empty pin list would have sailed through with nothing checked. Three apks: the daemon, its systemd units, and the session agent, which does nothing until a user writes ~/.config/fingerprintd/fingers.conf. The daemon needs the kernel aport's CONFIG_QCOMTEE=m (pkgrel 101) and fp6-vendor-blobs 1-r2's mbn directive to reassemble the trustlet, both built in this run; 0.2.3 says >=1-r2 so a mismatched pair is refused rather than installed. The CI publish step skips fingerprintd-* like imsd-*: registry-sourced, not ours to republish. README: fingerprint in the list, and the two things a user will otherwise report as a dead sensor -- the lock screen listens for 60 seconds after it appears, and a held press is what the matcher was measured on -- plus the untested question of stock Android's own fingerprints after using this. Verified on the dev phone (fp6 repo journal/fingerprint/, 2026-09-05): the registry 0.2.2 package enrols through Plasma's Users page and unlocks the lock screen; 0.2.3 differs by the dependency and a post-upgrade restart. The image build itself, with the fprintd purge inside the chroot, runs first in CI. --- .forgejo/workflows/build.yml | 6 ++-- README.md | 28 +++++++++++++++++-- build.sh | 53 ++++++++++++++++++++++++++---------- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index fa00071..ea5f6b0 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -50,8 +50,8 @@ jobs: # the FP6 patches to the next upstream version bump. Requires the # PACKAGE_TOKEN repo secret (catbot account, package:write scope); # skips quietly until it exists. 409 = same version already published. - # imsd is skipped: build.sh 3b took it FROM the registry (re-signed - # for the chroot), so it is not ours to publish. + # imsd and fingerprintd are skipped: build.sh 3b took them FROM the + # registry (re-signed for the chroot), so they are not ours to publish. - name: Publish packages to the apk registry env: PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} @@ -65,7 +65,7 @@ jobs: for f in /home/build/.local/var/pmbootstrap/packages/*/aarch64/*.apk; do [ -e "$f" ] || continue case "$(basename "$f")" in - imsd-*) echo "registry-sourced, not republished: $(basename "$f")"; continue ;; + imsd-*|fingerprintd-*) echo "registry-sourced, not republished: $(basename "$f")"; continue ;; esac found=1 code=$(curl -s -o /dev/null -w '%{http_code}' \ diff --git a/README.md b/README.md index fdd08a3..e94b37f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ Maintained by [Jorijn van der Graaf](https://catcrafts.net/about) Everything on the `combined-stable` branch: display, touch, wifi, cellular data, NFC (reader), speaker audio, microphone, IMU, magnetometer, barometer, -ambient light/proximity plus VoLTE calls (both directions) through imsd. +ambient light/proximity, fingerprint unlock through +[fingerprintd](https://forgejo.catcrafts.net/Catcrafts/fingerprintd), plus +VoLTE calls (both directions) through imsd. ## Flashing @@ -49,4 +51,26 @@ Find it in a stock-firmware capture or your carrier's IMS documentation, then `systemctl restart imsd` (the service is enabled at boot and waits for this file to exist). See the [imsd README](https://forgejo.catcrafts.net/Catcrafts/imsd) for the full -variable reference and carrier assumptions. \ No newline at end of file +variable reference and carrier assumptions. + +## Fingerprint + +Enrol under **Settings → Users** (Plasma's own fingerprint page; the `fprintd` +command-line tools are not installed, fingerprintd replaces that package). +**Hold** the finger on the sensor for each of the ~20 presses rather than +tapping it; a held press is what the matcher was measured on. + +Two things to know: + +- The lock screen listens for a finger for **60 seconds after it appears**. + A press after that reaches nothing and looks like a dead sensor. Lock and + unlock again to re-arm it. +- The matcher is the phone's own proprietary trustlet, reassembled from the + stock modem partition on first boot and never shipped by us. Templates are + stored on the Android `persist` partition, sealed to the same hardware + anti-rollback counter stock Android uses. Whether fingerprints enrolled under + stock Android survive a return to it after using this has **not** been + tested. + +A finger can also run something in your session on a match +(`~/.config/fingerprintd/fingers.conf`, see the fingerprintd README). diff --git a/build.sh b/build.sh index 64bd9f7..805c6a3 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,7 @@ #!/bin/sh -eu # fp6-img pipeline: build a flashable postmarketOS image for the Fairphone 6 -# with the Catcrafts kernel (milos-linux combined-stable) and imsd (VoLTE) +# with the Catcrafts kernel (milos-linux combined-stable), imsd (VoLTE) and +# fingerprintd (fingerprint unlock) # installed from the Catcrafts apk registry. # # Runs in CI inside an Alpine container on the privileged "pmos" runner @@ -30,6 +31,19 @@ IMSD_SHA256=" f1c317d7ff9448c05df068d683d31da08e4bfc074704d96cf52cb8acbdee6304 imsd-0.3.1-r0.apk a78ef31fc2943ac02e120c46df26353d515ac9840d959cf5193b2afe1c665fa6 imsd-systemd-0.3.1-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")" @@ -215,7 +229,7 @@ aports = $WORK/pmaports device = fairphone-fp6 ui = plasma-mobile systemd = always -extra_packages = soc-fairphone-fp6-audio,callaudioshim,imsd,fp6-device-tweaks,fp6-charging-mode,catcrafts-fp6-repo,postmarketos-base-ui-audio-backend-pipewire,pipewire-pulse,pipewire-echo-cancel +extra_packages = soc-fairphone-fp6-audio,callaudioshim,imsd,fingerprintd,fingerprintd-systemd,fingerprintd-agent,fp6-device-tweaks,fp6-charging-mode,catcrafts-fp6-repo,postmarketos-base-ui-audio-backend-pipewire,pipewire-pulse,pipewire-echo-cancel EOF # All four source tarballs are generated locally above, so every checksum @@ -244,9 +258,9 @@ retry "build modemmanager" pmbootstrap $NOCROSS build --arch aarch64 modemmanage # patched -r2 exists for the publish step even if the install set resolves # it before the overlay is considered. retry "build libcamera" pmbootstrap $NOCROSS build --arch aarch64 libcamera -# --- 3b. imsd: the published apk, not a local build -------------------------- -# The imsd repo's package CI is the only producer of the imsd apk; the image -# installs the exact registry package users later get via 'apk upgrade'. +# --- 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'. # 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 @@ -256,13 +270,21 @@ retry "build libcamera" pmbootstrap $NOCROSS build --arch aarch64 libcamera # 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. -IMSD_DL="$WORK/imsd-apk" -rm -rf "$IMSD_DL" -mkdir -p "$IMSD_DL" -for _f in "imsd-$IMSD_VERSION.apk" "imsd-systemd-$IMSD_VERSION.apk"; do - retry "fetch $_f" curl -fsSL -o "$IMSD_DL/$_f" "$IMSD_REGISTRY/aarch64/$_f" +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 "$IMSD_DL" && printf '%s\n' "$IMSD_SHA256" | grep . | sha256sum -c -) +(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 @@ -271,9 +293,9 @@ fi # abuild-keygen ran inside the chroot as pmbootstrap's user (uid 12345), so # the key is 0600 to that uid and unreadable here (run 49 died on exactly # this); sign from a private copy taken via sudo, then drop it. -KEYCOPY="$IMSD_DL/abuild-key.rsa" +KEYCOPY="$REG_DL/abuild-key.rsa" sudo install -m 0600 -o "$(id -un)" "$ABUILD_KEY" "$KEYCOPY" -for _f in "$IMSD_DL"/*.apk; do +for _f in "$REG_DL"/*.apk; do python3 ./apk-resign.py "$_f" "$KEYCOPY" "$(basename "$ABUILD_KEY").pub" done rm -f "$KEYCOPY" @@ -286,11 +308,11 @@ if [ ! -d "$PKGDIR" ]; then echo "$PKGDIR missing - the package builds above should have created it" >&2 exit 1 fi -for _f in "$IMSD_DL"/*.apk; do +for _f in "$REG_DL"/*.apk; do sudo install -m 0644 -o "$(stat -c %u "$PKGDIR")" -g "$(stat -c %g "$PKGDIR")" \ "$_f" "$PKGDIR/$(basename "$_f")" done -rm -f "$IMSD_DL"/*.apk +rm -f "$REG_DL"/*.apk pmbootstrap index # --- 4. build the image ------------------------------------------------------- @@ -323,6 +345,7 @@ cp README.md install.sh "$STAGE/fp6-img/" 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)" } > "$STAGE/fp6-img/build-info.txt" # sums of the extracted contents (cd "$STAGE/fp6-img" && sha256sum -- * > sha256sums.txt) From 7bf226e73b2bed74f502ee79c21f368a1d91334c Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 5 Sep 2026 22:26:06 +0200 Subject: [PATCH 12/19] fp6-vendor-blobs: the checksum the extractor change forgot, and a check that finds the next one in a second CI run 52 failed at minute 57 with `fp6-vendor-blobs-extract: FAILED` from abuild's checksum verification: 9575e55 changed the extractor and left its sha512sums entry alone. Nothing was installed or published; latest is the run-51 image. Fix the sum, and stop paying an hour to learn it. check-aports.sh sources every APKBUILD under aports/ and compares the committed sha512sums of its local source files (scripts, units, configs, patches, including ones in a subdirectory) against the files themselves. build.sh runs it before pmbootstrap touches anything, so this class of mistake now fails in the first seconds of a run and prints the line to paste. Aports whose sums build.sh regenerates with pmbootstrap checksum are read from build.sh and skipped, so the two lists cannot drift. Verified: the checker reports exactly the run-52 mismatch on the tree as pushed and nothing on the tree as fixed; a scratch copy with one corrupted sum is caught; the fixed aport builds under abuild in an alpine:edge container (the only complaint was the throwaway signing key at the index step, which the CI's pmbootstrap flow does not have). --- aports/device/fp6-vendor-blobs/APKBUILD | 2 +- build.sh | 5 +++ check-aports.sh | 55 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100755 check-aports.sh diff --git a/aports/device/fp6-vendor-blobs/APKBUILD b/aports/device/fp6-vendor-blobs/APKBUILD index 8dc7a5b..0a6d8c8 100644 --- a/aports/device/fp6-vendor-blobs/APKBUILD +++ b/aports/device/fp6-vendor-blobs/APKBUILD @@ -55,7 +55,7 @@ package() { } sha512sums=" -a71b2c86f980734d0aae6e135b26272fe52ae5603050bea52f55f7e74c8bd98c62b6194047711248d4fef40851792adc47d77c40016d99a0d68ecd2459894b80 fp6-vendor-blobs-extract +2caafdedf93e103516834a1f815dd828ecee66c82d569e4a925ccc6bd6ac75d6778290adb02db69538af3bb6ad36cee5c13c8afba2c722a4c4550efe761ba8a0 fp6-vendor-blobs-extract b4c290095d9f39515378dfef08de720ce49324210342aa13c131dfce1103785e796e6f821f0c659671a4c44b46f466ce0e03f11f216fdcdee2a99db5e7970800 fp6-vendor-blobs.service 9e79dd0aed13f11a71282aa24b2a26331e85c105e25ab0c0fed6189b8c300769a5f4308b18b91d9855868d658ad3a57c03e26c9b11bd27fd5e03f9a5decbbd6a fp6-vendor-blobs.preset " diff --git a/build.sh b/build.sh index 805c6a3..8da477b 100755 --- a/build.sh +++ b/build.sh @@ -47,6 +47,11 @@ FPD_SHA256=" PMAPORTS_REPO=https://gitlab.postmarketos.org/postmarketOS/pmaports.git cd "$(dirname "$0")" +# Fail in seconds, not at minute 57: a stale sha512sum in one of our own +# aports (run 52, fp6-vendor-blobs 1-r2) only surfaces when abuild reaches that +# aport, an hour into the run. This checks every aport's local source files +# against the committed sums before pmbootstrap does anything. +./check-aports.sh # pmbootstrap refuses to run as root: install deps, then re-exec as a build # user with passwordless sudo (pmbootstrap escalates itself where needed). diff --git a/check-aports.sh b/check-aports.sh new file mode 100755 index 0000000..018c671 --- /dev/null +++ b/check-aports.sh @@ -0,0 +1,55 @@ +#!/bin/sh -eu +# check-aports.sh - verify the committed sha512sums of every aport's LOCAL +# source files (scripts, units, configs, patches) against the files actually +# in the aport directory. +# +# Why this exists: CI run 52 (2026-09-05) died after 57 minutes, at the point +# abuild reached fp6-vendor-blobs, because the extractor had been changed and +# its sha512sum had not. abuild finds that only when it gets to that aport; +# this finds it in under a second, before pmbootstrap does anything. build.sh +# runs it first; run it by hand before pushing too. +# +# Out of scope, by design: sources fetched from a URL (abuild verifies those +# against the same sums after fetching), and the aports whose sums build.sh +# regenerates at build time with 'pmbootstrap checksum' (their tarballs are +# generated there and do not exist here) - that list is read from build.sh so +# the two cannot drift apart. +cd "$(dirname "$0")" +regen=$(sed -n 's/^pmbootstrap checksum \([a-z0-9-]*\)$/\1/p' build.sh | tr '\n' ' ') +rc=0 +for d in aports/*/*/; do + [ -f "$d/APKBUILD" ] || continue + d=${d%/} + case " $regen " in *" ${d##*/} "*) continue ;; esac + info=$(cd "$d" && sh -c '. ./APKBUILD; printf "%s\n" $source; printf "==\n"; printf "%s\n" "$sha512sums"' 2>/dev/null) || { + echo "$d: APKBUILD does not source cleanly" >&2; rc=1; continue + } + srcs=$(printf '%s\n' "$info" | sed '/^==$/,$d') + sums=$(printf '%s\n' "$info" | sed '1,/^==$/d') + for s in $srcs; do + case "$s" in + *://*) continue ;; # remote: abuild fetches and verifies + *::*) f=${s%%::*} ;; + *) f=${s##*/} ;; + esac + # a local source may sit in a subdirectory (rules/00_log_all.nft); + # the sums entry is keyed by its basename either way + path="$d/$s"; [ -f "$path" ] || path="$d/$f" + want=$(printf '%s\n' "$sums" | awk -v f="$f" '$2==f{print $1}') + [ "$want" = REPLACED_BY_CI ] && continue + if [ ! -f "$path" ]; then + echo "$d: local source '$s' is missing" >&2; rc=1; continue + fi + if [ -z "$want" ]; then + echo "$d: '$f' has no sha512sums entry" >&2; rc=1; continue + fi + have=$(sha512sum "$path" | awk '{print $1}') + if [ "$want" != "$have" ]; then + echo "$d: sha512 MISMATCH for '$f' (APKBUILD has ${want%"${want#????????????????}"}..., file is ${have%"${have#????????????????}"}...)" >&2 + echo "$d: fix: update the sha512sums entry to: $have $f" >&2 + rc=1 + fi + done +done +[ "$rc" = 0 ] && echo "check-aports: all local source checksums match" +exit $rc From 11eb0f1aa8d58ec9e87d80eadbafb0ea6006372e Mon Sep 17 00:00:00 2001 From: jorijnvdgraaf Date: Sun, 6 Sep 2026 14:00:13 +0000 Subject: [PATCH 13/19] Update README.md --- README.md | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e94b37f..02529ce 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ cd fp6-img inside Linux where the bootloader's NV-wipe cannot see it, then reads the modem NV back and reports the result. The bootloader never writes `boot`. -Default login: `user` / `147147` (same as official postmarketOS images — -change it). **Never re-lock the bootloader** with a custom image installed. +Default login: `user` / `147147` (same as official postmarketOS images). +**Never re-lock the bootloader** with a custom image installed. ## VoLTE configuration @@ -47,30 +47,8 @@ imsd is installed but needs your carrier's P-CSCF address: PCSCF= ``` -Find it in a stock-firmware capture or your carrier's IMS documentation, then +Find it via android adb using `adb shell dumpsys telephony.registry`, then `systemctl restart imsd` (the service is enabled at boot and waits for this file to exist). See the [imsd README](https://forgejo.catcrafts.net/Catcrafts/imsd) for the full variable reference and carrier assumptions. - -## Fingerprint - -Enrol under **Settings → Users** (Plasma's own fingerprint page; the `fprintd` -command-line tools are not installed, fingerprintd replaces that package). -**Hold** the finger on the sensor for each of the ~20 presses rather than -tapping it; a held press is what the matcher was measured on. - -Two things to know: - -- The lock screen listens for a finger for **60 seconds after it appears**. - A press after that reaches nothing and looks like a dead sensor. Lock and - unlock again to re-arm it. -- The matcher is the phone's own proprietary trustlet, reassembled from the - stock modem partition on first boot and never shipped by us. Templates are - stored on the Android `persist` partition, sealed to the same hardware - anti-rollback counter stock Android uses. Whether fingerprints enrolled under - stock Android survive a return to it after using this has **not** been - tested. - -A finger can also run something in your session on a match -(`~/.config/fingerprintd/fingers.conf`, see the fingerprintd README). From 60a29c25901114d53fe4e7d6e047b52354c9ed76 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 8 Sep 2026 20:00:51 +0200 Subject: [PATCH 14/19] imsd 0.3.3-r0: landline callers ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the registry's imsd 0.3.3-r0. The media leg plays AMR narrowband and G.711 as well as AMR-WB, so calls from landlines and other narrowband gateways ring instead of being refused with 488; a refused INVITE no longer flashes the dialer; the offer lists AMR-NB after AMR-WB; CODECS= overrides the codec set for bench work. 0.3.3 over 0.3.2: the media leg resolves the audio user once and thread-safely — a getpwnam race between the mic and playout threads could start both PipeWire helpers in /run/user/0 and answer a call into static. Verified on the dev phone 2026-09-08 with the registry apks: 0.3.2 exposed the race on an incoming call (found by this very check), 0.3.3 carries the fix; the code path had sixteen bench calls that evening (PCMA and AMR-NB in both directions, default AMR-WB unchanged). --- build.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/build.sh b/build.sh index 8da477b..a58ca9f 100755 --- a/build.sh +++ b/build.sh @@ -25,11 +25,16 @@ KERNEL_BRANCH=combined-stable # 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.1-r0 +IMSD_VERSION=0.3.3-r0 IMSD_SHA256=" -f1c317d7ff9448c05df068d683d31da08e4bfc074704d96cf52cb8acbdee6304 imsd-0.3.1-r0.apk -a78ef31fc2943ac02e120c46df26353d515ac9840d959cf5193b2afe1c665fa6 imsd-systemd-0.3.1-r0.apk +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. From 27f923a8170803c13328f33de176713f82406515 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 6 Sep 2026 00:02:19 +0200 Subject: [PATCH 15/19] kernel: build for the cores the phone has, pkgrel 102 The FP6 is 4x Cortex-A520 + 4x Cortex-A720 and this image boots nothing else, so pass the compiler KCFLAGS="-march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720". The stock arm64 build gives the compiler no arch flag at all (so one image can boot any Armv8 board). The string is what `gcc -march=native` derives on the phone, re-based on armv8.6-a so clang can express the same set (clang has no flagm2/rcpc2/ frintts tokens; it reaches them through the architecture level). 8.6 is the highest level whose mandatory set the phone exposes: 8.7 would assume WFxT and 9.x SVE2, and neither is in its hwcaps or ID registers. clang emits identical code for this and for -mcpu=cortex-a720+nosve+nomemtag; the difference is only which system features (SPE, ETE, TRBE, FPAC) it knows about, none of which generate code. Verified on eef717f978f1 with the aport config: builds with zero warnings, same module set as r100 (417), compat vDSO untouched, boots the dev phone with a clean dmesg and a GREEN selftest. Record: fp6 journal/base/captures/2026-09-05-mcpu-a720-verification.md. --- aports/device/linux-postmarketos-qcom-milos/APKBUILD | 9 ++++++++- aports/temp/libcamera/APKBUILD | 9 ++++++++- aports/temp/libqmi/APKBUILD | 8 +++++++- aports/temp/modemmanager/APKBUILD | 8 +++++++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/aports/device/linux-postmarketos-qcom-milos/APKBUILD b/aports/device/linux-postmarketos-qcom-milos/APKBUILD index c7c3224..fbe947c 100644 --- a/aports/device/linux-postmarketos-qcom-milos/APKBUILD +++ b/aports/device/linux-postmarketos-qcom-milos/APKBUILD @@ -8,7 +8,7 @@ _flavor="postmarketos-qcom-milos" pkgname=linux-$_flavor pkgver=7.2.0 # always sorts above the upstream aport (r0..r99) -pkgrel=101 +pkgrel=102 pkgdesc="Milos mainline kernel + Catcrafts FP6 bring-up carries (combined-stable)" arch="aarch64" _carch="arm64" @@ -57,7 +57,14 @@ prepare() { build() { unset LDFLAGS + # Build for the cores this image runs on: 4x Cortex-A520 + 4x Cortex-A720. + # The stock arm64 build gives the compiler no arch flag so one image boots + # any Armv8 board; this one boots the FP6. armv8.6-a is the highest level + # whose mandatory set the phone exposes (8.7 would assume WFxT, 9.x SVE2; + # neither is in its hwcaps); +fp16fml+aes+sha3+sm4 are the optional + # extensions it has, the same set `gcc -march=native` derives on the phone. make ARCH="$_carch" LLVM=1 \ + KCFLAGS="-march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720" \ KBUILD_BUILD_VERSION="$((pkgrel + 1 ))-$_flavor" } diff --git a/aports/temp/libcamera/APKBUILD b/aports/temp/libcamera/APKBUILD index adb297d..f7785b2 100644 --- a/aports/temp/libcamera/APKBUILD +++ b/aports/temp/libcamera/APKBUILD @@ -3,7 +3,7 @@ maintainer="Robert Mader " pkgname=libcamera _pkgver=0.7.2 pkgver=9999$_pkgver -pkgrel=9 +pkgrel=10 pkgdesc="Linux camera framework" url="https://libcamera.org/" arch="all" @@ -83,6 +83,13 @@ builddir="$srcdir/$pkgname-v$_pkgver" # gstreamer tests fail # manual strip because ipa .sign files depend on the file contents- have to re-sign after strip options="!strip !check" +# fp6-img: this package only ever runs on the FP6 (4x Cortex-A520 + 4x +# Cortex-A720), so tune for it. Spelled -march/-mtune, not -mcpu: GCC 16 +# expands -mcpu=cortex-a720 into a +sve-bitperm token that binutils 2.45 +# rejects at the assembler. +nosve+nomemtag: the FP6 exposes neither SVE nor +# MTE (no sve/mte hwcaps), and without them the code would SIGILL. +export CFLAGS="$CFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" +export CXXFLAGS="$CXXFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" case "$CARCH" in arm*|aarch64) diff --git a/aports/temp/libqmi/APKBUILD b/aports/temp/libqmi/APKBUILD index 317b7c8..520c5d9 100644 --- a/aports/temp/libqmi/APKBUILD +++ b/aports/temp/libqmi/APKBUILD @@ -7,7 +7,7 @@ maintainer="Achill Gilgenast " pkgname=libqmi pkgver=1.39.1_git20260808 _commit=30f3e998e6cbda364ac1bc73223de20561e6d555 -pkgrel=100 +pkgrel=101 pkgdesc="QMI modem protocol helper library" url="https://www.freedesktop.org/wiki/Software/libqmi" arch="all" @@ -27,6 +27,12 @@ makedepends=" # fp6-img: crossdirect breaks meson compiler introspection (cc1 spawn # failure), same as modemmanager; build under plain qemu. options="!pmb:crossdirect" +# fp6-img: this package only ever runs on the FP6 (4x Cortex-A520 + 4x +# Cortex-A720), so tune for it. Spelled -march/-mtune, not -mcpu: GCC 16 +# expands -mcpu=cortex-a720 into a +sve-bitperm token that binutils 2.45 +# rejects at the assembler. +nosve+nomemtag: the FP6 exposes neither SVE nor +# MTE (no sve/mte hwcaps), and without them the code would SIGILL. +export CFLAGS="$CFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" subpackages=" $pkgname-dev $pkgname-doc diff --git a/aports/temp/modemmanager/APKBUILD b/aports/temp/modemmanager/APKBUILD index 95d537e..0891e6b 100644 --- a/aports/temp/modemmanager/APKBUILD +++ b/aports/temp/modemmanager/APKBUILD @@ -5,7 +5,7 @@ maintainer="Achill Gilgenast " pkgname=modemmanager pkgver=1.25.95_git20260709 -pkgrel=100 +pkgrel=101 _commit=d776ea38d29ca472a12323c1d45002ee19a66f57 pkgdesc="ModemManager library" url="https://www.freedesktop.org/wiki/Software/ModemManager" @@ -42,6 +42,12 @@ checkdepends="glib-dev py3-gobject3 py3-dbus" # native builders, and none exercise the GNSS code our patches change) - # on-device validation is the real test. options="!pmb:crossdirect !check" +# fp6-img: this package only ever runs on the FP6 (4x Cortex-A520 + 4x +# Cortex-A720), so tune for it. Spelled -march/-mtune, not -mcpu: GCC 16 +# expands -mcpu=cortex-a720 into a +sve-bitperm token that binutils 2.45 +# rejects at the assembler. +nosve+nomemtag: the FP6 exposes neither SVE nor +# MTE (no sve/mte hwcaps), and without them the code would SIGILL. +export CFLAGS="$CFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" subpackages=" $pkgname-lang $pkgname-doc From 34c687d160dd7c8f31fbf4fe0d26cc57b618bccc Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 6 Sep 2026 00:02:19 +0200 Subject: [PATCH 16/19] libcamera, libqmi, modemmanager: build for the cores the phone has Same string as the kernel commit, for the three compiled aports this image builds itself: -march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720 appended to CFLAGS (and CXXFLAGS for libcamera). abuild builds these with Alpine's GCC; the string is gcc's own -march=native expansion on the phone re-based on armv8.6-a, and it assembles on GCC 15 with binutils 2.45.1 and on GCC 16 (whose -mcpu=cortex-a720 expansion binutils 2.45 rejects). On the phone itself gcc -flto, g++ and clang all compile, link and run it. The base stops at 8.6 on purpose: this phone exposes neither SVE nor MTE (userspace autovectorised for SVE2 would SIGILL), and not WFxT either. pkgrel bumps so apk upgrade delivers the rebuilt binaries. --- aports/temp/libcamera/APKBUILD | 13 +++++++------ aports/temp/libqmi/APKBUILD | 11 ++++++----- aports/temp/modemmanager/APKBUILD | 11 ++++++----- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/aports/temp/libcamera/APKBUILD b/aports/temp/libcamera/APKBUILD index f7785b2..8e25f13 100644 --- a/aports/temp/libcamera/APKBUILD +++ b/aports/temp/libcamera/APKBUILD @@ -84,12 +84,13 @@ builddir="$srcdir/$pkgname-v$_pkgver" # manual strip because ipa .sign files depend on the file contents- have to re-sign after strip options="!strip !check" # fp6-img: this package only ever runs on the FP6 (4x Cortex-A520 + 4x -# Cortex-A720), so tune for it. Spelled -march/-mtune, not -mcpu: GCC 16 -# expands -mcpu=cortex-a720 into a +sve-bitperm token that binutils 2.45 -# rejects at the assembler. +nosve+nomemtag: the FP6 exposes neither SVE nor -# MTE (no sve/mte hwcaps), and without them the code would SIGILL. -export CFLAGS="$CFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" -export CXXFLAGS="$CXXFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" +# Cortex-A720), so build for it. armv8.6-a is the highest level whose +# mandatory set the phone exposes (8.7 would assume WFxT, 9.x SVE2; neither +# is in its hwcaps); +fp16fml+aes+sha3+sm4 are the optional extensions it +# has, the same set `gcc -march=native` derives on the phone. One spelling +# for gcc and clang, and the same string the kernel aport uses. +export CFLAGS="$CFLAGS -march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720" +export CXXFLAGS="$CXXFLAGS -march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720" case "$CARCH" in arm*|aarch64) diff --git a/aports/temp/libqmi/APKBUILD b/aports/temp/libqmi/APKBUILD index 520c5d9..bb04704 100644 --- a/aports/temp/libqmi/APKBUILD +++ b/aports/temp/libqmi/APKBUILD @@ -28,11 +28,12 @@ makedepends=" # failure), same as modemmanager; build under plain qemu. options="!pmb:crossdirect" # fp6-img: this package only ever runs on the FP6 (4x Cortex-A520 + 4x -# Cortex-A720), so tune for it. Spelled -march/-mtune, not -mcpu: GCC 16 -# expands -mcpu=cortex-a720 into a +sve-bitperm token that binutils 2.45 -# rejects at the assembler. +nosve+nomemtag: the FP6 exposes neither SVE nor -# MTE (no sve/mte hwcaps), and without them the code would SIGILL. -export CFLAGS="$CFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" +# Cortex-A720), so build for it. armv8.6-a is the highest level whose +# mandatory set the phone exposes (8.7 would assume WFxT, 9.x SVE2; neither +# is in its hwcaps); +fp16fml+aes+sha3+sm4 are the optional extensions it +# has, the same set `gcc -march=native` derives on the phone. One spelling +# for gcc and clang, and the same string the kernel aport uses. +export CFLAGS="$CFLAGS -march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720" subpackages=" $pkgname-dev $pkgname-doc diff --git a/aports/temp/modemmanager/APKBUILD b/aports/temp/modemmanager/APKBUILD index 0891e6b..569d1e6 100644 --- a/aports/temp/modemmanager/APKBUILD +++ b/aports/temp/modemmanager/APKBUILD @@ -43,11 +43,12 @@ checkdepends="glib-dev py3-gobject3 py3-dbus" # on-device validation is the real test. options="!pmb:crossdirect !check" # fp6-img: this package only ever runs on the FP6 (4x Cortex-A520 + 4x -# Cortex-A720), so tune for it. Spelled -march/-mtune, not -mcpu: GCC 16 -# expands -mcpu=cortex-a720 into a +sve-bitperm token that binutils 2.45 -# rejects at the assembler. +nosve+nomemtag: the FP6 exposes neither SVE nor -# MTE (no sve/mte hwcaps), and without them the code would SIGILL. -export CFLAGS="$CFLAGS -march=armv9.2-a+nosve+nomemtag -mtune=cortex-a720" +# Cortex-A720), so build for it. armv8.6-a is the highest level whose +# mandatory set the phone exposes (8.7 would assume WFxT, 9.x SVE2; neither +# is in its hwcaps); +fp16fml+aes+sha3+sm4 are the optional extensions it +# has, the same set `gcc -march=native` derives on the phone. One spelling +# for gcc and clang, and the same string the kernel aport uses. +export CFLAGS="$CFLAGS -march=armv8.6-a+fp16fml+aes+sha3+sm4 -mtune=cortex-a720" subpackages=" $pkgname-lang $pkgname-doc From ed18cce7d0d2627057c8bd81b2c488d2a292d7b2 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 11 Sep 2026 13:03:22 +0200 Subject: [PATCH 17/19] 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:03:22 +0200 Subject: [PATCH 18/19] 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 19/19] 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:])