Skip to content

Commit 3344d70

Browse files
author
Daniel Chen
authored
Merge pull request buildpacks-community#1449 from buildpacks-community/implement-slsa
SLSA attestations for the Build resource (aka app image attestations)
2 parents e77cc0f + 2046f40 commit 3344d70

43 files changed

Lines changed: 4478 additions & 245 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,25 @@ jobs:
380380
# make the registry container accessible by name from inside the cluster
381381
docker network connect kind registry.local
382382
383-
kapp deploy -a kpack -y -f prerelease.yaml
383+
cat <<EOF > overlay.yaml
384+
#@ load("@ytt:overlay", "overlay")
385+
#@overlay/match by=overlay.subset({"metadata":{"name":"kpack-controller"}, "kind": "Deployment"})
386+
---
387+
spec:
388+
template:
389+
spec:
390+
containers:
391+
#@overlay/match by="name"
392+
- name: controller
393+
#@overlay/match-child-defaults missing_ok=True
394+
env:
395+
#@overlay/match by="name"
396+
#@overlay/replace or_add=True
397+
- name: EXPERIMENTAL_GENERATE_SLSA_ATTESTATION
398+
value: "true"
399+
EOF
400+
401+
ytt -f prerelease.yaml -f overlay.yaml | kapp deploy -a kpack -y -f-
384402
385403
export IMAGE_REGISTRY=${{ env.REGISTRY_URL }}
386404
export IMAGE_REGISTRY_USERNAME=${{ env.REGISTRY_USER }}

cmd/build-init/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const (
7272
platformDir = "/platform"
7373
buildSecretsDir = "/var/build-secrets"
7474
registrySourcePullSecretsDir = "/registrySourcePullSecrets"
75-
projectMetadataDir = "/projectMetadata"
75+
projectMetadataDir = "/projectMetadata" // place to write project-metadata.toml which gets exported to image label by the lifecycle
7676
networkWaitLauncherDir = "/networkWait"
7777
networkWaitLauncherBinary = "network-wait-launcher.exe"
7878
)
@@ -223,7 +223,7 @@ func fetchSource(logger *log.Logger, keychain authn.Keychain) error {
223223
fetcher := blob.Fetcher{
224224
Logger: logger,
225225
}
226-
return fetcher.Fetch(appDir, *blobURL, *stripComponents)
226+
return fetcher.Fetch(appDir, *blobURL, *stripComponents, projectMetadataDir)
227227
case *registryImage != "":
228228
registrySourcePullSecrets, err := dockercreds.ParseDockerConfigSecret(registrySourcePullSecretsDir)
229229
if err != nil {
@@ -235,7 +235,7 @@ func fetchSource(logger *log.Logger, keychain authn.Keychain) error {
235235
Client: &registry.Client{},
236236
Keychain: authn.NewMultiKeychain(registrySourcePullSecrets, keychain),
237237
}
238-
return fetcher.Fetch(appDir, *registryImage)
238+
return fetcher.Fetch(appDir, *registryImage, projectMetadataDir)
239239
default:
240240
return errors.New("no git url, blob url, or registry image provided")
241241
}

cmd/controller/main.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import (
5959
"github.com/pivotal/kpack/pkg/reconciler/sourceresolver"
6060
"github.com/pivotal/kpack/pkg/registry"
6161
"github.com/pivotal/kpack/pkg/secret"
62+
"github.com/pivotal/kpack/pkg/slsa"
6263
)
6364

6465
const (
@@ -85,11 +86,14 @@ func main() {
8586
flag.StringVar(&images.CompletionWindowsImage, "completion-windows-image", os.Getenv("COMPLETION_WINDOWS_IMAGE"), "The image used to finish a build on windows")
8687
flag.StringVar(&images.BuildWaiterImage, "build-waiter-image", os.Getenv("BUILD_WAITER_IMAGE"), "The image used to initialize a build")
8788

89+
flag.StringVar(&cfg.SystemNamespace, "system-namespace", os.Getenv("SYSTEM_NAMESPACE"), "Namespace for the the controller, this will be used to lookup secrets for image signing and attestation.")
90+
flag.StringVar(&cfg.SystemServiceAccount, "system-service-account", os.Getenv("SYSTEM_SERVICE_ACCOUNT"), "Service account for the the controller, this will be used to lookup secrets for image signing and attestation.")
8891
flag.BoolVar(&cfg.EnablePriorityClasses, "enable-priority-classes", flaghelpers.GetEnvBool("ENABLE_PRIORITY_CLASSES", false), "if set to true, enables different pod priority classes for normal builds and automated builds")
8992
flag.StringVar(&cfg.MaximumPlatformApiVersion, "maximum-platform-api-version", os.Getenv("MAXIMUM_PLATFORM_API_VERSION"), "The maximum allowed platform api version a build can utilize")
9093
flag.BoolVar(&cfg.SshTrustUnknownHosts, "insecure-ssh-trust-unknown-hosts", flaghelpers.GetEnvBool("INSECURE_SSH_TRUST_UNKNOWN_HOSTS", true), "if set to true, automatically trust unknown hosts when using git ssh source")
9194

9295
flag.BoolVar(&featureFlags.InjectedSidecarSupport, "injected-sidecar-support", flaghelpers.GetEnvBool("INJECTED_SIDECAR_SUPPORT", false), "if set to true, all builds will execute in standard containers instead of init containers to support injected sidecars")
96+
flag.BoolVar(&featureFlags.GenerateSlsaAttestation, "experimental-generate-slsa-attestation", flaghelpers.GetEnvBool("EXPERIMENTAL_GENERATE_SLSA_ATTESTATION", false), "if set to true, SLSA attestations will be generated for each build")
9397

9498
flag.Parse()
9599

@@ -205,9 +209,24 @@ func main() {
205209
K8sClient: k8sClient,
206210
}
207211

208-
secretFetcher := &secret.Fetcher{Client: k8sClient}
212+
slsaAttester := slsa.Attester{
213+
Version: cmd.Version,
209214

210-
buildController := build.NewController(ctx, options, k8sClient, buildInformer, podInformer, metadataRetriever, buildpodGenerator, podProgressLogger, keychainFactory, featureFlags.InjectedSidecarSupport)
215+
LifecycleProvider: lifecycleProvider,
216+
ImageReader: slsa.NewImageReader(&registry.Client{}),
217+
218+
Images: images,
219+
Features: featureFlags,
220+
Config: cfg,
221+
}
222+
223+
secretFetcher := &secret.Fetcher{
224+
Client: k8sClient,
225+
SystemNamespace: cfg.SystemNamespace,
226+
SystemServiceAccountName: cfg.SystemServiceAccount,
227+
}
228+
229+
buildController := build.NewController(ctx, options, k8sClient, buildInformer, podInformer, metadataRetriever, buildpodGenerator, podProgressLogger, keychainFactory, &slsaAttester, secretFetcher, featureFlags)
211230
imageController := image.NewController(ctx, options, k8sClient, imageInformer, buildInformer, duckBuilderInformer, sourceResolverInformer, pvcInformer, cfg.EnablePriorityClasses)
212231
sourceResolverController := sourceresolver.NewController(ctx, options, sourceResolverInformer, gitResolver, blobResolver, registryResolver)
213232
builderController, builderResync := builder.NewController(ctx, options, builderInformer, builderCreator, keychainFactory, clusterStoreInformer, buildpackInformer, clusterBuildpackInformer, clusterStackInformer, secretFetcher)

config/controller.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ spec:
9898
value: "false"
9999
- name: INJECTED_SIDECAR_SUPPORT
100100
value: "false"
101+
- name: EXPERIMENTAL_GENERATE_SLSA_ATTESTATION
102+
value: "false"
101103
- name: INSECURE_SSH_TRUST_UNKNOWN_HOSTS
102104
value: "true"
103105
- name: CONFIG_LOGGING_NAME
@@ -110,6 +112,8 @@ spec:
110112
valueFrom:
111113
fieldRef:
112114
fieldPath: metadata.namespace
115+
- name: SYSTEM_SERVICE_ACCOUNT
116+
value: controller
113117
- name: BUILD_INIT_IMAGE
114118
valueFrom:
115119
configMapKeyRef:

config/controllerrole.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ rules:
4646
resources:
4747
- secrets
4848
- pods/log
49+
- namespaces
4950
verbs:
5051
- get
5152
- apiGroups:

docs/slsa.md

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
# SLSA attestations
2+
3+
Kpack supports generating a [SLSA v1 provenance](https://slsa.dev/spec/v1.0/provenance) with each Build. These
4+
attestations are written to the same registry as the app image and uses the same tag-based discovery mechanism as
5+
[cosign](https://github.com/sigstore/cosign) for linking an image digest to an attestation image tag.
6+
7+
If enabled, an attestation will be generated for every newly completed [Build](./build.md) in the cluster. Kpack will
8+
search through the secrets attached to the Build's service account, as well as the kpack-controller's service account
9+
for signing keys. If at least one signing key is found, the attestation will be signed by all the keys. Otherwise an
10+
unsigned attestation will be generated.
11+
12+
## Configuration
13+
14+
SLSA attestation can be enabled or disabled at the cluster level using the `EXPERIMENTAL_GENERATE_SLSA_ATTESTATION`
15+
environment variable in the [kpack-controller's deployment](../config/controller.yaml).
16+
17+
## SLSA security level
18+
19+
Reference: https://slsa.dev/spec/v1.0/levels
20+
21+
By default, kpack provides `L0`, if SLSA attestation is enabled, it automatically achieves `L1`. For signed builds,
22+
kpack achieves `L3` because:
23+
- The build occurs on a Kubernetes cluster, usually this means it's on dedicated infrastructure but we won't judge you
24+
for running your cluster on kind. (L2)
25+
- The signing private keys are provided via Kubernetes Secret, which can use RBAC to ensure minimal access. (L2)
26+
- Builds are run in pods which are isolated from each other via Kubernetes principles. (L3)
27+
- The only place the private keys are used to sign the attestation become accessible on the build pod is during the
28+
`completion` step, which is completely under the control of kpack. Even adding custom buildpacks to the Builder
29+
wouldn't allow access to the secrets. (L3)
30+
31+
## Provenance schema
32+
33+
Consult the documentation for the individual builder ID.
34+
35+
| Builder ID | Documentation |
36+
|------------|---------------|
37+
| `https://kpack.io/slsa/signed-app-build` | [slsa_build.md](./slsa_build.md) |
38+
| `https://kpack.io/slsa/unsigned-app-build` | [slsa_build.md](./slsa_build.md) |
39+
40+
## Attestation storage
41+
42+
Attestations in kpack are attached to image digests and attests to the build environment of that particular image. As
43+
such, the attestations are stored in a way that is predictable given an (app) image's digest. This is the same approach
44+
that cosign uses and means the cosign CLI can be used to [verify kpack attestations](#verification-methods).
45+
46+
### Cosign tag-based discovery
47+
48+
Kpack attestations uses cosign's [tag-based
49+
discovery](https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#tag-based-discovery) with the only
50+
difference that the suffix is `.att` instead of `.sig` (this also how `cosign attest` works). For an image digest
51+
`registry.com/my/repo@sha256:1234`, the corresponding attestation will be uploaded to
52+
`registry.com/my/repo:sha256-1234.att`.
53+
54+
55+
### Storage format
56+
57+
The SLSA v1 _provenance_ is stored as a _predicate_ in an in-toto _statement_ which is base64 encoded and part of a DSSE
58+
_envelope_. The envelope looks something like:
59+
60+
```json
61+
{
62+
"payloadType": "application/vnd.in-toto+json",
63+
"payload": BASE64ENCODE({
64+
"_type": "https://in-toto.io/Statement/v0.1",
65+
"subject": [
66+
{
67+
"name": APP_IMAGE,
68+
"digest": {
69+
"sha256": APP_IMAGE_DIGEST
70+
}
71+
}
72+
"predicateType": "https://slsa.dev/provenance/v1",
73+
"predicate": SLSA V1 provenance...,
74+
],
75+
})
76+
"signatures": [
77+
{
78+
"keyid": ...,
79+
"sig": ...,
80+
},
81+
]
82+
}
83+
```
84+
85+
The envelope is stored as uncompressed text in the first layer of the attestation image. The image (and the registry)
86+
is treated as a blobstore and isn't intended to be a container image. That is, `docker pull $ATTESTATION_TAG` or
87+
trying to run the image in any way will **not** work.
88+
89+
If you want to access the attestation, you must use one of the tools that interact with the registry directly.
90+
91+
All of the following examples assume you have [jq](https://jqlang.github.io/jq/) installed. Given an `IMAGE_DIGEST`
92+
`registry.com/my/repo@sha256:1234`, the `ATTESTATION_TAG` would be `registry.com/my/repo:sha256-1234.att`
93+
94+
The easiest way is to use [cosign](https://github.com/sigstore/cosign/blob/main/doc/cosign.md):
95+
```bash
96+
cosign download attestation $IMAGE_DIGEST | jq -r '.payload' | base64 --decode | jq
97+
```
98+
99+
Another supported way is via [crane](https://github.com/google/go-containerregistry/blob/main/cmd/crane/README.md):
100+
```bash
101+
crane export $ATTESTATION_TAG | jq -r '.payload' | base64 --decode | jq
102+
```
103+
104+
It's also accessible by [skopeo](https://github.com/containers/skopeo/blob/main/docs/skopeo.1.md), abeit with quite a
105+
few more steps:
106+
```bash
107+
dir=$(mktemp -d)
108+
skopeo copy docker://$ATTESTATION_TAG dir:$dir
109+
sha=$(jq -r '.layers[0].digest | sub("^sha256:"; "")' $dir/manifest.json)
110+
jq -r '.payload' $dir/$sha | base64 --decode | jq
111+
rm -r $dir
112+
```
113+
114+
## Signing keys
115+
116+
Build specific signing keys can be attached to the Service Account used for the Build. Cluster-wide signing keys can be
117+
attached to the Service Account used in the `kpack-controller` Deployment in the system namespace (ususally `kpack`).
118+
119+
### PKCS#8 private key
120+
121+
A PKCS#8 private key using RSA, ECDSA, or ED25519 and stored in PEM format can be used to sign attestations. The private
122+
key must use the same format as the [Kubernetes SSH auth secret](https://kubernetes.io/docs/concepts/configuration/secret/#ssh-authentication-secrets)
123+
and have the `kpack.io/slsa: ""` annotation. Private keys with passwords are currently not supported.
124+
125+
``` yaml
126+
apiVersion: v1
127+
kind: Secret
128+
type: kubernetes.io/ssh-auth
129+
metadata:
130+
name: my-ecdsa-key
131+
annotations:
132+
kpack.io/slsa: ""
133+
stringData:
134+
ssh-privatekey: |
135+
-----BEGIN PRIVATE KEY-----
136+
<PRIVATE KEY DATA>
137+
-----END PRIVATE KEY-----
138+
```
139+
140+
### Cosign private key
141+
142+
A [cosign generated secret](https://github.com/sigstore/cosign/blob/main/doc/cosign_generate-key-pair.md) may also be
143+
used as long as it has the `kpack.io/slsa: ""` annotation. Private keys with passwords are currently not supported.
144+
145+
```yaml
146+
apiVersion: v1
147+
kind: Secret
148+
type: Opaque
149+
metadata:
150+
name: my-cosign-secret
151+
annotations:
152+
kpack.io/slsa: ""
153+
data:
154+
cosign.key: <PRIVATE KEY DATA>
155+
cosign.password: <COSIGN PASSWORD>
156+
cosign.pub: <PUBLIC KEY DATA>
157+
```
158+
159+
### Verification methods
160+
161+
A single signature consists of a `keyid` and a `sig` field where the `keyid` is the name of the Kubernetes Secret used
162+
to generate the signature and the `sig` is the base64 encoded signature. The attestation will contain an array of these
163+
signatures:
164+
165+
```json
166+
{
167+
"payloadType": ...,
168+
"payload": ...,
169+
"signatures": [
170+
{
171+
"keyid": "cosign-secret",
172+
"sig": "MEQCID8QIkYOqxkPcE/bazsSDRj9vJSOXk9esFJSaj07jn2DAiB9/hrt8Ezd17UFYdaMSmMLzuF1oGSzK1vQ8jz5VSHNCQ=="
173+
},
174+
{
175+
"keyid": "rsa-secret",
176+
"sig": "s8NjZ7b7l0lGkJBeREJ9pP7kehXZWSY46413r06SIdVJbDxwgRlmF3HhK8Ji629yJs1jVLUgusBvexAM3ck+ZSzXOoOmT2sgLlvSNatF0F4iOJVA4/MFFYHOZokpObDZ/XDKC9DP8sI++x8gLhOvcPs7p/PtGXXnEJzOoedrHGV17Q1OOLIDPGkYP/CA+u0OANaAbipmaUUq7gY+E9JVKuSxHG91N9qzzvhl+dAIkbSruxMkhHkdA72OpYohKZ+Q0h+ChPI7XLrKJBKj5fBB4oOCE2a6+trKeBAwWAnlZDCN8wOWj602slQSCHpSqO9oi/u7X9aLCfhUsCZ5luY3iQ=="
177+
},
178+
{
179+
"keyid": "ecdsa-secret",
180+
"sig": "MEUCIQDEnkmqxb9ypLDIC+9oz7i5U22Tgq71YMVTf2tIuk+ubwIgZZfpAjLe8iW2Rp50PZz7DcUYvLGeG1NAMmGRlujy9S0="
181+
},
182+
{
183+
"keyid": "ed25519-secret",
184+
"sig": "WPGuhBYBlempQVC5BeULFeilJr3avQicH4MjruWsc8tUwL8dHgHxcONH6nNacRV9hKHO8wRJOSGs0Eot47aBDQ=="
185+
}
186+
]
187+
}
188+
```
189+
190+
#### Cosign
191+
192+
To verify a cosign key, you can use the `cosign verify-attestation` command. This command will go through all the
193+
signatures and verify at least one of them is signed by the public key. If you have access to the Kubernetes Namespace
194+
(`$SECRET_NAMESPACE`) and Secret (`$SECRET_NAME`) containing the public-private keypair, you can use:
195+
196+
```bash
197+
cosign verify-attestation --insecure-ignore-tlog=true --key k8s://$SECRET_NAMESPACE/$SECRET_NAME --type=slsaprovenance1 $APP_IMAGE_DIGEST
198+
```
199+
200+
If you only have access to the file containing the public key (`$PUB_KEY_PATH`), you can use:
201+
202+
```bash
203+
cosign verify-attestation --insecure-ignore-tlog=true --key $PUB_KEY_PATH --type=slsaprovenance1 $APP_IMAGE_DIGEST
204+
```
205+
206+
#### PKCS#8
207+
208+
If you want to verify attestations signed by a PKCS#8 key (RSA, ECDSA, ED25519):
209+
210+
1. Grab and decode the base64 encoded payload from the attestation using one of the methods from [Storage format](#storage-format).
211+
1. Compute the [DSSE PAE](https://github.com/secure-systems-lab/dsse/blob/v1.0.0/protocol.md) using `application/vnd.in-toto+json` as the type.
212+
This basically means filling in `DSSEv1 28 application/vnd.in-toto+json $NUM_BYTES_IN_PAYLOAD $PAYLOAD`
213+
1. Grab and decode the base64 encoded signature you want to verify from the attestation.
214+
1. Use `openssl` to verify the signature is correct for the PAE.
215+
216+
In practice this looks something like:
217+
218+
```bash
219+
# Get attestation
220+
ATTESTATION="$(cosign download attestation $APP_IMAGE_DIGEST)"
221+
# Parse payload
222+
PAYLOAD="$(echo $ATTESTATION | jq -r '.payload' | base64 --decode)"
223+
# Parse signature, note: if you used multiple signing keys you will need to figure out which signature is from the key
224+
# you want. Kpack does not provide any guranatees on the ordering used for signing.
225+
echo $ATTESTATION | jq -r '.signatures[0].sig' | base64 --decode > message.sig
226+
# Compute the PAE as message
227+
echo -n $PAYLOAD | awk '{printf "DSSEv1 28 application/vnd.in-toto+json %d %s", length($0), $0}' > message.txt
228+
```
229+
230+
To use a RSA or ECDSA key stored in PKCS#8 format, it must be verified against the SHA256 digest of the PAE:
231+
232+
```
233+
openssl dgst -sha256 -binary message.txt | openssl pkeyutl -verify -pubin -inkey $PUB_KEY_PATH -pkeyopt digest:sha256 -sigfile message.sig
234+
```
235+
236+
To use an ED25519 key stored in PKCS#8 public key, it can be verified directly against the PAE:
237+
238+
```
239+
openssl pkeyutl -verify -pubin -inkey $PUB_KEY_PATH -sigfile message.sig -rawin -in message.txt
240+
```

0 commit comments

Comments
 (0)