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.
76 lines
2.7 KiB
Python
Executable file
76 lines
2.7 KiB
Python
Executable file
#!/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 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}")
|
|
sig = subprocess.run(
|
|
["openssl", "dgst", f"-{DIGEST[kind]}", "-sign", privkey],
|
|
input=control, capture_output=True, check=True,
|
|
).stdout
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tar:
|
|
info = tarfile.TarInfo(f".SIGN.{kind}.{pubname}")
|
|
info.size = len(sig)
|
|
info.mode = 0o644
|
|
tar.addfile(info, io.BytesIO(sig))
|
|
# like abuild-tar --cut: header + data blocks, no end-of-archive marker
|
|
cut = 512 + (len(sig) + 511) // 512 * 512
|
|
new_sig = gzip.compress(buf.getvalue()[:cut], mtime=0)
|
|
with open(path, "wb") as f:
|
|
f.write(new_sig + control + payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 4:
|
|
sys.exit(__doc__)
|
|
main(*sys.argv[1:])
|