diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml new file mode 100644 index 0000000..db93423 --- /dev/null +++ b/.github/workflows/attest-release.yml @@ -0,0 +1,96 @@ +# The second half of a release, after a person has signed it. +# +# The build workflow cannot sign: the key lives on a cryptographic card in a USB +# reader and cannot be exported, so `tools/sign_release.py` signs on the maintainer's +# machine and then dispatches this. Here the signed archive gets the statement that +# travels with it, and the release is still a draft when this finishes. +# +# 🔴 It DOWNLOADS the archive rather than trusting the digest it was handed. Everything +# attested here is then a statement about bytes this job is holding, which is the whole +# difference between an attestation and a rumour. The digest input is kept as a +# cross-check: if it disagrees with the file on the release, something moved between +# signing and publishing and the run stops. +# +# What it does NOT do: build provenance. That belongs to the workflow that actually +# built something, and it is made there, over the unsigned build. Claiming here that +# this workflow produced a file a person signed on their own machine would be false in +# the one document nobody should have to doubt. +name: Attest a signed release + +on: + workflow_dispatch: + inputs: + tag: + description: "The release tag, e.g. v0.5.0-rc.2" + required: true + digest: + description: "sha256 of the signed archive, as sign_release.py printed it" + required: true + +permissions: + contents: read + +jobs: + attest: + name: Attest the signed archive + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # Reading the draft's assets and uploading the bundle back to it. + contents: write + # `id-token` mints the short-lived OIDC token that signs the attestation, + # `attestations` writes the result to the repository's attestation store. + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Fetch what the maintainer signed + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + CLAIMED: ${{ inputs.digest }} + run: | + mkdir -p signed && cd signed + gh release download "$TAG" --pattern '*.zip' --pattern '*.spdx.json' + archive="$(ls *.zip)" + actual="$(sha256sum "$archive" | cut -d' ' -f1)" + echo "release carries: $actual" + if [ "$actual" != "$CLAIMED" ]; then + echo "::error::the archive on the release hashes to $actual, but the run was" + echo "::error::dispatched for $CLAIMED - something changed in between" + exit 1 + fi + { + echo "ARCHIVE=signed/$archive" + echo "SBOM=signed/$(ls *.spdx.json)" + echo "BUNDLE=BeanNetworkTester-${TAG}.sigstore.json" + } >> "$GITHUB_ENV" + + # The SBOM, bound to the file a user actually downloads. Before the signature + # moved to a card this binding was made at build time; now it is made here, + # because the signature changes the bytes and a statement about the wrong bytes + # verifies against nothing. + - name: Attest the SBOM against the signed archive + id: attestation + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ env.ARCHIVE }} + sbom-path: ${{ env.SBOM }} + + # 🔴 As an ASSET, not only in the attestation store. Scorecard's Signed-Releases + # check reads release assets by file extension and never opens that store, and a + # user with no route to the API cannot use it either. `gh attestation verify + # --bundle ` answers offline, from a mirror, from anywhere. + - name: Publish the bundle beside the archive + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + BUNDLE_PATH: ${{ steps.attestation.outputs.bundle-path }} + run: | + cp "$BUNDLE_PATH" "$BUNDLE" + python -c "import json,sys; json.load(open(sys.argv[1])); print('bundle parses as JSON')" "$BUNDLE" + gh release upload "$TAG" "$BUNDLE" --clobber + echo "attached $BUNDLE to $TAG (still a draft)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87ffeb1..77fbe62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,14 +122,17 @@ jobs: test "$v" = "0" || { echo "--version failed"; exit 1; } test "$r" = "0" || { echo "simulated run failed"; exit 1; } - # Zip the onedir bundle and write the checksum the README tells users to verify. - - name: Package (zip + SHA-256) + # Zip the onedir bundle. 🔴 No SHA256SUMS.txt here any more: these bytes are not + # the bytes a user downloads. The executable inside is still unsigned, and + # `tools/sign_release.py` signs it on the machine that holds the card - which + # changes the archive, and would leave a checksum written here describing a file + # nobody has. The checksum is written next to the signature, over the same bytes. + - name: Package (zip) shell: bash run: | name="BeanNetworkTester-${GITHUB_REF_NAME}-windows-x64" ( cd dist && 7z a "../${name}.zip" BeanNetworkTester >/dev/null ) - sha256sum "${name}.zip" > SHA256SUMS.txt - cat SHA256SUMS.txt + sha256sum "${name}.zip" echo "ASSET=${name}.zip" >> "$GITHUB_ENV" # The SBOM is generated from `beantester/legal.py`, the reviewed list of what @@ -151,26 +154,6 @@ jobs: "BeanNetworkTester-${GITHUB_REF_NAME}.spdx.json" echo "SBOM=BeanNetworkTester-${GITHUB_REF_NAME}.spdx.json" >> "$GITHUB_ENV" - # Signs the SBOM against the zip a user downloads, so `gh attestation verify` - # can prove the pair belongs together. Without this the SBOM is a text file - # anybody could replace, which is the difference between a bill of materials - # and a note claiming to be one. - - name: Attest the SBOM against the release archive - # Was actions/attest-sbom until 2026-08-12. Its own README now says it is - # deprecated in favour of actions/attest and runs as a wrapper over it, and - # that "all of the existing action inputs are compatible" - checked against - # actions/attest's action.yml rather than taken on trust: `sbom-path` is - # there, with the same meaning, and providing it is what makes this an SBOM - # attestation rather than build provenance. So both inputs stay as they are. - # - # Pinned by SHA, not by tag. This is the first third-party-shaped action in - # the workflow that publishes the release, and a tag can be moved; the SHA - # below is what `v4.2.2` pointed at on 2026-08-12, read from the API. - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ env.ASSET }} - sbom-path: ${{ env.SBOM }} - # WHO built it, and FROM WHAT. The SBOM attestation above says what is inside # the zip; this one says the zip came out of this repository, from this commit, # through this workflow, on a GitHub-hosted runner - signed with the same @@ -195,37 +178,6 @@ jobs: with: subject-path: ${{ env.ASSET }} - # The same bundle, published as an ASSET rather than left only in the - # attestation store. Two reasons, one for a person and one for a scanner. - # - # For a person: the file travels with the archive it describes, so - # `gh attestation verify --bundle ` answers without asking an - # API - which is the difference between "GitHub says this is fine" and "these - # bytes say so". A mirror that copies the release page copies the proof too. - # - # 🔴 For a scanner: OpenSSF Scorecard's Signed-Releases check reads release - # assets BY FILE EXTENSION (`.sigstore.json`, `.asc`, `.sig`, `.intoto.jsonl`) - # and never looks in the attestation store. Read in probes/releasesAreSigned - # on 2026-08-19, after the check scored 0/10 on releases that already carried - # two attestations. Producing evidence nobody can find is the same as not - # producing it. - # - # The name says what the file IS: a Sigstore bundle, which is what the action - # writes. `.intoto.jsonl` would score two points higher there and would be a - # different format - not a rename. - - name: Publish the provenance bundle beside the archive - shell: bash - env: - # Through the environment, never interpolated into the script: `${{ }}` is - # expanded before a shell exists, so it is source code rather than an - # argument. Guarded by tests/test_repo_conventions.py. - BUNDLE_PATH: ${{ steps.provenance.outputs.bundle-path }} - run: | - bundle="BeanNetworkTester-${GITHUB_REF_NAME}.sigstore.json" - cp "$BUNDLE_PATH" "$bundle" - python -c "import json,sys; json.load(open(sys.argv[1])); print('bundle parses as JSON')" "$bundle" - echo "BUNDLE=$bundle" >> "$GITHUB_ENV" - # gh is preinstalled on the runner - no third-party action, uses the job token. # A -rc/-beta/-alpha tag publishes as a "Pre-release"; a plain tag as "Latest". # @@ -235,14 +187,39 @@ jobs: # would have been 43 PR titles. Extracting the section instead means the release # page, a blog post and CHANGELOG.md are one text that cannot drift apart. # The step above has already proved that section exists and is dated. - - name: Publish the GitHub Release + - name: Open the release as a DRAFT env: GH_TOKEN: ${{ github.token }} shell: bash run: | python tools/release_notes.py > RELEASE_NOTES.md head -5 RELEASE_NOTES.md - flags=(--title "$TITLE" --notes-file RELEASE_NOTES.md) + flags=(--draft --title "$TITLE" --notes-file RELEASE_NOTES.md) if [ "$PRERELEASE" = "true" ]; then flags+=(--prerelease); else flags+=(--latest); fi - gh release create "$GITHUB_REF_NAME" \ - "$ASSET" SHA256SUMS.txt "$SBOM" "$BUNDLE" "${flags[@]}" + gh release create "$GITHUB_REF_NAME" "$SBOM" "${flags[@]}" + + # 🔴 The build leaves as an ARTEFACT, not as a release asset, and the reason is + # that it is not finished. The executable inside is unsigned, and this runner + # cannot sign it: the key lives on a cryptographic card in a USB reader on the + # maintainer's desk and cannot be exported. Attaching it here would put an + # unsigned file on a public release page for as long as the ritual takes, and + # somebody would download it. + # + # The name is what `tools/sign_release.py` fetches. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsigned-build-${{ github.ref_name }} + path: ${{ env.ASSET }} + retention-days: 7 + + - name: Say what happens next + shell: bash + run: | + echo "Built, attested, and opened as a DRAFT release." + echo + echo "Now, on the machine with the card:" + echo " python tools/sign_release.py ${GITHUB_REF_NAME}" + echo + echo "That signs the executable, checks it was the pinned certificate that" + echo "signed it, uploads the archive and its checksum, and asks" + echo "attest-release.yml for the bundle. Then read the draft and publish it." diff --git a/CHANGELOG.md b/CHANGELOG.md index b89151a..5e0d490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol Accents are optional: `opoznienie` finds `Opóźnienie`. A setting that lives in the Settings window is named rather than missed. +- **The download is signed.** From this version the executable carries a code-signing signature, so + Windows names the publisher instead of saying "Unknown publisher". The key lives on a hardware + card, so the signing is done by hand and the release page fills in two steps. Know what it does + not buy: a new certificate has no SmartScreen reputation yet, so a warning can still appear for a + while - it now says who signed the file. The attestation you check with `--bundle` is made after + signing, over the exact bytes you download. + - **You can now check where a download came from, not just that it is unchanged.** Every release archive carries a signed build attestation, so one command answers "was this really built from that source": diff --git a/README.md b/README.md index 6f042fa..b9cb8c6 100644 --- a/README.md +++ b/README.md @@ -1468,11 +1468,14 @@ and only when you click the corresponding button yourself. ## A note on SmartScreen and antivirus -The .exe is not (yet) signed with a certificate, and at the same time it asks for administrator -rights and loads a network driver - so Windows SmartScreen may show an "Unknown publisher" warning, -and some antivirus tools may raise a false alarm. The **WinDivert driver itself is digitally signed -by its author**. You can compare the release's SHA-256 checksum (`SHA256SUMS.txt`) to confirm the -file has not been modified. +**From 0.5.0 the .exe is signed**, with a certificate whose private key lives on a hardware card +that never leaves the maintainer's desk - so Windows names the publisher instead of saying "Unknown +publisher". Be aware of what that does and does not buy you: a new certificate has no SmartScreen +reputation yet, so a warning can still appear for a while, and some antivirus tools may still raise +a false alarm about a program that asks for administrator rights and loads a network driver. What +changes is that the warning now says who signed it. The **WinDivert driver itself is digitally +signed by its author**. You can still compare the release's SHA-256 checksum (`SHA256SUMS.txt`) to +confirm the file arrived unchanged. **You can also check where the download came from, not just that it is unchanged.** Every release archive carries a signed build attestation, so one command answers "was this really built from that @@ -1496,6 +1499,13 @@ gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip --bundle BeanNetw Useful if you got the files from a mirror, or from a machine that cannot reach the API - the evidence travelled with the download instead of living somewhere you have to trust separately. +Three statements, and it is worth knowing they answer different questions. The **signature** says +who stands behind the file. The **bundle above** binds this exact archive to its bill of materials, +and it is made after signing, over the bytes you downloaded. The **build provenance** says which +commit and which workflow produced the build that was then signed - it necessarily describes the +unsigned build, because signing changes the bytes, and it lives in this repository's attestation +store rather than in the download. + ### What is inside the download, and how to check it Every release carries an **SBOM** - a list, in the standard SPDX format, of every third-party diff --git a/README.pl.md b/README.pl.md index b899e37..031da82 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1329,12 +1329,16 @@ odpowiedni przycisk. ## Uwaga: SmartScreen i antywirusy -Plik .exe nie jest (jeszcze) podpisany certyfikatem, a jednocześnie prosi o -uprawnienia administratora i ładuje sterownik sieciowy - dlatego Windows -SmartScreen może pokazać ostrzeżenie „Nieznany wydawca”, a niektóre antywirusy -mogą zgłosić fałszywy alarm. Sam sterownik **WinDivert jest podpisany cyfrowo -przez jego autora**. Sumę kontrolną SHA-256 wydania (`SHA256SUMS.txt`) możesz -porównać, żeby potwierdzić, że plik nie został zmodyfikowany. +**Od 0.5.0 plik .exe jest podpisany** certyfikatem, którego klucz prywatny siedzi +na karcie kryptograficznej i nigdy z niej nie wychodzi - więc Windows pokazuje +nazwę wydawcy zamiast „Nieznany wydawca”. Warto wiedzieć, czego to nie załatwia: +świeży certyfikat nie ma jeszcze reputacji w SmartScreenie, więc ostrzeżenie może +przez jakiś czas nadal się pojawiać, a niektóre antywirusy nadal mogą zgłosić +fałszywy alarm przy programie, który prosi o uprawnienia administratora i ładuje +sterownik sieciowy. Zmienia się to, że ostrzeżenie mówi teraz, kto go podpisał. +Sam sterownik **WinDivert jest podpisany cyfrowo przez jego autora**. Sumę +kontrolną SHA-256 wydania (`SHA256SUMS.txt`) nadal możesz porównać, żeby +potwierdzić, że plik dotarł bez zmian. **Możesz też sprawdzić, skąd ten plik pochodzi, a nie tylko czy się nie zmienił.** Każde archiwum wydania niesie podpisaną atestację builda, więc jedno polecenie odpowiada na pytanie „czy to @@ -1358,6 +1362,12 @@ gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip --bundle BeanNetw Przydaje się, gdy pliki masz z kopii lustrzanej albo na maszynie bez dostępu do API - dowód przyjechał razem z pobranym plikiem, zamiast leżeć w miejscu, któremu trzeba osobno ufać. +Trzy oświadczenia, i warto wiedzieć, że odpowiadają na różne pytania. **Podpis** mówi, kto za tym +plikiem stoi. **Powyższy bundle** wiąże dokładnie to archiwum z jego listą składników i powstaje +już po podpisaniu, nad bajtami, które pobrałeś. **Prowenancja builda** mówi, z którego commita i +którym workflow powstał build, który potem podpisano - z konieczności opisuje wersję niepodpisaną, +bo podpis zmienia bajty, i leży w magazynie atestacji tego repozytorium, a nie w pobranym pliku. + ### Co jest w środku pobranego pliku i jak to sprawdzić Każde wydanie niesie **SBOM** - listę, w standardowym formacie SPDX, wszystkich diff --git a/beantester/legal.py b/beantester/legal.py index 1475ac6..bab72a0 100644 --- a/beantester/legal.py +++ b/beantester/legal.py @@ -40,6 +40,26 @@ "WinDivert64.sys": "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2", } +# The certificate the shipped executable is signed with, as the sha256 of its DER +# bytes (recorded 2026-08-19). Public information by construction: a certificate is +# the half of the pair that travels inside every signed file. The private key lives +# on a hardware card, cannot be exported, and is why the signing step is a person at +# a keyboard rather than a runner. +# +# 🔴 Why a pin and not a subject name: "signed" is a claim, "signed by THIS +# certificate" is a measurement. `tools/sign_release.py` refuses to publish when the +# certificate that actually signed the file hashes to something else, so a second +# code-signing certificate on the same machine - a renewal, a test one, one from +# another project - cannot quietly sign a release under this project's name. +# +# The signing tool selects a certificate by its SHA-1 thumbprint, which is what +# `signtool /sha1` takes; the script resolves this digest to that thumbprint in the +# Windows store rather than carrying two constants that can disagree. +# +# It expires 2027-08-19. A renewal issues a NEW certificate, so this digest moves +# with it - and the guard failing on that day is the point, not an inconvenience. +CODESIGN_SHA256 = "47b79ad3cfa53ef846cad03a59148f8c981d0b1196891e48b8c8d7982b10c148" + # The components we ship, in the order a reader cares about. ``module`` is the # import name used to report the real version at run time (None = not a Python # package, so the version is fixed or reported by other means). diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 529a0ad..163b2c1 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -1048,15 +1048,44 @@ "test": "test_a_term_may_not_end_in_an_escape_that_swallows_the_separator", }, { - # Evidence that exists and cannot be found is evidence nobody has. The - # attestation stayed in GitHub's store, where the archive's own readers - - # a person offline, a mirror, a scanner reading assets by extension - never - # look. - "label": "release: the provenance bundle stops shipping as an asset", + # The escaping removed as "noise" - and the tool that writes our supply-chain + # hashes can again be pointed at a different PyPI endpoint by a `?` in a + # version string, silently answering about something else. + "label": "supply chain: the hash generator stops escaping what it asks about", + "file": "tools/pin_hashes.py", + "old": 'return API % (quote(name, safe=""), quote(version, safe=""))', + "new": "return API % (name, version)", + "test": "test_no_version_can_truncate_the_path_into_a_query", + }, + { + # The simplification that puts an UNSIGNED executable on a public release + # page for as long as the signing ritual takes. It looks like tidying: the + # archive is right there, why not attach it. + "label": "release: the draft ships the unsigned archive after all", "file": ".github/workflows/release.yml", - "old": '"$ASSET" SHA256SUMS.txt "$SBOM" "$BUNDLE"', - "new": '"$ASSET" SHA256SUMS.txt "$SBOM"', - "test": "test_the_provenance_bundle_ships_as_a_release_asset", + "old": 'gh release create "$GITHUB_REF_NAME" "$SBOM" "${flags[@]}"', + "new": 'gh release create "$GITHUB_REF_NAME" "$ASSET" "$SBOM" "${flags[@]}"', + "test": "test_the_release_never_publishes_an_unsigned_archive", + }, + { + # "Signed" going back to being a claim instead of a measurement. A second + # code-signing certificate on the same machine would then sign a release + # under this project's name and nothing would say so. + "label": "release: the signing script stops checking WHICH certificate signed", + "file": "tools/sign_release.py", + "old": " if actual != CODESIGN_SHA256:", + "new": " if actual == CODESIGN_SHA256:", + "test": "test_the_signing_certificate_is_pinned_by_its_bytes", + }, + { + # The tempting shortcut in the attestation half: it was HANDED a digest, so + # why download the file. Because then it attests something nobody checked - + # a rumour with a signature on it. + "label": "attestation: the signed release is attested from a digest, not the file", + "file": ".github/workflows/attest-release.yml", + "old": " subject-path: ${{ env.ARCHIVE }}", + "new": " subject-digest: sha256:${{ inputs.digest }}", + "test": "test_the_signed_archive_is_attested_over_bytes_the_job_holds", }, { # The one job here that costs money per run, and the line that decides whether diff --git a/tests/test_pin_hashes_url.py b/tests/test_pin_hashes_url.py new file mode 100644 index 0000000..155bee1 --- /dev/null +++ b/tests/test_pin_hashes_url.py @@ -0,0 +1,76 @@ +"""The tool that writes our supply-chain hashes cannot be talked off PyPI. + +`tools/pin_hashes.py` asks the PyPI JSON API what artefacts a pinned version has +and writes their digests into `requirements*.txt`. Those digests are what pip +refuses to install around, so an answer fetched from the wrong place would be a +lie the whole build then depends on. + +A scanner flags the `urlopen` call because it cannot see the shape of its argument, +and names `file://` as the risk. That specific risk is not reachable and this file +proves it rather than asserting it. The reachable one is quieter: `version` is only +barred from whitespace and semicolons, so before the fix a `?` or `#` in it turned +the path into a query or a fragment - a different endpoint, answering about +something else, silently. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools")) + +from urllib.parse import urlsplit # noqa: E402 + +from fakes import check # noqa: E402 +from pin_hashes import _url # noqa: E402 + +# Every shape that has ever been suggested as an escape, plus the ordinary one. +HOSTILE = [ + "3.1.3", # the real thing, so this cannot pass on nothing + "../../../etc/passwd", + "..%2F..%2Fetc", + "x?a=b", + "x#frag", + # An at-sign and an authority - the shape of an escape attempt. Written with an + # IP rather than a hostname on purpose: a hostname here reads as an email address + # to `test_no_stray_email_addresses_in_the_public_tree`, which is right to say so + # about a public repository, and an IP is the more realistic attempt anyway. + "x@127.0.0.1", + "/../..//", + "file:///c:/windows/win.ini", + "https://evil.example/pypi", +] + + +def test_no_version_can_move_the_request_off_pypi(): + """Scheme and host are fixed, whatever the version says.""" + for version in HOSTILE: + parts = urlsplit(_url("pydivert", version)) + check(f"scheme stays https for {version!r}", parts.scheme == "https", + f"({parts.scheme})") + check(f"host stays pypi.org for {version!r}", parts.netloc == "pypi.org", + f"({parts.netloc})") + + +def test_no_version_can_truncate_the_path_into_a_query(): + """The whole version stays one path segment - no query, no fragment. + + This is the half that was actually reachable: `1.0?x=y` used to ask PyPI a + different question and get a confident answer to it. + """ + for version in HOSTILE: + url = _url("pydivert", version) + parts = urlsplit(url) + check(f"no query for {version!r}", parts.query == "", f"({url})") + check(f"no fragment for {version!r}", parts.fragment == "", f"({url})") + check(f"the path still ends at /json for {version!r}", + parts.path.endswith("/json"), f"({parts.path})") + + +def test_the_name_is_escaped_too(): + """The regex bars a slash in a name today. The escaping does not rely on it. + + A guard that only holds while a second, unrelated pattern keeps its current + shape is a guard waiting to stop holding. + """ + parts = urlsplit(_url("../evil", "1.0")) + check("host survives a hostile name", parts.netloc == "pypi.org", f"({parts})") + check("the name stays one segment", parts.path.count("/") == 4, f"({parts.path})") diff --git a/tests/test_version_and_release.py b/tests/test_version_and_release.py index 6e1ac44..4017860 100644 --- a/tests/test_version_and_release.py +++ b/tests/test_version_and_release.py @@ -490,82 +490,150 @@ def test_the_downloads_tool_refuses_anything_that_is_not_owner_slash_name(): "" if rejected else f"({reason})") -def test_the_release_attests_exactly_the_archive_it_publishes(): - """Two attestations, one subject, and the subject is the download. - - A release carries two signed statements about the zip: an SBOM attestation - (what is inside it) and a build-provenance attestation (which repository, - commit and workflow produced it). Both are worth nothing if they name a - different file from the one `gh release create` uploads, and that mismatch is - invisible on the release page - the attestation store simply ends up holding a - statement about a digest nobody downloads. - - So this pins the shape rather than the wording: every `subject-path` in the - workflow names the same variable the publish step uploads, and both actions - are still there. +def test_the_release_never_publishes_an_unsigned_archive(): + """The build workflow opens a DRAFT and hands the archive over. It does not ship it. + + 🔴 The reason is that the build is not finished when the build workflow ends. The + executable inside is unsigned, and this runner cannot sign it: the key lives on a + cryptographic card in a USB reader and cannot be exported, which is the whole + value of it. So the archive leaves as a workflow ARTEFACT, `tools/sign_release.py` + signs it where the card is, and the release stays a draft until a person looks at + it. + + What this guards is the shape of that split, because every piece of it is one line + somebody could "simplify" back into a single publish step: + + * `gh release create` must pass `--draft`, and must NOT carry the archive. An + unsigned executable on a public release page, for as long as the ritual takes, + is a file somebody downloads; + * the archive must leave as an artefact instead, under the name the signing script + fetches - a rename here strands the ritual with a clear-looking error much later; + * no `SHA256SUMS.txt` is written here. These are not the bytes a user gets, and a + checksum describing a file nobody has is worse than none. + + The provenance attestation stays, over the unsigned build, because that is the one + thing this workflow can honestly say: it built these bytes from this commit. """ - import re - with open(os.path.join(ROOT, ".github", "workflows", "release.yml"), - encoding="utf-8") as handle: - text = handle.read() + path = os.path.join(ROOT, ".github", "workflows", "release.yml") + with open(path, encoding="utf-8") as handle: + lines = handle.read().splitlines() + code = [ln.split("#", 1)[0] for ln in lines] + body = "\n".join(code) - subjects = re.findall(r"subject-path:\s*(\S.*?)\s*$", text, re.MULTILINE) - check("both attestations name a subject", len(subjects) == 2, f"({subjects})") - check("both attest the same file", len(set(subjects)) == 1, f"({subjects})") - - # The command spans lines: match it through its backslash continuations, or the - # asset list looks empty and every check below passes on nothing. - publish = re.search(r"gh release create(?:[^\n]*\\\n)*[^\n]*", text) - check("the workflow publishes a release", publish is not None) - uploaded = publish.group(0) if publish else "" - # `subject-path: ${{ env.ASSET }}` against `gh release create ... "$ASSET" ...` - name = subjects[0].strip("${} ").replace("env.", "").strip() - check(f"the attested subject ({name}) is what gets uploaded", - ("$" + name) in uploaded or ("${" + name + "}") in uploaded, - f"({uploaded[:120]})") - - for action in ("actions/attest@", "actions/attest-build-provenance@"): - check(f"{action} is still in the release workflow", action in text) - - -def test_the_provenance_bundle_ships_as_a_release_asset(): - """An attestation nobody can find is an attestation nobody has. - - Both attestations went into GitHub's attestation store and nowhere else, which - is enough for `gh attestation verify -R ` and not enough for two - other readers: - - * a person holding the download and no network path to that API, or a mirror - that copied the release page - the proof has to travel with the archive; - * 🔴 OpenSSF Scorecard, whose Signed-Releases check reads release assets BY - FILE EXTENSION and never opens the attestation store. Measured 2026-08-19: - the check scored 0/10 on releases that already carried two attestations. - `probes/releasesAreSigned` accepts `.asc`, `.minisig`, `.sig`, `.sign`, - `.sigstore` and `.sigstore.json`; `probes/releasesHaveProvenance` accepts - `.intoto.jsonl` and nothing else. - - So the bundle is copied to a named asset and uploaded. The extension is part of - what this guards: renaming it to `.intoto.jsonl` would score two points higher - and would be a lie about the format, because the action writes a Sigstore - bundle. + # The WHOLE step, not just the `gh release create` line: the flags are assembled + # in an array above it, so a guard reading one line reads the wrong thing - and + # would have passed while `--draft` was missing. + import re + step = re.search(r"- name: Open the release as a DRAFT.*?(?=\n - |\Z)", + body, re.S) + check("the workflow still opens a release", step is not None) + opening = step.group(0) if step else "" + check("it opens it as a draft", "--draft" in opening, f"({opening[-200:]})") + created = re.search(r"gh release create(?:[^\n]*\\\n)*[^\n]*", opening) + created = created.group(0) if created else "" + check("it does not publish the archive", "$ASSET" not in created, + f"(an unsigned executable would sit on a public page: {created[:160]})") + check("it writes no checksum over bytes nobody downloads", + "SHA256SUMS.txt" not in body, + "(the checksum belongs next to the signature, over the same bytes)") + + check("the build leaves as an artefact", + "actions/upload-artifact@" in body, "(the signing script fetches it)") + check("under the name the signing script fetches", + "unsigned-build-" in body and "unsigned-build-%s" in + _read_text(os.path.join(ROOT, "tools", "sign_release.py")), + "(rename this on one side only and the ritual strands)") + + check("build provenance is still attested here", + "actions/attest-build-provenance@" in body, + "(this workflow DID build these bytes - that claim is true and worth making)") + + +def test_the_signed_archive_is_attested_over_bytes_the_job_holds(): + """The second half of a release: a statement about the file a user downloads. + + The signature changes the bytes, so a statement made at build time verifies + against nothing afterwards. `attest-release.yml` makes it after the signing, + which raises the question this guards: how does a workflow attest something a + person made on their own machine without lying? + + 🔴 By downloading it. The job fetches the archive from the draft, so everything it + attests is about bytes it is holding - the difference between an attestation and a + rumour. The digest it was dispatched with is kept as a cross-check and the run + stops when the two disagree, which is what "something moved between signing and + publishing" looks like. + + Deliberately NOT here: build provenance. That belongs to the workflow that built + something, and claiming it in the one document nobody should have to doubt would + be false. + + The bundle is published as an ASSET, not left only in the attestation store, and + the extension is part of what this guards: OpenSSF Scorecard's Signed-Releases + check reads release assets by file extension and never opens that store (measured + 2026-08-19, `probes/releasesAreSigned`), and a user with no route to the API + cannot use the store either. """ + path = os.path.join(ROOT, ".github", "workflows", "attest-release.yml") + check("the attestation workflow exists", os.path.exists(path)) + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as handle: + lines = handle.read().splitlines() + code = [ln.split("#", 1)[0] for ln in lines] + body = "\n".join(code) + + check("it is asked for, not automatic", "workflow_dispatch:" in body) + check("it fetches what was signed", "gh release download" in body) + check("it attests bytes it holds, not a digest it was told", + "subject-path:" in body and "subject-digest:" not in body, + "(attesting a digest nobody checked is a rumour with a signature on it)") + check("it cross-checks the digest it was dispatched with", + "CLAIMED" in body and "exit 1" in body) + check("it binds the SBOM to that same file", "sbom-path:" in body) + check("it does not claim to have built it", + "attest-build-provenance" not in body, + "(a person signed it on their own machine - saying otherwise is false)") + check("the bundle is published as an asset", + ".sigstore.json" in body and "gh release upload" in body) + + +def test_the_signing_certificate_is_pinned_by_its_bytes(): + """"Signed" is a claim. "Signed by THIS certificate" is a measurement. + + A second code-signing certificate on the same machine - a renewal, a test one, one + from another project - would sign a release just as happily, and the release page + would look identical. So the certificate is pinned by the sha256 of its DER bytes, + the signing script reads the certificate back OUT of the file it just signed, and + it refuses to upload anything when the two disagree. + + Same shape as `WINDIVERT_SHA256`, and for the same reason: a version, a subject + line or a file name is a label, and a digest is not. + + The certificate expires; a renewal issues a new one and this digest moves with it. + The guard failing on that day is the point. + """ + from beantester.legal import CODESIGN_SHA256 import re - with open(os.path.join(ROOT, ".github", "workflows", "release.yml"), - encoding="utf-8") as handle: - text = handle.read() - - check("the provenance step is addressable (it needs an id for its output)", - re.search(r"id:\s*provenance\b", text) is not None) - check("the bundle path is read from that step's output", - "steps.provenance.outputs.bundle-path" in text) - check("the bundle is named as a Sigstore bundle", - ".sigstore.json" in text, - "(the action writes a Sigstore bundle - the name has to say so)") - - publish = re.search(r"gh release create(?:[^\n]*\\\n)*[^\n]*", text) - uploaded = publish.group(0) if publish else "" - check("the bundle is uploaded with the other assets", - '"$BUNDLE"' in uploaded, f"({uploaded[:160]})") + check("the certificate is pinned as a sha256", + re.fullmatch(r"[0-9a-f]{64}", CODESIGN_SHA256) is not None, + f"({CODESIGN_SHA256[:24]}...)") + + script = _read_text(os.path.join(ROOT, "tools", "sign_release.py")) + check("the signing script reads the pin rather than carrying its own copy", + "from beantester.legal import CODESIGN_SHA256" in script) + check("it compares what actually signed the file against the pin", + "actual != CODESIGN_SHA256" in script) + check("and refuses without uploading anything", + "Nothing has been uploaded." in script, + "(a mismatch caught after the upload is not caught)") + check("it timestamps the signature", + "/tr" in script and "time.certum.pl" in script, + "(without a timestamp the signature dies when the certificate expires)") + + +def _read_text(path): + with open(path, encoding="utf-8") as handle: + return handle.read() def _check_every_requirement_carries_hashes(filename): diff --git a/tools/pin_hashes.py b/tools/pin_hashes.py index ecbeb0f..1fe85a9 100644 --- a/tools/pin_hashes.py +++ b/tools/pin_hashes.py @@ -36,14 +36,34 @@ import re import sys import urllib.request +from urllib.parse import quote API = "https://pypi.org/pypi/%s/%s/json" PINNED = re.compile(r"^(?P[A-Za-z0-9._-]+)==(?P[^\s;\\]+)(?P.*)$") +def _url(name, version): + """The PyPI JSON endpoint for one exact version, with both parts escaped. + + 🔴 The escaping is not about the scheme. A scanner flags `urlopen` on a value it + cannot see the shape of, and the risk it names - a `file://` URL reading a local + file - is not reachable here: `API` is a literal `https://` and both parts land in + the PATH, after the authority. Measured across `../../etc/passwd`, an at-sign + followed by an address, and a literal `file:///c:/windows` as the version: the + scheme stays `https` and the host stays `pypi.org` in every case. + + What IS reachable is quieter and worth closing anyway. `version` is only barred + from whitespace and semicolons, so `1.0?x=y` or `1.0#frag` used to truncate the + path into a query or a fragment - a DIFFERENT endpoint, answering about something + else, and this file writes the hashes that gate the supply chain. Escaped, such a + version asks about a release that does not exist and fails loudly instead. + """ + return API % (quote(name, safe=""), quote(version, safe="")) + + def artefact_hashes(name, version, timeout=30): """Every sha256 on PyPI for that exact version, newest artefact last.""" - with urllib.request.urlopen(API % (name, version), timeout=timeout) as response: + with urllib.request.urlopen(_url(name, version), timeout=timeout) as response: payload = json.load(response) digests = [f["digests"]["sha256"] for f in payload.get("urls", [])] if not digests: diff --git a/tools/sign_release.py b/tools/sign_release.py new file mode 100644 index 0000000..f71c645 --- /dev/null +++ b/tools/sign_release.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Sign a release build with the hardware card, then hand it back to the workflow. + + python tools/sign_release.py v0.5.0-rc.2 + python tools/sign_release.py v0.5.0-rc.2 --dry-run # everything except sign/upload + +Why this exists as a step a person runs +--------------------------------------- +The signing key lives on a cryptographic card in a USB reader. It cannot be +exported - that is the whole value of it - so no GitHub-hosted runner can ever +reach it. A self-hosted runner could, and this is a PUBLIC repository, where a +self-hosted runner is a machine strangers can aim a pull request at. So the build +happens where builds belong and the signature happens where the card is, and this +script is the seam between them. + +What it does, in order, and what it refuses +------------------------------------------- +1. downloads the unsigned build the release workflow produced for this tag; +2. **verifies that build's provenance attestation** before touching it. Signing + something you did not check is how a supply chain gets a signature on it; +3. signs the executable with the card, with an RFC 3161 timestamp - **without a + timestamp the signature dies when the certificate expires**, and this one expires + after a year; +4. reads the certificate back OUT of the signed file and refuses to go on unless it + hashes to ``legal.CODESIGN_SHA256``. A second code-signing certificate on the + same machine - a renewal, a test one, one from another project - is exactly the + accident this catches; +5. repacks the archive, writes ``SHA256SUMS.txt`` over what it just made; +6. uploads both to the DRAFT release and asks the workflow to attest the signed + bytes, so the ``.sigstore.json`` a user verifies describes the file they hold. + +Nothing here publishes. The release stays a draft until a person looks at it and +presses the button. +""" +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import zipfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from beantester.legal import CODESIGN_SHA256 # noqa: E402 + +REPO = "donislawdev/BeanNetworkTester" +ATTEST_WORKFLOW = "attest-release.yml" +TIMESTAMP_URL = "http://time.certum.pl/" + + +def run(argv, **kw): + """Run a command, echo it, and stop the ritual on a non-zero exit.""" + print(" $ %s" % " ".join(argv)) + result = subprocess.run(argv, text=True, capture_output=True, **kw) + if result.returncode != 0: + sys.stderr.write(result.stdout + result.stderr) + raise SystemExit("sign_release: '%s' failed (%d)" + % (argv[0], result.returncode)) + return result.stdout + + +def powershell(script): + """Run a PowerShell snippet, preferring pwsh 7 and falling back to 5.1. + + Convention 46: `pwsh` first everywhere, because 5.1 is the one that is merely + always present. The fallback stays because a machine may not have seven. + """ + for exe in ("pwsh", "powershell"): + if shutil.which(exe): + return run([exe, "-NoProfile", "-NonInteractive", "-Command", script]) + raise SystemExit("sign_release: neither pwsh nor powershell is on PATH") + + +def find_signtool(): + """The newest x64 signtool.exe from the Windows SDK, or a clear refusal.""" + kits = r"C:\Program Files (x86)\Windows Kits\10\bin" + found = [] + for version in sorted(os.listdir(kits)) if os.path.isdir(kits) else []: + candidate = os.path.join(kits, version, "x64", "signtool.exe") + if os.path.exists(candidate): + found.append(candidate) + if not found: + raise SystemExit( + "sign_release: no signtool.exe under %s - install the Windows SDK " + "('Windows SDK Signing Tools' is enough)" % kits) + return found[-1] + + +def signing_thumbprint(): + """The SHA-1 thumbprint of the certificate whose DER bytes hash to our pin. + + 🔴 Two digests, one source of truth. `signtool /sha1` selects by SHA-1 because + that is the only selector it takes; the repository pins SHA-256 because that is + the digest worth pinning. Resolving one to the other here means the two can + never drift apart in a config file. + """ + script = ( + "$out = @(); " + "Get-ChildItem Cert:\\CurrentUser\\My, Cert:\\LocalMachine\\My " + "-ErrorAction SilentlyContinue | ForEach-Object { " + " $h = [System.Security.Cryptography.SHA256]::Create()" + ".ComputeHash($_.RawData); " + " $out += [pscustomobject]@{ " + " sha256 = (($h | ForEach-Object { $_.ToString('x2') }) -join ''); " + " thumb = $_.Thumbprint; subject = $_.Subject; notAfter = $_.NotAfter } " + "}; $out | ConvertTo-Json -Compress" + ) + raw = powershell(script).strip() or "[]" + entries = json.loads(raw) + if isinstance(entries, dict): + entries = [entries] + for entry in entries: + if entry.get("sha256") == CODESIGN_SHA256: + print(" certificate: %s" % entry.get("subject", "").split(",")[0]) + print(" expires: %s" % entry.get("notAfter")) + return entry["thumb"] + raise SystemExit( + "sign_release: the pinned certificate (%s...) is not in the Windows store. " + "Plug in the card reader and check proCertum can see it; if the certificate " + "was renewed, legal.CODESIGN_SHA256 has to move with it." + % CODESIGN_SHA256[:16]) + + +def certificate_of(path): + """The sha256 of the certificate that actually signed ``path``.""" + script = ( + "$s = Get-AuthenticodeSignature -LiteralPath '%s'; " + "if ($s.Status -ne 'Valid') { Write-Error ('signature status: ' + $s.Status); exit 1 }; " + "$h = [System.Security.Cryptography.SHA256]::Create()" + ".ComputeHash($s.SignerCertificate.RawData); " + "(($h | ForEach-Object { $_.ToString('x2') }) -join '')" % path + ) + return powershell(script).strip() + + +def sha256_of(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("tag", help="the release tag, e.g. v0.5.0-rc.2") + parser.add_argument("--dry-run", action="store_true", + help="do everything except sign, upload and dispatch") + parser.add_argument("--work", default=os.path.join(ROOT, "build", "signing"), + help="scratch directory (default: build/signing)") + args = parser.parse_args(argv) + + if os.name != "nt": + raise SystemExit("sign_release: the card lives on Windows; run this there") + + work = os.path.join(args.work, args.tag) + if os.path.isdir(work): + shutil.rmtree(work) + os.makedirs(work) + print("working in %s" % work) + + print("\n[1/6] fetching the build this tag produced") + run(["gh", "run", "download", "--repo", REPO, "--name", + "unsigned-build-%s" % args.tag, "--dir", work]) + archives = [f for f in os.listdir(work) if f.endswith(".zip")] + if len(archives) != 1: + raise SystemExit("sign_release: expected one archive in the artefact, got %r" + % archives) + archive = os.path.join(work, archives[0]) + + print("\n[2/6] verifying what the workflow says it built") + run(["gh", "attestation", "verify", archive, "--repo", REPO]) + + print("\n[3/6] unpacking") + unpacked = os.path.join(work, "unpacked") + with zipfile.ZipFile(archive) as zf: + zf.extractall(unpacked) + exes = [os.path.join(base, name) + for base, _dirs, names in os.walk(unpacked) + for name in names if name.lower().endswith(".exe")] + if len(exes) != 1: + raise SystemExit("sign_release: expected one .exe in the archive, got %r" + % [os.path.basename(e) for e in exes]) + exe = exes[0] + print(" %s (%d bytes, unsigned)" % (os.path.basename(exe), os.path.getsize(exe))) + + print("\n[4/6] signing with the card") + thumbprint = signing_thumbprint() + signtool = find_signtool() + command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256", + "/tr", TIMESTAMP_URL, "/td", "sha256", "/v", exe] + if args.dry_run: + print(" DRY RUN, would run: %s" % " ".join(command)) + else: + run(command) + run([signtool, "verify", "/pa", "/v", exe]) + actual = certificate_of(exe) + if actual != CODESIGN_SHA256: + raise SystemExit( + "sign_release: the file was signed by a DIFFERENT certificate\n" + " expected %s\n got %s\nNothing has been uploaded." + % (CODESIGN_SHA256, actual)) + print(" signed by the pinned certificate, timestamped") + + print("\n[5/6] repacking and checksumming") + signed = os.path.join(work, archives[0]) + if not args.dry_run: + os.remove(archive) + with zipfile.ZipFile(signed, "w", zipfile.ZIP_DEFLATED) as zf: + for base, _dirs, names in os.walk(unpacked): + for name in names: + full = os.path.join(base, name) + zf.write(full, os.path.relpath(full, unpacked)) + sums = os.path.join(work, "SHA256SUMS.txt") + with open(sums, "w", encoding="utf-8", newline="\n") as handle: + handle.write("%s *%s\n" % (sha256_of(signed), archives[0])) + print(" %s" % sha256_of(signed)) + + print("\n[6/6] handing it back to the workflow") + if args.dry_run: + print(" DRY RUN, would upload the archive and SHA256SUMS.txt to the draft") + print(" DRY RUN, would dispatch %s with the digest" % ATTEST_WORKFLOW) + print("\ndry run finished - nothing was signed, uploaded or published") + return 0 + run(["gh", "release", "upload", args.tag, signed, sums, + "--repo", REPO, "--clobber"]) + run(["gh", "workflow", "run", ATTEST_WORKFLOW, "--repo", REPO, + "-f", "tag=%s" % args.tag, "-f", "digest=%s" % sha256_of(signed)]) + print("\nDone. The release is still a DRAFT.") + print("Wait for '%s' to attach the .sigstore.json, read the draft, then publish it." + % ATTEST_WORKFLOW) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())