From 553945ab8e4f6f8db23abe275d0c025c934c171d Mon Sep 17 00:00:00 2001 From: JasonTheDeveloper Date: Tue, 26 Mar 2024 20:37:36 +1100 Subject: [PATCH] Add verification support for notation signed artifacts Introduces a new verification provider `notation` to verify notation signed artifacts. Currently only cosign is supported and that is a problem if the end user utilises notation. --------- Signed-off-by: Jason Signed-off-by: JasonTheDeveloper Signed-off-by: Jagpreet Singh Tamber Co-authored-by: souleb Co-authored-by: Jagpreet Singh Tamber Co-authored-by: Sunny --- DEVELOPMENT.md | 7 +- README.md | 2 +- api/v1beta2/ocirepository_types.go | 2 +- .../source.toolkit.fluxcd.io_helmcharts.yaml | 1 + ...rce.toolkit.fluxcd.io_ocirepositories.yaml | 1 + .../testdata/helmchart-from-oci/notation.yaml | 25 + .../ocirepository/signed-with-notation.yaml | 14 + docs/spec/v1beta2/helmcharts.md | 66 +- docs/spec/v1beta2/ocirepositories.md | 74 +- go.mod | 11 +- go.sum | 29 +- hack/ci/e2e.sh | 9 + internal/controller/helmchart_controller.go | 100 ++- .../controller/helmchart_controller_test.go | 336 +++++++- .../controller/ocirepository_controller.go | 133 +++- .../ocirepository_controller_test.go | 720 +++++++++++++++++- internal/helm/chart/builder.go | 4 + internal/helm/chart/builder_remote.go | 8 +- internal/helm/common/string_resource.go | 39 + internal/helm/getter/client_opts.go | 3 + internal/helm/getter/client_opts_test.go | 20 + internal/helm/registry/auth.go | 17 +- internal/helm/repository/chart_repository.go | 5 +- .../helm/repository/oci_chart_repository.go | 32 +- internal/helm/repository/repository.go | 4 +- internal/oci/cosign/cosign.go | 168 ++++ .../cosign_test.go} | 36 +- internal/oci/notation/notation.go | 388 ++++++++++ internal/oci/notation/notation_test.go | 591 ++++++++++++++ internal/oci/verifier.go | 159 +--- 30 files changed, 2755 insertions(+), 249 deletions(-) create mode 100644 config/testdata/helmchart-from-oci/notation.yaml create mode 100644 config/testdata/ocirepository/signed-with-notation.yaml create mode 100644 internal/helm/common/string_resource.go create mode 100644 internal/oci/cosign/cosign.go rename internal/oci/{verifier_test.go => cosign/cosign_test.go} (80%) create mode 100644 internal/oci/notation/notation.go create mode 100644 internal/oci/notation/notation_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 072e7232b..8b6c8c9d6 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -58,7 +58,7 @@ make run ### Building the container image -Set the name of the container image to be created from the source code. This will be used +Set the name of the container image to be created from the source code. This will be used when building, pushing and referring to the image on YAML files: ```sh @@ -79,7 +79,7 @@ make docker-push ``` Alternatively, the three steps above can be done in a single line: - + ```sh IMG=registry-path/source-controller TAG=latest BUILD_ARGS=--push \ make docker-build @@ -128,7 +128,8 @@ Create a `.vscode/launch.json` file: "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}/main.go" + "program": "${workspaceFolder}/main.go", + "args": ["--storage-adv-addr=:0", "--storage-path=${workspaceFolder}/bin/data"] } ] } diff --git a/README.md b/README.md index ab4d4f1ef..ee43f8e0c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ and is a core component of the [GitOps toolkit](https://fluxcd.io/flux/component ## Features * authenticates to sources (SSH, user/password, API token, Workload Identity) -* validates source authenticity (PGP, Cosign) +* validates source authenticity (PGP, Cosign, Notation) * detects source changes based on update policies (semver) * fetches resources on-demand and on-a-schedule * packages the fetched resources into a well-known format (tar.gz, yaml) diff --git a/api/v1beta2/ocirepository_types.go b/api/v1beta2/ocirepository_types.go index 581269b1d..540a18ac2 100644 --- a/api/v1beta2/ocirepository_types.go +++ b/api/v1beta2/ocirepository_types.go @@ -182,7 +182,7 @@ type OCILayerSelector struct { // OCIRepositoryVerification verifies the authenticity of an OCI Artifact type OCIRepositoryVerification struct { // Provider specifies the technology used to sign the OCI Artifact. - // +kubebuilder:validation:Enum=cosign + // +kubebuilder:validation:Enum=cosign;notation // +kubebuilder:default:=cosign Provider string `json:"provider"` diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml index 969263473..4a5063c4c 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -468,6 +468,7 @@ spec: OCI Artifact. enum: - cosign + - notation type: string secretRef: description: |- diff --git a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml index 6254f527c..f083276ba 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml @@ -224,6 +224,7 @@ spec: OCI Artifact. enum: - cosign + - notation type: string secretRef: description: |- diff --git a/config/testdata/helmchart-from-oci/notation.yaml b/config/testdata/helmchart-from-oci/notation.yaml new file mode 100644 index 000000000..713af91c9 --- /dev/null +++ b/config/testdata/helmchart-from-oci/notation.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: podinfo-notation +spec: + url: oci://ghcr.io/stefanprodan/charts + type: "oci" + interval: 1m +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmChart +metadata: + name: podinfo-notation +spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo-notation + version: '6.6.0' + interval: 1m + verify: + provider: notation + secretRef: + name: notation-config diff --git a/config/testdata/ocirepository/signed-with-notation.yaml b/config/testdata/ocirepository/signed-with-notation.yaml new file mode 100644 index 000000000..39f3fe81f --- /dev/null +++ b/config/testdata/ocirepository/signed-with-notation.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: OCIRepository +metadata: + name: podinfo-deploy-signed-with-notation +spec: + interval: 5m + url: oci://ghcr.io/stefanprodan/podinfo-deploy + ref: + semver: "6.6.x" + verify: + provider: notation + secretRef: + name: notation-config diff --git a/docs/spec/v1beta2/helmcharts.md b/docs/spec/v1beta2/helmcharts.md index 2c06b23ef..5d32e9d7b 100644 --- a/docs/spec/v1beta2/helmcharts.md +++ b/docs/spec/v1beta2/helmcharts.md @@ -252,15 +252,20 @@ For practical information, see **Note:** This feature is available only for Helm charts fetched from an OCI Registry. -`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign) +`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign) or [Notation](https://github.com/notaryproject/notation) signatures. The field offers three subfields: -- `.provider`, to specify the verification provider. Only supports `cosign` at present. +- `.provider`, to specify the verification provider. The supported options are `cosign` and `notation` at present. - `.secretRef.name`, to specify a reference to a Secret in the same namespace as - the HelmChart, containing the Cosign public keys of trusted authors. -- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers. Please see + the HelmChart, containing the public keys of trusted authors. For Notation this Secret should also include the [trust policy](https://github.com/notaryproject/specifications/blob/v1.0.0/specs/trust-store-trust-policy.md#trust-policy) in + addition to the CA certificate. +- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers (only supported when using `cosign` as the verification provider). Please see [Keyless verification](#keyless-verification) for more details. +#### Cosign + +The `cosign` provider can be used to verify the signature of an OCI artifact using either a known public key or via the [Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure. + ```yaml --- apiVersion: source.toolkit.fluxcd.io/v1beta2 @@ -281,7 +286,7 @@ following attributes to the HelmChart's `.status.conditions`: - `status: "True"` - `reason: Succeeded` -#### Public keys verification +##### Public keys verification To verify the authenticity of HelmChart hosted in an OCI Registry, create a Kubernetes secret with the Cosign public keys: @@ -303,7 +308,7 @@ Note that the keys must have the `.pub` extension for Flux to make use of them. Flux will loop over the public keys and use them to verify a HelmChart's signature. This allows for older HelmCharts to be valid as long as the right key is in the secret. -#### Keyless verification +##### Keyless verification For publicly available HelmCharts, which are signed using the [Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure, @@ -362,6 +367,55 @@ instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/). Note that keyless verification is an **experimental feature**, using custom root CAs or self-hosted Rekor instances are not currently supported. +#### Notation + +The `notation` provider can be used to verify the signature of an OCI artifact using known +trust policy and CA certificate. + +```yaml +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmChart +metadata: + name: podinfo +spec: + verify: + provider: notation + secretRef: + name: notation-config +``` + +When the verification succeeds, the controller adds a Condition with the +following attributes to the HelmChart's `.status.conditions`: + +- `type: SourceVerified` +- `status: "True"` +- `reason: Succeeded` + +To verify the authenticity of an OCI artifact, create a Kubernetes secret +containing Certificate Authority (CA) root certificates and the a `trust policy` + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: notation-config +type: Opaque +data: + certificate1.pem: + certificate2.crt: + trustpolicy.json: +``` + +Note that the CA certificates must have either `.pem` or `.crt` extension and your trust policy must +be named `trustpolicy.json` for Flux to make use of them. + +For more information on the signing and verification process see [Signing and Verification Workflow](https://github.com/notaryproject/specifications/blob/v1.0.0/specs/signing-and-verification-workflow.md). + +Flux will loop over the certificates and use them to verify an artifact's signature. +This allows for older artifacts to be valid as long as the right certificate is in the secret. + ## Working with HelmCharts ### Triggering a reconcile diff --git a/docs/spec/v1beta2/ocirepositories.md b/docs/spec/v1beta2/ocirepositories.md index 4ef84823c..39a34e217 100644 --- a/docs/spec/v1beta2/ocirepositories.md +++ b/docs/spec/v1beta2/ocirepositories.md @@ -237,7 +237,7 @@ patches: target: kind: Deployment name: source-controller -``` +``` When using pod-managed identity on an AKS cluster, AAD Pod Identity has to be used to give the `source-controller` pod access to the ACR. @@ -279,7 +279,7 @@ patches: target: kind: ServiceAccount name: source-controller -``` +``` The Artifact Registry service uses the permission `artifactregistry.repositories.downloadArtifacts` that is located under the Artifact Registry Reader role. If you are using @@ -454,7 +454,7 @@ metadata: spec: ref: digest: "sha256:" -``` +``` This field takes precedence over all other fields. @@ -501,14 +501,23 @@ for more information. ### Verification `.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign) +or [Notation](https://github.com/notaryproject/notation) signatures. The field offers three subfields: -- `.provider`, to specify the verification provider. Only supports `cosign` at present. +- `.provider`, to specify the verification provider. The supported options are `cosign` and `notation` at present. - `.secretRef.name`, to specify a reference to a Secret in the same namespace as - the OCIRepository, containing the Cosign public keys of trusted authors. -- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers. Please see + the OCIRepository, containing the Cosign public keys of trusted authors. For Notation this Secret should also + include the [trust policy](https://github.com/notaryproject/specifications/blob/v1.0.0/specs/trust-store-trust-policy.md#trust-policy) in + addition to the CA certificate. +- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers (only supported when using `cosign` as the + verification provider). Please see [Keyless verification](#keyless-verification) for more details. +#### Cosign + +The `cosign` provider can be used to verify the signature of an OCI artifact using either a known public key +or via the [Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure. + ```yaml --- apiVersion: source.toolkit.fluxcd.io/v1beta2 @@ -529,7 +538,7 @@ following attributes to the OCIRepository's `.status.conditions`: - `status: "True"` - `reason: Succeeded` -#### Public keys verification +##### Public keys verification To verify the authenticity of an OCI artifact, create a Kubernetes secret with the Cosign public keys: @@ -551,7 +560,7 @@ Note that the keys must have the `.pub` extension for Flux to make use of them. Flux will loop over the public keys and use them to verify an artifact's signature. This allows for older artifacts to be valid as long as the right key is in the secret. -#### Keyless verification +##### Keyless verification For publicly available OCI artifacts, which are signed using the [Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure, @@ -593,6 +602,55 @@ instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/). Note that keyless verification is an **experimental feature**, using custom root CAs or self-hosted Rekor instances are not currently supported. +#### Notation + +The `notation` provider can be used to verify the signature of an OCI artifact using known +trust policy and CA certificate. + +```yaml +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: OCIRepository +metadata: + name: +spec: + verify: + provider: notation + secretRef: + name: notation-config +``` + +When the verification succeeds, the controller adds a Condition with the +following attributes to the OCIRepository's `.status.conditions`: + +- `type: SourceVerified` +- `status: "True"` +- `reason: Succeeded` + +To verify the authenticity of an OCI artifact, create a Kubernetes secret +containing Certificate Authority (CA) root certificates and the a `trust policy` + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: notation-config +type: Opaque +data: + certificate1.pem: + certificate2.crt: + trustpolicy.json: +``` + +Note that the CA certificates must have either `.pem` or `.crt` extension and your trust policy must +be named `trustpolicy.json` for Flux to make use of them. + +For more information on the signing and verification process see [Signing and Verification Workflow](https://github.com/notaryproject/specifications/blob/v1.0.0/specs/signing-and-verification-workflow.md). + +Flux will loop over the certificates and use them to verify an artifact's signature. +This allows for older artifacts to be valid as long as the right certificate is in the secret. + ### Suspend `.spec.suspend` is an optional field to suspend the reconciliation of a diff --git a/go.mod b/go.mod index 2264ddf25..8e82151e4 100644 --- a/go.mod +++ b/go.mod @@ -44,9 +44,12 @@ require ( github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20231202142526-55ffb0092afd github.com/google/uuid v1.6.0 github.com/minio/minio-go/v7 v7.0.66 + github.com/notaryproject/notation-core-go v1.0.2 + github.com/notaryproject/notation-go v1.1.0 github.com/onsi/gomega v1.31.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98 + github.com/opencontainers/image-spec v1.1.0 github.com/ory/dockertest/v3 v3.10.0 github.com/otiai10/copy v1.14.0 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 @@ -64,6 +67,7 @@ require ( k8s.io/apimachinery v0.28.6 k8s.io/client-go v0.28.6 k8s.io/utils v0.0.0-20231127182322-b307cd553661 + oras.land/oras-go/v2 v2.3.1 sigs.k8s.io/controller-runtime v0.16.3 sigs.k8s.io/yaml v1.4.0 ) @@ -87,6 +91,7 @@ require ( github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 // indirect github.com/BurntSushi/toml v1.3.2 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect @@ -169,11 +174,14 @@ require ( github.com/fluxcd/gitkit v0.6.0 // indirect github.com/fluxcd/pkg/apis/acl v0.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.5.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v3 v3.0.1 // indirect + github.com/go-ldap/ldap/v3 v3.4.6 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/analysis v0.22.0 // indirect @@ -265,7 +273,6 @@ require ( github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/oleiade/reflections v1.0.1 // indirect - github.com/opencontainers/image-spec v1.1.0-rc5 // indirect github.com/opencontainers/runc v1.1.5 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect @@ -312,6 +319,8 @@ require ( github.com/tjfoc/gmsm v1.4.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/vbatts/tar-split v0.11.5 // indirect + github.com/veraison/go-cose v1.2.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/go-gitlab v0.96.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect diff --git a/go.sum b/go.sum index 57fa1c7e1..24a2ae701 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -95,6 +97,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.2/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 h1:iC9YFYKDGEy3n/FtqJnOkZsene9olVspKmkX5A2YBEo= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= @@ -368,8 +372,12 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/gliderlabs/ssh v0.3.6 h1:ZzjlDa05TcFRICb3anf/dSPN3ewz1Zx6CMLPWgkm3b8= github.com/gliderlabs/ssh v0.3.6/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= @@ -387,6 +395,8 @@ github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpj github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A= +github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -526,6 +536,7 @@ github.com/google/trillian v1.5.3/go.mod h1:p4tcg7eBr7aT6DxrAoILpc3uXNfcuAvZSnQK github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= @@ -721,6 +732,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/notaryproject/notation-core-go v1.0.2 h1:VEt+mbsgdANd9b4jqgmx2C7U0DmwynOuD2Nhxh3bANw= +github.com/notaryproject/notation-core-go v1.0.2/go.mod h1:2HkQzUwg08B3x9oVIztHsEh7Vil2Rj+tYgxH+JObLX4= +github.com/notaryproject/notation-go v1.1.0 h1:7WBeH8FGoA+GkeUwmBIBnlJc/PpdYaUKfiXu6ZZeEeg= +github.com/notaryproject/notation-go v1.1.0/go.mod h1:ZSk34URQar5fnWflaFByzpDvuefgZKm/mp8Q2tQpBaw= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481/go.mod h1:yKZQO8QE2bHlgozqWDiRVqTFlLQSj30K/6SAK8EeYFw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -748,8 +763,8 @@ github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be h1:f2Pl github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98 h1:LTxrNWOPwquJy9Cu3oz6QHJIO5M5gNyOZtSybXdyLA4= github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98/go.mod h1:kqQaIc6bZstKgnGpL7GD5dWoLKbA6mH1Y9ULjGImBnM= -github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= -github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/runc v1.1.5 h1:L44KXEpKmfWDcS02aeGm8QNTFXTo2D+8MYGDIJ/GDEs= github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= @@ -930,8 +945,12 @@ github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXG github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= +github.com/veraison/go-cose v1.2.0 h1:Ok0Hr3GMAf8K/1NB4sV65QGgCiukG1w1QD+H5tmt0Ow= +github.com/veraison/go-cose v1.2.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/go-gitlab v0.96.0 h1:LGkZ+wSNMRtHIBaYE4Hq3dZVjprwHv3Y1+rhKU3WETs= github.com/xanzy/go-gitlab v0.96.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -1039,6 +1058,7 @@ golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2Uz golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1139,6 +1159,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1148,6 +1169,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1161,6 +1183,7 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= @@ -1289,6 +1312,8 @@ k8s.io/utils v0.0.0-20231127182322-b307cd553661 h1:FepOBzJ0GXm8t0su67ln2wAZjbQ6R k8s.io/utils v0.0.0-20231127182322-b307cd553661/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go v1.2.4 h1:djpBY2/2Cs1PV87GSJlxv4voajVOMZxqqtq9AB8YNvY= oras.land/oras-go v1.2.4/go.mod h1:DYcGfb3YF1nKjcezfX2SNlDAeQFKSXmf+qrFmrh4324= +oras.land/oras-go/v2 v2.3.1 h1:lUC6q8RkeRReANEERLfH86iwGn55lbSWP20egdFHVec= +oras.land/oras-go/v2 v2.3.1/go.mod h1:5AQXVEu1X/FKp1F9DMOb5ZItZBOa0y5dha0yCm4NR9c= sigs.k8s.io/controller-runtime v0.16.3 h1:2TuvuokmfXvDUamSx1SuAOO3eTyye+47mJCigwG62c4= sigs.k8s.io/controller-runtime v0.16.3/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= diff --git a/hack/ci/e2e.sh b/hack/ci/e2e.sh index ad4aaad7a..b00eda00c 100755 --- a/hack/ci/e2e.sh +++ b/hack/ci/e2e.sh @@ -144,6 +144,12 @@ kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/helmchart-from-oc kubectl -n source-system wait helmchart/podinfo --for=condition=ready --timeout=1m kubectl -n source-system wait helmchart/podinfo-keyless --for=condition=ready --timeout=1m +kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/helmchart-from-oci/notation.yaml" +curl -sSLo notation.crt https://raw.githubusercontent.com/stefanprodan/podinfo/master/.notation/notation.crt +curl -sSLo trustpolicy.json https://raw.githubusercontent.com/stefanprodan/podinfo/master/.notation/trustpolicy.json +kubectl -n source-system create secret generic notation-config --from-file=notation.crt --from-file=trustpolicy.json --dry-run=client -o yaml | kubectl apply -f - +kubectl -n source-system wait helmchart/podinfo-notation --for=condition=ready --timeout=1m + echo "Run OCIRepository verify tests" kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/ocirepository/signed-with-key.yaml" kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/ocirepository/signed-with-keyless.yaml" @@ -152,3 +158,6 @@ kubectl -n source-system create secret generic cosign-key --from-file=cosign.pub kubectl -n source-system wait ocirepository/podinfo-deploy-signed-with-key --for=condition=ready --timeout=1m kubectl -n source-system wait ocirepository/podinfo-deploy-signed-with-keyless --for=condition=ready --timeout=1m + +kubectl -n source-system apply -f "${ROOT_DIR}/config/testdata/ocirepository/signed-with-notation.yaml" +kubectl -n source-system wait ocirepository/podinfo-deploy-signed-with-notation --for=condition=ready --timeout=1m diff --git a/internal/controller/helmchart_controller.go b/internal/controller/helmchart_controller.go index b8d23be53..647056a41 100644 --- a/internal/controller/helmchart_controller.go +++ b/internal/controller/helmchart_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "crypto/tls" + "encoding/json" "errors" "fmt" "net/url" @@ -29,6 +30,7 @@ import ( "time" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/notaryproject/notation-go/verifier/trustpolicy" "github.com/opencontainers/go-digest" "github.com/sigstore/cosign/v2/pkg/cosign" helmgetter "helm.sh/helm/v3/pkg/getter" @@ -69,7 +71,10 @@ import ( "github.com/fluxcd/source-controller/internal/helm/chart" "github.com/fluxcd/source-controller/internal/helm/getter" "github.com/fluxcd/source-controller/internal/helm/repository" + "github.com/fluxcd/source-controller/internal/oci" soci "github.com/fluxcd/source-controller/internal/oci" + scosign "github.com/fluxcd/source-controller/internal/oci/cosign" + "github.com/fluxcd/source-controller/internal/oci/notation" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" "github.com/fluxcd/source-controller/internal/reconcile/summarize" "github.com/fluxcd/source-controller/internal/util" @@ -579,7 +584,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * provider := obj.Spec.Verify.Provider verifiers, err = r.makeVerifiers(ctx, obj, *clientOpts) if err != nil { - if obj.Spec.Verify.SecretRef == nil { + if obj.Spec.Verify.SecretRef == nil && obj.Spec.Verify.Provider == "cosign" { provider = fmt.Sprintf("%s keyless", provider) } e := serror.NewGeneric( @@ -1244,7 +1249,9 @@ func observeChartBuild(ctx context.Context, sp *patch.SerialPatcher, pOpts []pat if build.Complete() { conditions.Delete(obj, sourcev1.FetchFailedCondition) conditions.Delete(obj, sourcev1.BuildFailedCondition) - conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, fmt.Sprintf("verified signature of version %s", build.Version)) + if build.VerifiedResult == oci.VerificationResultSuccess { + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, fmt.Sprintf("verified signature of version %s", build.Version)) + } } if obj.Spec.Verify == nil { @@ -1318,26 +1325,27 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel switch obj.Spec.Verify.Provider { case "cosign": - defaultCosignOciOpts := []soci.Options{ - soci.WithRemoteOptions(verifyOpts...), + defaultCosignOciOpts := []scosign.Options{ + scosign.WithRemoteOptions(verifyOpts...), } // get the public keys from the given secret if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil { - certSecretName := types.NamespacedName{ + + verifySecret := types.NamespacedName{ Namespace: obj.Namespace, Name: secretRef.Name, } - var pubSecret corev1.Secret - if err := r.Get(ctx, certSecretName, &pubSecret); err != nil { + pubSecret, err := r.retrieveSecret(ctx, verifySecret) + if err != nil { return nil, err } for k, data := range pubSecret.Data { // search for public keys in the secret if strings.HasSuffix(k, ".pub") { - verifier, err := soci.NewCosignVerifier(ctx, append(defaultCosignOciOpts, soci.WithPublicKey(data))...) + verifier, err := scosign.NewCosignVerifier(ctx, append(defaultCosignOciOpts, scosign.WithPublicKey(data))...) if err != nil { return nil, err } @@ -1346,7 +1354,7 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel } if len(verifiers) == 0 { - return nil, fmt.Errorf("no public keys found in secret '%s'", certSecretName) + return nil, fmt.Errorf("no public keys found in secret '%s'", verifySecret.String()) } return verifiers, nil } @@ -1359,9 +1367,67 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel SubjectRegExp: match.Subject, }) } - defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIdentities(identities)) + defaultCosignOciOpts = append(defaultCosignOciOpts, scosign.WithIdentities(identities)) + + verifier, err := scosign.NewCosignVerifier(ctx, defaultCosignOciOpts...) + if err != nil { + return nil, err + } + verifiers = append(verifiers, verifier) + return verifiers, nil + case "notation": + // get the public keys from the given secret + secretRef := obj.Spec.Verify.SecretRef + + if secretRef == nil { + return nil, fmt.Errorf("verification secret cannot be empty: '%s'", obj.Name) + } + + verifySecret := types.NamespacedName{ + Namespace: obj.Namespace, + Name: secretRef.Name, + } + + pubSecret, err := r.retrieveSecret(ctx, verifySecret) + if err != nil { + return nil, err + } - verifier, err := soci.NewCosignVerifier(ctx, defaultCosignOciOpts...) + data, ok := pubSecret.Data[notation.DefaultTrustPolicyKey] + if !ok { + return nil, fmt.Errorf("'%s' not found in secret '%s'", notation.DefaultTrustPolicyKey, verifySecret.String()) + } + + var doc trustpolicy.Document + + if err := json.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("error occurred while parsing %s: %w", notation.DefaultTrustPolicyKey, err) + } + + var certs [][]byte + + for k, data := range pubSecret.Data { + if strings.HasSuffix(k, ".crt") || strings.HasSuffix(k, ".pem") { + certs = append(certs, data) + } + } + + if certs == nil { + return nil, fmt.Errorf("no certificates found in secret '%s'", verifySecret.String()) + } + + trustPolicy := notation.CleanTrustPolicy(&doc, ctrl.LoggerFrom(ctx)) + defaultNotationOciOpts := []notation.Options{ + notation.WithTrustPolicy(trustPolicy), + notation.WithRemoteOptions(verifyOpts...), + notation.WithAuth(clientOpts.Authenticator), + notation.WithKeychain(clientOpts.Keychain), + notation.WithInsecureRegistry(clientOpts.Insecure), + notation.WithLogger(ctrl.LoggerFrom(ctx)), + notation.WithRootCertificates(certs), + } + + verifier, err := notation.NewNotationVerifier(defaultNotationOciOpts...) if err != nil { return nil, err } @@ -1371,3 +1437,15 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel return nil, fmt.Errorf("unsupported verification provider: %s", obj.Spec.Verify.Provider) } } + +// retrieveSecret retrieves a secret from the specified namespace with the given secret name. +// It returns the retrieved secret and any error encountered during the retrieval process. +func (r *HelmChartReconciler) retrieveSecret(ctx context.Context, verifySecret types.NamespacedName) (corev1.Secret, error) { + + var pubSecret corev1.Secret + + if err := r.Get(ctx, verifySecret, &pubSecret); err != nil { + return corev1.Secret{}, err + } + return pubSecret, nil +} diff --git a/internal/controller/helmchart_controller_test.go b/internal/controller/helmchart_controller_test.go index c7c753b98..cad153265 100644 --- a/internal/controller/helmchart_controller_test.go +++ b/internal/controller/helmchart_controller_test.go @@ -19,7 +19,9 @@ package controller import ( "bytes" "context" + "crypto/x509" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -34,6 +36,12 @@ import ( "time" "github.com/foxcpp/go-mockdns" + "github.com/notaryproject/notation-core-go/signature/cose" + "github.com/notaryproject/notation-core-go/testhelper" + "github.com/notaryproject/notation-go" + nr "github.com/notaryproject/notation-go/registry" + "github.com/notaryproject/notation-go/signer" + "github.com/notaryproject/notation-go/verifier/trustpolicy" . "github.com/onsi/gomega" coptions "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" @@ -45,6 +53,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/record" + oras "oras.land/oras-go/v2/registry/remote" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -66,6 +75,7 @@ import ( "github.com/fluxcd/source-controller/internal/helm/chart/secureloader" "github.com/fluxcd/source-controller/internal/helm/registry" "github.com/fluxcd/source-controller/internal/oci" + snotation "github.com/fluxcd/source-controller/internal/oci/notation" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" "github.com/fluxcd/source-controller/internal/reconcile/summarize" ) @@ -2733,7 +2743,331 @@ func TestHelmChartRepository_reconcileSource_verifyOCISourceSignature_keyless(t } } -func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) { +func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignatureNotation(t *testing.T) { + g := NewWithT(t) + + tmpDir := t.TempDir() + server, err := setupRegistryServer(ctx, tmpDir, registryOptions{}) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(func() { + server.Close() + }) + + const ( + chartPath = "testdata/charts/helmchart-0.1.0.tgz" + ) + + // Load a test chart + chartData, err := os.ReadFile(chartPath) + g.Expect(err).ToNot(HaveOccurred()) + + // Upload the test chart + metadata, err := loadTestChartToOCI(chartData, server, "", "", "") + g.Expect(err).NotTo(HaveOccurred()) + + storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords) + g.Expect(err).ToNot(HaveOccurred()) + + cachedArtifact := &sourcev1.Artifact{ + Revision: "0.1.0", + Path: metadata.Name + "-" + metadata.Version + ".tgz", + } + g.Expect(storage.CopyFromPath(cachedArtifact, "testdata/charts/helmchart-0.1.0.tgz")).To(Succeed()) + + certTuple := testhelper.GetRSASelfSignedSigningCertTuple("notation self-signed certs for testing") + certs := []*x509.Certificate{certTuple.Cert} + + signer, err := signer.New(certTuple.PrivateKey, certs) + g.Expect(err).ToNot(HaveOccurred()) + + policyDocument := trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelStrict.Name, Override: map[trustpolicy.ValidationType]trustpolicy.ValidationAction{trustpolicy.TypeRevocation: trustpolicy.ActionSkip}}, + TrustStores: []string{"ca:valid-trust-store"}, + TrustedIdentities: []string{"*"}, + }, + }, + } + + tests := []struct { + name string + shouldSign bool + beforeFunc func(obj *helmv1.HelmChart) + want sreconcile.Result + wantErr bool + wantErrMsg string + addMultipleCerts bool + provideNoCert bool + provideNoPolicy bool + assertConditions []metav1.Condition + cleanFunc func(g *WithT, build *chart.Build) + }{ + { + name: "unsigned charts should not pass verification", + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &helmv1.OCIRepositoryVerification{ + Provider: "notation", + SecretRef: &meta.LocalObjectReference{Name: "notation-config"}, + } + }, + want: sreconcile.ResultEmpty, + wantErr: true, + wantErrMsg: "chart verification error: failed to verify : no signature", + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify : no signature"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify : no signature"), + }, + }, + { + name: "signed charts should pass verification", + shouldSign: true, + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &helmv1.OCIRepositoryVerification{ + Provider: "notation", + SecretRef: &meta.LocalObjectReference{Name: "notation-config"}, + } + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version "), + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + }, + }, + { + name: "multiple certs should still pass verification", + addMultipleCerts: true, + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &helmv1.OCIRepositoryVerification{ + Provider: "notation", + SecretRef: &meta.LocalObjectReference{Name: "notation-config"}, + } + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version "), + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + }, + }, + { + name: "verify failed before, removed from spec, remove condition", + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = nil + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, "VerifyFailed", "fail msg") + obj.Status.Artifact = &sourcev1.Artifact{Path: metadata.Name + "-" + metadata.Version + ".tgz"} + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled '' chart with version ''"), + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '' chart with version ''"), + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + }, + }, + { + name: "no cert provided should not pass verification", + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &helmv1.OCIRepositoryVerification{ + Provider: "notation", + SecretRef: &meta.LocalObjectReference{Name: "notation-config"}, + } + }, + wantErr: true, + provideNoCert: true, + // no namespace but the namespace name should appear before the /notation-config + wantErrMsg: "failed to verify the signature using provider 'notation': no certificates found in secret '/notation-config'", + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.FetchFailedCondition, "Unknown", "failed to verify the signature using provider 'notation': no certificates found in secret '/notation-config'"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider 'notation': no certificates found in secret '/notation-config'"), + }, + }, + { + name: "empty string should fail verification", + beforeFunc: func(obj *helmv1.HelmChart) { + obj.Spec.Chart = metadata.Name + obj.Spec.Version = metadata.Version + obj.Spec.Verify = &helmv1.OCIRepositoryVerification{ + Provider: "notation", + SecretRef: &meta.LocalObjectReference{Name: "notation-config"}, + } + }, + provideNoPolicy: true, + wantErr: true, + wantErrMsg: fmt.Sprintf("failed to verify the signature using provider 'notation': '%s' not found in secret '/notation-config'", snotation.DefaultTrustPolicyKey), + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.FetchFailedCondition, "Unknown", fmt.Sprintf("failed to verify the signature using provider 'notation': '%s' not found in secret '/notation-config'", snotation.DefaultTrustPolicyKey)), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, fmt.Sprintf("failed to verify the signature using provider 'notation': '%s' not found in secret '/notation-config'", snotation.DefaultTrustPolicyKey)), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + clientBuilder := fakeclient.NewClientBuilder() + + repository := &helmv1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-", + }, + Spec: helmv1.HelmRepositorySpec{ + URL: fmt.Sprintf("oci://%s/testrepo", server.registryHost), + Timeout: &metav1.Duration{Duration: timeout}, + Provider: helmv1.GenericOCIProvider, + Type: helmv1.HelmRepositoryTypeOCI, + Insecure: true, + }, + } + + policy, err := json.Marshal(policyDocument) + g.Expect(err).NotTo(HaveOccurred()) + + data := map[string][]byte{} + + if tt.addMultipleCerts { + data["a.crt"] = testhelper.GetRSASelfSignedSigningCertTuple("a not used for signing").Cert.Raw + data["b.crt"] = testhelper.GetRSASelfSignedSigningCertTuple("b not used for signing").Cert.Raw + data["c.crt"] = testhelper.GetRSASelfSignedSigningCertTuple("c not used for signing").Cert.Raw + } + + if !tt.provideNoCert { + data["notation.crt"] = certTuple.Cert.Raw + } + + if !tt.provideNoPolicy { + data["trustpolicy.json"] = policy + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "notation-config", + }, + Data: data, + } + + caSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-trust-store", + Generation: 1, + }, + Data: map[string][]byte{ + "ca.crt": tlsCA, + }, + } + + clientBuilder.WithObjects(repository, secret, caSecret) + + r := &HelmChartReconciler{ + Client: clientBuilder.Build(), + EventRecorder: record.NewFakeRecorder(32), + Getters: testGetters, + Storage: storage, + RegistryClientGenerator: registry.ClientGenerator, + patchOptions: getPatchOptions(helmChartReadyCondition.Owned, "sc"), + } + + obj := &helmv1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmchart-", + }, + Spec: helmv1.HelmChartSpec{ + SourceRef: helmv1.LocalHelmChartSourceReference{ + Kind: helmv1.HelmRepositoryKind, + Name: repository.Name, + }, + }, + } + + chartUrl := fmt.Sprintf("oci://%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version) + + if tt.beforeFunc != nil { + tt.beforeFunc(obj) + } + + if tt.shouldSign { + artifact := fmt.Sprintf("%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version) + + remoteRepo, err := oras.NewRepository(artifact) + g.Expect(err).ToNot(HaveOccurred()) + + remoteRepo.PlainHTTP = true + + repo := nr.NewRepository(remoteRepo) + + signatureMediaType := cose.MediaTypeEnvelope + + signOptions := notation.SignOptions{ + SignerSignOptions: notation.SignerSignOptions{ + SignatureMediaType: signatureMediaType, + }, + ArtifactReference: artifact, + } + + _, err = notation.Sign(ctx, signer, repo, signOptions) + g.Expect(err).ToNot(HaveOccurred()) + } + + assertConditions := tt.assertConditions + for k := range assertConditions { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", metadata.Name) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", metadata.Version) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", chartUrl) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", "notation") + } + + var b chart.Build + if tt.cleanFunc != nil { + defer tt.cleanFunc(g, &b) + } + + g.Expect(r.Client.Create(context.TODO(), obj)).ToNot(HaveOccurred()) + defer func() { + g.Expect(r.Client.Delete(context.TODO(), obj)).ToNot(HaveOccurred()) + }() + + sp := patch.NewSerialPatcher(obj, r.Client) + + got, err := r.reconcileSource(ctx, sp, obj, &b) + if tt.wantErr { + tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", chartUrl) + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErrMsg)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) + }) + } +} + +func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignatureCosign(t *testing.T) { g := NewWithT(t) tmpDir := t.TempDir() diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index 9e6e69145..57449fdb3 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" cryptotls "crypto/tls" + "encoding/json" "errors" "fmt" "io" @@ -35,6 +36,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" gcrv1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/notaryproject/notation-go/verifier/trustpolicy" "github.com/sigstore/cosign/v2/pkg/cosign" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" @@ -68,6 +70,8 @@ import ( ociv1 "github.com/fluxcd/source-controller/api/v1beta2" serror "github.com/fluxcd/source-controller/internal/error" soci "github.com/fluxcd/source-controller/internal/oci" + scosign "github.com/fluxcd/source-controller/internal/oci/cosign" + "github.com/fluxcd/source-controller/internal/oci/notation" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" "github.com/fluxcd/source-controller/internal/reconcile/summarize" "github.com/fluxcd/source-controller/internal/tls" @@ -430,10 +434,10 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch conditions.GetObservedGeneration(obj, sourcev1.SourceVerifiedCondition) != obj.Generation || conditions.IsFalse(obj, sourcev1.SourceVerifiedCondition) { - err := r.verifySignature(ctx, obj, ref, opts...) + result, err := r.verifySignature(ctx, obj, ref, keychain, auth, opts...) if err != nil { provider := obj.Spec.Verify.Provider - if obj.Spec.Verify.SecretRef == nil { + if obj.Spec.Verify.SecretRef == nil && obj.Spec.Verify.Provider == "cosign" { provider = fmt.Sprintf("%s keyless", provider) } e := serror.NewGeneric( @@ -444,7 +448,9 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch return sreconcile.ResultEmpty, e } - conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision %s", revision) + if result == soci.VerificationResultSuccess { + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision %s", revision) + } } // Skip pulling if the artifact revision and the source configuration has @@ -609,38 +615,42 @@ func (r *OCIRepositoryReconciler) digestFromRevision(revision string) string { } // verifySignature verifies the authenticity of the given image reference URL. +// It supports two different verification providers: cosign and notation. // First, it tries to use a key if a Secret with a valid public key is provided. -// If not, it falls back to a keyless approach for verification. -func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *ociv1.OCIRepository, ref name.Reference, opt ...remote.Option) error { +// If not, when using cosign it falls back to a keyless approach for verification. +// When notation is used, a trust policy is required to verify the image. +// The verification result is returned as a VerificationResult and any error encountered. +func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *ociv1.OCIRepository, ref name.Reference, keychain authn.Keychain, auth authn.Authenticator, opt ...remote.Option) (soci.VerificationResult, error) { ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration) defer cancel() provider := obj.Spec.Verify.Provider switch provider { case "cosign": - defaultCosignOciOpts := []soci.Options{ - soci.WithRemoteOptions(opt...), + defaultCosignOciOpts := []scosign.Options{ + scosign.WithRemoteOptions(opt...), } // get the public keys from the given secret if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil { - certSecretName := types.NamespacedName{ + + verifySecret := types.NamespacedName{ Namespace: obj.Namespace, Name: secretRef.Name, } - var pubSecret corev1.Secret - if err := r.Get(ctxTimeout, certSecretName, &pubSecret); err != nil { - return err + pubSecret, err := r.retrieveSecret(ctxTimeout, verifySecret) + if err != nil { + return soci.VerificationResultFailed, err } signatureVerified := false for k, data := range pubSecret.Data { // search for public keys in the secret if strings.HasSuffix(k, ".pub") { - verifier, err := soci.NewCosignVerifier(ctxTimeout, append(defaultCosignOciOpts, soci.WithPublicKey(data))...) + verifier, err := scosign.NewCosignVerifier(ctxTimeout, append(defaultCosignOciOpts, scosign.WithPublicKey(data))...) if err != nil { - return err + return soci.VerificationResultFailed, err } signatures, _, err := verifier.VerifyImageSignatures(ctxTimeout, ref) @@ -656,10 +666,10 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *ociv } if !signatureVerified { - return fmt.Errorf("no matching signatures were found for '%s'", ref) + return soci.VerificationResultFailed, fmt.Errorf("no matching signatures were found for '%s'", ref) } - return nil + return soci.VerificationResultSuccess, nil } // if no secret is provided, try keyless verification @@ -672,26 +682,105 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *ociv SubjectRegExp: match.Subject, }) } - defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIdentities(identities)) + defaultCosignOciOpts = append(defaultCosignOciOpts, scosign.WithIdentities(identities)) - verifier, err := soci.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...) + verifier, err := scosign.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...) if err != nil { - return err + return soci.VerificationResultFailed, err } signatures, _, err := verifier.VerifyImageSignatures(ctxTimeout, ref) if err != nil { - return err + return soci.VerificationResultFailed, err } if len(signatures) > 0 { - return nil + return soci.VerificationResultSuccess, nil } - return fmt.Errorf("no matching signatures were found for '%s'", ref) + return soci.VerificationResultFailed, fmt.Errorf("no matching signatures were found for '%s'", ref) + + case "notation": + // get the public keys from the given secret + secretRef := obj.Spec.Verify.SecretRef + + if secretRef == nil { + return soci.VerificationResultFailed, fmt.Errorf("verification secret cannot be empty: '%s'", ref) + } + + verifySecret := types.NamespacedName{ + Namespace: obj.Namespace, + Name: secretRef.Name, + } + + pubSecret, err := r.retrieveSecret(ctxTimeout, verifySecret) + if err != nil { + return soci.VerificationResultFailed, err + } + + data, ok := pubSecret.Data[notation.DefaultTrustPolicyKey] + if !ok { + return soci.VerificationResultFailed, fmt.Errorf("'%s' not found in secret '%s'", notation.DefaultTrustPolicyKey, verifySecret.String()) + } + + var doc trustpolicy.Document + + if err := json.Unmarshal(data, &doc); err != nil { + return soci.VerificationResultFailed, fmt.Errorf("error occurred while parsing %s: %w", notation.DefaultTrustPolicyKey, err) + } + + var certs [][]byte + + for k, data := range pubSecret.Data { + if strings.HasSuffix(k, ".crt") || strings.HasSuffix(k, ".pem") { + certs = append(certs, data) + } + } + + if certs == nil { + return soci.VerificationResultFailed, fmt.Errorf("no certificates found in secret '%s'", verifySecret.String()) + } + + trustPolicy := notation.CleanTrustPolicy(&doc, ctrl.LoggerFrom(ctx)) + defaultNotationOciOpts := []notation.Options{ + notation.WithTrustPolicy(trustPolicy), + notation.WithRemoteOptions(opt...), + notation.WithAuth(auth), + notation.WithKeychain(keychain), + notation.WithInsecureRegistry(obj.Spec.Insecure), + notation.WithLogger(ctrl.LoggerFrom(ctx)), + notation.WithRootCertificates(certs), + } + + verifier, err := notation.NewNotationVerifier(defaultNotationOciOpts...) + if err != nil { + return soci.VerificationResultFailed, err + } + + result, err := verifier.Verify(ctxTimeout, ref) + if err != nil { + return result, err + } + + if result == soci.VerificationResultFailed { + return soci.VerificationResultFailed, fmt.Errorf("no matching signatures were found for '%s'", ref) + } + + return result, nil + default: + return soci.VerificationResultFailed, fmt.Errorf("unsupported verification provider: %s", obj.Spec.Verify.Provider) } +} - return nil +// retrieveSecret retrieves a secret from the specified namespace with the given secret name. +// It returns the retrieved secret and any error encountered during the retrieval process. +func (r *OCIRepositoryReconciler) retrieveSecret(ctx context.Context, verifySecret types.NamespacedName) (corev1.Secret, error) { + var pubSecret corev1.Secret + + if err := r.Get(ctx, verifySecret, &pubSecret); err != nil { + return corev1.Secret{}, err + } + return pubSecret, nil } // parseRepository validates and extracts the repository URL. diff --git a/internal/controller/ocirepository_controller_test.go b/internal/controller/ocirepository_controller_test.go index 86f034432..faf31fd76 100644 --- a/internal/controller/ocirepository_controller_test.go +++ b/internal/controller/ocirepository_controller_test.go @@ -19,6 +19,7 @@ package controller import ( "crypto/tls" "crypto/x509" + "encoding/json" "errors" "fmt" "net/http" @@ -35,7 +36,14 @@ import ( gcrv1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/notaryproject/notation-core-go/signature/cose" + "github.com/notaryproject/notation-core-go/testhelper" + "github.com/notaryproject/notation-go" + "github.com/notaryproject/notation-go/registry" + "github.com/notaryproject/notation-go/signer" + "github.com/notaryproject/notation-go/verifier/trustpolicy" . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" coptions "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" "github.com/sigstore/cosign/v2/pkg/cosign" @@ -44,6 +52,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/record" "k8s.io/utils/ptr" + oras "oras.land/oras-go/v2/registry/remote" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -62,6 +71,7 @@ import ( ociv1 "github.com/fluxcd/source-controller/api/v1beta2" intdigest "github.com/fluxcd/source-controller/internal/digest" serror "github.com/fluxcd/source-controller/internal/error" + snotation "github.com/fluxcd/source-controller/internal/oci/notation" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" ) @@ -1167,7 +1177,715 @@ func TestOCIRepository_reconcileSource_remoteReference(t *testing.T) { } } -func TestOCIRepository_reconcileSource_verifyOCISourceSignature(t *testing.T) { +func TestOCIRepository_reconcileSource_verifyOCISourceSignatureNotation(t *testing.T) { + g := NewWithT(t) + + tests := []struct { + name string + reference *ociv1.OCIRepositoryRef + insecure bool + want sreconcile.Result + wantErr bool + wantErrMsg string + shouldSign bool + useDigest bool + addMultipleCerts bool + provideNoCert bool + beforeFunc func(obj *ociv1.OCIRepository, tag, revision string) + assertConditions []metav1.Condition + }{ + { + name: "signed image should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + shouldSign: true, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "unsigned image should not pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.5", + }, + wantErr: true, + useDigest: true, + wantErrMsg: "failed to verify the signature using provider 'notation': no signature is associated with \"\"", + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider '': no signature is associated with \"\", make sure the artifact was signed successfully"), + }, + }, + { + name: "verify failed before, removed from spec, remove condition", + reference: &ociv1.OCIRepositoryRef{Tag: "6.1.4"}, + beforeFunc: func(obj *ociv1.OCIRepository, tag, revision string) { + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, "VerifyFailed", "fail msg") + obj.Spec.Verify = nil + obj.Status.Artifact = &sourcev1.Artifact{Revision: fmt.Sprintf("%s@%s", tag, revision)} + }, + want: sreconcile.ResultSuccess, + }, + { + name: "same artifact, verified before, change in obj gen verify again", + reference: &ociv1.OCIRepositoryRef{Tag: "6.1.4"}, + shouldSign: true, + beforeFunc: func(obj *ociv1.OCIRepository, tag, revision string) { + obj.Status.Artifact = &sourcev1.Artifact{Revision: fmt.Sprintf("%s@%s", tag, revision)} + // Set Verified with old observed generation and different reason/message. + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, "Verified", "verified") + // Set new object generation. + obj.SetGeneration(3) + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "no verify for already verified, verified condition remains the same", + reference: &ociv1.OCIRepositoryRef{Tag: "6.1.4"}, + shouldSign: true, + beforeFunc: func(obj *ociv1.OCIRepository, tag, revision string) { + // Artifact present and custom verified condition reason/message. + obj.Status.Artifact = &sourcev1.Artifact{Revision: fmt.Sprintf("%s@%s", tag, revision)} + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, "Verified", "verified") + }, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, "Verified", "verified"), + }, + }, + { + name: "signed image on an insecure registry passes verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.6", + }, + shouldSign: true, + insecure: true, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "signed image on an insecure registry using digest as reference passes verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.6", + }, + shouldSign: true, + insecure: true, + useDigest: true, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "verification level audit and correct trust identity should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.6", + }, + shouldSign: true, + insecure: true, + useDigest: true, + want: sreconcile.ResultSuccess, + addMultipleCerts: true, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "no cert provided should not pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.5", + }, + wantErr: true, + useDigest: true, + provideNoCert: true, + // no namespace but the namespace name should appear before the /notation-config + wantErrMsg: "failed to verify the signature using provider 'notation': no certificates found in secret '/notation-config'", + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider '': no certificates found in secret '/notation-config'"), + }, + }, + } + + clientBuilder := fakeclient.NewClientBuilder(). + WithScheme(testEnv.GetScheme()). + WithStatusSubresource(&ociv1.OCIRepository{}) + + r := &OCIRepositoryReconciler{ + Client: clientBuilder.Build(), + EventRecorder: record.NewFakeRecorder(32), + Storage: testStorage, + patchOptions: getPatchOptions(ociRepositoryReadyCondition.Owned, "sc"), + } + + certTuple := testhelper.GetRSASelfSignedSigningCertTuple("notation self-signed certs for testing") + certs := []*x509.Certificate{certTuple.Cert} + + signer, err := signer.New(certTuple.PrivateKey, certs) + g.Expect(err).ToNot(HaveOccurred()) + + policyDocument := trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelStrict.Name, Override: map[trustpolicy.ValidationType]trustpolicy.ValidationAction{trustpolicy.TypeRevocation: trustpolicy.ActionSkip}}, + TrustStores: []string{"ca:valid-trust-store"}, + TrustedIdentities: []string{"*"}, + }, + }, + } + + tmpDir := t.TempDir() + + policy, err := json.Marshal(policyDocument) + g.Expect(err).NotTo(HaveOccurred()) + + caSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-trust-store", + Generation: 1, + }, + Data: map[string][]byte{ + "ca.crt": tlsCA, + }, + } + + g.Expect(r.Create(ctx, caSecret)).ToNot(HaveOccurred()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + workspaceDir := t.TempDir() + regOpts := registryOptions{ + withTLS: !tt.insecure, + } + server, err := setupRegistryServer(ctx, workspaceDir, regOpts) + g.Expect(err).NotTo(HaveOccurred()) + t.Cleanup(func() { + server.Close() + }) + + obj := &ociv1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "verify-oci-source-signature-", + Generation: 1, + }, + Spec: ociv1.OCIRepositorySpec{ + URL: fmt.Sprintf("oci://%s/podinfo", server.registryHost), + Verify: &ociv1.OCIRepositoryVerification{ + Provider: "notation", + }, + Interval: metav1.Duration{Duration: interval}, + Timeout: &metav1.Duration{Duration: timeout}, + }, + } + + data := map[string][]byte{} + + if tt.addMultipleCerts { + data["a.crt"] = testhelper.GetRSASelfSignedSigningCertTuple("a not used for signing").Cert.Raw + data["b.crt"] = testhelper.GetRSASelfSignedSigningCertTuple("b not used for signing").Cert.Raw + data["c.crt"] = testhelper.GetRSASelfSignedSigningCertTuple("c not used for signing").Cert.Raw + } + + if !tt.provideNoCert { + data["notation.crt"] = certTuple.Cert.Raw + } + + data["trustpolicy.json"] = policy + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "notation-config", + }, + Data: data, + } + + g.Expect(r.Create(ctx, secret)).NotTo(HaveOccurred()) + + if tt.insecure { + obj.Spec.Insecure = true + } else { + obj.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: "valid-trust-store", + } + } + + obj.Spec.Verify.SecretRef = &meta.LocalObjectReference{Name: "notation-config"} + + if tt.reference != nil { + obj.Spec.Reference = tt.reference + } + + podinfoVersions, err := pushMultiplePodinfoImages(server.registryHost, tt.insecure, tt.reference.Tag) + g.Expect(err).ToNot(HaveOccurred()) + + if tt.useDigest { + obj.Spec.Reference.Digest = podinfoVersions[tt.reference.Tag].digest.String() + } + + keychain, err := r.keychain(ctx, obj) + if err != nil { + g.Expect(err).ToNot(HaveOccurred()) + } + + opts := makeRemoteOptions(ctx, makeTransport(true), keychain, nil) + + artifactRef, err := r.getArtifactRef(obj, opts) + g.Expect(err).ToNot(HaveOccurred()) + + if tt.shouldSign { + remoteRepo, err := oras.NewRepository(artifactRef.String()) + g.Expect(err).ToNot(HaveOccurred()) + + if tt.insecure { + remoteRepo.PlainHTTP = true + } + + repo := registry.NewRepository(remoteRepo) + + signatureMediaType := cose.MediaTypeEnvelope + + signOptions := notation.SignOptions{ + SignerSignOptions: notation.SignerSignOptions{ + SignatureMediaType: signatureMediaType, + }, + ArtifactReference: artifactRef.String(), + } + + _, err = notation.Sign(ctx, signer, repo, signOptions) + g.Expect(err).ToNot(HaveOccurred()) + } + + image := podinfoVersions[tt.reference.Tag] + assertConditions := tt.assertConditions + for k := range assertConditions { + if tt.useDigest { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", image.digest.String()) + } else { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", fmt.Sprintf("%s@%s", tt.reference.Tag, image.digest.String())) + } + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", artifactRef.String()) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", "notation") + } + + if tt.beforeFunc != nil { + tt.beforeFunc(obj, image.tag, image.digest.String()) + } + + g.Expect(r.Client.Create(ctx, obj)).ToNot(HaveOccurred()) + defer func() { + g.Expect(r.Client.Delete(ctx, obj)).ToNot(HaveOccurred()) + g.Expect(r.Delete(ctx, secret)).NotTo(HaveOccurred()) + }() + + sp := patch.NewSerialPatcher(obj, r.Client) + + artifact := &sourcev1.Artifact{} + got, err := r.reconcileSource(ctx, sp, obj, artifact, tmpDir) + if tt.wantErr { + tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", artifactRef.String()) + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErrMsg)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) + }) + } +} + +func TestOCIRepository_reconcileSource_verifyOCISourceTrustPolicyNotation(t *testing.T) { + g := NewWithT(t) + + tests := []struct { + name string + reference *ociv1.OCIRepositoryRef + insecure bool + signatureVerification trustpolicy.SignatureVerification + trustedIdentities []string + trustStores []string + want sreconcile.Result + wantErr bool + wantErrMsg string + useDigest bool + usePolicyJson bool + provideNoPolicy bool + policyJson string + beforeFunc func(obj *ociv1.OCIRepository, tag, revision string) + assertConditions []metav1.Condition + }{ + { + name: "verification level audit and incorrect trust identity should fail verification but not error", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + signatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelAudit.Name}, + trustedIdentities: []string{"x509.subject: C=US, ST=WA, L=Seattle, O=Notary, CN=example.com"}, + trustStores: []string{"ca:valid-trust-store"}, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + }, + }, + { + name: "verification level permissive and incorrect trust identity should fail verification and error", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + signatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelPermissive.Name}, + trustedIdentities: []string{"x509.subject: C=US, ST=WA, L=Seattle, O=Notary, CN=example.com"}, + trustStores: []string{"ca:valid-trust-store"}, + useDigest: true, + want: sreconcile.ResultEmpty, + wantErr: true, + wantErrMsg: "failed to verify the signature using provider 'notation': signature verification failed\nfailed to verify signature with digest , signing certificate from the digital signature does not match the X.509 trusted identities [map[\"C\":\"US\" \"CN\":\"example.com\" \"L\":\"Seattle\" \"O\":\"Notary\" \"ST\":\"WA\"]] defined in the trust policy \"test-statement-name\"", + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider 'notation': signature verification failed\nfailed to verify signature with digest , signing certificate from the digital signature does not match the X.509 trusted identities [map[\"C\":\"US\" \"CN\":\"example.com\" \"L\":\"Seattle\" \"O\":\"Notary\" \"ST\":\"WA\"]] defined in the trust policy \"test-statement-name\""), + }, + }, + { + name: "verification level permissive and correct trust identity should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + signatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelPermissive.Name}, + trustedIdentities: []string{"*"}, + trustStores: []string{"ca:valid-trust-store"}, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "verification level audit and correct trust identity should pass verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + signatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelAudit.Name}, + trustedIdentities: []string{"*"}, + trustStores: []string{"ca:valid-trust-store"}, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision "), + }, + }, + { + name: "verification level skip and should not be marked as verified", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + signatureVerification: trustpolicy.SignatureVerification{VerificationLevel: trustpolicy.LevelSkip.Name}, + trustedIdentities: []string{}, + want: sreconcile.ResultSuccess, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + }, + }, + { + name: "valid json but empty policy json should fail verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + usePolicyJson: true, + policyJson: "{}", + wantErr: true, + wantErrMsg: "trust policy document is missing or has empty version, it must be specified", + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "trust policy document is missing or has empty version, it must be specified"), + }, + }, + { + name: "empty string should fail verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + usePolicyJson: true, + policyJson: "", + wantErr: true, + wantErrMsg: fmt.Sprintf("error occurred while parsing %s: unexpected end of JSON input", snotation.DefaultTrustPolicyKey), + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, fmt.Sprintf("error occurred while parsing %s: unexpected end of JSON input", snotation.DefaultTrustPolicyKey)), + }, + }, + { + name: "invalid character in string should fail verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + usePolicyJson: true, + policyJson: "{\"version\": \"1.0\u000A\", \"trust_policies\": []}", + wantErr: true, + wantErrMsg: fmt.Sprintf("error occurred while parsing %s: invalid character '\\n' in string literal", snotation.DefaultTrustPolicyKey), + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, fmt.Sprintf("error occurred while parsing %s: invalid character '\\n' in string literal", snotation.DefaultTrustPolicyKey)), + }, + }, + { + name: "empty string should fail verification", + reference: &ociv1.OCIRepositoryRef{ + Tag: "6.1.4", + }, + provideNoPolicy: true, + wantErr: true, + wantErrMsg: fmt.Sprintf("failed to verify the signature using provider 'notation': '%s' not found in secret '/notation'", snotation.DefaultTrustPolicyKey), + want: sreconcile.ResultEmpty, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '' for ''"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, fmt.Sprintf("failed to verify the signature using provider 'notation': '%s' not found in secret '/notation'", snotation.DefaultTrustPolicyKey)), + }, + }, + } + + clientBuilder := fakeclient.NewClientBuilder(). + WithScheme(testEnv.GetScheme()). + WithStatusSubresource(&ociv1.OCIRepository{}) + + r := &OCIRepositoryReconciler{ + Client: clientBuilder.Build(), + EventRecorder: record.NewFakeRecorder(32), + Storage: testStorage, + patchOptions: getPatchOptions(ociRepositoryReadyCondition.Owned, "sc"), + } + + certTuple := testhelper.GetRSASelfSignedSigningCertTuple("notation self-signed certs for testing") + certs := []*x509.Certificate{certTuple.Cert} + + signer, err := signer.New(certTuple.PrivateKey, certs) + g.Expect(err).ToNot(HaveOccurred()) + + tmpDir := t.TempDir() + + caSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-trust-store", + Generation: 1, + }, + Data: map[string][]byte{ + "ca.crt": tlsCA, + }, + } + + g.Expect(r.Create(ctx, caSecret)).ToNot(HaveOccurred()) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + workspaceDir := t.TempDir() + regOpts := registryOptions{ + withTLS: !tt.insecure, + } + server, err := setupRegistryServer(ctx, workspaceDir, regOpts) + g.Expect(err).NotTo(HaveOccurred()) + t.Cleanup(func() { + server.Close() + }) + + obj := &ociv1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "verify-oci-source-signature-", + Generation: 1, + }, + Spec: ociv1.OCIRepositorySpec{ + URL: fmt.Sprintf("oci://%s/podinfo", server.registryHost), + Verify: &ociv1.OCIRepositoryVerification{ + Provider: "notation", + }, + Interval: metav1.Duration{Duration: interval}, + Timeout: &metav1.Duration{Duration: timeout}, + }, + } + + var policy []byte + + if !tt.usePolicyJson { + policyDocument := trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: tt.signatureVerification, + TrustStores: tt.trustStores, + TrustedIdentities: tt.trustedIdentities, + }, + }, + } + + policy, err = json.Marshal(policyDocument) + g.Expect(err).NotTo(HaveOccurred()) + } else { + policy = []byte(tt.policyJson) + } + + data := map[string][]byte{} + + if !tt.provideNoPolicy { + data["trustpolicy.json"] = policy + } + + data["notation.crt"] = certTuple.Cert.Raw + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "notation", + }, + Data: data, + } + + g.Expect(r.Create(ctx, secret)).NotTo(HaveOccurred()) + + if tt.insecure { + obj.Spec.Insecure = true + } else { + obj.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: "valid-trust-store", + } + } + + obj.Spec.Verify.SecretRef = &meta.LocalObjectReference{Name: "notation"} + + if tt.reference != nil { + obj.Spec.Reference = tt.reference + } + + podinfoVersions, err := pushMultiplePodinfoImages(server.registryHost, tt.insecure, tt.reference.Tag) + g.Expect(err).ToNot(HaveOccurred()) + + if tt.useDigest { + obj.Spec.Reference.Digest = podinfoVersions[tt.reference.Tag].digest.String() + } + + keychain, err := r.keychain(ctx, obj) + if err != nil { + g.Expect(err).ToNot(HaveOccurred()) + } + + opts := makeRemoteOptions(ctx, makeTransport(true), keychain, nil) + + artifactRef, err := r.getArtifactRef(obj, opts) + g.Expect(err).ToNot(HaveOccurred()) + + remoteRepo, err := oras.NewRepository(artifactRef.String()) + g.Expect(err).ToNot(HaveOccurred()) + + if tt.insecure { + remoteRepo.PlainHTTP = true + } + + repo := registry.NewRepository(remoteRepo) + + signatureMediaType := cose.MediaTypeEnvelope + + signOptions := notation.SignOptions{ + SignerSignOptions: notation.SignerSignOptions{ + SignatureMediaType: signatureMediaType, + }, + ArtifactReference: artifactRef.String(), + } + + _, err = notation.Sign(ctx, signer, repo, signOptions) + g.Expect(err).ToNot(HaveOccurred()) + + image := podinfoVersions[tt.reference.Tag] + signatureDigest := "" + + artifactDescriptor, err := repo.Resolve(ctx, image.tag) + g.Expect(err).ToNot(HaveOccurred()) + _ = repo.ListSignatures(ctx, artifactDescriptor, func(signatureManifests []ocispec.Descriptor) error { + g.Expect(len(signatureManifests)).Should(Equal(1)) + signatureDigest = signatureManifests[0].Digest.String() + return nil + }) + + assertConditions := tt.assertConditions + for k := range assertConditions { + if tt.useDigest { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", image.digest.String()) + } else { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", fmt.Sprintf("%s@%s", tt.reference.Tag, image.digest.String())) + } + + if signatureDigest != "" { + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", signatureDigest) + } + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", artifactRef.String()) + assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "", "notation") + } + + if tt.beforeFunc != nil { + tt.beforeFunc(obj, image.tag, image.digest.String()) + } + + g.Expect(r.Client.Create(ctx, obj)).ToNot(HaveOccurred()) + defer func() { + g.Expect(r.Client.Delete(ctx, obj)).ToNot(HaveOccurred()) + }() + + sp := patch.NewSerialPatcher(obj, r.Client) + + artifact := &sourcev1.Artifact{} + got, err := r.reconcileSource(ctx, sp, obj, artifact, tmpDir) + g.Expect(r.Delete(ctx, secret)).NotTo(HaveOccurred()) + if tt.wantErr { + tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", artifactRef.String()) + if signatureDigest != "" { + tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "", signatureDigest) + } + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErrMsg)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions)) + }) + } +} + +func TestOCIRepository_reconcileSource_verifyOCISourceSignatureCosign(t *testing.T) { g := NewWithT(t) tests := []struct { diff --git a/internal/helm/chart/builder.go b/internal/helm/chart/builder.go index b5ac93825..e7be2dfcb 100644 --- a/internal/helm/chart/builder.go +++ b/internal/helm/chart/builder.go @@ -28,6 +28,7 @@ import ( "helm.sh/helm/v3/pkg/chartutil" "github.com/fluxcd/source-controller/internal/fs" + "github.com/fluxcd/source-controller/internal/oci" ) // Reference holds information to locate a chart. @@ -146,6 +147,9 @@ type Build struct { // This can for example be false if ValuesFiles is empty and the chart // source was already packaged. Packaged bool + // VerifiedResult indicates the results of verifying the chart. + // If no verification was performed, this field should be VerificationResultIgnored. + VerifiedResult oci.VerificationResult } // Summary returns a human-readable summary of the Build. diff --git a/internal/helm/chart/builder_remote.go b/internal/helm/chart/builder_remote.go index 5ecfe9873..345fedf96 100644 --- a/internal/helm/chart/builder_remote.go +++ b/internal/helm/chart/builder_remote.go @@ -35,6 +35,7 @@ import ( "github.com/fluxcd/source-controller/internal/fs" "github.com/fluxcd/source-controller/internal/helm/chart/secureloader" "github.com/fluxcd/source-controller/internal/helm/repository" + "github.com/fluxcd/source-controller/internal/oci" ) type remoteChartBuilder struct { @@ -141,9 +142,11 @@ func (b *remoteChartBuilder) downloadFromRepository(ctx context.Context, remote return nil, nil, &BuildError{Reason: reason, Err: err} } + verifiedResult := oci.VerificationResultIgnored + // Verify the chart if necessary if opts.Verify { - if err := remote.VerifyChart(ctx, cv); err != nil { + if verifiedResult, err = remote.VerifyChart(ctx, cv); err != nil { return nil, nil, &BuildError{Reason: ErrChartVerification, Err: err} } } @@ -153,6 +156,8 @@ func (b *remoteChartBuilder) downloadFromRepository(ctx context.Context, remote return nil, nil, err } + result.VerifiedResult = verifiedResult + if shouldReturn { return nil, result, nil } @@ -173,6 +178,7 @@ func generateBuildResult(cv *repo.ChartVersion, opts BuildOptions) (*Build, bool result := &Build{} result.Version = cv.Version result.Name = cv.Name + result.VerifiedResult = oci.VerificationResultIgnored // Set build specific metadata if instructed if opts.VersionMetadata != "" { diff --git a/internal/helm/common/string_resource.go b/internal/helm/common/string_resource.go new file mode 100644 index 000000000..b4cdada9f --- /dev/null +++ b/internal/helm/common/string_resource.go @@ -0,0 +1,39 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import "strings" + +// StringResource is there to satisfy the github.com/google/go-containerregistry/pkg/authn.Resource interface. +// It merely wraps a given string and returns it for all of the interface's methods. +type StringResource struct { + Registry string +} + +// String returns a string representation of the StringResource. +// It converts the StringResource object to a string. +// The returned string contains the value of the StringResource. +func (r StringResource) String() string { + return r.Registry +} + +// RegistryStr returns the string representation of the registry resource. +// It converts the StringResource object to a string that represents the registry resource. +// The returned string can be used to interact with the registry resource. +func (r StringResource) RegistryStr() string { + return strings.Split(r.Registry, "/")[0] +} diff --git a/internal/helm/getter/client_opts.go b/internal/helm/getter/client_opts.go index 4dfc97b40..91b2f5c92 100644 --- a/internal/helm/getter/client_opts.go +++ b/internal/helm/getter/client_opts.go @@ -54,6 +54,7 @@ type ClientOpts struct { RegLoginOpts []helmreg.LoginOption TlsConfig *tls.Config GetterOpts []helmgetter.Option + Insecure bool } // MustLoginToRegistry returns true if the client options contain at least @@ -172,6 +173,8 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit err = ErrDeprecatedTLSConfig } + hrOpts.Insecure = obj.Spec.Insecure + return hrOpts, dir, err } diff --git a/internal/helm/getter/client_opts_test.go b/internal/helm/getter/client_opts_test.go index 91bcd32f8..c05640d74 100644 --- a/internal/helm/getter/client_opts_test.go +++ b/internal/helm/getter/client_opts_test.go @@ -44,6 +44,7 @@ func TestGetClientOpts(t *testing.T) { authSecret *corev1.Secret afterFunc func(t *WithT, hcOpts *ClientOpts) oci bool + insecure bool err error }{ { @@ -109,9 +110,27 @@ func TestGetClientOpts(t *testing.T) { t.Expect(err).ToNot(HaveOccurred()) t.Expect(config.Username).To(Equal("user")) t.Expect(config.Password).To(Equal("pass")) + t.Expect(hcOpts.Insecure).To(BeFalse()) }, oci: true, }, + { + name: "OCI HelmRepository with insecure repository", + authSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-oci", + }, + Data: map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + }, + }, + afterFunc: func(t *WithT, hcOpts *ClientOpts) { + t.Expect(hcOpts.Insecure).To(BeTrue()) + }, + oci: true, + insecure: true, + }, } for _, tt := range tests { @@ -123,6 +142,7 @@ func TestGetClientOpts(t *testing.T) { Timeout: &metav1.Duration{ Duration: time.Second, }, + Insecure: tt.insecure, }, } if tt.oci { diff --git a/internal/helm/registry/auth.go b/internal/helm/registry/auth.go index 1b9b3332f..c8b3ca6ae 100644 --- a/internal/helm/registry/auth.go +++ b/internal/helm/registry/auth.go @@ -23,6 +23,7 @@ import ( "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/credentials" + "github.com/fluxcd/source-controller/internal/helm/common" "github.com/fluxcd/source-controller/internal/oci" "github.com/google/go-containerregistry/pkg/authn" "helm.sh/helm/v3/pkg/registry" @@ -95,7 +96,7 @@ func KeychainAdaptHelper(keyChain authn.Keychain) func(string) (registry.LoginOp if err != nil { return nil, fmt.Errorf("unable to parse registry URL '%s'", registryURL) } - authenticator, err := keyChain.Resolve(stringResource{parsedURL.Host}) + authenticator, err := keyChain.Resolve(common.StringResource{Registry: parsedURL.Host}) if err != nil { return nil, fmt.Errorf("unable to resolve credentials for registry '%s': %w", registryURL, err) } @@ -126,20 +127,6 @@ func AuthAdaptHelper(auth authn.Authenticator) (registry.LoginOption, error) { return registry.LoginOptBasicAuth(username, password), nil } -// stringResource is there to satisfy the github.com/google/go-containerregistry/pkg/authn.Resource interface. -// It merely wraps a given string and returns it for all of the interface's methods. -type stringResource struct { - registry string -} - -func (r stringResource) String() string { - return r.registry -} - -func (r stringResource) RegistryStr() string { - return r.registry -} - // NewLoginOption returns a registry login option for the given HelmRepository. // If the HelmRepository does not specify a secretRef, a nil login option is returned. func NewLoginOption(auth authn.Authenticator, keychain authn.Keychain, registryURL string) (registry.LoginOption, error) { diff --git a/internal/helm/repository/chart_repository.go b/internal/helm/repository/chart_repository.go index 4908e8f36..79f8a136a 100644 --- a/internal/helm/repository/chart_repository.go +++ b/internal/helm/repository/chart_repository.go @@ -40,6 +40,7 @@ import ( "github.com/fluxcd/pkg/version" "github.com/fluxcd/source-controller/internal/helm" + "github.com/fluxcd/source-controller/internal/oci" "github.com/fluxcd/source-controller/internal/transport" ) @@ -465,9 +466,9 @@ func (r *ChartRepository) invalidate() { // VerifyChart verifies the chart against a signature. // It returns an error on failure. -func (r *ChartRepository) VerifyChart(_ context.Context, _ *repo.ChartVersion) error { +func (r *ChartRepository) VerifyChart(_ context.Context, _ *repo.ChartVersion) (oci.VerificationResult, error) { // this is a no-op because this is not implemented yet. - return fmt.Errorf("not implemented") + return oci.VerificationResultIgnored, fmt.Errorf("not implemented") } // jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML diff --git a/internal/helm/repository/oci_chart_repository.go b/internal/helm/repository/oci_chart_repository.go index 89798b5dc..c858befff 100644 --- a/internal/helm/repository/oci_chart_repository.go +++ b/internal/helm/repository/oci_chart_repository.go @@ -357,15 +357,16 @@ func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error } // VerifyChart verifies the chart against a signature. -// If no signature is provided, a keyless verification is performed. -// It returns an error on failure. -func (r *OCIChartRepository) VerifyChart(ctx context.Context, chart *repo.ChartVersion) error { +// Supports signature verification using either cosign or notation providers. +// If no signature is provided, when cosign is used, a keyless verification is performed. +// The verification result is returned as a VerificationResult and any error encountered. +func (r *OCIChartRepository) VerifyChart(ctx context.Context, chart *repo.ChartVersion) (oci.VerificationResult, error) { if len(r.verifiers) == 0 { - return fmt.Errorf("no verifiers available") + return oci.VerificationResultFailed, fmt.Errorf("no verifiers available") } if len(chart.URLs) == 0 { - return fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name) + return oci.VerificationResultFailed, fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name) } var nameOpts []name.Option @@ -375,17 +376,26 @@ func (r *OCIChartRepository) VerifyChart(ctx context.Context, chart *repo.ChartV ref, err := name.ParseReference(strings.TrimPrefix(chart.URLs[0], fmt.Sprintf("%s://", registry.OCIScheme)), nameOpts...) if err != nil { - return fmt.Errorf("invalid chart reference: %s", err) + return oci.VerificationResultFailed, fmt.Errorf("invalid chart reference: %s", err) } + verificationResult := oci.VerificationResultFailed + // verify the chart for _, verifier := range r.verifiers { - if verified, err := verifier.Verify(ctx, ref); err != nil { - return fmt.Errorf("failed to verify %s: %w", chart.URLs[0], err) - } else if verified { - return nil + result, err := verifier.Verify(ctx, ref) + if err != nil { + return result, fmt.Errorf("failed to verify %s: %w", chart.URLs[0], err) } + if result == oci.VerificationResultSuccess { + return result, nil + } + verificationResult = result + } + + if verificationResult == oci.VerificationResultIgnored { + return verificationResult, nil } - return fmt.Errorf("no matching signatures were found for '%s'", ref.Name()) + return oci.VerificationResultFailed, fmt.Errorf("no matching signatures were found for '%s'", ref.Name()) } diff --git a/internal/helm/repository/repository.go b/internal/helm/repository/repository.go index 5fdf62bfa..6cee5f658 100644 --- a/internal/helm/repository/repository.go +++ b/internal/helm/repository/repository.go @@ -21,6 +21,8 @@ import ( "context" "helm.sh/helm/v3/pkg/repo" + + "github.com/fluxcd/source-controller/internal/oci" ) // Downloader is used to download a chart from a remote Helm repository or OCI Helm repository. @@ -31,7 +33,7 @@ type Downloader interface { // DownloadChart downloads a chart from the remote Helm repository or OCI Helm repository. DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) // VerifyChart verifies the chart against a signature. - VerifyChart(ctx context.Context, chart *repo.ChartVersion) error + VerifyChart(ctx context.Context, chart *repo.ChartVersion) (oci.VerificationResult, error) // Clear removes all temporary files created by the downloader, caching the files if the cache is configured, // and calling garbage collector to remove unused files. Clear() error diff --git a/internal/oci/cosign/cosign.go b/internal/oci/cosign/cosign.go new file mode 100644 index 000000000..3c0630c18 --- /dev/null +++ b/internal/oci/cosign/cosign.go @@ -0,0 +1,168 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cosign + +import ( + "context" + "crypto" + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/fulcio" + coptions "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/rekor" + "github.com/sigstore/cosign/v2/pkg/cosign" + "github.com/sigstore/cosign/v2/pkg/oci" + ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/signature" + + soci "github.com/fluxcd/source-controller/internal/oci" +) + +// options is a struct that holds options for verifier. +type options struct { + publicKey []byte + rOpt []remote.Option + identities []cosign.Identity +} + +// Options is a function that configures the options applied to a Verifier. +type Options func(opts *options) + +// WithPublicKey sets the public key. +func WithPublicKey(publicKey []byte) Options { + return func(opts *options) { + opts.publicKey = publicKey + } +} + +// WithRemoteOptions is a functional option for overriding the default +// remote options used by the verifier. +func WithRemoteOptions(opts ...remote.Option) Options { + return func(o *options) { + o.rOpt = opts + } +} + +// WithIdentities specifies the identity matchers that have to be met +// for the signature to be deemed valid. +func WithIdentities(identities []cosign.Identity) Options { + return func(opts *options) { + opts.identities = identities + } +} + +// CosignVerifier is a struct which is responsible for executing verification logic. +type CosignVerifier struct { + opts *cosign.CheckOpts +} + +// NewCosignVerifier initializes a new CosignVerifier. +func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, error) { + o := options{} + for _, opt := range opts { + opt(&o) + } + + checkOpts := &cosign.CheckOpts{} + + ro := coptions.RegistryOptions{} + co, err := ro.ClientOpts(ctx) + if err != nil { + return nil, err + } + + checkOpts.Identities = o.identities + if o.rOpt != nil { + co = append(co, ociremote.WithRemoteOptions(o.rOpt...)) + } + + checkOpts.RegistryClientOpts = co + + // If a public key is provided, it will use it to verify the signature. + // If there is no public key provided, it will try keyless verification. + // https://github.com/sigstore/cosign/blob/main/KEYLESS.md. + if len(o.publicKey) > 0 { + checkOpts.Offline = true + // TODO(hidde): this is an oversight in our implementation. As it is + // theoretically possible to have a custom PK, without disabling tlog. + checkOpts.IgnoreTlog = true + + pubKeyRaw, err := cryptoutils.UnmarshalPEMToPublicKey(o.publicKey) + if err != nil { + return nil, err + } + + checkOpts.SigVerifier, err = signature.LoadVerifier(pubKeyRaw, crypto.SHA256) + if err != nil { + return nil, err + } + } else { + checkOpts.RekorClient, err = rekor.NewClient(coptions.DefaultRekorURL) + if err != nil { + return nil, fmt.Errorf("unable to create Rekor client: %w", err) + } + + // This performs an online fetch of the Rekor public keys, but this is needed + // for verifying tlog entries (both online and offline). + // TODO(hidde): above note is important to keep in mind when we implement + // "offline" tlog above. + if checkOpts.RekorPubKeys, err = cosign.GetRekorPubs(ctx); err != nil { + return nil, fmt.Errorf("unable to get Rekor public keys: %w", err) + } + + checkOpts.CTLogPubKeys, err = cosign.GetCTLogPubs(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get CTLog public keys: %w", err) + } + + if checkOpts.RootCerts, err = fulcio.GetRoots(); err != nil { + return nil, fmt.Errorf("unable to get Fulcio root certs: %w", err) + } + + if checkOpts.IntermediateCerts, err = fulcio.GetIntermediates(); err != nil { + return nil, fmt.Errorf("unable to get Fulcio intermediate certs: %w", err) + } + } + + return &CosignVerifier{ + opts: checkOpts, + }, nil +} + +// VerifyImageSignatures verify the authenticity of the given ref OCI image. +func (v *CosignVerifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) { + return cosign.VerifyImageSignatures(ctx, ref, v.opts) +} + +// Verify verifies the authenticity of the given ref OCI image. +// It returns a boolean indicating if the verification was successful. +// It returns an error if the verification fails, nil otherwise. +func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) (soci.VerificationResult, error) { + signatures, _, err := v.VerifyImageSignatures(ctx, ref) + if err != nil { + return soci.VerificationResultFailed, err + } + + if len(signatures) == 0 { + return soci.VerificationResultFailed, nil + } + + return soci.VerificationResultSuccess, nil +} diff --git a/internal/oci/verifier_test.go b/internal/oci/cosign/cosign_test.go similarity index 80% rename from internal/oci/verifier_test.go rename to internal/oci/cosign/cosign_test.go index 114601616..17af9523f 100644 --- a/internal/oci/verifier_test.go +++ b/internal/oci/cosign/cosign_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package oci +package cosign import ( "net/http" @@ -38,15 +38,15 @@ func TestOptions(t *testing.T) { name: "signature option", opts: []Options{WithPublicKey([]byte("foo"))}, want: &options{ - PublicKey: []byte("foo"), - ROpt: nil, + publicKey: []byte("foo"), + rOpt: nil, }, }, { name: "keychain option", opts: []Options{WithRemoteOptions(remote.WithAuthFromKeychain(authn.DefaultKeychain))}, want: &options{ - PublicKey: nil, - ROpt: []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)}, + publicKey: nil, + rOpt: []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)}, }, }, { name: "keychain and authenticator option", @@ -55,8 +55,8 @@ func TestOptions(t *testing.T) { remote.WithAuthFromKeychain(authn.DefaultKeychain), )}, want: &options{ - PublicKey: nil, - ROpt: []remote.Option{ + publicKey: nil, + rOpt: []remote.Option{ remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), remote.WithAuthFromKeychain(authn.DefaultKeychain), }, @@ -69,8 +69,8 @@ func TestOptions(t *testing.T) { remote.WithTransport(http.DefaultTransport), )}, want: &options{ - PublicKey: nil, - ROpt: []remote.Option{ + publicKey: nil, + rOpt: []remote.Option{ remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithTransport(http.DefaultTransport), @@ -89,7 +89,7 @@ func TestOptions(t *testing.T) { }, })}, want: &options{ - Identities: []cosign.Identity{ + identities: []cosign.Identity{ { SubjectRegExp: "test-user", IssuerRegExp: "^https://token.actions.githubusercontent.com$", @@ -109,20 +109,20 @@ func TestOptions(t *testing.T) { for _, opt := range test.opts { opt(&o) } - if !reflect.DeepEqual(o.PublicKey, test.want.PublicKey) { - t.Errorf("got %#v, want %#v", &o.PublicKey, test.want.PublicKey) + if !reflect.DeepEqual(o.publicKey, test.want.publicKey) { + t.Errorf("got %#v, want %#v", &o.publicKey, test.want.publicKey) } - if test.want.ROpt != nil { - if len(o.ROpt) != len(test.want.ROpt) { - t.Errorf("got %d remote options, want %d", len(o.ROpt), len(test.want.ROpt)) + if test.want.rOpt != nil { + if len(o.rOpt) != len(test.want.rOpt) { + t.Errorf("got %d remote options, want %d", len(o.rOpt), len(test.want.rOpt)) } return } - if test.want.ROpt == nil { - if len(o.ROpt) != 0 { - t.Errorf("got %d remote options, want %d", len(o.ROpt), 0) + if test.want.rOpt == nil { + if len(o.rOpt) != 0 { + t.Errorf("got %d remote options, want %d", len(o.rOpt), 0) } } }) diff --git a/internal/oci/notation/notation.go b/internal/oci/notation/notation.go new file mode 100644 index 000000000..4ae63fb14 --- /dev/null +++ b/internal/oci/notation/notation.go @@ -0,0 +1,388 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notation + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "strings" + + "github.com/go-logr/logr" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + _ "github.com/notaryproject/notation-core-go/signature/cose" + _ "github.com/notaryproject/notation-core-go/signature/jws" + "github.com/notaryproject/notation-go" + "github.com/notaryproject/notation-go/registry" + verifier "github.com/notaryproject/notation-go/verifier" + "github.com/notaryproject/notation-go/verifier/trustpolicy" + "github.com/notaryproject/notation-go/verifier/truststore" + oras "oras.land/oras-go/v2/registry/remote" + oauth "oras.land/oras-go/v2/registry/remote/auth" + retryhttp "oras.land/oras-go/v2/registry/remote/retry" + + "github.com/fluxcd/source-controller/internal/helm/common" + "github.com/fluxcd/source-controller/internal/oci" +) + +// name of the trustpolicy file defined in the Secret containing +// notation public keys. +const DefaultTrustPolicyKey = "trustpolicy.json" + +// options is a struct that holds options for verifier. +type options struct { + rootCertificates [][]byte + rOpt []remote.Option + trustPolicy *trustpolicy.Document + auth authn.Authenticator + keychain authn.Keychain + insecure bool + logger logr.Logger +} + +// Options is a function that configures the options applied to a Verifier. +type Options func(opts *options) + +// WithInsecureRegistry sets notation to verify against insecure registry. +func WithInsecureRegistry(insecure bool) Options { + return func(opts *options) { + opts.insecure = insecure + } +} + +// WithTrustPolicy sets the trust policy configuration. +func WithTrustPolicy(trustPolicy *trustpolicy.Document) Options { + return func(opts *options) { + opts.trustPolicy = trustPolicy + } +} + +// WithRootCertificates is a functional option for overriding the default +// rootCertificate options used by the verifier to set the root CA certificate for notary. +// It takes in a list of certificate data as an array of byte slices. +// The function returns a options function option that sets the public certificate +// in the notation options. +func WithRootCertificates(data [][]byte) Options { + return func(opts *options) { + opts.rootCertificates = data + } +} + +// WithRemoteOptions is a functional option for overriding the default +// remote options used by the verifier +func WithRemoteOptions(opts ...remote.Option) Options { + return func(o *options) { + o.rOpt = opts + } +} + +// WithAuth is a functional option for overriding the default +// authenticator options used by the verifier +func WithAuth(auth authn.Authenticator) Options { + return func(o *options) { + o.auth = auth + } +} + +// WithKeychain is a functional option for overriding the default +// keychain options used by the verifier +func WithKeychain(key authn.Keychain) Options { + return func(o *options) { + o.keychain = key + } +} + +// WithLogger is a function that returns an Options function to set the logger for the options. +// The logger is used for logging purposes within the options. +func WithLogger(logger logr.Logger) Options { + return func(o *options) { + o.logger = logger + } +} + +// NotationVerifier is a struct which is responsible for executing verification logic +type NotationVerifier struct { + auth authn.Authenticator + keychain authn.Keychain + verifier *notation.Verifier + opts []remote.Option + insecure bool + logger logr.Logger +} + +var _ truststore.X509TrustStore = &trustStore{} + +// trustStore is used by notation-go/verifier to retrieve the root certificate for notary. +// The default behaviour is to read the certificate from disk and return it as a byte slice. +// The reason for implementing the interface here is to avoid reading the certificate from disk +// as the certificate is already available in memory. +type trustStore struct { + certs [][]byte +} + +// GetCertificates implements truststore.X509TrustStore. +func (s trustStore) GetCertificates(ctx context.Context, storeType truststore.Type, namedStore string) ([]*x509.Certificate, error) { + certs := []*x509.Certificate{} + for _, data := range s.certs { + raw := data + block, _ := pem.Decode(raw) + if block != nil { + raw = block.Bytes + } + + cert, err := x509.ParseCertificates(raw) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate '%s': %s", namedStore, err) + } + + certs = append(certs, cert...) + } + + return certs, nil +} + +// NewNotationVerifier initializes a new Verifier +func NewNotationVerifier(opts ...Options) (*NotationVerifier, error) { + o := options{} + for _, opt := range opts { + opt(&o) + } + + store := &trustStore{ + certs: o.rootCertificates, + } + + trustpolicy := o.trustPolicy + if trustpolicy == nil { + return nil, fmt.Errorf("trust policy cannot be empty") + } + + verifier, err := verifier.New(trustpolicy, store, nil) + if err != nil { + return nil, err + } + + return &NotationVerifier{ + auth: o.auth, + keychain: o.keychain, + verifier: &verifier, + opts: o.rOpt, + insecure: o.insecure, + logger: o.logger, + }, nil +} + +// CleanTrustPolicy cleans the given trust policy by removing trust stores and trusted identities +// for trust policy statements that are set to skip signature verification but still have configured trust stores and/or trusted identities. +// It takes a pointer to a trustpolicy.Document and a logger from the logr package as input parameters. +// If the trustPolicy is nil, it returns nil. +// Otherwise, it iterates over the trustPolicy.TrustPolicies and checks if each trust policy statement's +// SignatureVerification.VerificationLevel is set to trustpolicy.LevelSkip.Name. +// If it is, it logs a warning message and removes the trust stores and trusted identities for that trust policy statement. +// Finally, it returns the modified trustPolicy. +func CleanTrustPolicy(trustPolicy *trustpolicy.Document, logger logr.Logger) *trustpolicy.Document { + if trustPolicy == nil { + return nil + } + + for i, j := range trustPolicy.TrustPolicies { + if j.SignatureVerification.VerificationLevel == trustpolicy.LevelSkip.Name { + if len(j.TrustStores) > 0 || len(j.TrustedIdentities) > 0 { + logger.Info(fmt.Sprintf("warning: trust policy statement '%s' is set to skip signature verification but configured with trust stores and/or trusted identities. Ignoring trust stores and trusted identities", j.Name)) + } + trustPolicy.TrustPolicies[i].TrustStores = []string{} + trustPolicy.TrustPolicies[i].TrustedIdentities = []string{} + } + } + + return trustPolicy +} + +// Verify verifies the authenticity of the given ref OCI image. +// It returns a boolean indicating if the verification was successful. +// It returns an error if the verification fails, nil otherwise. +func (v *NotationVerifier) Verify(ctx context.Context, ref name.Reference) (oci.VerificationResult, error) { + url := ref.Name() + + remoteRepo, err := v.remoteRepo(url) + if err != nil { + return oci.VerificationResultFailed, err + } + + repo := registry.NewRepository(remoteRepo) + + repoUrl, err := v.repoUrlWithDigest(url, ref) + if err != nil { + return oci.VerificationResultFailed, err + } + + verifyOptions := notation.VerifyOptions{ + ArtifactReference: repoUrl, + MaxSignatureAttempts: 3, + } + + _, outcomes, err := notation.Verify(ctx, *v.verifier, repo, verifyOptions) + if err != nil { + return oci.VerificationResultFailed, err + } + + return v.checkOutcome(outcomes, url) +} + +// checkOutcome checks the verification outcomes for a given URL and returns the corresponding OCI verification result. +// It takes a slice of verification outcomes and a URL as input parameters. +// If there are no verification outcomes, it returns a failed verification result with an error message. +// If the first verification outcome has a verification level of "trustpolicy.LevelSkip", it returns an ignored verification result. +// This function assumes that "trustpolicy.TypeIntegrity" is always enforced. It will return a successful validation result if "trustpolicy.TypeAuthenticity" is successful too. +// If any of the verification results have an error, it logs the error message and sets the "ignore" flag to true if the error type is "trustpolicy.TypeAuthenticity". +// If the "ignore" flag is true, it returns an ignored verification result. +// Otherwise, it returns a successful verification result. +// The function returns the OCI verification result and an error, if any. +func (v *NotationVerifier) checkOutcome(outcomes []*notation.VerificationOutcome, url string) (oci.VerificationResult, error) { + if len(outcomes) == 0 { + return oci.VerificationResultFailed, fmt.Errorf("signature verification failed for all the signatures associated with %s", url) + } + + // should only ever be one item in the outcomes slice + outcome := outcomes[0] + + // if the verification level is set to skip, we ignore the verification result + // as there should be no verification results in outcome and we do not want + // to mark the result as verified + if outcome.VerificationLevel == trustpolicy.LevelSkip { + return oci.VerificationResultIgnored, nil + } + + ignore := false + + // loop through verification results to check for errors + for _, i := range outcome.VerificationResults { + // error if action is not marked as `skip` and there is an error + if i.Error != nil { + // flag to ignore the verification result if the error is related to type `authenticity` + if i.Type == trustpolicy.TypeAuthenticity { + ignore = true + } + // log results of error + v.logger.Info(fmt.Sprintf("verification check for type '%s' failed for '%s' with message: '%s'", i.Type, url, i.Error.Error())) + } + } + + // if the ignore flag is set, we ignore the verification result so not to mark as verified + if ignore { + return oci.VerificationResultIgnored, nil + } + + // result is okay to mark as verified + return oci.VerificationResultSuccess, nil +} + +// remoteRepo is a function that creates a remote repository object for the given repository URL. +// It initializes the repository with the provided URL and sets the PlainHTTP flag based on the value of the 'insecure' field in the Verifier struct. +// It also sets up the credential provider based on the authentication configuration provided in the Verifier struct. +// If authentication is required, it retrieves the authentication credentials and sets up the repository client with the appropriate headers and credentials. +// Finally, it returns the remote repository object and any error encountered during the process. +func (v *NotationVerifier) remoteRepo(repoUrl string) (*oras.Repository, error) { + remoteRepo, err := oras.NewRepository(repoUrl) + if err != nil { + return &oras.Repository{}, err + } + + remoteRepo.PlainHTTP = v.insecure + + credentialProvider := func(ctx context.Context, registry string) (oauth.Credential, error) { + return oauth.EmptyCredential, nil + } + + auth := authn.Anonymous + + if v.auth != nil { + auth = v.auth + } else if v.keychain != nil { + source := common.StringResource{Registry: repoUrl} + + auth, err = v.keychain.Resolve(source) + if err != nil { + return &oras.Repository{}, err + } + } + + if auth != authn.Anonymous { + authConfig, err := auth.Authorization() + if err != nil { + return &oras.Repository{}, err + } + + credentialProvider = func(ctx context.Context, registry string) (oauth.Credential, error) { + if authConfig.Username != "" || authConfig.Password != "" || authConfig.IdentityToken != "" || authConfig.RegistryToken != "" { + return oauth.Credential{ + Username: authConfig.Username, + Password: authConfig.Password, + RefreshToken: authConfig.IdentityToken, + AccessToken: authConfig.RegistryToken, + }, nil + } + return oauth.EmptyCredential, nil + } + } + + repoClient := &oauth.Client{ + Client: retryhttp.DefaultClient, + Header: http.Header{ + "User-Agent": {"flux"}, + }, + Credential: credentialProvider, + } + + remoteRepo.Client = repoClient + + return remoteRepo, nil +} + +// repoUrlWithDigest takes a repository URL and a reference and returns the repository URL with the digest appended to it. +// If the repository URL does not contain a tag or digest, it returns an error. +func (v *NotationVerifier) repoUrlWithDigest(repoUrl string, ref name.Reference) (string, error) { + if !strings.Contains(repoUrl, "@") { + image, err := remote.Image(ref, v.opts...) + if err != nil { + return "", err + } + + digest, err := image.Digest() + if err != nil { + return "", err + } + + lastIndex := strings.LastIndex(repoUrl, ":") + if lastIndex == -1 { + return "", fmt.Errorf("url %s does not contain tag or digest", repoUrl) + } + + firstPart := repoUrl[:lastIndex] + + if s := strings.Split(repoUrl, ":"); len(s) >= 2 { + repoUrl = fmt.Sprintf("%s@%s", firstPart, digest) + } else { + return "", fmt.Errorf("url %s does not contain tag or digest", repoUrl) + } + } + return repoUrl, nil +} diff --git a/internal/oci/notation/notation_test.go b/internal/oci/notation/notation_test.go new file mode 100644 index 000000000..16054ca06 --- /dev/null +++ b/internal/oci/notation/notation_test.go @@ -0,0 +1,591 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notation + +import ( + "fmt" + "net/http" + "reflect" + "testing" + + "github.com/go-logr/logr" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/notaryproject/notation-go" + "github.com/notaryproject/notation-go/verifier/trustpolicy" + . "github.com/onsi/gomega" + + "github.com/fluxcd/source-controller/internal/oci" +) + +func TestOptions(t *testing.T) { + testCases := []struct { + name string + opts []Options + want *options + }{ + { + name: "no options", + want: &options{}, + }, + { + name: "signature option", + opts: []Options{WithRootCertificates([][]byte{[]byte("foo")})}, + want: &options{ + rootCertificates: [][]byte{[]byte("foo")}, + rOpt: nil, + }, + }, + { + name: "keychain option", + opts: []Options{ + WithRemoteOptions(remote.WithAuthFromKeychain(authn.DefaultKeychain)), + WithKeychain(authn.DefaultKeychain), + }, + want: &options{ + rootCertificates: nil, + rOpt: []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)}, + keychain: authn.DefaultKeychain, + }, + }, + { + name: "keychain and authenticator option", + opts: []Options{ + WithRemoteOptions( + remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + ), + WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), + WithKeychain(authn.DefaultKeychain), + }, + want: &options{ + rootCertificates: nil, + rOpt: []remote.Option{ + remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + }, + auth: &authn.Basic{Username: "foo", Password: "bar"}, + keychain: authn.DefaultKeychain, + }, + }, + { + name: "keychain, authenticator and transport option", + opts: []Options{ + WithRemoteOptions( + remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithTransport(http.DefaultTransport), + ), + WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), + WithKeychain(authn.DefaultKeychain), + }, + want: &options{ + rootCertificates: nil, + rOpt: []remote.Option{ + remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithTransport(http.DefaultTransport), + }, + auth: &authn.Basic{Username: "foo", Password: "bar"}, + keychain: authn.DefaultKeychain, + }, + }, + { + name: "truststore, empty document", + opts: []Options{WithTrustPolicy(&trustpolicy.Document{})}, + want: &options{ + rootCertificates: nil, + rOpt: nil, + trustPolicy: &trustpolicy.Document{}, + }, + }, + { + name: "truststore, dummy document", + opts: []Options{WithTrustPolicy(dummyPolicyDocument())}, + want: &options{ + rootCertificates: nil, + rOpt: nil, + trustPolicy: dummyPolicyDocument(), + }, + }, + { + name: "insecure, false", + opts: []Options{WithInsecureRegistry(false)}, + want: &options{ + rootCertificates: nil, + rOpt: nil, + trustPolicy: nil, + insecure: false, + }, + }, + { + name: "insecure, true", + opts: []Options{WithInsecureRegistry(true)}, + want: &options{ + rootCertificates: nil, + rOpt: nil, + trustPolicy: nil, + insecure: true, + }, + }, + { + name: "insecure, default", + opts: []Options{}, + want: &options{ + rootCertificates: nil, + rOpt: nil, + trustPolicy: nil, + insecure: false, + }, + }, + } + + // Run the test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + o := options{} + for _, opt := range tc.opts { + opt(&o) + } + if !reflect.DeepEqual(o.rootCertificates, tc.want.rootCertificates) { + t.Errorf("got %#v, want %#v", &o.rootCertificates, tc.want.rootCertificates) + } + + if !reflect.DeepEqual(o.trustPolicy, tc.want.trustPolicy) { + t.Errorf("got %#v, want %#v", &o.trustPolicy, tc.want.trustPolicy) + } + + if tc.want.rOpt != nil { + if len(o.rOpt) != len(tc.want.rOpt) { + t.Errorf("got %d remote options, want %d", len(o.rOpt), len(tc.want.rOpt)) + } + return + } + + if tc.want.rOpt == nil { + if len(o.rOpt) != 0 { + t.Errorf("got %d remote options, want %d", len(o.rOpt), 0) + } + } + }) + } +} + +func TestCleanTrustPolicy(t *testing.T) { + testCases := []struct { + name string + policy []trustpolicy.TrustPolicy + want *trustpolicy.Document + wantLogMessage string + }{ + { + name: "no trust policy", + want: nil, + }, + { + name: "trust policy verification level set to strict and should not be cleaned", + policy: []trustpolicy.TrustPolicy{{ + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: nil, + }}, + want: &trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{{ + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: nil, + }}, + }, + }, + { + name: "trust policy with multiple policies and should not be cleaned", + policy: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + }, + { + Name: "test-statement-name-2", + RegistryScopes: []string{"example.com/podInfo"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: nil, + }, + }, + want: &trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + }, + { + Name: "test-statement-name-2", + RegistryScopes: []string{"example.com/podInfo"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: nil, + }, + }, + }, + }, + { + name: "trust policy verification level skip should be cleaned", + policy: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"}, + TrustStores: []string{"test"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + }, + }, + want: &trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"}, + TrustStores: []string{}, + TrustedIdentities: []string{}, + }, + }, + }, + wantLogMessage: "warning: trust policy statement 'test-statement-name' is set to skip signature verification but configured with trust stores and/or trusted identities. Ignoring trust stores and trusted identities", + }, + { + name: "trust policy with multiple policies and mixture of verification levels including skip", + policy: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + }, + { + Name: "test-statement-name-2", + RegistryScopes: []string{"example.com/podInfo"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"}, + TrustStores: []string{"test"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + }, + }, + want: &trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{ + { + Name: "test-statement-name", + RegistryScopes: []string{"*"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"test"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + }, + { + Name: "test-statement-name-2", + RegistryScopes: []string{"example.com/podInfo"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"}, + TrustStores: []string{}, + TrustedIdentities: []string{}, + }, + }, + }, + wantLogMessage: "warning: trust policy statement 'test-statement-name-2' is set to skip signature verification but configured with trust stores and/or trusted identities. Ignoring trust stores and trusted identities", + }, + } + + // Run the test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + l := &testLogger{[]string{}, logr.RuntimeInfo{CallDepth: 1}} + logger := logr.New(l) + + var policy *trustpolicy.Document + + if tc.policy != nil { + policy = &trustpolicy.Document{ + Version: "1.0", + TrustPolicies: tc.policy, + } + } + + cleanedPolicy := CleanTrustPolicy(policy, logger) + + if !reflect.DeepEqual(cleanedPolicy, tc.want) { + t.Errorf("got %#v, want %#v", cleanedPolicy, tc.want) + } + + if tc.wantLogMessage != "" { + g.Expect(len(l.Output)).Should(Equal(1)) + g.Expect(l.Output[0]).Should(Equal(tc.wantLogMessage)) + } + }) + } +} + +func TestOutcomeChecker(t *testing.T) { + testCases := []struct { + name string + outcome []*notation.VerificationOutcome + wantErrMessage string + wantLogMessage []string + wantVerificationResult oci.VerificationResult + }{ + { + name: "no outcome failed with error message", + wantVerificationResult: oci.VerificationResultFailed, + wantErrMessage: "signature verification failed for all the signatures associated with example.com/podInfo", + }, + { + name: "verification result ignored with log message", + outcome: []*notation.VerificationOutcome{ + { + VerificationLevel: trustpolicy.LevelAudit, + VerificationResults: []*notation.ValidationResult{ + { + Type: trustpolicy.TypeAuthenticity, + Action: trustpolicy.ActionLog, + Error: fmt.Errorf("123"), + }, + }, + }, + }, + wantVerificationResult: oci.VerificationResultIgnored, + wantLogMessage: []string{"verification check for type 'authenticity' failed for 'example.com/podInfo' with message: '123'"}, + }, + { + name: "verification result ignored with no log message (skip)", + outcome: []*notation.VerificationOutcome{ + { + VerificationLevel: trustpolicy.LevelSkip, + VerificationResults: []*notation.ValidationResult{}, + }, + }, + wantVerificationResult: oci.VerificationResultIgnored, + }, + { + name: "verification result success with log message", + outcome: []*notation.VerificationOutcome{ + { + VerificationLevel: trustpolicy.LevelAudit, + VerificationResults: []*notation.ValidationResult{ + { + Type: trustpolicy.TypeAuthenticTimestamp, + Action: trustpolicy.ActionLog, + Error: fmt.Errorf("456"), + }, + { + Type: trustpolicy.TypeExpiry, + Action: trustpolicy.ActionLog, + Error: fmt.Errorf("789"), + }, + }, + }, + }, + wantVerificationResult: oci.VerificationResultSuccess, + wantLogMessage: []string{ + "verification check for type 'authenticTimestamp' failed for 'example.com/podInfo' with message: '456'", + "verification check for type 'expiry' failed for 'example.com/podInfo' with message: '789'", + }, + }, + { + name: "verification result success with no log message", + outcome: []*notation.VerificationOutcome{ + { + VerificationLevel: trustpolicy.LevelAudit, + VerificationResults: []*notation.ValidationResult{}, + }, + }, + wantVerificationResult: oci.VerificationResultSuccess, + }, + } + + // Run the test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + l := &testLogger{[]string{}, logr.RuntimeInfo{CallDepth: 1}} + logger := logr.New(l) + + v := NotationVerifier{ + logger: logger, + } + + result, err := v.checkOutcome(tc.outcome, "example.com/podInfo") + + if tc.wantErrMessage != "" { + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).Should(Equal(tc.wantErrMessage)) + } else { + g.Expect(err).To(BeNil()) + } + + g.Expect(result).Should(Equal(tc.wantVerificationResult)) + g.Expect(len(l.Output)).Should(Equal(len(tc.wantLogMessage))) + + for i, j := range tc.wantLogMessage { + g.Expect(l.Output[i]).Should(Equal(j)) + } + }) + } +} + +func TestRepoUrlWithDigest(t *testing.T) { + testCases := []struct { + name string + repoUrl string + digest string + tag string + wantResultUrl string + wantErrMessage string + passUrlWithoutTag bool + }{ + { + name: "valid repo url with digest", + repoUrl: "ghcr.io/stefanprodan/charts/podinfo", + digest: "sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb", + wantResultUrl: "ghcr.io/stefanprodan/charts/podinfo@sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb", + wantErrMessage: "", + }, + { + name: "valid repo url with tag", + repoUrl: "ghcr.io/stefanprodan/charts/podinfo", + tag: "6.6.0", + wantResultUrl: "ghcr.io/stefanprodan/charts/podinfo@sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb", + wantErrMessage: "", + }, + { + name: "valid repo url without tag", + repoUrl: "ghcr.io/stefanprodan/charts/podinfo", + tag: "6.6.0", + wantResultUrl: "ghcr.io/stefanprodan/charts/podinfo@sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb", + wantErrMessage: "url ghcr.io/stefanprodan/charts/podinfo does not contain tag or digest", + passUrlWithoutTag: true, + }, + } + + // Run the test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + l := &testLogger{[]string{}, logr.RuntimeInfo{CallDepth: 1}} + logger := logr.New(l) + + v := NotationVerifier{ + logger: logger, + } + + var url string + repo, _ := name.NewRepository(tc.repoUrl) + var ref name.Reference + if tc.digest != "" { + ref = repo.Digest(tc.digest) + url = fmt.Sprintf("%s@%s", tc.repoUrl, tc.digest) + } else if tc.tag != "" { + ref = repo.Tag(tc.tag) + if !tc.passUrlWithoutTag { + url = fmt.Sprintf("%s:%s", tc.repoUrl, tc.tag) + } else { + url = tc.repoUrl + } + } else { + ref = repo.Tag(name.DefaultTag) + url = fmt.Sprintf("%s:%s", tc.repoUrl, name.DefaultTag) + } + + result, err := v.repoUrlWithDigest(url, ref) + + if tc.wantErrMessage != "" { + g.Expect(err).ToNot(BeNil()) + g.Expect(err.Error()).Should(Equal(tc.wantErrMessage)) + } else { + g.Expect(err).To(BeNil()) + g.Expect(result).Should(Equal(tc.wantResultUrl)) + } + }) + } +} + +func dummyPolicyDocument() (policyDoc *trustpolicy.Document) { + policyDoc = &trustpolicy.Document{ + Version: "1.0", + TrustPolicies: []trustpolicy.TrustPolicy{dummyPolicyStatement()}, + } + return +} + +func dummyPolicyStatement() (policyStatement trustpolicy.TrustPolicy) { + policyStatement = trustpolicy.TrustPolicy{ + Name: "test-statement-name", + RegistryScopes: []string{"registry.acme-rockets.io/software/net-monitor"}, + SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"}, + TrustStores: []string{"ca:valid-trust-store", "signingAuthority:valid-trust-store"}, + TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"}, + } + return +} + +// mocking LogSink to capture log messages. Source: https://stackoverflow.com/a/71425740 +type testLogger struct { + Output []string + r logr.RuntimeInfo +} + +func (t *testLogger) doLog(msg string) { + t.Output = append(t.Output, msg) +} + +func (t *testLogger) Init(info logr.RuntimeInfo) { + t.r = info +} + +func (t *testLogger) Enabled(level int) bool { + return true +} + +func (t *testLogger) Info(level int, msg string, keysAndValues ...interface{}) { + t.doLog(msg) +} + +func (t *testLogger) Error(err error, msg string, keysAndValues ...interface{}) { + t.doLog(msg) +} + +func (t *testLogger) WithValues(keysAndValues ...interface{}) logr.LogSink { + return t +} + +func (t *testLogger) WithName(name string) logr.LogSink { + return t +} diff --git a/internal/oci/verifier.go b/internal/oci/verifier.go index 2fb304e4e..eeb301eb0 100644 --- a/internal/oci/verifier.go +++ b/internal/oci/verifier.go @@ -18,154 +18,25 @@ package oci import ( "context" - "crypto" - "fmt" "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/sigstore/cosign/v2/cmd/cosign/cli/fulcio" - coptions "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" - "github.com/sigstore/cosign/v2/cmd/cosign/cli/rekor" - "github.com/sigstore/cosign/v2/pkg/cosign" - "github.com/sigstore/cosign/v2/pkg/oci" - ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" - "github.com/sigstore/sigstore/pkg/cryptoutils" - "github.com/sigstore/sigstore/pkg/signature" +) + +// VerificationResult represents the result of a verification process. +type VerificationResult string + +const ( + // VerificationResultSuccess indicates that the artifact has been verified. + VerificationResultSuccess VerificationResult = "verified" + // VerificationResultFailed indicates that the artifact could not be verified. + VerificationResultFailed VerificationResult = "unverified" + // VerificationResultIgnored indicates that the artifact has not been verified + // but is allowed to proceed. This is used primarily when notation is used + // as the verifier. + VerificationResultIgnored VerificationResult = "ignored" ) // Verifier is an interface for verifying the authenticity of an OCI image. type Verifier interface { - Verify(ctx context.Context, ref name.Reference) (bool, error) -} - -// options is a struct that holds options for verifier. -type options struct { - PublicKey []byte - ROpt []remote.Option - Identities []cosign.Identity -} - -// Options is a function that configures the options applied to a Verifier. -type Options func(opts *options) - -// WithPublicKey sets the public key. -func WithPublicKey(publicKey []byte) Options { - return func(opts *options) { - opts.PublicKey = publicKey - } -} - -// WithRemoteOptions is a functional option for overriding the default -// remote options used by the verifier. -func WithRemoteOptions(opts ...remote.Option) Options { - return func(o *options) { - o.ROpt = opts - } -} - -// WithIdentities specifies the identity matchers that have to be met -// for the signature to be deemed valid. -func WithIdentities(identities []cosign.Identity) Options { - return func(opts *options) { - opts.Identities = identities - } -} - -// CosignVerifier is a struct which is responsible for executing verification logic. -type CosignVerifier struct { - opts *cosign.CheckOpts -} - -// NewCosignVerifier initializes a new CosignVerifier. -func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, error) { - o := options{} - for _, opt := range opts { - opt(&o) - } - - checkOpts := &cosign.CheckOpts{} - - ro := coptions.RegistryOptions{} - co, err := ro.ClientOpts(ctx) - if err != nil { - return nil, err - } - - checkOpts.Identities = o.Identities - if o.ROpt != nil { - co = append(co, ociremote.WithRemoteOptions(o.ROpt...)) - } - - checkOpts.RegistryClientOpts = co - - // If a public key is provided, it will use it to verify the signature. - // If there is no public key provided, it will try keyless verification. - // https://github.com/sigstore/cosign/blob/main/KEYLESS.md. - if len(o.PublicKey) > 0 { - checkOpts.Offline = true - // TODO(hidde): this is an oversight in our implementation. As it is - // theoretically possible to have a custom PK, without disabling tlog. - checkOpts.IgnoreTlog = true - - pubKeyRaw, err := cryptoutils.UnmarshalPEMToPublicKey(o.PublicKey) - if err != nil { - return nil, err - } - - checkOpts.SigVerifier, err = signature.LoadVerifier(pubKeyRaw, crypto.SHA256) - if err != nil { - return nil, err - } - } else { - checkOpts.RekorClient, err = rekor.NewClient(coptions.DefaultRekorURL) - if err != nil { - return nil, fmt.Errorf("unable to create Rekor client: %w", err) - } - - // This performs an online fetch of the Rekor public keys, but this is needed - // for verifying tlog entries (both online and offline). - // TODO(hidde): above note is important to keep in mind when we implement - // "offline" tlog above. - if checkOpts.RekorPubKeys, err = cosign.GetRekorPubs(ctx); err != nil { - return nil, fmt.Errorf("unable to get Rekor public keys: %w", err) - } - - checkOpts.CTLogPubKeys, err = cosign.GetCTLogPubs(ctx) - if err != nil { - return nil, fmt.Errorf("unable to get CTLog public keys: %w", err) - } - - if checkOpts.RootCerts, err = fulcio.GetRoots(); err != nil { - return nil, fmt.Errorf("unable to get Fulcio root certs: %w", err) - } - - if checkOpts.IntermediateCerts, err = fulcio.GetIntermediates(); err != nil { - return nil, fmt.Errorf("unable to get Fulcio intermediate certs: %w", err) - } - } - - return &CosignVerifier{ - opts: checkOpts, - }, nil -} - -// VerifyImageSignatures verify the authenticity of the given ref OCI image. -func (v *CosignVerifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) { - return cosign.VerifyImageSignatures(ctx, ref, v.opts) -} - -// Verify verifies the authenticity of the given ref OCI image. -// It returns a boolean indicating if the verification was successful. -// It returns an error if the verification fails, nil otherwise. -func (v *CosignVerifier) Verify(ctx context.Context, ref name.Reference) (bool, error) { - signatures, _, err := v.VerifyImageSignatures(ctx, ref) - if err != nil { - return false, err - } - - if len(signatures) == 0 { - return false, nil - } - - return true, nil + Verify(ctx context.Context, ref name.Reference) (VerificationResult, error) }