diff --git a/.gitea/workflows/build-linux.yml b/.gitea/workflows/build-linux.yml index 2258a194..10c25d63 100644 --- a/.gitea/workflows/build-linux.yml +++ b/.gitea/workflows/build-linux.yml @@ -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 diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index b23fc806..af1fef23 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -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. diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 6f30e9e1..c1b764db 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -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 diff --git a/contrib/release/sign_macos.py b/contrib/release/sign_macos.py index 207eef3f..7d1f3cc6 100644 --- a/contrib/release/sign_macos.py +++ b/contrib/release/sign_macos.py @@ -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: diff --git a/contrib/release/tests/test_receipt.py b/contrib/release/tests/test_receipt.py new file mode 100644 index 00000000..4785f336 --- /dev/null +++ b/contrib/release/tests/test_receipt.py @@ -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) diff --git a/contrib/release/write_receipt.py b/contrib/release/write_receipt.py new file mode 100644 index 00000000..cba4f611 --- /dev/null +++ b/contrib/release/write_receipt.py @@ -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()