fix(release): bind candidate hashes and signing evidence to each run

This commit is contained in:
2026-10-17 06:20:40 -04:00
parent 25a341fd10
commit ea04f585aa
6 changed files with 160 additions and 2 deletions
+6 -1
View File
@@ -149,11 +149,16 @@ jobs:
ARCH=x86_64 ./appimagetool AppDir wowlet-x86_64.AppImage
ls -la wowlet-x86_64.AppImage
- name: Record candidate source and hashes
run: python3 -B contrib/release/write_receipt.py --platform linux --file wowlet-x86_64.AppImage --output wowlet-linux-receipt.json
- name: upload wowlet AppImage
# v3: Gitea implements the older artifact protocol and rejects
# @actions/artifact v2 clients (upload-artifact@v4+) as unsupported.
uses: actions/upload-artifact@v3
with:
name: wowlet-x86_64-linux-AppImage
path: wowlet-x86_64.AppImage
path: |
wowlet-x86_64.AppImage
wowlet-linux-receipt.json
if-no-files-found: error
+6 -1
View File
@@ -209,11 +209,16 @@ jobs:
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
ls -la ../wowlet-macos-*.zip
- name: Record signed candidate source and hashes
run: python3 -B contrib/release/write_receipt.py --platform macos --file build/wowlet-macos-$(uname -m).zip --output build/wowlet-macos-receipt.json
- name: upload artifact
uses: actions/upload-artifact@v3
with:
name: wowlet-macos
path: build/wowlet-macos-*.zip
path: |
build/wowlet-macos-*.zip
build/wowlet-macos-receipt.json
retention-days: 14
# Without this an empty upload passes silently, which is how a signed
# build can appear to publish while shipping nothing.
+54
View File
@@ -141,6 +141,9 @@ jobs:
(cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true
ls -la stage stage/tor
- name: Record unsigned build source and hash
run: python3 -B contrib/release/write_receipt.py --platform windows-build --file stage/wowlet.exe --output stage/wowlet-build.json
- name: upload unsigned staging tree
# v3, not v4: Gitea Actions implements the older artifact protocol and
# rejects @actions/artifact v2 clients with
@@ -156,12 +159,39 @@ jobs:
needs: build
runs-on: windows # gitea label windows:host -> desktop-iim2fs2
steps:
- name: Prepare the exact signing run workspace
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
throw "Signing run differs from the reviewed source"
}
foreach ($path in @('stage', 'unsigned', 'wowlet-build.json', 'wowlet-windows-receipt.json', 'wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
}
- name: download unsigned staging tree
uses: actions/download-artifact@v3
with:
name: wowlet-x86_64-windows-unsigned
path: stage
- name: Verify exact signing source and unsigned input
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
if ($env:EXPECTED_SHA -notmatch '^[0-9a-f]{40}$' -or $env:GITHUB_SHA -ne $env:EXPECTED_SHA) {
throw "Signing run differs from the reviewed source"
}
$build = Get-Content 'stage/wowlet-build.json' -Raw | ConvertFrom-Json
if ($build.dispatch_commit -ne $env:EXPECTED_SHA -or $build.platform -ne 'windows-build' -or $build.artifacts.Count -ne 1) {
throw "Unsigned build receipt differs from the reviewed signing run"
}
if ($build.artifacts[0].name -ne 'wowlet.exe' -or $build.artifacts[0].sha256 -ne (Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant()) {
throw "Unsigned executable differs from its build receipt"
}
Move-Item 'stage/wowlet-build.json' 'wowlet-build.json'
- name: keep the unsigned tree for the reproducible archive
shell: powershell
run: |
@@ -265,6 +295,29 @@ jobs:
(Get-FileHash $archive -Algorithm SHA256).Hash
}
- name: Record signed candidate source, hashes and certificate
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$build = Get-Content 'wowlet-build.json' -Raw | ConvertFrom-Json
$sig = Get-AuthenticodeSignature 'stage/wowlet.exe'
if ($sig.Status -ne 'Valid' -or -not $sig.TimeStamperCertificate -or $sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
throw "Final executable signature is not valid, timestamped and from the expected publisher"
}
$files = @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip') | ForEach-Object {
@{name=$_;size=(Get-Item $_).Length;sha256=(Get-FileHash $_ -Algorithm SHA256).Hash.ToLowerInvariant()}
}
$receipt = @{
schema_version=1;app='wowlet';version=$build.version;platform='windows';
dispatch_commit=$env:GITHUB_SHA;wallet_library_commit=$build.wallet_library_commit;
environment_run_id=$env:GITHUB_RUN_ID;environment_run_attempt=$env:GITHUB_RUN_ATTEMPT;
artifacts=@($files);unsigned_executable=$build.artifacts[0];
signed_executable_sha256=(Get-FileHash 'stage/wowlet.exe' -Algorithm SHA256).Hash.ToLowerInvariant();
signing=@{status=$sig.Status.ToString();timestamped=$true;subject=$sig.SignerCertificate.Subject;
certificate_thumbprint=$sig.SignerCertificate.Thumbprint;certificate_not_after=$sig.SignerCertificate.NotAfter.ToUniversalTime().ToString('o')}
}
$receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 'wowlet-windows-receipt.json'
- name: upload Windows zips
uses: actions/upload-artifact@v3
with:
@@ -272,4 +325,5 @@ jobs:
path: |
wowlet-x86_64-windows.zip
wowlet-x86_64-windows-unsigned.zip
wowlet-windows-receipt.json
if-no-files-found: error
+9
View File
@@ -12,6 +12,7 @@ import secrets
import subprocess
import sys
import tempfile
import uuid
FIELDS = (
@@ -137,8 +138,16 @@ def main():
response = json.loads(raw)
if response.get("status") != "Accepted":
raise RuntimeError("Apple notarization did not return Accepted")
try:
uuid.UUID(response.get("id", ""))
except (ValueError, TypeError, AttributeError):
raise RuntimeError("Apple notarization submission identity is missing or malformed") from None
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
private_file(app.parent / "macos-signing.json", json.dumps({
"developer_id_verified": True, "notarization_status": "Accepted",
"notary_submission_id": response.get("id"), "staple_validated": True,
}, sort_keys=True).encode())
print("Developer ID signature verified; notarization accepted; staple validated")
finally:
if created:
+26
View File
@@ -0,0 +1,26 @@
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).parents[1]))
SPEC = importlib.util.spec_from_file_location("write_receipt", Path(__file__).parents[1] / "write_receipt.py")
receipt = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(receipt)
class ReceiptTests(unittest.TestCase):
def test_hash_changes_with_artifact_bytes_and_symlinks_refuse(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "candidate.zip"
path.write_bytes(b"candidate")
first = receipt.file_record(path)
path.write_bytes(b"different candidate")
second = receipt.file_record(path)
self.assertNotEqual(first["sha256"], second["sha256"])
self.assertEqual(second["size"], path.stat().st_size)
link = Path(tmp) / "link.zip"
link.symlink_to(path)
with self.assertRaisesRegex(ValueError, "regular file"):
receipt.file_record(link)
+59
View File
@@ -0,0 +1,59 @@
"""Bind candidate archive hashes to the checked source and provider run."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
from check_dispatch import check
def file_record(path):
if not path.is_file() or path.is_symlink() or path.stat().st_size == 0:
raise ValueError("Candidate artifact must be a nonempty regular file")
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return {"name": path.name, "size": path.stat().st_size, "sha256": digest.hexdigest()}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform", required=True, choices=("linux", "macos", "windows-build"))
parser.add_argument("--file", type=Path, required=True, action="append")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
run_id = os.environ.get("GITHUB_RUN_ID", "")
if not re.fullmatch(r"[1-9][0-9]*", run_id):
raise ValueError("Provider run identity is absent")
version = re.findall(r'^\s*VERSION "([0-9]+\.[0-9]+\.[0-9]+)"\s*$', Path("CMakeLists.txt").read_text(), re.M)
if len(version) != 1:
raise ValueError("Committed Wowlet version is ambiguous")
module = subprocess.check_output(["git", "ls-tree", "HEAD", "monero"], text=True).split()
if len(module) != 4 or module[:2] != ["160000", "commit"] or module[3] != "monero":
raise ValueError("Committed wallet-library pin is absent")
files = [file_record(path) for path in args.file]
if len({row["name"] for row in files}) != len(files):
raise ValueError("Candidate artifact filenames repeat")
receipt = {"schema_version": 1, "app": "wowlet", "version": version[0], "platform": args.platform,
"dispatch_commit": head, "wallet_library_commit": module[2],
"environment_run_id": run_id, "environment_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
"artifacts": files}
if args.platform == "macos":
signing = json.loads(Path("build/dist/macos-signing.json").read_text())
if signing.get("developer_id_verified") is not True or signing.get("notarization_status") != "Accepted" or signing.get("staple_validated") is not True:
raise ValueError("macOS signing evidence is incomplete")
receipt["signing"] = signing
with args.output.open("x") as stream:
json.dump(receipt, stream, indent=2)
stream.write("\n")
print("Candidate source and artifact hashes recorded")
if __name__ == "__main__":
main()