mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
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:
1 parent
5587c25675
commit
1a96853aee
7 files changed
+104
-3
No files matched your search
@@ -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.
|
||||
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 |
|
||||
|----------|---------------:|-----------|----------|
|
||||
| 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 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*
|
||||
@@ -0,0 +1 @@
|
||||
releases@retrobios ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIkAHTwnLhKeUvYC7+i8dnQMJnpElcV/hQq0FcZoKzI9
|
||||
@@ -292,6 +292,12 @@ def generate_readme(db: dict, platforms_dir: str) -> str:
|
||||
" (`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 |",
|
||||
"|----------|---------------:|-----------|----------|",
|
||||
]
|
||||
|
||||
@@ -69,6 +69,52 @@ class ReadmeRegressions(unittest.TestCase):
|
||||
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):
|
||||
"""The test suite must sit on every road into main.
|
||||
|
||||
|
||||
@@ -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
|
||||
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`.
|
||||
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`)
|
||||
carry a fixed date rather than the wall clock, and the manifest's `generated`
|
||||
|
||||
+42
-2
@@ -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 --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)
|
||||
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
|
||||
for f in dist/*.zip; do
|
||||
@@ -183,7 +187,7 @@ PY
|
||||
# during the whole upload.
|
||||
DATE=$(date +%Y.%m.%d)
|
||||
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
|
||||
done
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
volumes: both downloaders group them under one platform name, download each
|
||||
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
|
||||
temp directory, which is a RAM disk on Batocera, ROCKNIX and RetroDECK.
|
||||
`RETROBIOS_API` points them at another releases endpoint, HTTPS only, plain
|
||||
|
||||
Reference in new issue
Block a user