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