From 849e5432783691febc3f3923ce6578607e73056e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 15:00:32 +0200 Subject: [PATCH 1/3] ci: sign Docker images and attach build provenance Images pushed to Docker Hub are now signed with cosign using the GitHub Actions OIDC identity (Sigstore keyless), and BuildKit SLSA provenance and SBOM attestations are embedded in the image index. This lets users verify that a published image was built by this repository's CI workflow, rather than pushed manually with Docker Hub credentials. Signing runs in a separate job because GitHub does not issue OIDC tokens to workflows triggered from forked pull requests, and to avoid exposing the OIDC credential to third-party build actions. Assisted-by: AI --- .github/workflows/build-images.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 0641e95ce..ec1721c2b 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -19,6 +19,8 @@ concurrency: jobs: docker: runs-on: ubuntu-latest + outputs: + image_digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v7 @@ -71,6 +73,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push + id: build uses: docker/build-push-action@v7 with: context: . @@ -84,6 +87,10 @@ jobs: GIT_VERSION=${{ steps.version.outputs.git_version }} GIT_COMMIT=${{ steps.version.outputs.git_commit }} GIT_BRANCH=${{ steps.version.outputs.git_branch }} + # SLSA provenance and SBOM, embedded as attestation manifests in the + # image index; made tamper-evident by the signature in the sign job. + provenance: mode=max + sbom: true - name: Build and push development image if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/master'}} @@ -93,3 +100,25 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: nutsfoundation/nuts-node:dev + + sign: + # Separate job so id-token is only granted where no third-party build + # actions run. Skipped on pull_request: GitHub issues no OIDC tokens to + # workflows triggered from forks. + runs-on: ubuntu-latest + needs: docker + if: ${{ github.event_name != 'pull_request' }} + permissions: + id-token: write + steps: + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Install cosign + uses: sigstore/cosign-installer@v4 + + - name: Sign image with the workflow's OIDC identity (Sigstore keyless) + run: cosign sign --yes nutsfoundation/nuts-node@${{ needs.docker.outputs.image_digest }} From 67799a3ee38889155f173c51e96cb264007f3525 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 15:14:56 +0200 Subject: [PATCH 2/3] docs: describe how to verify Docker image signatures Adds a deployment page showing how to verify image signatures with cosign, deploy by digest, and enforce verification with Kyverno on Kubernetes/AKS and in Azure DevOps pipelines. Assisted-by: AI --- docs/index.rst | 1 + docs/pages/deployment/docker.rst | 2 + docs/pages/deployment/verifying-images.rst | 147 +++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 docs/pages/deployment/verifying-images.rst diff --git a/docs/index.rst b/docs/index.rst index c6316da26..757306eef 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,6 +34,7 @@ Nuts documentation pages/deployment/clustering.rst pages/deployment/certificates.rst pages/deployment/docker.rst + pages/deployment/verifying-images.rst pages/deployment/storage.rst pages/deployment/verifiable-credentials.rst pages/deployment/logging.rst diff --git a/docs/pages/deployment/docker.rst b/docs/pages/deployment/docker.rst index 3c853c1cd..54f0c47cd 100644 --- a/docs/pages/deployment/docker.rst +++ b/docs/pages/deployment/docker.rst @@ -6,6 +6,8 @@ Running on Docker This guide helps you to configure the Nuts node in Docker. To use the most recent release use ``nutsfoundation/nuts-node:latest``. For production environments it's advised to use a specific version. +Published images are signed. See :ref:`verifying-images` to check that an image was built from the published source code. + Examples ******** diff --git a/docs/pages/deployment/verifying-images.rst b/docs/pages/deployment/verifying-images.rst new file mode 100644 index 000000000..8fea6bbf9 --- /dev/null +++ b/docs/pages/deployment/verifying-images.rst @@ -0,0 +1,147 @@ +.. _verifying-images: + +Verifying image signatures +########################## + +Docker images of the Nuts node are built and pushed to Docker Hub by a GitHub Actions workflow. +The workflow signs each pushed image with `Sigstore `_ cosign, using the identity of the workflow itself. +A valid signature proves that the image was built by the CI pipeline of the ``nuts-foundation/nuts-node`` repository, from a specific commit. +An image built on a developer machine and pushed with Docker Hub credentials does not carry a valid signature. + +This page shows how to check a signature by hand, how to deploy a verified digest, and how to enforce verification in Kubernetes and in CI pipelines. + +.. note:: + + Images published before signing was added to the release pipeline are not signed. + Security fixes may be released before their source code is public. + Such an image can fail strict verification until the source is published; the release notes state this when it applies. + +Checking a signature with cosign +******************************** + +Install `cosign `_ (version 2 or later) and verify a tag: + +.. code-block:: shell + + cosign verify nutsfoundation/nuts-node:latest \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp \ + '^https://github.com/nuts-foundation/nuts-node/\.github/workflows/build-images\.yaml@' + +cosign exits with code 0 and prints the verified claims when the signature is valid. +The two flags pin the identity you trust: + +* ``--certificate-oidc-issuer``: the identity provider. For images built on GitHub Actions this is always ``https://token.actions.githubusercontent.com``. +* ``--certificate-identity-regexp``: the workflow that requested the signing certificate. Only the ``build-images.yaml`` workflow in the ``nuts-foundation/nuts-node`` repository matches this expression. + +Each signature is also recorded in the public `Rekor `_ transparency log, so anyone can audit when and by which workflow signatures were produced. + +Deploying a verified digest +*************************** + +A tag such as ``latest`` or a version number is mutable: verifying a tag and pulling the same tag later can yield different images. +To close that gap, deploy by digest. +cosign prints the digest of the image it verified (this command requires ``jq``): + +.. code-block:: shell + + DIGEST=$(cosign verify nutsfoundation/nuts-node:latest \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/nuts-foundation/nuts-node/\.github/workflows/build-images\.yaml@' \ + --output json | jq -r '.[0].critical.image."docker-manifest-digest"') + echo "nutsfoundation/nuts-node@${DIGEST}" + +Use the printed reference in ``docker run`` or in ``docker-compose.yaml``: + +.. code-block:: yaml + + services: + nuts: + image: nutsfoundation/nuts-node@sha256:... + +Enforcing verification in Kubernetes +************************************ + +An admission controller can reject any pod whose image does not carry a valid signature. +The example below uses `Kyverno `_. +The Sigstore `policy-controller `_ offers the same enforcement through a ``ClusterImagePolicy``. + +.. code-block:: yaml + + apiVersion: kyverno.io/v1 + kind: ClusterPolicy + metadata: + name: verify-nuts-node-images + spec: + validationFailureAction: Enforce + webhookTimeoutSeconds: 30 + rules: + - name: require-signed-nuts-node + match: + any: + - resources: + kinds: + - Pod + verifyImages: + - imageReferences: + - "docker.io/nutsfoundation/nuts-node*" + attestors: + - entries: + - keyless: + issuer: "https://token.actions.githubusercontent.com" + subjectRegExp: "^https://github.com/nuts-foundation/nuts-node/\\.github/workflows/build-images\\.yaml@" + rekor: + url: "https://rekor.sigstore.dev" + +The policy matches only Nuts node images; other images in the cluster are unaffected. +Kyverno replaces the tag with the verified digest on admission, so the pod runs exactly the image that was verified. +The cluster needs outbound access to Docker Hub to fetch signatures. + +Azure +***** + +* **Azure Kubernetes Service (AKS)**: the Kyverno policy above works unchanged. Azure also offers a built-in image integrity feature based on Azure Policy and `Ratify `_; see the `AKS image integrity documentation `_ for the signature formats it currently supports. +* **Azure Container Apps and Container Instances**: these services have no admission control. Verify in the deployment pipeline and deploy by digest. +* **Azure DevOps pipelines**: add a verification step before deployment. Pin the cosign version in real pipelines instead of downloading ``latest``. + +.. code-block:: yaml + + steps: + - task: Bash@3 + displayName: Verify nuts-node image signature + inputs: + targetType: inline + script: | + set -euo pipefail + curl -sLo cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 + chmod +x cosign + DIGEST=$(./cosign verify "nutsfoundation/nuts-node:$(NUTS_VERSION)" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github.com/nuts-foundation/nuts-node/\.github/workflows/build-images\.yaml@' \ + --output json | jq -r '.[0].critical.image."docker-manifest-digest"') + echo "##vso[task.setvariable variable=NUTS_IMAGE]nutsfoundation/nuts-node@${DIGEST}" + +Later pipeline steps deploy ``$(NUTS_IMAGE)``, for example with the ``AzureContainerApps`` or ``KubernetesManifest`` tasks. + +Provenance and SBOM +******************* + +Each image contains SLSA build provenance and an SPDX software bill of materials, embedded as attestation manifests in the image index. +The provenance records the source repository, the commit, and the build parameters. +Inspect them with: + +.. code-block:: shell + + docker buildx imagetools inspect nutsfoundation/nuts-node:latest \ + --format '{{ json .Provenance }}' + docker buildx imagetools inspect nutsfoundation/nuts-node:latest \ + --format '{{ json .SBOM }}' + +The attestations are part of the image index, so the cosign signature on the image digest covers them. + +Scope of the guarantee +********************** + +A valid signature proves that the image was built and pushed by the ``build-images.yaml`` workflow of the ``nuts-foundation/nuts-node`` repository, at the commit recorded in the certificate, and that the image was not modified afterwards. +It does not prove that the source code at that commit is free of defects or malicious changes. +Review of the source code, and of who may change it, remains the basis of trust. From 4343c56b77a3ff3bdee6331d08881e6e44605d78 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 15:23:08 +0200 Subject: [PATCH 3/3] docs: accept nuts-node-private identity when verifying images Embargoed security releases are built from the private clone nuts-foundation/nuts-node-private, so their signature carries that repository's workflow identity. Accepting only the public identity would make enforcing verifiers reject exactly the security releases users most need to install. Assisted-by: AI --- docs/pages/deployment/verifying-images.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/pages/deployment/verifying-images.rst b/docs/pages/deployment/verifying-images.rst index 8fea6bbf9..27ca703e8 100644 --- a/docs/pages/deployment/verifying-images.rst +++ b/docs/pages/deployment/verifying-images.rst @@ -13,8 +13,9 @@ This page shows how to check a signature by hand, how to deploy a verified diges .. note:: Images published before signing was added to the release pipeline are not signed. - Security fixes may be released before their source code is public. - Such an image can fail strict verification until the source is published; the release notes state this when it applies. + Security fixes are prepared in the private repository ``nuts-foundation/nuts-node-private`` and may be released before their source code is public. + Images of such a release are signed with the identity of that repository's workflow; the verification commands below accept both identities. + The source code of an embargoed release becomes available in the public repository at disclosure. Checking a signature with cosign ******************************** @@ -26,13 +27,13 @@ Install `cosign `_ cosign verify nutsfoundation/nuts-node:latest \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp \ - '^https://github.com/nuts-foundation/nuts-node/\.github/workflows/build-images\.yaml@' + '^https://github.com/nuts-foundation/nuts-node(-private)?/\.github/workflows/build-images\.yaml@' cosign exits with code 0 and prints the verified claims when the signature is valid. The two flags pin the identity you trust: * ``--certificate-oidc-issuer``: the identity provider. For images built on GitHub Actions this is always ``https://token.actions.githubusercontent.com``. -* ``--certificate-identity-regexp``: the workflow that requested the signing certificate. Only the ``build-images.yaml`` workflow in the ``nuts-foundation/nuts-node`` repository matches this expression. +* ``--certificate-identity-regexp``: the workflow that requested the signing certificate. Only the ``build-images.yaml`` workflow in the ``nuts-foundation/nuts-node`` repository, or in ``nuts-foundation/nuts-node-private`` for embargoed security releases, matches this expression. Each signature is also recorded in the public `Rekor `_ transparency log, so anyone can audit when and by which workflow signatures were produced. @@ -47,7 +48,7 @@ cosign prints the digest of the image it verified (this command requires ``jq``) DIGEST=$(cosign verify nutsfoundation/nuts-node:latest \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp '^https://github.com/nuts-foundation/nuts-node/\.github/workflows/build-images\.yaml@' \ + --certificate-identity-regexp '^https://github.com/nuts-foundation/nuts-node(-private)?/\.github/workflows/build-images\.yaml@' \ --output json | jq -r '.[0].critical.image."docker-manifest-digest"') echo "nutsfoundation/nuts-node@${DIGEST}" @@ -89,7 +90,7 @@ The Sigstore `policy-controller