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.
This commit is contained in:
Jorijn van der Graaf 2026-09-11 13:21:18 +02:00
commit e21a2dd0ec
2 changed files with 216 additions and 51 deletions

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:])