Merge pull request #1075 from JasonTheDeveloper/feat/notation

feat(notation): add support for notation in HelmChart and OCIRepository configuration
This commit is contained in:
souleb 2024-03-26 14:52:05 +01:00 committed by GitHub
commit 55a2cdb9ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2756 additions and 250 deletions

View File

@ -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"]
}
]
}

View File

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

View File

@ -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"`

View File

@ -468,6 +468,7 @@ spec:
OCI Artifact.
enum:
- cosign
- notation
type: string
secretRef:
description: |-

View File

@ -224,6 +224,7 @@ spec:
OCI Artifact.
enum:
- cosign
- notation
type: string
secretRef:
description: |-

View File

@ -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

View File

@ -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

View File

@ -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: <BASE64>
certificate2.crt: <BASE64>
trustpolicy.json: <BASE64>
```
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

View File

@ -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:<SHA-value>"
```
```
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: <repository-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: <BASE64>
certificate2.crt: <BASE64>
trustpolicy.json: <BASE64>
```
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

11
go.mod
View File

@ -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

29
go.sum
View File

@ -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=

View File

@ -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

View File

@ -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 := soci.NewCosignVerifier(ctx, defaultCosignOciOpts...)
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
}
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
}

View File

@ -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 <url>: no signature",
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no signature"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify <url>: 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 <version>"),
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<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 <version>"),
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<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 '<name>' chart with version '<version>'"),
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<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, "<name>", metadata.Name)
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<version>", metadata.Version)
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", chartUrl)
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "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, "<url>", 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()

View File

@ -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)
return nil
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)
}
}
// 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.

View File

@ -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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <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 \"<url>\"",
want: sreconcile.ResultEmpty,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider '<provider>': no signature is associated with \"<url>\", 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 <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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider '<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, "<revision>", image.digest.String())
} else {
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<revision>", fmt.Sprintf("%s@%s", tt.reference.Tag, image.digest.String()))
}
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", artifactRef.String())
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "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, "<url>", 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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
},
},
{
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 <sigrevision>, 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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider 'notation': signature verification failed\nfailed to verify signature with digest <sigrevision>, 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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
},
},
{
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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*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 '<revision>' for '<url>'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
*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, "<revision>", image.digest.String())
} else {
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<revision>", fmt.Sprintf("%s@%s", tt.reference.Tag, image.digest.String()))
}
if signatureDigest != "" {
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<sigrevision>", signatureDigest)
}
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", artifactRef.String())
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "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, "<url>", artifactRef.String())
if signatureDigest != "" {
tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "<sigrevision>", 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 {

View File

@ -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.

View File

@ -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 != "" {

View File

@ -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]
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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

View File

@ -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
}
return fmt.Errorf("no matching signatures were found for '%s'", ref.Name())
if verificationResult == oci.VerificationResultIgnored {
return verificationResult, nil
}
return oci.VerificationResultFailed, fmt.Errorf("no matching signatures were found for '%s'", ref.Name())
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}
})

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}