diff --git a/.github/allowed_signers b/.github/allowed_signers new file mode 100644 index 0000000..df0478e --- /dev/null +++ b/.github/allowed_signers @@ -0,0 +1 @@ +ivan.vydrin.99@gmail.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEP+eaAvmygExDDthOJmJRDbzptN1yVzTOi9FQVhpbgQ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b300c91..043e2c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,22 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The provenance attestation proves WHAT built an artifact. It cannot prove WHO authorised + # the release: anyone able to push a tag can start this workflow, and the attestation would + # be perfectly valid. The signing key is the one credential GitHub never holds, so requiring + # a signed tag is what makes a stolen GitHub account insufficient on its own. + - name: Require a signed tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + git fetch --force origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" + git config gpg.ssh.allowedSignersFile .github/allowed_signers + if ! git verify-tag "${GITHUB_REF_NAME}"; then + echo "::error::Tag ${GITHUB_REF_NAME} is not signed by a key listed in .github/allowed_signers. See RELEASING.md." + exit 1 + fi + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: '10.0.x' diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bed8a..5fa0e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ reconstructed is now a real session. What has been verified and what has not is ### Security +- **Release tags must now be signed.** The workflow verifies the tag's SSH signature against + `.github/allowed_signers` before it builds anything. The provenance attestation proves *what* + built an artifact; it cannot prove *who* authorised the release, because anyone able to push a + tag starts the workflow and the resulting attestation would be valid. The signing key is the one + credential GitHub does not hold, so this is what makes a stolen GitHub account insufficient on + its own. See [RELEASING.md](RELEASING.md#signed-tags), including what it does not protect + against. + - **The service-principal recipe no longer puts the client secret in a command-line argument.** `curl --user "$ID:$SECRET"` — the form Databricks' own documentation shows — makes the secret readable from the process table by anything else on the machine while the request runs. On a diff --git a/README.md b/README.md index 9dc13aa..8b77806 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![LakeSpeak.Genie](https://img.shields.io/nuget/v/LakeSpeak.Genie?label=LakeSpeak.Genie)](https://www.nuget.org/packages/LakeSpeak.Genie/) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/ivanvyd/LakeSpeak.NET/badge)](https://scorecard.dev/viewer/?uri=github.com/ivanvyd/LakeSpeak.NET) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13969/badge)](https://www.bestpractices.dev/projects/13969) Talk to governed Databricks data from your terminal and .NET applications. diff --git a/RELEASING.md b/RELEASING.md index 21c133b..7b65987 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -52,6 +52,36 @@ lakespeak --version CI already does this on every PR (the `tool-smoke` job), but doing it by hand once before a real release is cheap. +## Signed tags + +The release workflow verifies the tag's signature before it builds anything, against the public +keys in [`.github/allowed_signers`](.github/allowed_signers). + +This exists because the provenance attestation answers a different question than people assume. It +proves **what** built an artifact — this workflow, this repository, this commit. It cannot prove +**who** authorised the release: anyone able to push a tag starts the workflow, and the attestation +on the result would be perfectly valid. The signing key is the one credential GitHub does not hold, +so requiring a signed tag is what makes a stolen GitHub account insufficient by itself. + +Signing uses SSH, not GPG — the same key already used for commits, so there is no second key to +manage: + +```bash +git config gpg.format ssh +git config user.signingkey ~/.ssh/id_ed25519.pub +git config tag.gpgSign true +``` + +Two consequences worth knowing before relying on this. + +**A lost key blocks releases** until a new public key is committed to `.github/allowed_signers`. +That is the trade: the control is only as available as the key. Keep a second maintainer key in +that file if the project ever gains one. + +**It is not absolute.** Someone holding the GitHub account could open a pull request removing the +verification step and merge it. Signing makes that a multi-step attack recorded in git history +rather than a single silent tag push, which is the realistic protection available here. + ## Cut the release ### Prerequisites, once @@ -134,13 +164,20 @@ recreating it under the same name, and publishing as if nothing changed. 2. Confirm `docs/compatibility.md` reflects what has actually been verified for this version. An entry there with no evidence behind it is worse than a missing one. 3. Merge those to `main`. -4. Tag and push: +4. Tag and push. **The tag must be signed** — the workflow refuses to build an unsigned one: ```bash git tag -a v1.2.3 -m "v1.2.3" git push origin v1.2.3 ``` + `tag.gpgSign` is set locally to `true`, so `git tag -a` signs without `-s`. Check before + pushing if you want to be sure: + + ```bash + git -c gpg.ssh.allowedSignersFile=.github/allowed_signers verify-tag v1.2.3 + ``` + 5. The workflow runs. If the environment gate is configured (see prerequisites), it stops there for approval — otherwise it publishes straight away. 6. Check the GitHub release: three binaries, checksums, SBOM, generated notes. diff --git a/SECURITY.md b/SECURITY.md index f6f4b02..c8a20b7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,6 +80,8 @@ Recorded in [docs/security/threat-model.md](docs/security/threat-model.md). effect of pushing a tag. - Live integration tests never run for pull requests from forks, because they need workspace credentials. +- Release tags must be signed by a key in `.github/allowed_signers`; the workflow refuses to build + an unsigned tag, so a release cannot be triggered with GitHub access alone. ## Verifying a release @@ -105,6 +107,15 @@ A passing check tells you the file was built by `.github/workflows/release.yml` repository, and not rebuilt or replaced by anyone afterwards. A failing one means the file did not come from here — treat it as hostile rather than as a tooling problem. +The attestation answers *what built this*. **Who authorised it** is a separate question, answered +by the release tag: the workflow verifies the tag's SSH signature against +[`.github/allowed_signers`](.github/allowed_signers) before it builds, so a release cannot be +started by someone holding only the GitHub account. You can check any release tag yourself: + +```bash +git -c gpg.ssh.allowedSignersFile=.github/allowed_signers verify-tag v0.1.0 +``` + To check the binaries against their published digests instead: ```bash