Skip to content

Commit e51a45d

Browse files
committed
Add container image signature verification blog
Signed-off-by: Sascha Grunert <[email protected]>
1 parent 23c89f3 commit e51a45d

File tree

3 files changed

+272
-0
lines changed

3 files changed

+272
-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 --> D[fa:fa-check Admitted]
5+
C --> E[fa:fa-xmark Not admitted]
6+
D --> |if necessary| F[Image Pull]
70 KB
Loading
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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 that
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. They
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+
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 runtimes on the nodes, which gets initiated by the kubelet. This
48+
benefit also incorporates the drawback of separation: The node which should pull
49+
the container image is not necessarily the same which does the admission. This
50+
means that if the controller is compromised, then a cluster-wide policy
51+
enforcement could not be possible any more.
52+
53+
One way to solve that 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 the
58+
upcoming v1.28 release.
59+
60+
[cri]: /docs/concepts/architecture/cri
61+
[kubelet]: /docs/reference/command-line-tools-reference/kubelet
62+
[cri-o]: https://github.com/cri-o/cri-o
63+
64+
How does it work? CRI-O reads a file called [`policy.json`][policy.json], which
65+
contains all the rules defined for container images. For example, I can define a
66+
policy which only allows signed images `quay.io/crio/signed` for any tag or
67+
digest like this:
68+
69+
[policy.json]: https://github.com/containers/image/blob/b3e0ba2/docs/containers-policy.json.5.md#sigstoresigned
70+
71+
```json
72+
{
73+
"default": [{ "type": "reject" }],
74+
"transports": {
75+
"docker": {
76+
"quay.io/crio/signed": [
77+
{
78+
"type": "sigstoreSigned",
79+
"signedIdentity": { "type": "matchRepository" },
80+
"fulcio": {
81+
"oidcIssuer": "https://github.com/login/oauth",
82+
"subjectEmail": "[email protected]",
83+
"caData": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUI5ekNDQVh5Z0F3SUJBZ0lVQUxaTkFQRmR4SFB3amVEbG9Ed3lZQ2hBTy80d0NnWUlLb1pJemowRUF3TXcKS2pFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUkV3RHdZRFZRUURFd2h6YVdkemRHOXlaVEFlRncweQpNVEV3TURjeE16VTJOVGxhRncwek1URXdNRFV4TXpVMk5UaGFNQ294RlRBVEJnTlZCQW9UREhOcFozTjBiM0psCkxtUmxkakVSTUE4R0ExVUVBeE1JYzJsbmMzUnZjbVV3ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBSWdOaUFBVDcKWGVGVDRyYjNQUUd3UzRJYWp0TGszL09sbnBnYW5nYUJjbFlwc1lCcjVpKzR5bkIwN2NlYjNMUDBPSU9aZHhleApYNjljNWlWdXlKUlErSHowNXlpK1VGM3VCV0FsSHBpUzVzaDArSDJHSEU3U1hyazFFQzVtMVRyMTlMOWdnOTJqCll6QmhNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCUlkKd0I1ZmtVV2xacWw2ekpDaGt5TFFLc1hGK2pBZkJnTlZIU01FR0RBV2dCUll3QjVma1VXbFpxbDZ6SkNoa3lMUQpLc1hGK2pBS0JnZ3Foa2pPUFFRREF3TnBBREJtQWpFQWoxbkhlWFpwKzEzTldCTmErRURzRFA4RzFXV2cxdENNCldQL1dIUHFwYVZvMGpoc3dlTkZaZ1NzMGVFN3dZSTRxQWpFQTJXQjlvdDk4c0lrb0YzdlpZZGQzL1Z0V0I1YjkKVE5NZWE3SXgvc3RKNVRmY0xMZUFCTEU0Qk5KT3NRNHZuQkhKCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
84+
},
85+
"rekorPublicKeyData": "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFMkcyWSsydGFiZFRWNUJjR2lCSXgwYTlmQUZ3cgprQmJtTFNHdGtzNEwzcVg2eVlZMHp1ZkJuaEM4VXIvaXk1NUdoV1AvOUEvYlkyTGhDMzBNOStSWXR3PT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
86+
}
87+
]
88+
}
89+
}
90+
}
91+
```
92+
93+
CRI-O has to be started to use that policy as global source of truth:
94+
95+
```console
96+
> sudo crio --log-level debug --signature-policy ./policy.json
97+
```
98+
99+
CRI-O is now able to pull the image while verifying its signatures. This can be
100+
done by using [`crictl` (cri-tools)][cri-tools], for example:
101+
102+
[cri-tools]: https://github.com/kubernetes-sigs/cri-tools
103+
104+
```console
105+
> sudo crictl -D pull quay.io/crio/signed
106+
DEBU[…] get image connection
107+
DEBU[…] PullImageRequest: &PullImageRequest{Image:&ImageSpec{Image:quay.io/crio/signed,Annotations:map[string]string{},},Auth:nil,SandboxConfig:nil,}
108+
DEBU[…] PullImageResponse: &PullImageResponse{ImageRef:quay.io/crio/signed@sha256:18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a,}
109+
Image is up to date for quay.io/crio/signed@sha256:18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a
110+
```
111+
112+
The CRI-O debug logs will also indicate that the signature got successfully
113+
validated:
114+
115+
```console
116+
DEBU[…] IsRunningImageAllowed for image docker:quay.io/crio/signed:latest
117+
DEBU[…] Using transport "docker" specific policy section quay.io/crio/signed
118+
DEBU[…] Reading /var/lib/containers/sigstore/crio/signed@sha256=18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a/signature-1
119+
DEBU[…] Looking for sigstore attachments in quay.io/crio/signed:sha256-18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a.sig
120+
DEBU[…] GET https://quay.io/v2/crio/signed/manifests/sha256-18b42e8ea347780f35d979a829affa178593a8e31d90644466396e1187a07f3a.sig
121+
DEBU[…] Content-Type from manifest GET is "application/vnd.oci.image.manifest.v1+json"
122+
DEBU[…] Found a sigstore attachment manifest with 1 layers
123+
DEBU[…] Fetching sigstore attachment 1/1: sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45
124+
DEBU[…] Downloading /v2/crio/signed/blobs/sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45
125+
DEBU[…] GET https://quay.io/v2/crio/signed/blobs/sha256:8276724a208087e73ae5d9d6e8f872f67808c08b0acdfdc73019278807197c45
126+
DEBU[…] Requirement 0: allowed
127+
DEBU[…] Overall: allowed
128+
```
129+
130+
All of the defined fields like `oidcIssuer` and `subjectEmail` in the policy
131+
have to match, while `fulcio.caData` and `rekorPublicKeyData` are the public
132+
keys from the upstream [fulcio (OIDC PKI)][fulcio] and [rekor
133+
(transparency log)][rekor] instances.
134+
135+
[fulcio]: https://github.com/sigstore/fulcio
136+
[rekor]: https://github.com/sigstore/rekor
137+
138+
This means if I now invalidate the `subjectEmail` of the policy, for example to
139+
140+
141+
```console
142+
> jq '.transports.docker."quay.io/crio/signed"[0].fulcio.subjectEmail = "[email protected]"' policy.json > new-policy.json
143+
> mv new-policy.json policy.json
144+
```
145+
146+
Then removing the image, because it already exists locally:
147+
148+
```console
149+
> sudo crictl rmi quay.io/crio/signed
150+
```
151+
152+
Now when pulling the image, CRI-O complains that the required email is wrong:
153+
154+
```console
155+
> sudo crictl pull quay.io/crio/signed
156+
FATA[…] pulling image: rpc error: code = Unknown desc = Source image rejected: Required email [email protected] not found (got []string{"[email protected]"})
157+
```
158+
159+
It is also possible to test an unsigned image against the policy. For that we
160+
have to modify the key `quay.io/crio/signed` to something like
161+
`quay.io/crio/unsigned`:
162+
163+
```console
164+
> sed -i 's;quay.io/crio/signed;quay.io/crio/unsigned;' policy.json
165+
```
166+
167+
If I now pull the container image, CRI-O will complain that no signature exists
168+
for it:
169+
170+
```console
171+
> sudo crictl pull quay.io/crio/unsigned
172+
FATA[…] pulling image: rpc error: code = Unknown desc = SignatureValidationFailed: Source image rejected: A signature was required, but no signature exists
173+
```
174+
175+
The error code `SignatureValidationFailed` got [recently added to
176+
Kubernetes][pr-117717] and will be available from v1.28. This error code allows
177+
end-users to understand image pull failures directly from the kubectl CLI. For
178+
example, if I run CRI-O together with Kubernetes using the policy which requires
179+
`quay.io/crio/unsigned` to be signed, then a pod definition like this:
180+
181+
[pr-117717]: https://github.com/kubernetes/kubernetes/pull/117717
182+
183+
```yaml
184+
apiVersion: v1
185+
kind: Pod
186+
metadata:
187+
name: pod
188+
spec:
189+
containers:
190+
- name: container
191+
image: quay.io/crio/unsigned
192+
```
193+
194+
Will cause the `SignatureValidationFailed` error when applying the pod manifest:
195+
196+
```console
197+
> kubectl apply -f pod.yaml
198+
pod/pod created
199+
```
200+
201+
```console
202+
> kubectl get pods
203+
NAME READY STATUS RESTARTS AGE
204+
pod 0/1 SignatureValidationFailed 0 4s
205+
```
206+
207+
```console
208+
> kubectl describe pod pod | tail -n8
209+
Type Reason Age From Message
210+
---- ------ ---- ---- -------
211+
Normal Scheduled 58s default-scheduler Successfully assigned default/pod to 127.0.0.1
212+
Normal BackOff 22s (x2 over 55s) kubelet Back-off pulling image "quay.io/crio/unsigned"
213+
Warning Failed 22s (x2 over 55s) kubelet Error: ImagePullBackOff
214+
Normal Pulling 9s (x3 over 58s) kubelet Pulling image "quay.io/crio/unsigned"
215+
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
216+
Warning Failed 6s (x3 over 55s) kubelet Error: SignatureValidationFailed
217+
```
218+
219+
This overall behavior provides a more Kubernetes native experience and does not
220+
rely on third party software to be installed in the cluster.
221+
222+
There are still a few corner cases to consider: For example, what if we want to
223+
allow policies per namespace in the same way the policy-controller supports it?
224+
Well, there is an upcoming CRI-O feature in v1.28 for that! CRI-O will support
225+
the `--signature-policy-dir` / `signature_policy_dir` option, which defines the
226+
root path for pod namespace-separated signature policies. This means that CRI-O
227+
will lookup that path and assemble a policy like `<SIGNATURE_POLICY_DIR>/<NAMESPACE>.json`,
228+
which will be used on image pull if existing. If no pod namespace is being
229+
provided on image pull ([via the sandbox config][sandbox-config]), or the
230+
concatenated path is non-existent, then CRI-O's global policy will be used as
231+
fallback.
232+
233+
[sandbox-config]: https://github.com/kubernetes/cri-api/blob/e5515a5/pkg/apis/runtime/v1/api.proto#L1448
234+
235+
Another corner case to consider is cricital for the correct signature
236+
verification within container runtimes: The kubelet only invokes container image
237+
pulls if the image does not already exist on disk. This means, that a
238+
unrestricted policy from Kubernetes namespace A can allow pulling an image,
239+
while namespace B is not able to enforce the policy because it already exits on
240+
the node. Finally, CRI-O has to verify the policy not only on image pull, but
241+
also on container creation. This fact makes things even a bit more complicated,
242+
because the CRI does not really pass down the user specified image reference on
243+
container creation, but more an already resolved iamge ID or digest. A [small
244+
change to the CRI][pr-118652] can help with that.
245+
246+
[pr-118652]: https://github.com/kubernetes/kubernetes/pull/118652
247+
248+
Now that everything happens within the container runtime, someone has to
249+
maintain and define the policies to provide a good user experience around that
250+
feature. The CRDs of the policy-controller are great, while I could imagine that
251+
a daemon within the cluster can write the policies for CRI-O per namespace. This
252+
would make any additional hook obsolete and moves the responsibility of
253+
verifying the image signature to the actual instance which pulls the image. [I
254+
was evaluating][thread] other possible paths towards a better container image
255+
signature verification within plain Kubernetes, but I could not find a great fit
256+
for a native API. This means that I believe that a CRD is the way to go, but we
257+
still need an instance which actually serves it.
258+
259+
[thread]: https://groups.google.com/g/kubernetes-sig-node/c/kgpxqcsJ7Vc/m/7X7t_ElsAgAJ
260+
261+
Thank you for reading this blog post! If you're interested in more, providing
262+
feedback or asking for help, then feel free to get in touch with us directly via
263+
[Slack (#crio)][slack] or the [SIG node mailing list][mail].
264+
265+
[slack]: https://kubernetes.slack.com/messages/crio
266+
[mail]: https://groups.google.com/forum/#!forum/kubernetes-sig-node

0 commit comments

Comments
 (0)