source-controller/internal/oci/notation/notation.go

405 lines
14 KiB
Go

/*
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
transport *http.Transport
}
// 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
}
}
// WithTransport is a function that returns an Options function to set the transport for the options.
func WithTransport(transport *http.Transport) Options {
return func(o *options) {
o.transport = transport
}
}
// 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
transport *http.Transport
}
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,
transport: o.transport,
}, 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
}
}
hc := retryhttp.DefaultClient
if v.transport != nil {
hc = &http.Client{
Transport: retryhttp.NewTransport(v.transport),
}
}
repoClient := &oauth.Client{
Client: hc,
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
}