Skip to content

Commit bfed013

Browse files
authored
Merge pull request #41616 from saschagrunert/container-image-signature-verification-blog
Add container image signature verification blog
2 parents b16f8db + 92c9ae4 commit bfed013

File tree

4 files changed

+311
-0
lines changed

4 files changed

+311
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
flowchart TD
2+
A(Create Policy\ninstance) -->|annotate namespace\nto validate signatures| B(Create Pod)
3+
B --> C{policy evaluation}
4+
C --> |pass| D[fa:fa-check Admitted]
5+
C --> |fail| E[fa:fa-xmark Not admitted]
6+
D --> |if necessary| F[Image Pull]
93.9 KB
Loading
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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+
![flow](flow.png "Admission controller flow")
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
86 KB
Loading

0 commit comments

Comments
 (0)