Zero-friction “keyless signing” with Github Actions

For more background on “keyless signing”, see our previous posts on Fulcio and keyless signing with EKS. Here we will walk through how to apply these concepts to Github’s recently launched support for OpenID Connect (OIDC) tokens in Actions.

Enable OIDC

In order to enable Github Actions OIDC tokens you will need to make sure your actions workflow has the following permissions: block (more info):

jobs:
  release_job:
    runs-on: ubuntu-latest 
    permissions:
      id-token: write   # This is the key for OIDC!

The id-token: write line enables our release_job to create tokens as this workflow. It is notable that this permission may only be granted to workflows on the main repository, so it cannot be granted during pull request workflows. To facilitate OIDC support several environment variables are injected that tell the workflow how to get these tokens (read more here).

Install Cosign

The next thing we need to do is install cosign, which is a CLI supported by the sigstore community to make signing easy. The community has an action available through the Github Action Marketplace, which can be added to your workflows via:

    - name: Install cosign
      uses: sigstore/cosign-installer@main

You can configure this to use a particular release of cosign (e.g. v1.3.1) with:

    - name: Install cosign
      uses: sigstore/cosign-installer@main
      with:
        cosign-release: 'v1.3.1'

Signing Container Images

To sign container images, the last thing we need to do is add a step that invokes the cosign CLI to sign the container image digest (replace << INSERT DIGEST >> with:

    - name: Sign the container image
      env:
        COSIGN_EXPERIMENTAL: "true"
      run: cosign sign << INSERT DIGEST >>

Note: If the image you are signing is private, then you will need to pass --force if you want to record signatures to the Rekor transparency log. Prior to cosign v1.4.x this is required for keyless signing of private images.

The most common use case here is to sign something you built earlier in the workflow, so for example using the docker/build-push-action step (e.g. id: push-step) you can use that step’s output to access the digest like this:

    - name: Sign the container image
      env:
        COSIGN_EXPERIMENTAL: "true"
      run: |
        cosign sign \
          ${REPO}@${{ steps.push-step.outputs.digest }}

Note: A common mistake here is to sign the tag you just pushed. Tags are mutable and can point to different image digests over time, so if you use the tag here you are opening yourself up to both race conditions and malicious actors (incl. the registry!) which could have you sign something other than what you just pushed. By signing the digest you just pushed, you effectively eliminate the need to trust the registry because you are signing a crypographically verifiable checksum of the image.

However, the docker/build-push-action currently has a bug, so you will need to add the following step earlier in your workflow to avoid this:

    - name: Setup buildx
      uses: docker/setup-buildx-action@main

Putting it together

I have put together a small demo repo to show all of these pieces working together. You can try this out yourself by forking this repo, and triggering this workflow from your fork’s Actions tab.

If you take the resulting image digest and run:

  COSIGN_EXPERIMENTAL=true cosign verify $DIGEST | jq .

You should see (snipped for brevity):

Verification for $DIGEST --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - Any certificates were verified against the Fulcio roots.
[
  {
    "critical": {
      "identity": {
        "docker-reference": "ghcr.io/mattmoor/zero-friction-actions"
      },
      "image": {
        "docker-manifest-digest": "..."
      },
      "type": "cosign container image signature"
    },
    "optional": {
      ...
      "Issuer": "https://token.actions.githubusercontent.com",
      "Subject": "https://github.com/mattmoor/zero-friction-actions/.github/workflows/docker-publish.yml@refs/heads/main"
    }
  }
]

In particular, note the Github Actions "Issuer", and how the "Subject" is the actions workflow used to sign the image!

Bonus: More than just containers!

While containers are an increasingly prominent type of artifact, they are not the only game in town! Thankfully the concepts we discussed here apply to most forms of artifacts, and we are seeing the Sigstore community working to integrate with all manner of artifacts.

In the Go ecosystem, the popular goreleaser project recently added support for keyless signing (example repo, blog). In the Ruby ecosystem, Shopify is investing in signing rubygems. In the Python ecosystem, there is PEP-480 trying to address package signing.

If you are interested in getting involved, or learning more about sigstore, please reach out via slack, email, or join the weekly community call.

Show Comments