Skip to content

feat(release): sign the release on the machine that holds the card - #139

Merged
donislawdev merged 3 commits into
masterfrom
ci/signed-release
Aug 19, 2026
Merged

feat(release): sign the release on the machine that holds the card#139
donislawdev merged 3 commits into
masterfrom
ci/signed-release

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

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 the
unsigned 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 user
gets, 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-run
does everything except sign, upload and dispatch.

Phase C (attest-release.yml, dispatched): 🔴 it downloads the archive rather than
trusting 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 person
signed that file on their own machine.

The certificate is pinned by its bytes

legal.CODESIGN_SHA256, the same shape as WINDIVERT_SHA256 and 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. 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.

Also here

The hash generator can no longer be pointed at a different PyPI endpoint. A scanner
flagged urlopen and named file://; that risk is not reachable and the new tests prove it
rather than asserting it. What was reachable: a ? or # in a version truncated the path
into a query or fragment - a different endpoint, answering confidently about something else,
into the file that gates the supply chain.

Verification

  • As far as it goes without the card's PIN: the script resolves the pinned certificate in the
    Windows store and finds the newest SDK signtool; both workflows parse; pin_hashes.py
    regenerates requirements-lint.txt byte-identically.
  • Four mutations, all caught: the draft shipping the unsigned archive, the script no
    longer checking which certificate signed, the attestation taking a digest instead of the
    file, and the escaping removed from the hash generator.
  • The first version of the draft guard read only the gh release create line while --draft
    was 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.
  • Full suite not run locally - it runs here, on Linux and Windows.

🤖 Generated with Claude Code

donislawdev and others added 3 commits August 19, 2026 18:23
…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>
@donislawdev
donislawdev merged commit d61ca58 into master Aug 19, 2026
13 of 14 checks passed
@donislawdev
donislawdev deleted the ci/signed-release branch August 19, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant