From 4d077a65a697c4d0d692ff9a9b0c6d259d0b4c5f Mon Sep 17 00:00:00 2001 From: mescon <5875228+mescon@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:02:31 +0200 Subject: [PATCH] ci(release): sign every asset and attach SLSA provenance The signing job only ever covered the Arch packages and their repository database, although the README and the assurance document said every asset was signed; the Debian packages, the telemetry helpers and the SBOMs went out bare. The release workflow now ends with three jobs that run once every asset is attached: one signs every asset that has no detached signature yet with the same key, one hashes the whole asset list, and one hands the list to the SLSA generic generator, which signs a build provenance statement through Sigstore and attaches it as release-assets.intoto.jsonl. slsa-verifier can then confirm an asset against the repository and the tag. The generator is referenced by tag because slsa-verifier cannot verify its ref otherwise. A provenance-only dispatch input skips every channel and exercises the provenance jobs against an existing release without uploading anything, which is how the jobs get tested before the next release. The docs now say what was signed up to 0.41.0 instead of overstating it. --- .github/workflows/publish-release.yml | 126 ++++++++++++++++++++++++++ CHANGELOG.md | 14 +++ README.md | 35 +++++-- docs/SECURITY_ASSURANCE.md | 25 +++-- 4 files changed, 182 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 78c454c..adaf4cd 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,6 +15,15 @@ name: Publish release packages # release as a pacman repository (needs secrets # GPG_SIGNING_KEY and GPG_PASSPHRASE). Independent # of the AUR, which has had long outages. +# * Signatures + provenance -> once every asset is attached, each one +# without a detached signature gets one from the +# same key, and a SLSA provenance statement +# listing every asset by SHA-256 is signed through +# Sigstore and attached as +# release-assets.intoto.jsonl. A manual dispatch +# with provenance-only set skips every channel and +# only exercises the provenance jobs against an +# existing release, uploading nothing. # # Required repository secrets (Settings -> Secrets and variables -> Actions): # COPR_CONFIG the contents of your ~/.config/copr @@ -48,6 +57,10 @@ on: tag: description: "Existing release tag to (re)publish, e.g. v0.12.1" required: true + provenance-only: + description: "Only hash the existing assets and build provenance, without uploading it" + type: boolean + default: false # Read-only token by default; the jobs that attach assets to the release # ask for contents: write themselves, and the channel pushes use their own @@ -79,6 +92,7 @@ jobs: debian: name: Debian/Ubuntu .deb needs: validate + if: ${{ !inputs.provenance-only }} runs-on: ubuntu-latest # Only this job needs write access, to attach the .deb to the release. permissions: @@ -129,6 +143,7 @@ jobs: sbom: name: SBOM (CycloneDX) needs: validate + if: ${{ !inputs.provenance-only }} runs-on: ubuntu-latest permissions: contents: write @@ -156,6 +171,7 @@ jobs: telemetry-helpers: name: Telemetry helpers (relay + SCS plugin) needs: validate + if: ${{ !inputs.provenance-only }} runs-on: ubuntu-latest # Attaches the built helpers to the release. permissions: @@ -210,6 +226,7 @@ jobs: copr: name: Fedora COPR (akmod) needs: validate + if: ${{ !inputs.provenance-only }} runs-on: ubuntu-latest container: fedora:latest steps: @@ -258,6 +275,7 @@ jobs: obs: name: openSUSE OBS needs: validate + if: ${{ !inputs.provenance-only }} runs-on: ubuntu-latest container: opensuse/tumbleweed:latest steps: @@ -358,6 +376,7 @@ jobs: aur-build: name: Arch AUR (build check) needs: validate + if: ${{ !inputs.provenance-only }} runs-on: ubuntu-latest container: image: archlinux:base-devel @@ -586,3 +605,110 @@ jobs: logitech-trueforce-signing-key.asc \ --clobber + # Everything below runs once every job that attaches assets has finished, + # so that the signatures and the provenance cover the release as shipped. + # On a provenance-only dispatch the asset jobs are skipped, which is fine; + # anything else short of success means the release is incomplete and gets + # no signatures and no provenance until it is republished. + sign-assets: + name: Sign the remaining assets + needs: [validate, debian, sbom, telemetry-helpers, archrepo] + if: ${{ !cancelled() && needs.validate.result == 'success' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && !inputs.provenance-only }} + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Download every asset of the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir assets + ls -1 assets + - name: Sign every asset that has no signature yet + env: + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GH_TOKEN: ${{ github.token }} + run: | + set -eu + if [ -z "${GPG_SIGNING_KEY:-}" ]; then + echo "::error::GPG_SIGNING_KEY is not set; see README 'Arch Linux repository'." >&2 + exit 1 + fi + # Same scratch keyring and loopback pinentry as the Arch repo job. + export GNUPGHOME + GNUPGHOME=$(mktemp -d) + chmod 700 "$GNUPGHOME" + printf '%s\n' "$GPG_SIGNING_KEY" | gpg --batch --import + KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/ {print $5; exit}') + echo "signing with $KEYID" + cd assets + new="" + for f in *; do + # Signatures, the public key and provenance sign nothing. + case "$f" in *.sig|*.asc|*.intoto.jsonl) continue;; esac + [ -e "$f.sig" ] && continue + gpg --batch --yes --pinentry-mode loopback \ + --passphrase "$GPG_PASSPHRASE" \ + --detach-sign --no-armor -u "$KEYID" "$f" + gpg --verify "$f.sig" "$f" + new="$new $f.sig" + done + if [ -n "$new" ]; then + # shellcheck disable=SC2086 + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" $new --clobber + fi + echo "signed:${new:- nothing, every asset already had a signature}" + + # A SLSA build provenance statement listing every asset of the release by + # SHA-256, signed keylessly through Sigstore by the SLSA generic generator + # and attached as release-assets.intoto.jsonl. Anyone can then check that + # an asset they downloaded is the one this workflow published: + # slsa-verifier verify-artifact \ + # --provenance-path release-assets.intoto.jsonl \ + # --source-uri github.com/mescon/logitech-trueforce-linux-driver \ + # --source-tag vX.Y.Z + hashes: + name: Hash the release assets + needs: [validate, sign-assets] + if: ${{ !cancelled() && needs.validate.result == 'success' && needs.sign-assets.result != 'failure' && needs.sign-assets.result != 'cancelled' }} + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + steps: + - name: Download every asset of the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir assets + # A republish must not list the previous statement as a subject. + rm -f assets/*.intoto.jsonl + ls -1 assets + - name: List them as sha256sum output, base64-encoded + id: hash + run: | + set -eu + cd assets + sha256sum -- * + echo "hashes=$(sha256sum -- * | base64 -w0)" >> "$GITHUB_OUTPUT" + + provenance: + name: Sign the provenance + needs: hashes + permissions: + actions: read # the generator reads this run's metadata + id-token: write # keyless signing through Sigstore + contents: write # attaches the statement to the release + # Referenced by tag, not by commit hash, on purpose: slsa-verifier can + # only verify the generator's ref when it is a tag (the generator's + # README, "Referencing SLSA builders and generators"). + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 + with: + base64-subjects: ${{ needs.hashes.outputs.hashes }} + provenance-name: release-assets.intoto.jsonl + upload-assets: ${{ !inputs.provenance-only }} + upload-tag-name: ${{ github.event.release.tag_name || inputs.tag }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 050fc45..7e3a2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ the contract is "it works on RS50 and G Pro as listed here". ## Unreleased +**Every release asset is signed, and the release carries provenance.** The +signing job only ever covered the Arch packages and their repository +database, although the documentation said every asset; the Debian +packages, the telemetry helpers and the SBOMs went out unsigned. The +release workflow now ends by signing every asset that has no detached +signature yet, with the same key, and by attaching a SLSA build provenance +statement, `release-assets.intoto.jsonl`, that lists every asset by SHA-256 +and is signed through Sigstore by the SLSA generic generator. +`slsa-verifier` can then confirm that an asset is the one this repository's +workflow published at that tag; the README shows how. The workflow accepts +a `provenance-only` dispatch that exercises the provenance jobs against an +existing release without uploading anything. The README and the assurance +document now say what was signed up to 0.41.0 instead of overstating it. + **Reviewed dependency advisories are recorded where the scanners look.** The four RustSec notices against the GUI toolkit's font and image stack (bincode, paste, rustybuzz, ttf-parser, all "crate is unmaintained", none a diff --git a/README.md b/README.md index 40d3ab7..b1f8cf2 100644 --- a/README.md +++ b/README.md @@ -717,24 +717,39 @@ with the problems we know about and have not fixed. ## Verifying a release -Every asset on a [release](https://github.com/mescon/logitech-trueforce-linux-driver/releases) -is signed with the project's GnuPG key, fingerprint +Assets on a [release](https://github.com/mescon/logitech-trueforce-linux-driver/releases) +are signed with the project's GnuPG key, fingerprint `4B5B DD78 0272 3B28 9FA9 34CA CD77 C00A 443B 9E79`, whose public half is -attached to each release as `logitech-trueforce-signing-key.asc`. The Arch -repository above is verified by pacman once the key is trusted. For any -other asset: +attached to each release as `logitech-trueforce-signing-key.asc`. Up to +0.41.0 that covers the Arch packages and the repository database, which +pacman verifies once the key is trusted; from 0.42.0 every asset has a +detached `.sig`. To check one by hand: ```bash gpg --import logitech-trueforce-signing-key.asc gpg --fingerprint 4B5BDD7802723B289FA934CACD77C00A443B9E79 # must match the line above -gpg --verify logi-wheel_0.41.0-1_amd64.deb.sig logi-wheel_0.41.0-1_amd64.deb +gpg --verify logi-wheel-0.41.0-1-x86_64.pkg.tar.zst.sig logi-wheel-0.41.0-1-x86_64.pkg.tar.zst ``` A release is from this project when it verifies against that key; a change -of key would be announced here and in the release notes. From 0.42.0 each -release also carries a CycloneDX SBOM per binary. Only the latest release -is supported; see [SECURITY.md](SECURITY.md) for the policy and the -reporting process. +of key would be announced here and in the release notes. + +From 0.42.0 each release also carries a CycloneDX SBOM per binary and a +SLSA provenance statement, `release-assets.intoto.jsonl`, listing every +asset by SHA-256 and signed through Sigstore by the release workflow itself. +[slsa-verifier](https://github.com/slsa-framework/slsa-verifier) checks that +an asset is the one the workflow published, from this repository, at that +tag: + +```bash +slsa-verifier verify-artifact logi-wheel_0.42.0-1_amd64.deb \ + --provenance-path release-assets.intoto.jsonl \ + --source-uri github.com/mescon/logitech-trueforce-linux-driver \ + --source-tag v0.42.0 +``` + +Only the latest release is supported; see [SECURITY.md](SECURITY.md) for +the policy and the reporting process. ## Project documents diff --git a/docs/SECURITY_ASSURANCE.md b/docs/SECURITY_ASSURANCE.md index bbf1a3e..b107066 100644 --- a/docs/SECURITY_ASSURANCE.md +++ b/docs/SECURITY_ASSURANCE.md @@ -36,7 +36,7 @@ Who could attack, through what, and what the consequence would be. | **A Wine process** writing force-feedback reports to the virtual wheel | The PID report decoder in logi-ffb | A proxy crash (loss of force) or wrong forces | The decoder validates report ids, lengths and block indexes and is fuzzed; effects are translated into the kernel's own force-feedback interface, which bounds them; the proxy runs as the user, with no more access than the game itself has. | | **A game's prefix** where the Windows helpers run | The escape proxy and relay, and through them the wheel's sysfs range | Wrong rotation range; a stalled game | The helpers only read the game's shared memory and write telemetry to loopback; the one write they perform on the wheel (the rotation range the SDK announces) is bounds-checked to the range a wheel accepts, and can be switched off. | | **Another local user** | The wheel's sysfs attributes, which the udev rule makes world-writable | Changed settings; a changed rotation range while someone is driving | Accepted risk, stated here: the attributes are world-writable so that settings apps and games work without root, and a machine with a racing wheel attached is assumed to be single-user. No attribute grants any privilege beyond the wheel itself. | -| **A dependency or a build tool** | Everything | Compromised releases | Dependencies are pinned by `Cargo.lock`, updated by Dependabot, checked by `cargo audit` on every push and weekly; GitHub Actions are pinned to commit hashes; workflow tokens are read-only by default; releases are signed and the signing key's fingerprint is published. | +| **A dependency or a build tool** | Everything | Compromised releases | Dependencies are pinned by `Cargo.lock`, updated by Dependabot, checked by `cargo audit` on every push and weekly; GitHub Actions are pinned to commit hashes; workflow tokens are read-only by default; releases are signed, carry SLSA provenance for every asset, and the signing key's fingerprint is published. | | **The maintainer's accounts and secrets** | Releases and channels | Compromised releases | Two-factor authentication is required for write access; secrets live only in GitHub Actions and are rotated on any suspicion; the rules are in GOVERNANCE.md. | The critical code paths, in order of consequence, are the kernel module's @@ -107,14 +107,23 @@ module source itself, which the packages ship. ## Verifying a release -Every release asset is signed with the project's GnuPG key, fingerprint +Release assets are signed with the project's GnuPG key, fingerprint `4B5B DD78 0272 3B28 9FA9 34CA CD77 C00A 443B 9E79`, published with each -release as `logitech-trueforce-signing-key.asc`. The README's install -section says how to verify: pacman does it once the key is trusted; for -any other asset, `gpg --verify .sig ` after importing the -key and checking the fingerprint. The identity behind a release is that -key: a release signed by any other key is not from this project until the -README says the key has changed. +release as `logitech-trueforce-signing-key.asc`: the Arch packages and +repository database up to 0.41.0, every asset from 0.42.0. The README's +"Verifying a release" says how to check one: pacman does it once the key +is trusted; for any other asset, `gpg --verify .sig ` after +importing the key and checking the fingerprint. The identity behind a +release is that key: a release signed by any other key is not from this +project until the README says the key has changed. + +From 0.42.0 each release also carries a SLSA build provenance statement +(`release-assets.intoto.jsonl`) produced by the SLSA generic generator: +the release workflow hashes every attached asset and the generator signs +the list keylessly through Sigstore, recording the repository, the tag and +the workflow that published them. `slsa-verifier` checks an asset against +it, as the README shows. The GnuPG signature says who signed; the +provenance says what built it and from which commit. ## Support and end of support