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 }}
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..27ca703e8
--- /dev/null
+++ b/docs/pages/deployment/verifying-images.rst
@@ -0,0 +1,149 @@
+.. _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 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
+********************************
+
+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(-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, 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.
+
+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(-private)?/\.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(-private)?/\\.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(-private)?/\.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 (or ``nuts-node-private`` for embargoed security releases), at the commit recorded in the certificate, and that the image was not modified afterwards.
+For an embargoed release the commit is not publicly readable until disclosure; until then the signature proves the origin of the image but the source cannot be audited.
+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.