Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/attest-release.yml
Original file line number Diff line number Diff line change
@@ -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
# <zip> --bundle <this file>` 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)"
95 changes: 36 additions & 59 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <zip> --bundle <this file>` 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".
#
Expand All @@ -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."
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 16 additions & 6 deletions README.pl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions beantester/legal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading