Gitlab has built-in support for keyless signing with Sigstore - if you’re using a hosted version of Gitlab. If you’re self-hosting, this requires a bit more effort to setup.

Here’s how I set up a Gitlab CI workflow to build an image with kaniko, push it to a self-hosted Harbor registry, and sign the digest with Cosign and Vault.

Vault setup

Enable transit secrets engine, create the cosign key, and create a policy for access:

vault secrets enable transit
vault write -f transit/keys/cosign type=ecdsa-p256
vault policy write cosign - '
  path "transit/keys/cosign" {
    capabilities = ["read"]
  }
  path "transit/hmac/cosign/*" {
    capabilities = ["update"]
  }
  path "transit/sign/cosign/*" {
    capabilities = ["update"]
  }
  path "transit/verify/cosign" {
    capabilities = ["create"]
  }
  path "transit/verify/cosign/*" {
    capabilities = ["update"]
  }
'

Set up JWT auth:

vault auth enable jwt
vault write auth/jwt/role/cosign - '
  {
    "bound_audiences": [
      "https://gitlab.example.com"
    ],
    "bound_claims": {
      "iss": [
        "gitlab.example.com",
        "https:/gitlab.example.com"
      ],
      "project_path": [
        "container-images/*"
      ]
    },
    "bound_claims_type": "glob",
    "policies": [
      "cosign"
    ],
    "role_type": "jwt",
    "user_claim": "user_email"
  }
'
vault write auth/jwt/config \
  jwks_url="https://gitlab.example.com/oauth/discovery/keys"

kaniko image

Kaniko is now maintained by Chainguard, but they only provide the source code - not the images or binaries for actually doing things with kaniko. Fortunately there is an image available from gitlab.com/gitlab-ci-utils/container-images/kaniko. I’m going to build a custom image based on that and my own alpine image (as an easy way to bring in my internal CA certs):

ARG ALPINE_VERSION=3.21.3
ARG KANIKO_VERSION=1.25.1
 
FROM registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v${KANIKO_VERSION}-debug AS kaniko
FROM harbor.example.com/library/alpine:${ALPINE_VERSION}
 
COPY --from=kaniko /kaniko /kaniko 
ENV PATH=$PATH:/kaniko \
  DOCKER_CONFIG=/kaniko/.docker
 
CMD ["/bin/sh"]

cosign image

To be able to handle interacting with Vault and signing the digest in a single step, I’m using a customized image which contains both of those the needed tools.

ARG ALPINE_VERSION=3.21.3
ARG COSIGN_VERSION=2.5.3
ARG VAULT_VERSION=1.20.2
 
FROM ghcr.io/sigstore/cosign/cosign:v${COSIGN_VERSION} AS cosign
FROM hashicorp/vault:${VAULT_VERSION} AS vault
FROM harbor.example.com/library/alpine:${ALPINE_VERSION}
COPY --from=cosign /ko-app/cosign /cosign/cosign 
COPY --from=vault /bin/vault /cosign/vault
 
RUN mkdir -p /cosign/.docker
ENV PATH=$PATH:/cosign \
  DOCKER_CONFIG=/cosign/.docker
 
CMD ["/bin/sh"]

Gitlab pipeline

# .gitlab-ci.yml
 
stages:
- build
 
build-image:
  stage: build
  image: harbor.example.com/library/kaniko:latest
  before_script:
    - echo "{\"auths\":{\"${HARBOR_HOST}\":{\"auth\":\"$(echo -n ${HARBOR_USERNAME}:${HARBOR_PASSWORD} | base64 -w 0)\"}}}" > /kaniko/.docker/config.json
  script:
    - /kaniko/executor
      --context "${CI_PROJECT_DIR}"
      --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
      --destination "${HARBOR_HOST}/${HARBOR_PROJECT}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}"
      --digest-file "./digest"
  artifacts:
    paths:
      - digest
 
sign-image:
  stage: build
  image: harbor.example.com/library/cosign:latest
  needs:
    - build-image
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://gitlab.example.com
  variables:
    VAULT_ADDR: https://vault.example.com
    VAULT_JWT_ROLE: cosign
    COSIGN_KEY: hashivault://cosign
  before_script:
  - export VAULT_TOKEN="$(vault write --field=token auth/jwt/login role=$VAULT_JWT_ROLE jwt=$VAULT_ID_TOKEN)"
  - echo "{\"auths\":{\"${HARBOR_HOST}\":{\"auth\":\"$(echo -n ${HARBOR_USERNAME}:${HARBOR_PASSWORD} | base64 -w 0)\"}}}" > /cosign/.docker/config.json
  script:
  - /cosign/cosign sign "${HARBOR_HOST}/${HARBOR_PROJECT}/${CI_PROJECT_NAME}@$(busybox cat digest)" --tlog-upload=false

See also:

Refs: