Skip to content

Commit

Permalink
Merge pull request #1449 from buildpacks-community/implement-slsa
Browse files Browse the repository at this point in the history
SLSA attestations for the Build resource (aka app image attestations)
  • Loading branch information
chenbh authored Jan 17, 2024
2 parents e77cc0f + 2046f40 commit 3344d70
Show file tree
Hide file tree
Showing 43 changed files with 4,478 additions and 245 deletions.
20 changes: 19 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,25 @@ jobs:
# make the registry container accessible by name from inside the cluster
docker network connect kind registry.local
kapp deploy -a kpack -y -f prerelease.yaml
cat <<EOF > overlay.yaml
#@ load("@ytt:overlay", "overlay")
#@overlay/match by=overlay.subset({"metadata":{"name":"kpack-controller"}, "kind": "Deployment"})
---
spec:
template:
spec:
containers:
#@overlay/match by="name"
- name: controller
#@overlay/match-child-defaults missing_ok=True
env:
#@overlay/match by="name"
#@overlay/replace or_add=True
- name: EXPERIMENTAL_GENERATE_SLSA_ATTESTATION
value: "true"
EOF
ytt -f prerelease.yaml -f overlay.yaml | kapp deploy -a kpack -y -f-
export IMAGE_REGISTRY=${{ env.REGISTRY_URL }}
export IMAGE_REGISTRY_USERNAME=${{ env.REGISTRY_USER }}
Expand Down
6 changes: 3 additions & 3 deletions cmd/build-init/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const (
platformDir = "/platform"
buildSecretsDir = "/var/build-secrets"
registrySourcePullSecretsDir = "/registrySourcePullSecrets"
projectMetadataDir = "/projectMetadata"
projectMetadataDir = "/projectMetadata" // place to write project-metadata.toml which gets exported to image label by the lifecycle
networkWaitLauncherDir = "/networkWait"
networkWaitLauncherBinary = "network-wait-launcher.exe"
)
Expand Down Expand Up @@ -223,7 +223,7 @@ func fetchSource(logger *log.Logger, keychain authn.Keychain) error {
fetcher := blob.Fetcher{
Logger: logger,
}
return fetcher.Fetch(appDir, *blobURL, *stripComponents)
return fetcher.Fetch(appDir, *blobURL, *stripComponents, projectMetadataDir)
case *registryImage != "":
registrySourcePullSecrets, err := dockercreds.ParseDockerConfigSecret(registrySourcePullSecretsDir)
if err != nil {
Expand All @@ -235,7 +235,7 @@ func fetchSource(logger *log.Logger, keychain authn.Keychain) error {
Client: &registry.Client{},
Keychain: authn.NewMultiKeychain(registrySourcePullSecrets, keychain),
}
return fetcher.Fetch(appDir, *registryImage)
return fetcher.Fetch(appDir, *registryImage, projectMetadataDir)
default:
return errors.New("no git url, blob url, or registry image provided")
}
Expand Down
23 changes: 21 additions & 2 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import (
"github.com/pivotal/kpack/pkg/reconciler/sourceresolver"
"github.com/pivotal/kpack/pkg/registry"
"github.com/pivotal/kpack/pkg/secret"
"github.com/pivotal/kpack/pkg/slsa"
)

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

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.")
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.")
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")
flag.StringVar(&cfg.MaximumPlatformApiVersion, "maximum-platform-api-version", os.Getenv("MAXIMUM_PLATFORM_API_VERSION"), "The maximum allowed platform api version a build can utilize")
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")

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")
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")

flag.Parse()

Expand Down Expand Up @@ -205,9 +209,24 @@ func main() {
K8sClient: k8sClient,
}

secretFetcher := &secret.Fetcher{Client: k8sClient}
slsaAttester := slsa.Attester{
Version: cmd.Version,

buildController := build.NewController(ctx, options, k8sClient, buildInformer, podInformer, metadataRetriever, buildpodGenerator, podProgressLogger, keychainFactory, featureFlags.InjectedSidecarSupport)
LifecycleProvider: lifecycleProvider,
ImageReader: slsa.NewImageReader(&registry.Client{}),

Images: images,
Features: featureFlags,
Config: cfg,
}

secretFetcher := &secret.Fetcher{
Client: k8sClient,
SystemNamespace: cfg.SystemNamespace,
SystemServiceAccountName: cfg.SystemServiceAccount,
}

buildController := build.NewController(ctx, options, k8sClient, buildInformer, podInformer, metadataRetriever, buildpodGenerator, podProgressLogger, keychainFactory, &slsaAttester, secretFetcher, featureFlags)
imageController := image.NewController(ctx, options, k8sClient, imageInformer, buildInformer, duckBuilderInformer, sourceResolverInformer, pvcInformer, cfg.EnablePriorityClasses)
sourceResolverController := sourceresolver.NewController(ctx, options, sourceResolverInformer, gitResolver, blobResolver, registryResolver)
builderController, builderResync := builder.NewController(ctx, options, builderInformer, builderCreator, keychainFactory, clusterStoreInformer, buildpackInformer, clusterBuildpackInformer, clusterStackInformer, secretFetcher)
Expand Down
4 changes: 4 additions & 0 deletions config/controller.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ spec:
value: "false"
- name: INJECTED_SIDECAR_SUPPORT
value: "false"
- name: EXPERIMENTAL_GENERATE_SLSA_ATTESTATION
value: "false"
- name: INSECURE_SSH_TRUST_UNKNOWN_HOSTS
value: "true"
- name: CONFIG_LOGGING_NAME
Expand All @@ -110,6 +112,8 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: SYSTEM_SERVICE_ACCOUNT
value: controller
- name: BUILD_INIT_IMAGE
valueFrom:
configMapKeyRef:
Expand Down
1 change: 1 addition & 0 deletions config/controllerrole.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ rules:
resources:
- secrets
- pods/log
- namespaces
verbs:
- get
- apiGroups:
Expand Down
240 changes: 240 additions & 0 deletions docs/slsa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# SLSA attestations

Kpack supports generating a [SLSA v1 provenance](https://slsa.dev/spec/v1.0/provenance) with each Build. These
attestations are written to the same registry as the app image and uses the same tag-based discovery mechanism as
[cosign](https://github.com/sigstore/cosign) for linking an image digest to an attestation image tag.

If enabled, an attestation will be generated for every newly completed [Build](./build.md) in the cluster. Kpack will
search through the secrets attached to the Build's service account, as well as the kpack-controller's service account
for signing keys. If at least one signing key is found, the attestation will be signed by all the keys. Otherwise an
unsigned attestation will be generated.

## Configuration

SLSA attestation can be enabled or disabled at the cluster level using the `EXPERIMENTAL_GENERATE_SLSA_ATTESTATION`
environment variable in the [kpack-controller's deployment](../config/controller.yaml).

## SLSA security level

Reference: https://slsa.dev/spec/v1.0/levels

By default, kpack provides `L0`, if SLSA attestation is enabled, it automatically achieves `L1`. For signed builds,
kpack achieves `L3` because:
- The build occurs on a Kubernetes cluster, usually this means it's on dedicated infrastructure but we won't judge you
for running your cluster on kind. (L2)
- The signing private keys are provided via Kubernetes Secret, which can use RBAC to ensure minimal access. (L2)
- Builds are run in pods which are isolated from each other via Kubernetes principles. (L3)
- The only place the private keys are used to sign the attestation become accessible on the build pod is during the
`completion` step, which is completely under the control of kpack. Even adding custom buildpacks to the Builder
wouldn't allow access to the secrets. (L3)

## Provenance schema

Consult the documentation for the individual builder ID.

| Builder ID | Documentation |
|------------|---------------|
| `https://kpack.io/slsa/signed-app-build` | [slsa_build.md](./slsa_build.md) |
| `https://kpack.io/slsa/unsigned-app-build` | [slsa_build.md](./slsa_build.md) |

## Attestation storage

Attestations in kpack are attached to image digests and attests to the build environment of that particular image. As
such, the attestations are stored in a way that is predictable given an (app) image's digest. This is the same approach
that cosign uses and means the cosign CLI can be used to [verify kpack attestations](#verification-methods).

### Cosign tag-based discovery

Kpack attestations uses cosign's [tag-based
discovery](https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#tag-based-discovery) with the only
difference that the suffix is `.att` instead of `.sig` (this also how `cosign attest` works). For an image digest
`registry.com/my/repo@sha256:1234`, the corresponding attestation will be uploaded to
`registry.com/my/repo:sha256-1234.att`.


### Storage format

The SLSA v1 _provenance_ is stored as a _predicate_ in an in-toto _statement_ which is base64 encoded and part of a DSSE
_envelope_. The envelope looks something like:

```json
{
"payloadType": "application/vnd.in-toto+json",
"payload": BASE64ENCODE({
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [
{
"name": APP_IMAGE,
"digest": {
"sha256": APP_IMAGE_DIGEST
}
}
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": SLSA V1 provenance...,
],
})
"signatures": [
{
"keyid": ...,
"sig": ...,
},
]
}
```

The envelope is stored as uncompressed text in the first layer of the attestation image. The image (and the registry)
is treated as a blobstore and isn't intended to be a container image. That is, `docker pull $ATTESTATION_TAG` or
trying to run the image in any way will **not** work.

If you want to access the attestation, you must use one of the tools that interact with the registry directly.

All of the following examples assume you have [jq](https://jqlang.github.io/jq/) installed. Given an `IMAGE_DIGEST`
`registry.com/my/repo@sha256:1234`, the `ATTESTATION_TAG` would be `registry.com/my/repo:sha256-1234.att`

The easiest way is to use [cosign](https://github.com/sigstore/cosign/blob/main/doc/cosign.md):
```bash
cosign download attestation $IMAGE_DIGEST | jq -r '.payload' | base64 --decode | jq
```

Another supported way is via [crane](https://github.com/google/go-containerregistry/blob/main/cmd/crane/README.md):
```bash
crane export $ATTESTATION_TAG | jq -r '.payload' | base64 --decode | jq
```

It's also accessible by [skopeo](https://github.com/containers/skopeo/blob/main/docs/skopeo.1.md), abeit with quite a
few more steps:
```bash
dir=$(mktemp -d)
skopeo copy docker://$ATTESTATION_TAG dir:$dir
sha=$(jq -r '.layers[0].digest | sub("^sha256:"; "")' $dir/manifest.json)
jq -r '.payload' $dir/$sha | base64 --decode | jq
rm -r $dir
```

## Signing keys

Build specific signing keys can be attached to the Service Account used for the Build. Cluster-wide signing keys can be
attached to the Service Account used in the `kpack-controller` Deployment in the system namespace (ususally `kpack`).

### PKCS#8 private key

A PKCS#8 private key using RSA, ECDSA, or ED25519 and stored in PEM format can be used to sign attestations. The private
key must use the same format as the [Kubernetes SSH auth secret](https://kubernetes.io/docs/concepts/configuration/secret/#ssh-authentication-secrets)
and have the `kpack.io/slsa: ""` annotation. Private keys with passwords are currently not supported.

``` yaml
apiVersion: v1
kind: Secret
type: kubernetes.io/ssh-auth
metadata:
name: my-ecdsa-key
annotations:
kpack.io/slsa: ""
stringData:
ssh-privatekey: |
-----BEGIN PRIVATE KEY-----
<PRIVATE KEY DATA>
-----END PRIVATE KEY-----
```
### Cosign private key
A [cosign generated secret](https://github.com/sigstore/cosign/blob/main/doc/cosign_generate-key-pair.md) may also be
used as long as it has the `kpack.io/slsa: ""` annotation. Private keys with passwords are currently not supported.

```yaml
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: my-cosign-secret
annotations:
kpack.io/slsa: ""
data:
cosign.key: <PRIVATE KEY DATA>
cosign.password: <COSIGN PASSWORD>
cosign.pub: <PUBLIC KEY DATA>
```

### Verification methods

A single signature consists of a `keyid` and a `sig` field where the `keyid` is the name of the Kubernetes Secret used
to generate the signature and the `sig` is the base64 encoded signature. The attestation will contain an array of these
signatures:

```json
{
"payloadType": ...,
"payload": ...,
"signatures": [
{
"keyid": "cosign-secret",
"sig": "MEQCID8QIkYOqxkPcE/bazsSDRj9vJSOXk9esFJSaj07jn2DAiB9/hrt8Ezd17UFYdaMSmMLzuF1oGSzK1vQ8jz5VSHNCQ=="
},
{
"keyid": "rsa-secret",
"sig": "s8NjZ7b7l0lGkJBeREJ9pP7kehXZWSY46413r06SIdVJbDxwgRlmF3HhK8Ji629yJs1jVLUgusBvexAM3ck+ZSzXOoOmT2sgLlvSNatF0F4iOJVA4/MFFYHOZokpObDZ/XDKC9DP8sI++x8gLhOvcPs7p/PtGXXnEJzOoedrHGV17Q1OOLIDPGkYP/CA+u0OANaAbipmaUUq7gY+E9JVKuSxHG91N9qzzvhl+dAIkbSruxMkhHkdA72OpYohKZ+Q0h+ChPI7XLrKJBKj5fBB4oOCE2a6+trKeBAwWAnlZDCN8wOWj602slQSCHpSqO9oi/u7X9aLCfhUsCZ5luY3iQ=="
},
{
"keyid": "ecdsa-secret",
"sig": "MEUCIQDEnkmqxb9ypLDIC+9oz7i5U22Tgq71YMVTf2tIuk+ubwIgZZfpAjLe8iW2Rp50PZz7DcUYvLGeG1NAMmGRlujy9S0="
},
{
"keyid": "ed25519-secret",
"sig": "WPGuhBYBlempQVC5BeULFeilJr3avQicH4MjruWsc8tUwL8dHgHxcONH6nNacRV9hKHO8wRJOSGs0Eot47aBDQ=="
}
]
}
```

#### Cosign

To verify a cosign key, you can use the `cosign verify-attestation` command. This command will go through all the
signatures and verify at least one of them is signed by the public key. If you have access to the Kubernetes Namespace
(`$SECRET_NAMESPACE`) and Secret (`$SECRET_NAME`) containing the public-private keypair, you can use:

```bash
cosign verify-attestation --insecure-ignore-tlog=true --key k8s://$SECRET_NAMESPACE/$SECRET_NAME --type=slsaprovenance1 $APP_IMAGE_DIGEST
```

If you only have access to the file containing the public key (`$PUB_KEY_PATH`), you can use:

```bash
cosign verify-attestation --insecure-ignore-tlog=true --key $PUB_KEY_PATH --type=slsaprovenance1 $APP_IMAGE_DIGEST
```

#### PKCS#8

If you want to verify attestations signed by a PKCS#8 key (RSA, ECDSA, ED25519):

1. Grab and decode the base64 encoded payload from the attestation using one of the methods from [Storage format](#storage-format).
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.
This basically means filling in `DSSEv1 28 application/vnd.in-toto+json $NUM_BYTES_IN_PAYLOAD $PAYLOAD`
1. Grab and decode the base64 encoded signature you want to verify from the attestation.
1. Use `openssl` to verify the signature is correct for the PAE.

In practice this looks something like:

```bash
# Get attestation
ATTESTATION="$(cosign download attestation $APP_IMAGE_DIGEST)"
# Parse payload
PAYLOAD="$(echo $ATTESTATION | jq -r '.payload' | base64 --decode)"
# Parse signature, note: if you used multiple signing keys you will need to figure out which signature is from the key
# you want. Kpack does not provide any guranatees on the ordering used for signing.
echo $ATTESTATION | jq -r '.signatures[0].sig' | base64 --decode > message.sig
# Compute the PAE as message
echo -n $PAYLOAD | awk '{printf "DSSEv1 28 application/vnd.in-toto+json %d %s", length($0), $0}' > message.txt
```

To use a RSA or ECDSA key stored in PKCS#8 format, it must be verified against the SHA256 digest of the PAE:

```
openssl dgst -sha256 -binary message.txt | openssl pkeyutl -verify -pubin -inkey $PUB_KEY_PATH -pkeyopt digest:sha256 -sigfile message.sig
```
To use an ED25519 key stored in PKCS#8 public key, it can be verified directly against the PAE:
```
openssl pkeyutl -verify -pubin -inkey $PUB_KEY_PATH -sigfile message.sig -rawin -in message.txt
```
Loading

0 comments on commit 3344d70

Please sign in to comment.