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.