|
| 1 | +--- |
| 2 | +layout: blog |
| 3 | +title: "Verifying container image signatures within CRI runtimes" |
| 4 | +date: 2023-06-29 |
| 5 | +slug: container-image-signature-verification |
| 6 | +--- |
| 7 | + |
| 8 | +**Author**: Sascha Grunert |
| 9 | + |
| 10 | +The Kubernetes community has been signing their container image-based artifacts |
| 11 | +since release v1.24. While the graduation of the [corresponding enhancement][kep] |
| 12 | +from `alpha` to `beta` in v1.26 introduced signatures for the binary artifacts, |
| 13 | +other projects followed the approach by providing image signatures for their |
| 14 | +releases, too. This means that they either create the signatures within their |
| 15 | +own CI/CD pipelines, for example by using GitHub actions, or rely on the |
| 16 | +Kubernetes [image promotion][promo] process to automatically sign the images by |
| 17 | +proposing pull requests to the [k/k8s.io][k8s.io] repository. A requirement for |
| 18 | +using this process is that the project is part of the `kubernetes` or |
| 19 | +`kubernetes-sigs` GitHub organization, so that they can utilize the community |
| 20 | +infrastructure for pushing images into staging buckets. |
| 21 | + |
| 22 | +[kep]: https://github.com/kubernetes/enhancements/issues/3031 |
| 23 | +[promo]: https://github.com/kubernetes-sigs/promo-tools/blob/e2b96dd/docs/image-promotion.md |
| 24 | +[k8s.io]: https://github.com/kubernetes/k8s.io/tree/4b95cc2/k8s.gcr.io |
| 25 | + |
| 26 | +Assuming that a project now produces signed container image artifacts, how can |
| 27 | +one actually verify the signatures? It is possible to do it manually like |
| 28 | +outlined in the [official Kubernetes documentation][docs]. The problem with this |
| 29 | +approach is that it involves no automation at all and should be only done for |
| 30 | +testing purposes. In production environments, tools like the [sigstore |
| 31 | +policy-controller][policy-controller] can help with the automation. These tools |
| 32 | +provide a higher level API by using [Custom Resource Definitions (CRD)][crd] as |
| 33 | +well as an integrated [admission controller and webhook][admission] to verify |
| 34 | +the signatures. |
| 35 | + |
| 36 | +[docs]: /docs/tasks/administer-cluster/verify-signed-artifacts/#verifying-image-signatures |
| 37 | +[policy-controller]: https://docs.sigstore.dev/policy-controller/overview |
| 38 | +[crd]: /docs/concepts/extend-kubernetes/api-extension/custom-resources |
| 39 | +[admission]: /docs/reference/access-authn-authz/admission-controllers |
| 40 | + |
| 41 | +The general usage flow for an admission controller based verification is: |
| 42 | + |
| 43 | + |
| 44 | + |
| 45 | +A key benefit of this architecture is simplicity: A single instance within the |
| 46 | +cluster validates the signatures before any image pull can happen in the |
| 47 | +container runtime on the nodes, which gets initiated by the kubelet. This |
| 48 | +benefit also brings along the issue of separation: The node which should pull |
| 49 | +the container image is not necessarily the same node that performs the admission. This |
| 50 | +means that if the controller is compromised, then a cluster-wide policy |
| 51 | +enforcement can no longer be possible. |
| 52 | + |
| 53 | +One way to solve this issue is doing the policy evaluation directly within the |
| 54 | +[Container Runtime Interface (CRI)][cri] compatible container runtime. The |
| 55 | +runtime is directly connected to the [kubelet][kubelet] on a node and does all |
| 56 | +the tasks like pulling images. [CRI-O][cri-o] is one of those available runtimes |
| 57 | +and will feature full support for container image signature verification in v1.28. |
| 58 | + |
| 59 | +[cri]: /docs/concepts/architecture/cri |
| 60 | +[kubelet]: /docs/reference/command-line-tools-reference/kubelet |
| 61 | +[cri-o]: https://github.com/cri-o/cri-o |
| 62 | + |
| 63 | +How does it work? CRI-O reads a file called [`policy.json`][policy.json], which |
| 64 | +contains all the rules defined for container images. For example, you can define a |
| 65 | +policy which only allows signed images `quay.io/crio/signed` for any tag or |
| 66 | +digest like this: |
| 67 | + |
| 68 | +[policy.json]: https://github.com/containers/image/blob/b3e0ba2/docs/containers-policy.json.5.md#sigstoresigned |
| 69 | + |
| 70 | +```json |
| 71 | +{ |
| 72 | + "default": [{ "type": "reject" }], |
| 73 | + "transports": { |
| 74 | + "docker": { |
| 75 | + "quay.io/crio/signed": [ |
| 76 | + { |
| 77 | + "type": "sigstoreSigned", |
| 78 | + "signedIdentity": { "type": "matchRepository" }, |
| 79 | + "fulcio": { |
| 80 | + "oidcIssuer": "https://github.com/login/oauth", |
| 81 | + "subjectEmail": "[email protected]", |
| 82 | + "caData": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUI5ekNDQVh5Z0F3SUJBZ0lVQUxaTkFQRmR4SFB3amVEbG9Ed3lZQ2hBTy80d0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TURjeE16VTJOVGxhRncwek1URXdNRFV4TXpVMk5UaGFNQ294RlRBVEJnTlZCQW9UREhOcFozTjBiM0psCkxtUmxkakVSTUE4R0ExVUVBeE1JYzJsbmMzUnZjbVV3ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBSWdOaUFBVDcKWGVGVDRyYjNQUUd3UzRJYWp0TGszL09sbnBnYW5nYUJjbFlwc1lCcjVpKzR5bkIwN2NlYjNMUDBPSU9aZHhleApYNjljNWlWdXlKUlErSHowNXlpK1VGM3VCV0FsSHBpUzVzaDArSDJHSEU3U1hyazFFQzVtMVRyMTlMOWdnOTJqCll6QmhNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCUlkKd0I1ZmtVV2xacWw2ekpDaGt5TFFLc1hGK2pBZkJnTlZIU01FR0RBV2dCUll3QjVma1VXbFpxbDZ6SkNoa3lMUQpLc1hGK2pBS0JnZ3Foa2pPUFFRREF3TnBBREJtQWpFQWoxbkhlWFpwKzEzTldCTmErRURzRFA4RzFXV2cxdENNCldQL1dIUHFwYVZvMGpoc3dlTkZaZ1NzMGVFN3dZSTRxQWpFQTJXQjlvdDk4c0lrb0YzdlpZZGQzL1Z0V0I1YjkKVE5NZWE3SXgvc3RKNVRmY0xMZUFCTEU0Qk5KT3NRNHZuQkhKCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" |
| 83 | + }, |
| 84 | + "rekorPublicKeyData": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFMkcyWSsydGFiZFRWNUJjR2lCSXgwYTlmQUZ3cgprQmJtTFNHdGtzNEwzcVg2eVlZMHp1ZkJuaEM4VXIvaXk1NUdoV1AvOUEvYlkyTGhDMzBNOStSWXR3PT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==" |
| 85 | + } |
| 86 | + ] |
| 87 | + } |
| 88 | + } |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +CRI-O has to be started to use that policy as the global source of truth: |
| 93 | + |
| 94 | +```console |
| 95 | +> sudo crio --log-level debug --signature-policy ./policy.json |
| 96 | +``` |
| 97 | + |
| 98 | +CRI-O is now able to pull the image while verifying its signatures. This can be |
| 99 | +done by using [`crictl` (cri-tools)][cri-tools], for example: |
| 100 | + |
| 101 | +[cri-tools]: https://github.com/kubernetes-sigs/cri-tools |
| 102 | + |
| 103 | +```console |
| 104 | +> sudo crictl -D pull quay.io/crio/signed |
| 105 | +DEBU[…] get image connection |
| 106 | +DEBU[…] PullImageRequest: &PullImageRequest{Image:&ImageSpec{Image:quay.io/crio/signed,Annotations:map[string]string{},},Auth:nil,SandboxConfig:nil,} |
| 107 | +DEBU[…] PullImageResponse: &PullImageResponse{ImageRef:quay.io/crio/signed@sha256:18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a,} |
| 108 | +Image is up to date for quay.io/crio/signed@sha256:18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a |
| 109 | +``` |
| 110 | + |
| 111 | +The CRI-O debug logs will also indicate that the signature got successfully |
| 112 | +validated: |
| 113 | + |
| 114 | +```console |
| 115 | +DEBU[…] IsRunningImageAllowed for image docker:quay.io/crio/signed:latest |
| 116 | +DEBU[…] Using transport "docker" specific policy section quay.io/crio/signed |
| 117 | +DEBU[…] Reading /var/lib/containers/sigstore/crio/signed@sha256=18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a/signature-1 |
| 118 | +DEBU[…] Looking for sigstore attachments in quay.io/crio/signed:sha256-18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a.sig |
| 119 | +DEBU[…] GET https://quay.io/v2/crio/signed/manifests/sha256-18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a.sig |
| 120 | +DEBU[…] Content-Type from manifest GET is "application/vnd.oci.image.manifest.v1+json" |
| 121 | +DEBU[…] Found a sigstore attachment manifest with 1 layers |
| 122 | +DEBU[…] Fetching sigstore attachment 1/1: sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45 |
| 123 | +DEBU[…] Downloading /v2/crio/signed/blobs/sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45 |
| 124 | +DEBU[…] GET https://quay.io/v2/crio/signed/blobs/sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45 |
| 125 | +DEBU[…] Requirement 0: allowed |
| 126 | +DEBU[…] Overall: allowed |
| 127 | +``` |
| 128 | + |
| 129 | +All of the defined fields like `oidcIssuer` and `subjectEmail` in the policy |
| 130 | +have to match, while `fulcio.caData` and `rekorPublicKeyData` are the public |
| 131 | +keys from the upstream [fulcio (OIDC PKI)][fulcio] and [rekor |
| 132 | +(transparency log)][rekor] instances. |
| 133 | + |
| 134 | +[fulcio]: https://github.com/sigstore/fulcio |
| 135 | +[rekor]: https://github.com/sigstore/rekor |
| 136 | + |
| 137 | +This means that if you now invalidate the `subjectEmail` of the policy, for example to |
| 138 | + |
| 139 | + |
| 140 | +```console |
| 141 | +> jq '.transports.docker."quay.io/crio/signed"[0].fulcio.subjectEmail = "[email protected]"' policy.json > new-policy.json |
| 142 | +> mv new-policy.json policy.json |
| 143 | +``` |
| 144 | + |
| 145 | +Then remove the image, since it already exists locally: |
| 146 | + |
| 147 | +```console |
| 148 | +> sudo crictl rmi quay.io/crio/signed |
| 149 | +``` |
| 150 | + |
| 151 | +Now when you pull the image, CRI-O complains that the required email is wrong: |
| 152 | + |
| 153 | +```console |
| 154 | +> sudo crictl pull quay.io/crio/signed |
| 155 | +FATA[…] pulling image: rpc error: code = Unknown desc = Source image rejected: Required email [email protected] not found (got []string{"[email protected]"}) |
| 156 | +``` |
| 157 | + |
| 158 | +It is also possible to test an unsigned image against the policy. For that you |
| 159 | +have to modify the key `quay.io/crio/signed` to something like |
| 160 | +`quay.io/crio/unsigned`: |
| 161 | + |
| 162 | +```console |
| 163 | +> sed -i 's;quay.io/crio/signed;quay.io/crio/unsigned;' policy.json |
| 164 | +``` |
| 165 | + |
| 166 | +If you now pull the container image, CRI-O will complain that no signature exists |
| 167 | +for it: |
| 168 | + |
| 169 | +```console |
| 170 | +> sudo crictl pull quay.io/crio/unsigned |
| 171 | +FATA[…] pulling image: rpc error: code = Unknown desc = SignatureValidationFailed: Source image rejected: A signature was required, but no signature exists |
| 172 | +``` |
| 173 | + |
| 174 | +It is important to mention that CRI-O will match the |
| 175 | +`.critical.identity.docker-reference` field within the signature to match with |
| 176 | +the image repository. For example, if you verify the image |
| 177 | +`registry.k8s.io/kube-apiserver-amd64:v1.28.0-alpha.3`, then the corresponding |
| 178 | +`docker-reference` should be `registry.k8s.io/kube-apiserver-amd64`: |
| 179 | + |
| 180 | +```console |
| 181 | +> cosign verify registry.k8s.io/kube-apiserver-amd64:v1.28.0-alpha.3 \ |
| 182 | + --certificate-identity [email protected] \ |
| 183 | + --certificate-oidc-issuer https://accounts.google.com \ |
| 184 | + | jq -r '.[0].critical.identity."docker-reference"' |
| 185 | +… |
| 186 | + |
| 187 | +registry.k8s.io/kubernetes/kube-apiserver-amd64 |
| 188 | +``` |
| 189 | + |
| 190 | +The Kubernetes community introduced `registry.k8s.io` as proxy mirror for |
| 191 | +various registries. Before the release of [kpromo v4.0.2][kpromo], images |
| 192 | +had been signed with the actual mirror rather than `registry.k8s.io`: |
| 193 | + |
| 194 | +[kpromo]: https://github.com/kubernetes-sigs/promo-tools/releases/tag/v4.0.2 |
| 195 | + |
| 196 | +```console |
| 197 | +> cosign verify registry.k8s.io/kube-apiserver-amd64:v1.28.0-alpha.2 \ |
| 198 | + --certificate-identity [email protected] \ |
| 199 | + --certificate-oidc-issuer https://accounts.google.com \ |
| 200 | + | jq -r '.[0].critical.identity."docker-reference"' |
| 201 | +… |
| 202 | + |
| 203 | +asia-northeast2-docker.pkg.dev/k8s-artifacts-prod/images/kubernetes/kube-apiserver-amd64 |
| 204 | +``` |
| 205 | + |
| 206 | +The change of the `docker-reference` to `registry.k8s.io` makes it easier for |
| 207 | +end users to validate the signatures, because they cannot know anything about the |
| 208 | +underlying infrastructure being used. The feature to set the identity on image |
| 209 | +signing has been added to [cosign][cosign-pr] via the flag `sign |
| 210 | +--sign-container-identity` as well and will be part of its upcoming release. |
| 211 | + |
| 212 | +[cosign-pr]: https://github.com/sigstore/cosign/pull/2984 |
| 213 | + |
| 214 | +The Kubernetes image pull error code `SignatureValidationFailed` got [recently added to |
| 215 | +Kubernetes][pr-117717] and will be available from v1.28. This error code allows |
| 216 | +end-users to understand image pull failures directly from the kubectl CLI. For |
| 217 | +example, if you run CRI-O together with Kubernetes using the policy which requires |
| 218 | +`quay.io/crio/unsigned` to be signed, then a pod definition like this: |
| 219 | + |
| 220 | +[pr-117717]: https://github.com/kubernetes/kubernetes/pull/117717 |
| 221 | + |
| 222 | +```yaml |
| 223 | +apiVersion: v1 |
| 224 | +kind: Pod |
| 225 | +metadata: |
| 226 | + name: pod |
| 227 | +spec: |
| 228 | + containers: |
| 229 | + - name: container |
| 230 | + image: quay.io/crio/unsigned |
| 231 | +``` |
| 232 | +
|
| 233 | +Will cause the `SignatureValidationFailed` error when applying the pod manifest: |
| 234 | + |
| 235 | +```console |
| 236 | +> kubectl apply -f pod.yaml |
| 237 | +pod/pod created |
| 238 | +``` |
| 239 | + |
| 240 | +```console |
| 241 | +> kubectl get pods |
| 242 | +NAME READY STATUS RESTARTS AGE |
| 243 | +pod 0/1 SignatureValidationFailed 0 4s |
| 244 | +``` |
| 245 | + |
| 246 | +```console |
| 247 | +> kubectl describe pod pod | tail -n8 |
| 248 | + Type Reason Age From Message |
| 249 | + ---- ------ ---- ---- ------- |
| 250 | + Normal Scheduled 58s default-scheduler Successfully assigned default/pod to 127.0.0.1 |
| 251 | + Normal BackOff 22s (x2 over 55s) kubelet Back-off pulling image "quay.io/crio/unsigned" |
| 252 | + Warning Failed 22s (x2 over 55s) kubelet Error: ImagePullBackOff |
| 253 | + Normal Pulling 9s (x3 over 58s) kubelet Pulling image "quay.io/crio/unsigned" |
| 254 | + Warning Failed 6s (x3 over 55s) kubelet Failed to pull image "quay.io/crio/unsigned": SignatureValidationFailed: Source image rejected: A signature was required, but no signature exists |
| 255 | + Warning Failed 6s (x3 over 55s) kubelet Error: SignatureValidationFailed |
| 256 | +``` |
| 257 | + |
| 258 | +This overall behavior provides a more Kubernetes native experience and does not |
| 259 | +rely on third party software to be installed in the cluster. |
| 260 | + |
| 261 | +There are still a few corner cases to consider: For example, what if you want to |
| 262 | +allow policies per namespace in the same way the policy-controller supports it? |
| 263 | +Well, there is an upcoming CRI-O feature in v1.28 for that! CRI-O will support |
| 264 | +the `--signature-policy-dir` / `signature_policy_dir` option, which defines the |
| 265 | +root path for pod namespace-separated signature policies. This means that CRI-O |
| 266 | +will lookup that path and assemble a policy like `<SIGNATURE_POLICY_DIR>/<NAMESPACE>.json`, |
| 267 | +which will be used on image pull if existing. If no pod namespace is |
| 268 | +provided on image pull ([via the sandbox config][sandbox-config]), or the |
| 269 | +concatenated path is non-existent, then CRI-O's global policy will be used as |
| 270 | +fallback. |
| 271 | + |
| 272 | +[sandbox-config]: https://github.com/kubernetes/cri-api/blob/e5515a5/pkg/apis/runtime/v1/api.proto#L1448 |
| 273 | + |
| 274 | +Another corner case to consider is critical for the correct signature |
| 275 | +verification within container runtimes: The kubelet only invokes container image |
| 276 | +pulls if the image does not already exist on disk. This means that an |
| 277 | +unrestricted policy from Kubernetes namespace A can allow pulling an image, |
| 278 | +while namespace B is not able to enforce the policy because it already exits on |
| 279 | +the node. Finally, CRI-O has to verify the policy not only on image pull, but |
| 280 | +also on container creation. This fact makes things even a bit more complicated, |
| 281 | +because the CRI does not really pass down the user specified image reference on |
| 282 | +container creation, but an already resolved image ID, or digest. A [small |
| 283 | +change to the CRI][pr-118652] can help with that. |
| 284 | + |
| 285 | +[pr-118652]: https://github.com/kubernetes/kubernetes/pull/118652 |
| 286 | + |
| 287 | +Now that everything happens within the container runtime, someone has to |
| 288 | +maintain and define the policies to provide a good user experience around that |
| 289 | +feature. The CRDs of the policy-controller are great, while we could imagine that |
| 290 | +a daemon within the cluster can write the policies for CRI-O per namespace. This |
| 291 | +would make any additional hook obsolete and moves the responsibility of |
| 292 | +verifying the image signature to the actual instance which pulls the image. [We |
| 293 | +evaluated][thread] other possible paths toward a better container image |
| 294 | +signature verification within plain Kubernetes, but we could not find a great fit |
| 295 | +for a native API. This means that we believe that a CRD is the way to go, but we |
| 296 | +still need an instance which actually serves it. |
| 297 | + |
| 298 | +[thread]: https://groups.google.com/g/kubernetes-sig-node/c/kgpxqcsJ7Vc/m/7X7t_ElsAgAJ |
| 299 | + |
| 300 | +Thank you for reading this blog post! If you're interested in more, providing |
| 301 | +feedback or asking for help, then feel free to get in touch with us directly via |
| 302 | +[Slack (#crio)][slack] or the [SIG node mailing list][mail]. |
| 303 | + |
| 304 | +[slack]: https://kubernetes.slack.com/messages/crio |
| 305 | +[mail]: https://groups.google.com/forum/#!forum/kubernetes-sig-node |
0 commit comments