feat: sign the release checksum list

SHA256SUMS.txt sat beside the artifacts it vouches for, so whoever could
rewrite a release rewrote the list with it. The packs were already
reproducible, which answers corruption and lets a third party rebuild an
archive byte for byte; nothing answered a rewritten release.

The list is now signed with an ed25519 key kept for this alone, and the
public half is allowed_signers at the repository root, so verification
does not go through the release page: ssh-keygen -Y verify against the
committed file, then sha256sum --check. Rehearsed on all three outcomes:
a good signature, a tampered pack caught by the sums, a rewritten list
caught by the signature.

The release steps sign and upload the signature, the README points a
downloader at the procedure, and the reproducibility section says what
each half proves. Rotation keeps retired lines so past releases stay
verifiable. Three tests hold the trust root, the signing step and the
documented principal in agreement.
This commit is contained in:
Abdessamad Derraz committed 2026-09-04 14:01:05 +02:00
1 parent 5587c25675
commit 1a96853aee
7 files changed
+104 -3

No files matched your search

+3 -1
View File
@@ -32,6 +32,8 @@ The script auto-detects your platform, downloads only missing files, and verifie
One pack per platform, and it holds everything the platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. The installer above does the same file by file, and `--target` narrows it to one machine; for a region or a bare minimum, build your own pack below. One pack per platform, and it holds everything the platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. The installer above does the same file by file, and `--target` narrows it to one machine; for a region or a bare minimum, build your own pack below.
The size is what the files occupy once extracted; the ZIP itself downloads smaller, and anything over 2 GB arrives split into `.zip.001`, `.zip.002` volumes. Open the `.001` with 7-Zip or PeaZip, or join them first (`cat Pack.zip.0* > Pack.zip`, or `copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows). The size is what the files occupy once extracted; the ZIP itself downloads smaller, and anything over 2 GB arrives split into `.zip.001`, `.zip.002` volumes. Open the `.001` with 7-Zip or PeaZip, or join them first (`cat Pack.zip.0* > Pack.zip`, or `copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows).
Every release ships `SHA256SUMS.txt` and a detached signature of it, checkable against `allowed_signers` in this repository: [verifying a release](https://abdess.github.io/retrobios/wiki/release-process/#verifying-a-release).
| Platform | Extracted size | Extract to | Download | | Platform | Extracted size | Extract to | Download |
|----------|---------------:|-----------|----------| |----------|---------------:|-----------|----------|
| Batocera | 4.0 GB | `/userdata/bios/` | [Download](../../releases/latest) | | Batocera | 4.0 GB | `/userdata/bios/` | [Download](../../releases/latest) |
@@ -169,4 +171,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed. The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
The reasoning, and where it is weakest, is in the [FAQ](https://abdess.github.io/retrobios/wiki/faq/#is-this-legal). The reasoning, and where it is weakest, is in the [FAQ](https://abdess.github.io/retrobios/wiki/faq/#is-this-legal).
*Auto-generated on 2026-09-04T05:36:10Z* *Auto-generated on 2026-09-04T11:52:53Z*
+1
View File
@@ -0,0 +1 @@
releases@retrobios ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIkAHTwnLhKeUvYC7+i8dnQMJnpElcV/hQq0FcZoKzI9
+6
View File
@@ -292,6 +292,12 @@ def generate_readme(db: dict, platforms_dir: str) -> str:
" (`cat Pack.zip.0* > Pack.zip`, or" " (`cat Pack.zip.0* > Pack.zip`, or"
" `copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows).", " `copy /b Pack.zip.001+Pack.zip.002 Pack.zip` on Windows).",
"", "",
"Every release ships `SHA256SUMS.txt` and a detached signature of it,"
" checkable against `allowed_signers` in this repository:"
" [verifying a release]"
"(https://abdess.github.io/retrobios/wiki/release-process/"
"#verifying-a-release).",
"",
"| Platform | Extracted size | Extract to | Download |", "| Platform | Extracted size | Extract to | Download |",
"|----------|---------------:|-----------|----------|", "|----------|---------------:|-----------|----------|",
] ]
+46
View File
@@ -69,6 +69,52 @@ class ReadmeRegressions(unittest.TestCase):
self.assertEqual(unknown, set(), f"README advertises unknown flags: {unknown}") self.assertEqual(unknown, set(), f"README advertises unknown flags: {unknown}")
class ReleaseSigningRegressions(unittest.TestCase):
"""A checksum list published beside its own artifacts proves nothing.
SHA256SUMS.txt answers corruption; whoever can rewrite a release rewrites
the list with it. The signature is what a third party checks, and it is
only checkable while allowed_signers, the signing step and the upload
list stay in agreement.
"""
PRINCIPAL = "releases@retrobios"
def test_allowed_signers_names_one_usable_key(self):
path = ROOT / "allowed_signers"
self.assertTrue(path.exists(), "allowed_signers is the published trust root")
lines = [
line for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.startswith("#")
]
self.assertTrue(lines, "allowed_signers carries no key")
for line in lines:
principal, keytype, blob = line.split()[:3]
self.assertEqual(principal, self.PRINCIPAL)
self.assertTrue(keytype.startswith(("ssh-", "sk-", "ecdsa-")), keytype)
self.assertTrue(len(blob) > 40, "key material looks truncated")
def test_the_release_signs_the_list_and_ships_the_signature(self):
process = (ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8")
self.assertIn(
"ssh-keygen -Y sign", process, "the release no longer signs the list"
)
upload = next(
line for line in process.splitlines()
if line.startswith("for f in dist/SHA256SUMS.txt")
)
self.assertIn(
"dist/SHA256SUMS.txt.sig", upload,
"the signature is produced but never uploaded",
)
def test_the_documented_verification_matches_the_published_principal(self):
process = (ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8")
self.assertIn("ssh-keygen -Y verify", process)
self.assertIn(f"-I {self.PRINCIPAL}", process)
self.assertIn("allowed_signers", process)
class WorkflowRegressions(unittest.TestCase): class WorkflowRegressions(unittest.TestCase):
"""The test suite must sit on every road into main. """The test suite must sit on every road into main.
+3
View File
@@ -160,6 +160,9 @@ regional files are never collapsed merely because their display label matches.
A pack is a function of its inputs. Two builds from the same collection and A pack is a function of its inputs. Two builds from the same collection and
the same `database.json` produce the same bytes, so a third party can rebuild the same `database.json` produce the same bytes, so a third party can rebuild
a published pack and compare it against the checksum in `SHA256SUMS.txt`. a published pack and compare it against the checksum in `SHA256SUMS.txt`.
That list is signed with a release key whose public half is `allowed_signers`
at the repository root, so the comparison does not rest on the release page
being intact: see [verifying a release](release-process.md#verifying-a-release).
Three things make that hold. Generated members (`README.txt`, `manifest.json`) Three things make that hold. Generated members (`README.txt`, `manifest.json`)
carry a fixed date rather than the wall clock, and the manifest's `generated` carry a fixed date rather than the wall clock, and the manifest's `generated`
+42 -2
View File
@@ -145,8 +145,12 @@ python scripts/pipeline.py
python scripts/generate_pack.py --platform retropie --output-dir dist/ python scripts/generate_pack.py --platform retropie --output-dir dist/
python scripts/generate_pack.py --platform retropie --verify-packs --output-dir dist/ python scripts/generate_pack.py --platform retropie --verify-packs --output-dir dist/
# 3. Checksums of the full ZIPs, before splitting # 3. Checksums of the full ZIPs, before splitting, then sign the list. The
# checksums answer corruption; the signature answers a rewritten release,
# which is the one thing a checksum published beside its own artifacts
# cannot answer.
(cd dist && sha256sum *.zip > SHA256SUMS.txt) (cd dist && sha256sum *.zip > SHA256SUMS.txt)
ssh-keygen -Y sign -f ~/.ssh/retrobios_signing -n file dist/SHA256SUMS.txt
# 4. Split anything over 2 GB (GitHub asset cap); 7-Zip and PeaZip open .001 directly # 4. Split anything over 2 GB (GitHub asset cap); 7-Zip and PeaZip open .001 directly
for f in dist/*.zip; do for f in dist/*.zip; do
@@ -183,7 +187,7 @@ PY
# during the whole upload. # during the whole upload.
DATE=$(date +%Y.%m.%d) DATE=$(date +%Y.%m.%d)
gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file notes.md gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file notes.md
for f in dist/SHA256SUMS.txt dist/*.zip dist/*.zip.0*; do for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip dist/*.zip.0*; do
[ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber [ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber
done done
gh release view "v${DATE}" --json assets --jq '.assets | length' # expect every file gh release view "v${DATE}" --json assets --jq '.assets | length' # expect every file
@@ -196,6 +200,42 @@ gh release list --json tagName,createdAt \
| tail -n +2 | while read tag; do gh release delete "$tag" --yes --cleanup-tag; done | tail -n +2 | while read tag; do gh release delete "$tag" --yes --cleanup-tag; done
``` ```
## Verifying a release
`SHA256SUMS.txt` is signed with a key used for nothing else. Its public half
is `allowed_signers` at the repository root, so anyone can check a download
without trusting the release page it came from:
```bash
gh release download --pattern 'SHA256SUMS.txt*' --pattern 'RetroArch_BIOS_Pack.zip*'
curl -fsSLO https://raw.githubusercontent.com/Abdess/retrobios/main/allowed_signers
ssh-keygen -Y verify -f allowed_signers -I releases@retrobios -n file \
-s SHA256SUMS.txt.sig < SHA256SUMS.txt
sha256sum --check --ignore-missing SHA256SUMS.txt
```
The first command must print `Good "file" signature for releases@retrobios`.
Order matters: verify the list before trusting the sums in it, and join split
volumes before checking, since the sums are of the full ZIPs.
The signature and the reproducible build answer different questions. The
signature says the list came from the holder of the release key. The build
says the bytes are derivable: packs are deterministic, so rebuilding one from
the same collection yields the same archive, and its checksum can be compared
against the signed list without trusting either.
The private half lives on the maintainer's machine and is generated with
`ssh-keygen -t ed25519 -f ~/.ssh/retrobios_signing -C releases@retrobios`.
Registering its public half at
[GitHub signing keys](https://github.com/settings/keys) is optional
redundancy: it lets a verifier cross-check `allowed_signers` against
`https://api.github.com/users/Abdess/ssh_signing_keys`, and needs
`gh auth refresh -h github.com -s admin:ssh_signing_key` first.
Rotating the key means committing the new public half to `allowed_signers`
and keeping the retired line, so signatures on past releases keep verifying.
One pack per platform, the full one: the platform's list plus everything its One pack per platform, the full one: the platform's list plus everything its
cores load. Platform-only and per-emulator packs are build options, not cores load. Platform-only and per-emulator packs are build options, not
release assets, since a lighter pack means a core that fails with no message. release assets, since a lighter pack means a core that fails with no message.
+3
View File
@@ -398,6 +398,9 @@ ZIP when a manually reviewed pack release contains it; it is separate from the
per-file automatic installer above. A pack over 2 GB is published as numbered per-file automatic installer above. A pack over 2 GB is published as numbered
volumes: both downloaders group them under one platform name, download each volumes: both downloaders group them under one platform name, download each
one, join them and check the result against the release's `SHA256SUMS.txt`. one, join them and check the result against the release's `SHA256SUMS.txt`.
That list carries a detached signature, `SHA256SUMS.txt.sig`, verifiable
against `allowed_signers` at the repository root; the
[release process](release-process.md#verifying-a-release) gives the commands.
Volumes are staged inside the destination directory rather than the system Volumes are staged inside the destination directory rather than the system
temp directory, which is a RAM disk on Batocera, ROCKNIX and RetroDECK. temp directory, which is a RAM disk on Batocera, ROCKNIX and RetroDECK.
`RETROBIOS_API` points them at another releases endpoint, HTTPS only, plain `RETROBIOS_API` points them at another releases endpoint, HTTPS only, plain