feat(release): sign the release on the machine that holds the card - #139
Merged
Conversation
…bout A scanner flagged the `urlopen` call in `pin_hashes.py` and named a `file://` URL reading a local file as the risk. That risk is not reachable here, and the new tests prove it rather than asserting it: `API` is a literal https URL and both parts land in the path, so a version made of directory traversal, of an at-sign and a hostname, or of a literal file URL, all leave the scheme as https and the host as pypi.org. What was reachable is quieter, and is why this is a change rather than a dismissal. `version` is barred only from whitespace and semicolons, so a question mark or a hash in it truncated the path into a query or a fragment - a different endpoint, answering confidently about something else, into the file that gates the supply chain. Both parts are percent-encoded now, so such a version asks about a release that does not exist and fails loudly. The name is escaped too, although today's regex already bars a slash in it. A guard that holds only while a second, unrelated pattern keeps its current shape is a guard waiting to stop holding. Verified by regenerating requirements-lint.txt end to end: byte-identical to what is on disk, 413 hash lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The signing key lives on a cryptographic card in a USB reader and cannot be exported - that is what the certificate was bought for. No GitHub-hosted runner can reach it, and a self-hosted one is a machine strangers can aim a pull request at in a public repository. So the build happens where builds belong and the signature happens where the card is, in three phases. The tag workflow builds, smoke-tests, writes the SBOM, attests the provenance of the UNSIGNED archive, opens the release as a DRAFT carrying only the SBOM, and hands the build over as a workflow artefact. It publishes no archive and writes no checksum: those are not the bytes a user gets, and an unsigned executable on a public release page - for as long as the ritual takes - is a file somebody downloads. `tools/sign_release.py` then fetches that artefact and VERIFIES ITS PROVENANCE before touching it, because signing what you did not check is how a supply chain acquires a signature. It signs with an RFC 3161 timestamp, since without one the signature dies when the certificate expires a year from now. Then it reads the certificate back out of the file it just signed and refuses to upload anything unless it hashes to the pin in legal.py - a renewal, a test certificate, one from another project would otherwise sign a release under this project's name and the page would look identical. `--dry-run` does everything except sign, upload and dispatch. `attest-release.yml` finishes it, and downloads the archive rather than trusting the digest it was handed: everything it attests is then about bytes it holds, which is the difference between an attestation and a rumour. The digest it was dispatched with is kept as a cross-check. It binds the SBOM to the signed file - the binding used to be made at build time, and the signature changes the bytes, so that statement now verifies against nothing. It does not claim build provenance: a person signed that file on their own machine. The certificate is pinned by the sha256 of its DER bytes, the same shape as WINDIVERT_SHA256 and for the same reason: "signed" is a claim, "signed by THIS certificate" is a measurement. signtool selects by SHA-1 thumbprint, so the script resolves the pinned digest to that thumbprint in the Windows store rather than carrying two constants that can disagree. Verified as far as it can be without the card's PIN: the resolver finds the certificate in the store and the newest SDK signtool, both workflows parse, and three mutations are caught - the draft shipping the unsigned archive, the script no longer checking which certificate signed, and the attestation taking a digest instead of the file. The first version of the draft guard read only the `gh release create` line while `--draft` was assembled in an array above it; widening it to the whole step is what made it able to fail. Both READMEs now say the executable is signed from 0.5.0, what that does not buy (a new certificate has no SmartScreen reputation yet), and which of the three statements answers which question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught it, which is what it is for: the hostile version used to test at-sign handling was written as a user-and-hostname pair, and `test_no_stray_email_addresses_in_the_public_tree` reads that as an address in a public repository. It is right to. An address in a public tree is an address that gets scraped, and the one exemption that guard allows exists for a licence notice obliged to reproduce one - widening it for test data would spend a real guard on a convenience. An IP address takes its place, and the case gets stronger rather than weaker: an authority written as an IP is the more realistic escape attempt, and it is not an email address by any reading. Worth recording why it reached CI at all: the conventions suite ran before this test file existed and was not run again after, so the only place it could surface was the pull request. That is the arrangement working, not failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 5 of the agreed plan: the release is signed with the Certum card, in a ritual that
keeps every statement true.
Why it is three phases
The signing key lives on a cryptographic card in a USB reader and cannot be exported -
that is what the certificate was bought for. No GitHub-hosted runner can reach it, and a
self-hosted one is a machine strangers can aim a pull request at in a public repository.
So the build happens where builds belong, and the signature where the card is.
Phase A (
release.yml, on the tag): build, smoke, SBOM, attest the provenance of theunsigned archive, open the release as a draft with only the SBOM, hand the build
over as a workflow artefact.
🔴 It publishes no archive and writes no
SHA256SUMS.txt. Those are not the bytes a usergets, and an unsigned executable on a public release page - for as long as the ritual takes -
is a file somebody downloads.
Phase B (
python tools/sign_release.py <tag>, where the card is): fetch the artefact,verify its provenance before touching it (signing what you did not check is how a supply
chain acquires a signature), sign with an RFC 3161 timestamp, then read the certificate back
out of the signed file and refuse to upload anything unless it matches the pin.
--dry-rundoes everything except sign, upload and dispatch.
Phase C (
attest-release.yml, dispatched): 🔴 it downloads the archive rather thantrusting the digest it was handed, so everything it attests is about bytes it holds - the
difference between an attestation and a rumour. It binds the SBOM to the signed file and
publishes the bundle as
.sigstore.json. It does not claim build provenance: a personsigned that file on their own machine.
The certificate is pinned by its bytes
legal.CODESIGN_SHA256, the same shape asWINDIVERT_SHA256and for the same reason:"signed" is a claim, "signed by THIS certificate" is a measurement. A renewal, a test
certificate, or one from another project would otherwise sign a release under this project's
name and the page would look identical.
signtoolselects by SHA-1 thumbprint, so the scriptresolves the pinned digest to that thumbprint in the Windows store rather than carrying two
constants that can disagree.
Also here
The hash generator can no longer be pointed at a different PyPI endpoint. A scanner
flagged
urlopenand namedfile://; that risk is not reachable and the new tests prove itrather than asserting it. What was reachable: a
?or#in a version truncated the pathinto a query or fragment - a different endpoint, answering confidently about something else,
into the file that gates the supply chain.
Verification
Windows store and finds the newest SDK
signtool; both workflows parse;pin_hashes.pyregenerates
requirements-lint.txtbyte-identically.longer checking which certificate signed, the attestation taking a digest instead of the
file, and the escaping removed from the hash generator.
gh release createline while--draftwas assembled in an array above it. It passed over the wrong text; widening it to the whole
step is what made it able to fail.
🤖 Generated with Claude Code