diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 60f5d86..ea5f6b0 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 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 }} @@ -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-*|fingerprintd-*) 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/README.md b/README.md index c42aa32..02529ce 100644 --- a/README.md +++ b/README.md @@ -14,10 +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. - -Verified on exactly one device / one carrier (KPN NL). Reports from other -carriers are very welcome. +ambient light/proximity, fingerprint unlock through +[fingerprintd](https://forgejo.catcrafts.net/Catcrafts/fingerprintd), plus +VoLTE calls (both directions) through imsd. ## Flashing @@ -36,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 @@ -48,27 +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. - -## 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 diff --git a/apk-resign.py b/apk-resign.py new file mode 100755 index 0000000..af386fe --- /dev/null +++ b/apk-resign.py @@ -0,0 +1,84 @@ +#!/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 os +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}") + 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}") + 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/aports/device/fp6-vendor-blobs/APKBUILD b/aports/device/fp6-vendor-blobs/APKBUILD index b1e6d74..a03b09b 100644 --- a/aports/device/fp6-vendor-blobs/APKBUILD +++ b/aports/device/fp6-vendor-blobs/APKBUILD @@ -10,21 +10,30 @@ # 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=0 +pkgrel=3 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 fp6-vendor-blobs.service + fp6-vendor-blobs.preset " package() { @@ -34,14 +43,26 @@ 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 + # 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 +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 4239697..7f5b940 100644 --- a/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract +++ b/aports/device/fp6-vendor-blobs/fp6-vendor-blobs-extract @@ -10,8 +10,15 @@ # processed in sorted order; '#' comments and blank lines ignored: # # file +# 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 @@ -20,6 +27,26 @@ # 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. 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 @@ -37,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 @@ -44,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: $*"; } @@ -103,9 +141,125 @@ 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 +# 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 + # Structure first, before anything is written: an ELF64 header whose + # program header table fits in the .mdt, and for every segment with + # contents a .b0N file of exactly the declared size at a page-aligned + # offset. This is the whole verification when the manifest pins no hash; + # the loader's own signature check does the rest. + [ "$(hex4 "$mdt")" = 7f454c46 ] || { log "$mname.mdt: not an ELF image"; return 1; } + [ "$(u8 "$mdt" 4)" = 2 ] || { log "$mname.mdt: not ELF64"; return 1; } + phoff=$(u64 "$mdt" 32) phentsize=$(u16 "$mdt" 54) phnum=$(u16 "$mdt" 56) + [ -n "$phoff" ] && [ -n "$phentsize" ] && [ -n "$phnum" ] || return 1 + [ "$phentsize" -eq 56 ] 2>/dev/null || { log "$mname.mdt: phentsize $phentsize"; return 1; } + [ "$phnum" -gt 0 ] 2>/dev/null && [ "$phnum" -le 64 ] || { log "$mname.mdt: phnum $phnum"; return 1; } + [ "$(fsize "$mdt")" -ge $((phoff + phnum * phentsize)) ] || { log "$mname.mdt: shorter than its program header table"; return 1; } + + # 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))) + seg=$(printf '%s/%s.b%02d' "$mdir" "$mname" "$i") + [ -f "$seg" ] || { log "$mname: segment $i missing"; return 1; } + [ "$(fsize "$seg")" -eq "$pfsz" ] || { log "$mname: segment $i is $(fsize "$seg") bytes, header says $pfsz"; return 1; } + # dd seeks in whole blocks, which is only correct because + # every p_offset in these images is page aligned. Refuse + # rather than silently misplace a segment if that changes. + [ $((poff % 4096)) -eq 0 ] || { log "$mname: segment $i offset $poff is not page aligned"; return 1; } + [ $((poff + pfsz)) -gt "$total" ] && total=$((poff + pfsz)) + fi + i=$((i + 1)) + done + [ "$total" -gt 0 ] || { log "$mname: no segment has contents"; return 1; } + # an order of magnitude above any TA; a garbage p_offset would otherwise + # make a sparse multi-GiB file that then gets hashed + [ "$total" -le $((64 * 1024 * 1024)) ] || { log "$mname: image would be $total bytes"; return 1; } + : > "$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") + 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 $(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.$$" + 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 [ "$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 (sha256 $got)" + return 0 + done + kept= + [ -e "$dest" ] && kept=" (the existing file is left in place)" + if [ "$want" = - ]; then + fail "no listed partition ($parts) yields a well-formed $rname - $dest NOT installed$kept" + fi + fail "no listed partition ($parts) yields $rname with sha256 $want - $dest NOT installed$kept" +} + 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.$$" @@ -144,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 @@ -155,17 +316,21 @@ 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" ] && [ -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 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 +339,12 @@ for f in "$MANIFEST_DIR"/*.manifest; do extract "$a" "$b" "$c" "$d" /dev/null || : +exec /usr/lib/fp6-vendor-blobs/extract --if-device 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 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/linux-postmarketos-qcom-milos/APKBUILD b/aports/device/linux-postmarketos-qcom-milos/APKBUILD index 94fde64..6e3c06f 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=103 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/device/linux-postmarketos-qcom-milos/config-postmarketos-qcom-milos.aarch64 b/aports/device/linux-postmarketos-qcom-milos/config-postmarketos-qcom-milos.aarch64 index 9c26c1c..4ae9b46 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 @@ -1789,6 +1789,7 @@ CONFIG_ARM_SCMI_TRANSPORT_OPTEE=y # EFI (Extensible Firmware Interface) Support # CONFIG_EFI_ESRT=y +# CONFIG_EFI_VARS_PSTORE is not set CONFIG_EFI_PARAMS_FROM_FDT=y CONFIG_EFI_RUNTIME_WRAPPERS=y CONFIG_EFI_GENERIC_STUB=y @@ -7224,7 +7225,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 @@ -7449,7 +7450,14 @@ CONFIG_SQUASHFS_FRAGMENT_CACHE_SIZE=3 # CONFIG_QNX4FS_FS is not set # CONFIG_QNX6FS_FS is not set # CONFIG_ROMFS_FS is not set -# CONFIG_PSTORE is not set +CONFIG_PSTORE=y +CONFIG_PSTORE_DEFAULT_KMSG_BYTES=10240 +CONFIG_PSTORE_COMPRESS=y +CONFIG_PSTORE_CONSOLE=y +CONFIG_PSTORE_PMSG=y +# CONFIG_PSTORE_FTRACE is not set +CONFIG_PSTORE_RAM=y +# CONFIG_PSTORE_BLK is not set # CONFIG_UFS_FS is not set CONFIG_EROFS_FS=m # CONFIG_EROFS_FS_DEBUG is not set @@ -7963,6 +7971,9 @@ CONFIG_XZ_DEC_BCJ=y CONFIG_DECOMPRESS_GZIP=y CONFIG_DECOMPRESS_ZSTD=y CONFIG_GENERIC_ALLOCATOR=y +CONFIG_REED_SOLOMON=y +CONFIG_REED_SOLOMON_ENC8=y +CONFIG_REED_SOLOMON_DEC8=y CONFIG_INTERVAL_TREE=y CONFIG_XARRAY_MULTI=y CONFIG_ASSOCIATIVE_ARRAY=y 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 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/aports/temp/libcamera/APKBUILD b/aports/temp/libcamera/APKBUILD index adb297d..8e25f13 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,14 @@ 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 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 317b7c8..bb04704 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,13 @@ 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 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 95d537e..569d1e6 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,13 @@ 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 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 diff --git a/build.sh b/build.sh index 9c5cd0b..82ae48e 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,8 @@ #!/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), imsd (VoLTE) and +# fingerprintd (fingerprint unlock) +# 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 +18,26 @@ 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 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")" +# 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). @@ -35,7 +49,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 @@ -135,7 +149,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/" -cp -r aports/modem/imsd "$WORK/pmaports/modem/" +# 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/" @@ -161,16 +175,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. - -clone_retry "$WORK/imsd-src" -q "$IMSD_REPO" -git -C "$WORK/imsd-src" checkout -q "$IMSD_COMMIT" -IAPORT="$WORK/pmaports/modem/imsd" -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 @@ -215,15 +220,14 @@ 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 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 @@ -245,12 +249,60 @@ 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 + fingerprintd: the published apks, not local builds ----------- +# Each repo's package CI is the only producer of its apk; the image installs +# the newest registry package, the one users get via 'apk upgrade'. +# pmbootstrap has no knob for a third-party repository, and after the main +# 'apk add' it re-adds every package found in its local packages dir BY FILE +# PATH — which makes apk verify the package's own signature, and registry +# packages are signed with per-run keys nobody keeps (phones trust the +# registry-signed index instead). So: fetch the newest of each, verified the +# way a phone verifies them (registry-fetch.py: index signature against the +# shipped key, control checksum and data hash against the index), re-sign +# the envelope with this run's abuild key (control and data streams stay +# byte-identical, so the identity checksum equals the registry's), drop into +# the local packages dir, re-index. The abuild key exists because the builds +# above initialized the buildroot. Each group's first name decides the +# version; its subpackages must exist at that same version. +REG_DL="$WORK/registry-apks" +rm -rf "$REG_DL" +mkdir -p "$REG_DL" +REGISTRY_KEY=$(echo aports/device/catcrafts-fp6-repo/*.rsa.pub) +if [ ! -f "$REGISTRY_KEY" ]; then + echo "expected exactly one registry key in aports/device/catcrafts-fp6-repo" >&2 + 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 + 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="$REG_DL/abuild-key.rsa" +sudo install -m 0600 -o "$(id -un)" "$ABUILD_KEY" "$KEYCOPY" +for _f in "$REG_DL"/*.apk; do + python3 ./apk-resign.py "$_f" "$KEYCOPY" "$(basename "$ABUILD_KEY").pub" +done +rm -f "$KEYCOPY" +# 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 "$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 "$REG_DL"/*.apk +pmbootstrap index # --- 4. build the image ------------------------------------------------------- # Same default credentials as the official postmarketOS images. @@ -281,7 +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_REPO @ $IMSD_COMMIT (0.3.0)" + 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/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 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 --------------------------------------------------- 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:])