Compare commits

...
Author SHA1 Message Date
fa28b20df7 Merge branch 'main' of https://forgejo.catcrafts.net/Catcrafts/fp6-img
All checks were successful
image / image (push) Successful in 1h28m5s
2026-09-11 13:26:56 +02:00
e21a2dd0ec build.sh: install the newest registry imsd and fingerprintd, verified the way a phone does
Some checks failed
image / image (push) Has been cancelled
Until now both were pinned here by version and sha256, so every release
of either needed a commit and an image run, in the right order
(fingerprintd 0.2.4 would have taken: push, wait for the registry, pin,
push). The pin gated fresh installs only: every installed phone already
takes the newest registry package on 'apk upgrade'.

registry-fetch.py resolves the newest version of each group (imsd + its
systemd unit; fingerprintd + systemd + agent, the subpackages at the
anchor's version, or it fails) and verifies the way apk does on the
phone: the index signature against the key catcrafts-fp6-repo ships --
the signer's name must be that key's too -- each apk's control checksum
against the index, and its data segment against the control's datahash.
Anything that fails is not written. The resolved versions and sha256s go
into the release's build-info.txt, so an image still names its exact
packages.

Tested against the live registry: it resolves imsd 0.3.3-r0 and
fingerprintd 0.2.3-r0 with sha256s identical to the five pins this
removes; a wrong key, a key of another name, a tampered control segment,
a corrupt or swapped data segment, a truncated file and a missing
subpackage are each refused with a reason.
2026-09-11 13:21:18 +02:00
e57149f496 fp6-vendor-blobs 1-r3: extract from the active slot, and stop pinning a hash on a signed image
Two field units got no fingerprint sensor from fingerprintd 0.2.3's
manifest: its focal64 line pinned the sha256 of one Android build's
trustlet (16.82.0, the dev phone's), and Fairphone re-signs that trustlet
every release, so the pin matches exactly one of the six builds seen. A
user on 16.100.0 had to edit the manifest by hand; another ended up with
a file QTEE refuses.

The extractor now tries the active slot's partitions first
(androidboot.slot_suffix from the kernel command line): for a signed
image only the running TZ's own slot is guaranteed to load. An mbn line
may give '-' instead of a hash, which means structural verification
only: ELF64 header, every segment present at the size its program header
declares, page-aligned offsets, a sane total. The loader in TZ verifies
the signature and the per-segment hashes itself and refuses a damaged or
foreign image (one flipped byte -> ERROR_ELF_SIGNATURE_ERROR, measured),
so the whole-image hash added fragility and no protection. A real sha256
is still honoured, and the sha256 of what was installed is logged either
way.

--refresh re-derives mbn dests even when a file exists, replacing it
only with an image that verifies; consumers call it from
post-install/post-upgrade so a fresh 'apk add' needs no reboot and a
hand-placed or wrongly pinned trustlet is replaced on the next upgrade.

Verified on the dev phone (busybox): malformed inputs are refused with a
reason (missing, truncated or oversize segment; non-ELF, ELF32 or short
mdt; a garbage offset), both slots reassemble to the known-good hash, a
foreign file survives a plain run and is replaced by --refresh, a failed
refresh keeps the old file, pins still work, and the real post-upgrade
path re-derived the installed trustlet with the daemon restarting on it.
Record: fp6 repo journal/blobs/ and journal/fingerprint/, 2026-09-11.
2026-09-11 13:10:00 +02:00
ed18cce7d0 fp6-vendor-blobs 1-r3: extract from the active slot, and stop pinning a hash on a signed image
Two field units got no fingerprint sensor from fingerprintd 0.2.3's
manifest: its focal64 line pinned the sha256 of one Android build's
trustlet (16.82.0, the dev phone's), and Fairphone re-signs that trustlet
every release, so the pin matches exactly one of the six builds seen. A
user on 16.100.0 had to edit the manifest by hand; another ended up with
a file QTEE refuses.

The extractor now tries the active slot's partitions first
(androidboot.slot_suffix from the kernel command line): for a signed
image only the running TZ's own slot is guaranteed to load. An mbn line
may give '-' instead of a hash, which means structural verification
only: ELF64 header, every segment present at the size its program header
declares, page-aligned offsets, a sane total. The loader in TZ verifies
the signature and the per-segment hashes itself and refuses a damaged or
foreign image (one flipped byte -> ERROR_ELF_SIGNATURE_ERROR, measured),
so the whole-image hash added fragility and no protection. A real sha256
is still honoured, and the sha256 of what was installed is logged either
way.

--refresh re-derives mbn dests even when a file exists, replacing it
only with an image that verifies; consumers call it from
post-install/post-upgrade so a fresh 'apk add' needs no reboot and a
hand-placed or wrongly pinned trustlet is replaced on the next upgrade.

Verified on the dev phone (busybox): malformed inputs are refused with a
reason (missing, truncated or oversize segment; non-ELF, ELF32 or short
mdt; a garbage offset), both slots reassemble to the known-good hash, a
foreign file survives a plain run and is replaced by --refresh, a failed
refresh keeps the old file, pins still work, and the real post-upgrade
path re-derived the installed trustlet with the daemon restarting on it.
Record: fp6 repo journal/blobs/ and journal/fingerprint/, 2026-09-11.
2026-09-11 13:03:22 +02:00
34c687d160 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.
2026-09-08 20:24:15 +02:00
27f923a817 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.
2026-09-08 20:24:15 +02:00
60a29c2590 imsd 0.3.3-r0: landline callers ring
All checks were successful
image / image (push) Successful in 1h34m23s
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=<list>
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).
2026-09-08 20:24:07 +02:00
11eb0f1aa8 Update README.md
All checks were successful
image / image (push) Successful in 1h55m43s
2026-09-06 14:00:13 +00:00
7bf226e73b fp6-vendor-blobs: the checksum the extractor change forgot, and a check that finds the next one in a second
All checks were successful
image / image (push) Successful in 1h53m7s
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).
2026-09-05 22:26:33 +02:00
2a4427919e Install fingerprintd from the registry, so the image unlocks with a finger
Some checks failed
image / image (push) Failing after 57m23s
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.
2026-09-05 20:31:37 +02:00
155b427478 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.
2026-09-05 04:25:51 +02:00
9575e5517d 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.
2026-09-05 03:56:13 +02:00
57517217e4 imsd: place the re-signed apks with the packages dir's owner
All checks were successful
image / image (push) Successful in 1h45m31s
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.
2026-09-02 19:35:20 +02:00
877fe92a4b imsd: sign from a readable copy of the abuild key
Some checks failed
image / image (push) Failing after 2h3m26s
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.
2026-09-02 17:21:30 +02:00
e7a1825b50 imsd: install the published apk instead of building it
Some checks failed
image / image (push) Failing after 48m0s
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.
2026-09-02 16:11:50 +02:00
6d32d10a8a imsd 0.3.1: aport ownership moves to the imsd repo
All checks were successful
image / image (push) Successful in 1h48m1s
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 <noreply@anthropic.com>
2026-09-01 18:26:33 +02:00
1cb5099add aports: extract vendor blobs before udev coldplug so first-boot audio fully works
All checks were successful
image / image (push) Successful in 1h39m0s
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
2026-08-29 21:53:02 +02:00
7757449452 debian fix
All checks were successful
image / image (push) Successful in 1h35m22s
2026-08-29 00:40:38 +02:00
d1a6739405 readme change
Some checks failed
image / image (push) Has been cancelled
2026-08-28 00:59:45 +02:00
Jorijn van der Graaf
6ecc1dc87b 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
2026-08-28 00:57:56 +02:00
22 changed files with 713 additions and 162 deletions

View file

@ -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" \

View file

@ -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=<your carrier's P-CSCF IPv6 address>
```
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.

84
apk-resign.py Executable file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Re-sign an apk (v2 format) with another RSA key, in place.
apk-resign.py <pkg.apk> <private key .rsa> <public key file name>
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.<RSA|RSA256|RSA512>.<key>
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:])

View file

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

View file

@ -10,8 +10,15 @@
# processed in sorted order; '#' comments and blank lines ignored:
#
# file <partition[,partition...]> <path-in-partition> <dest> <sha256>
# mbn <partition[,partition...]> <dir-in-partition> <name> <dest> <sha256|->
# rebind <bus> <device>
#
# Partition lists are tried in the order written, except that on an A/B
# device the ACTIVE slot's partitions (androidboot.slot_suffix in
# /proc/cmdline) come first: the other slot may hold a different Android
# build, and for a signed image only the active slot's copy is guaranteed to
# match the TZ that is running.
#
# file: mount the first available listed partition READ-ONLY (ext4 also
# gets -o noload - never a byte written to the stock partitions, not
# even a journal replay), copy <path-in-partition> to <dest>, verify the
@ -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 (<name>.mdt) plus one payload per program header (<name>.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 <device> on <bus> 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' </proc/cmdline 2>/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() { # <partition,...>
first= rest=
for p in $(echo "$1" | tr ',' ' '); do
if [ -n "$ACTIVE_SLOT" ] && [ "${p%_$ACTIVE_SLOT}" != "$p" ]; then
first="$first $p"
else
rest="$rest $p"
fi
done
echo "$first $rest"
}
# Little-endian scalars out of an ELF header. aarch64 is little endian and so
# is the image, so od's host order is the right one.
u64() { od -An -tu8 -j "$2" -N 8 "$1" | tr -d ' '; }
u16() { od -An -tu2 -j "$2" -N 2 "$1" | tr -d ' '; }
u8() { od -An -tu1 -j "$2" -N 1 "$1" | tr -d ' '; }
hex4() { od -An -tx1 -N 4 "$1" | tr -d ' \n'; }
fsize() { stat -c %s "$1"; }
# Reassemble <dir>/<name>.mdt + .b0N into a flat image at <out>. Mirrors
# utilities/ta-analysis/reassemble.py in the fp6 bring-up repo, which is where
# 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() { # <dir> <name> <out>
mdir=$1 mname=$2 mout=$3
mdt="$mdir/$mname.mdt"
[ -f "$mdt" ] || return 1
# Structure first, before anything is written: an ELF64 header whose
# program header table fits in the .mdt, and for every segment with
# contents a .b0N file of exactly the declared size at a page-aligned
# offset. This is the whole verification when the manifest pins no hash;
# the loader's own signature check does the rest.
[ "$(hex4 "$mdt")" = 7f454c46 ] || { log "$mname.mdt: not an ELF image"; return 1; }
[ "$(u8 "$mdt" 4)" = 2 ] || { log "$mname.mdt: not ELF64"; return 1; }
phoff=$(u64 "$mdt" 32) phentsize=$(u16 "$mdt" 54) phnum=$(u16 "$mdt" 56)
[ -n "$phoff" ] && [ -n "$phentsize" ] && [ -n "$phnum" ] || return 1
[ "$phentsize" -eq 56 ] 2>/dev/null || { log "$mname.mdt: phentsize $phentsize"; return 1; }
[ "$phnum" -gt 0 ] 2>/dev/null && [ "$phnum" -le 64 ] || { log "$mname.mdt: phnum $phnum"; return 1; }
[ "$(fsize "$mdt")" -ge $((phoff + phnum * phentsize)) ] || { log "$mname.mdt: shorter than its program header table"; return 1; }
# 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() { # <partition,...> <dir-in-partition> <name> <dest> <sha256|->
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() { # <partition,...> <path-in-partition> <dest> <sha256>
parts=$1 src=$2 dest=$3 want=$4
for part in $(echo "$parts" | tr ',' ' '); do
for part in $(order_parts "$parts"); do
mount_part "$part" || { log "$part: not mountable, trying next"; continue; }
[ -f "$MNT/$src" ] || { log "$part: no $src, trying next"; continue; }
tmp="$dest.fp6-extract.$$"
@ -144,7 +298,14 @@ rebind_all() { # <bus/device ...>
done
}
if [ "${1:-}" = --if-device ] && [ ! -b "$SUPER" ]; then
for arg in "$@"; do
case "$arg" in
--if-device) IF_DEVICE=1 ;;
--refresh) REFRESH=1 ;;
*) fail "unknown option '$arg'" ;;
esac
done
if [ -n "$IF_DEVICE" ] && [ ! -b "$SUPER" ]; then
log "no stock super partition visible (build chroot?), nothing to do"
exit 0
fi
@ -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
extracted=1
;;
mbn)
[ -n "$e" ] || fail "$f: malformed mbn line"
[ -e "$d" ] && [ -z "$REFRESH" ] && continue
extract_mbn "$a" "$b" "$c" "$d" "$e" </dev/null
extracted=1
;;
rebind)
[ -n "$b" ] || fail "$f: malformed rebind line"
rebinds="$rebinds $a/$b"

View file

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

View file

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

View file

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

View file

@ -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=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"
}

View file

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

View file

@ -11,7 +11,7 @@
maintainer="Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>"
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

View file

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

View file

@ -1 +0,0 @@
enable imsd.service

View file

@ -1,74 +0,0 @@
maintainer="Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>"
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
"

View file

@ -1,2 +0,0 @@
[Desktop Entry]
Hidden=true

View file

@ -3,7 +3,7 @@ maintainer="Robert Mader <robert.mader@collabora.com>"
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)

View file

@ -7,7 +7,7 @@ maintainer="Achill Gilgenast <achill@achill.org>"
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

View file

@ -5,7 +5,7 @@
maintainer="Achill Gilgenast <achill@achill.org>"
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

108
build.sh
View file

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

55
check-aports.sh Executable file
View file

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

View file

@ -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 <iface> 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 ---------------------------------------------------

185
registry-fetch.py Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Fetch the newest published versions of our registry packages, verified.
registry-fetch.py <registry-url> <trusted-key.rsa.pub> <dest-dir> <group>...
<registry-url> is the Alpine repository root the phones carry in
/etc/apk/repositories (.../alpine/edge/fp6); <trusted-key.rsa.pub> is the key
they carry in /etc/apk/keys (aports/device/catcrafts-fp6-repo/); a <group> is
a comma-separated list of package names whose FIRST member decides the
version: "imsd,imsd-systemd" fetches the newest imsd and the imsd-systemd of
that same version, and fails if the registry lacks it.
Verification mirrors apk's own, so the image trusts exactly what an installed
phone trusts: the index signature (.SIGN.RSA*.<key>, over the index's
compressed tar) against the trusted key, and the signing key's NAME against
the trusted key's; each package's control segment against the index's C:
checksum ("Q1" + base64 sha1); each data segment against the control
segment's datahash (sha256). A package that fails any step is not written.
Prints one "name version sha256" line per apk and writes the same lines to
<dest-dir>/manifest.
Version order: apk's rules for the shapes our own packages use
(X.Y.Z[-rN], numeric components); a suffix like _git is compared as text.
"""
import base64
import gzip
import hashlib
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.request
import zlib
DIGEST = {"RSA": "sha1", "RSA256": "sha256", "RSA512": "sha512"}
def die(msg):
sys.exit(f"registry-fetch: {msg}")
def gzip_members(data):
off = 0
while off < len(data):
d = zlib.decompressobj(31)
d.decompress(data[off:])
end = len(data) - len(d.unused_data)
if end <= off:
raise ValueError("gzip stream did not advance")
yield data[off:end]
off = end
def tar_files(tar):
"""(name, bytes) for each regular file in a tar image; pax headers skipped."""
off = 0
while off + 512 <= len(tar):
hdr = tar[off:off + 512]
if hdr == b"\0" * 512:
return
size = int(hdr[124:136].split(b"\0")[0].strip() or b"0", 8)
name = hdr[:100].rstrip(b"\0").decode()
if hdr[156:157] not in (b"x", b"g"):
yield name, tar[off + 512:off + 512 + size]
off += 512 + (size + 511) // 512 * 512
def fetch(url):
last = None
for attempt in range(3):
try:
with urllib.request.urlopen(url, timeout=120) as r:
return r.read()
except Exception as e: # noqa: BLE001 - any transport failure retries
last = e
time.sleep(10)
die(f"cannot fetch {url}: {last}")
def verify_index(index_tgz, keyfile):
"""Returns the APKINDEX text after checking the signature against keyfile."""
try:
members = list(gzip_members(index_tgz))
except (zlib.error, ValueError) as e:
die(f"index: corrupt gzip stream ({e})")
if len(members) != 2:
die(f"index: expected 2 gzip streams, found {len(members)}")
sig_entries = list(tar_files(gzip.decompress(members[0])))
if not sig_entries:
die("index: no signature entry")
name, sig = sig_entries[0]
m = re.fullmatch(r"\.SIGN\.(RSA\d*)\.(.+)", name)
if not m or m.group(1) not in DIGEST:
die(f"index: unexpected signature entry {name!r}")
kind, signer = m.groups()
if signer != os.path.basename(keyfile):
die(f"index: signed by {signer!r}, phones trust {os.path.basename(keyfile)!r}")
with tempfile.TemporaryDirectory() as t:
sigf, dataf = os.path.join(t, "sig"), os.path.join(t, "data")
open(sigf, "wb").write(sig)
open(dataf, "wb").write(members[1])
r = subprocess.run(["openssl", "dgst", f"-{DIGEST[kind]}", "-verify", keyfile,
"-signature", sigf, dataf], capture_output=True, text=True)
if r.returncode != 0 or "Verified OK" not in r.stdout:
die(f"index: signature does NOT verify against {keyfile}: {r.stdout.strip()} {r.stderr.strip()}")
files = dict(tar_files(gzip.decompress(members[1])))
if "APKINDEX" not in files:
die("index: no APKINDEX entry")
return files["APKINDEX"].decode()
def parse_index(text):
"""{name: {version: fields}} for aarch64 entries."""
out = {}
for block in text.split("\n\n"):
f = dict(line.split(":", 1) for line in block.splitlines() if ":" in line)
if f.get("A", "aarch64") != "aarch64" or "P" not in f or "V" not in f:
continue
out.setdefault(f["P"], {})[f["V"]] = f
return out
def version_key(v):
ver, _, rel = v.partition("-r")
parts = tuple((0, int(t)) if t.isdigit() else (1, t) for t in re.split(r"[._]", ver))
return parts, int(rel) if rel.isdigit() else 0
def verify_apk(blob, fields, name):
try:
members = list(gzip_members(blob))
except (zlib.error, ValueError) as e:
die(f"{name}: corrupt gzip stream ({e})")
if len(members) != 3:
die(f"{name}: expected 3 gzip streams, found {len(members)}")
want = fields.get("C", "")
if not want.startswith("Q1"):
die(f"{name}: index has no Q1 checksum")
got = "Q1" + base64.b64encode(hashlib.sha1(members[1]).digest()).decode()
if got != want:
die(f"{name}: control checksum {got} != index {want}")
pkginfo = dict(tar_files(gzip.decompress(members[1]))).get(".PKGINFO", b"").decode()
datahash = next((l.split("=", 1)[1].strip() for l in pkginfo.splitlines()
if l.startswith("datahash")), None)
if not datahash:
die(f"{name}: .PKGINFO has no datahash")
if hashlib.sha256(members[2]).hexdigest() != datahash:
die(f"{name}: data segment does not match its datahash")
if "S" in fields and int(fields["S"]) != len(blob):
die(f"{name}: size {len(blob)} != index {fields['S']}")
def main(registry, keyfile, dest, groups):
registry = registry.rstrip("/")
if not os.path.isfile(keyfile):
die(f"trusted key {keyfile} not found")
os.makedirs(dest, exist_ok=True)
index = parse_index(verify_index(fetch(f"{registry}/aarch64/APKINDEX.tar.gz"), keyfile))
lines = []
for group in groups:
names = group.split(",")
anchor = names[0]
if anchor not in index:
die(f"{anchor}: not in the registry index")
version = max(index[anchor], key=version_key)
for n in names:
fields = index.get(n, {}).get(version)
if fields is None:
die(f"{n}-{version}: not in the registry (newest {anchor} is {version})")
fname = f"{n}-{version}.apk"
blob = fetch(f"{registry}/aarch64/{fname}")
verify_apk(blob, fields, fname)
open(os.path.join(dest, fname), "wb").write(blob)
lines.append(f"{n} {version} {hashlib.sha256(blob).hexdigest()}")
with open(os.path.join(dest, "manifest"), "w") as f:
f.write("\n".join(lines) + "\n")
print("\n".join(lines))
if __name__ == "__main__":
if len(sys.argv) < 5:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:])