diff --git a/errors.go b/errors.go index 08bb4ae5..90ec9f83 100644 --- a/errors.go +++ b/errors.go @@ -59,3 +59,15 @@ func (e ErrorVerificationFailed) Error() string { } return "signature verification failed" } + +// ErrorUserMetadataVerificationFailed is used when the signature does not contain the user specified metadata +type ErrorUserMetadataVerificationFailed struct { + Msg string +} + +func (e ErrorUserMetadataVerificationFailed) Error() string { + if e.Msg != "" { + return e.Msg + } + return "unable to find specified metadata in the signature" +} diff --git a/example_remoteSign_test.go b/example_remoteSign_test.go index e42e00b0..7d8d0d04 100644 --- a/example_remoteSign_test.go +++ b/example_remoteSign_test.go @@ -46,9 +46,11 @@ func Example_remoteSign() { exampleRepo := registry.NewRepository(remoteRepo) // exampleSignOptions is an example of notation.SignOptions. - exampleSignOptions := notation.SignOptions{ - ArtifactReference: exampleArtifactReference, - SignatureMediaType: exampleSignatureMediaType, + exampleSignOptions := notation.RemoteSignOptions{ + SignOptions: notation.SignOptions{ + ArtifactReference: exampleArtifactReference, + SignatureMediaType: exampleSignatureMediaType, + }, } // remote sign core process diff --git a/internal/mock/mocks.go b/internal/mock/mocks.go index 44c971ec..2daf3115 100644 --- a/internal/mock/mocks.go +++ b/internal/mock/mocks.go @@ -35,6 +35,9 @@ var MockSaExpiredSigEnv []byte //go:embed testdata/sa_plugin_sig_env.json var MockSaPluginSigEnv []byte // extended attributes are "SomeKey":"SomeValue", "io.cncf.notary.verificationPlugin":"plugin-name" +//go:embed testdata/sig_env_with_metadata.json +var MockSigEnvWithMetadata []byte + //go:embed testdata/ca_incompatible_pluginver_sig_env_1.0.9.json var MockCaIncompatiblePluginVerSigEnv_1_0_9 []byte @@ -67,7 +70,7 @@ var ( MediaType: "application/vnd.docker.distribution.manifest.v2+json", Digest: SampleDigest, Size: 528, - Annotations: nil, + Annotations: Annotations, } SigManfiestDescriptor = ocispec.Descriptor{ MediaType: "application/vnd.cncf.oras.artifact.manifest.v1+json", @@ -92,6 +95,12 @@ var ( Critical: true, Value: "SomeValue", } + MetadataSigEnvDescriptor = ocispec.Descriptor{ + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + Digest: digest.Digest("sha256:5a07385af4e6b6af81b0ebfd435aedccdfa3507f0609c658209e1aba57159b2b"), + Size: 942, + Annotations: map[string]string{"io.wabbit-networks.buildId": "123", "io.wabbit-networks.buildTime": "1672944615"}, + } ) type Repository struct { diff --git a/internal/mock/testdata/sig_env_with_metadata.json b/internal/mock/testdata/sig_env_with_metadata.json new file mode 100644 index 00000000..00044f8c --- /dev/null +++ b/internal/mock/testdata/sig_env_with_metadata.json @@ -0,0 +1,11 @@ +{ + "payload":"eyJ0YXJnZXRBcnRpZmFjdCI6eyJhbm5vdGF0aW9ucyI6eyJpby53YWJiaXQtbmV0d29ya3MuYnVpbGRJZCI6IjEyMyIsImlvLndhYmJpdC1uZXR3b3Jrcy5idWlsZFRpbWUiOiIxNjcyOTQ0NjE1In0sImRpZ2VzdCI6InNoYTI1Njo1YTA3Mzg1YWY0ZTZiNmFmODFiMGViZmQ0MzVhZWRjY2RmYTM1MDdmMDYwOWM2NTgyMDllMWFiYTU3MTU5YjJiIiwibWVkaWFUeXBlIjoiYXBwbGljYXRpb24vdm5kLmRvY2tlci5kaXN0cmlidXRpb24ubWFuaWZlc3QudjIranNvbiIsInNpemUiOjk0Mn19", + "protected":"eyJhbGciOiJQUzI1NiIsImNyaXQiOlsiaW8uY25jZi5ub3Rhcnkuc2lnbmluZ1NjaGVtZSJdLCJjdHkiOiJhcHBsaWNhdGlvbi92bmQuY25jZi5ub3RhcnkucGF5bG9hZC52MStqc29uIiwiaW8uY25jZi5ub3Rhcnkuc2lnbmluZ1NjaGVtZSI6Im5vdGFyeS54NTA5IiwiaW8uY25jZi5ub3Rhcnkuc2lnbmluZ1RpbWUiOiIyMDIzLTAxLTExVDEwOjAyOjU0LTA4OjAwIn0", + "header": { + "x5c": [ + "MIIDVjCCAj6gAwIBAgIBUTANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJVUzELMAkGA1UECBMCV0ExEDAOBgNVBAcTB1NlYXR0bGUxDzANBgNVBAoTBk5vdGFyeTEbMBkGA1UEAxMSd2FiYml0LW5ldHdvcmtzLmlvMB4XDTIzMDExMTAwNTIxMloXDTIzMDExMjAwNTIxMlowWjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAldBMRAwDgYDVQQHEwdTZWF0dGxlMQ8wDQYDVQQKEwZOb3RhcnkxGzAZBgNVBAMTEndhYmJpdC1uZXR3b3Jrcy5pbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANH4GCn0bO8LurJvnDh9F6E5iU8MydVw5bypnPlRpP3Mt9AmdWgBYhTegHT9DecA7smkLP3FAZG33Z9c1oxeZaeMnkWmiPGtuGQtXRHoj3+ioe4zH8LKYtCDW2uNs0xaDI1CldDXf4xZGa1mYqXVT1SeYXLwHf2dAL9q6FY98lYLax139PIwJwgEiod1hyIJyQZ2Zf9+IHe+v+Aja0wNLp/w4tO9Q5FR6VNhtmeGL/zPLD8chcj4iBzArsPyos2jBDUwogsEPTYoa6Rtn6IrUyrg4aJ8S3W0qGX7qGPeSY3wbsI63Q7XYQkRrD+cb1yvwt1+YhqN8nnvM/ujVtT+JfsCAwEAAaMnMCUwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMA0GCSqGSIb3DQEBCwUAA4IBAQBs475D3dkDhjTksg+ff0zhu2MaO0UR0kVuW+7tLFkgGptfos7Z6WN4xsjpMOL44xYx3DIKHkPybTFFEr75TGsfXUFRjYRoXCYW6L72p53kzR27Im14xiELGQoIw9n0/7ajIh1j4qKg+jP7dNSGg5234QllmZZMiRWl1/X2UlE1TEgJP26vuLKsw0bPsmRPaxoKcAAQxSWuOG5gdpZVw2p08rEwsaleK2Hbh7rIQwyL7JOGrUMYyEXuF/gE72Az4NYBVlLYPE5up/Cuq4bhjpRZ4qmVTQfiDoyhn5gSCJh+1wVewbqS/DECRpKETHTCYtrfrnxsROOkB8jtaSp7vTLk" + ], + "io.cncf.notary.signingAgent":"Notation/1.0.0" + }, + "signature":"Fqe_cSgUlbYXKYz5K-O_iZobcmwUdQVaT_mPsI-fnp2ibsFbWOfokYS-DJboJJJEJyzDH41WWAi9Xxr_yieub3Eq9vD4TIz5iVm7oJxI-x92mqe3MhgeybIQDyivtChmb2ufwmr1bFCtj4girLeYc_kUVj_BZDIUYo8rlx8nyr6ucFsxK-YyNYez9ySeInWCGz-Lce4ySuXCopgiGB-lVAeDzpxBwQHVYacKfvhvoXJgmsw372dBYUVVOHbfK5PX04r2ArpysNpvlPT7iY3t6pUVsRniDNFQ1nh2t7ZttuG9qQMTrpeegAIVDJ4i-PZnLS_8LQmF07Z6rpU8e1E6_Q" +} diff --git a/notation.go b/notation.go index b24dd6b6..db8e8929 100644 --- a/notation.go +++ b/notation.go @@ -9,9 +9,11 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/notaryproject/notation-core-go/signature" + "github.com/notaryproject/notation-go/internal/envelope" "github.com/notaryproject/notation-go/log" "github.com/notaryproject/notation-go/registry" "github.com/notaryproject/notation-go/verifier/trustpolicy" @@ -22,6 +24,7 @@ import ( const annotationX509ChainThumbprint = "io.cncf.notary.x509chain.thumbprint#S256" var errDoneVerification = errors.New("done verification") +var reservedAnnotationPrefixes = [...]string{"io.cncf.notary"} // SignOptions contains parameters for Signer.Sign. type SignOptions struct { @@ -44,6 +47,14 @@ type SignOptions struct { SigningAgent string } +// RemoteSignOptions contains parameters for notation.Sign. +type RemoteSignOptions struct { + SignOptions + + // UserMetadata contains key-value pairs that are added to the signature payload + UserMetadata map[string]string +} + // Signer is a generic interface for signing an artifact. // The interface allows signing with local or remote keys, // and packing in various signature formats. @@ -62,18 +73,18 @@ type signerAnnotation interface { // Sign signs the artifact in the remote registry and push the signature to the // remote. // The descriptor of the sign content is returned upon sucessful signing. -func Sign(ctx context.Context, signer Signer, repo registry.Repository, opts SignOptions) (ocispec.Descriptor, error) { +func Sign(ctx context.Context, signer Signer, repo registry.Repository, remoteOpts RemoteSignOptions) (ocispec.Descriptor, error) { // Input validation for expiry duration - if opts.ExpiryDuration < 0 { + if remoteOpts.ExpiryDuration < 0 { return ocispec.Descriptor{}, fmt.Errorf("expiry duration cannot be a negative value") } - if opts.ExpiryDuration%time.Second != 0 { + if remoteOpts.ExpiryDuration%time.Second != 0 { return ocispec.Descriptor{}, fmt.Errorf("expiry duration supports minimum granularity of seconds") } logger := log.GetLogger(ctx) - artifactRef := opts.ArtifactReference + artifactRef := remoteOpts.ArtifactReference ref, err := orasRegistry.ParseReference(artifactRef) if err != nil { return ocispec.Descriptor{}, err @@ -91,7 +102,12 @@ func Sign(ctx context.Context, signer Signer, repo registry.Repository, opts Sig logger.Infof("Resolved artifact tag `%s` to digest `%s` before signing", ref.Reference, targetDesc.Digest.String()) } - sig, signerInfo, err := signer.Sign(ctx, targetDesc, opts) + targetDesc, err = addUserMetadataToDescriptor(ctx, targetDesc, remoteOpts.UserMetadata) + if err != nil { + return ocispec.Descriptor{}, err + } + + sig, signerInfo, err := signer.Sign(ctx, targetDesc, remoteOpts.SignOptions) if err != nil { return ocispec.Descriptor{}, err } @@ -107,8 +123,8 @@ func Sign(ctx context.Context, signer Signer, repo registry.Repository, opts Sig return ocispec.Descriptor{}, err } logger.Debugf("Generated annotations: %+v", annotations) - logger.Debugf("Pushing signature of artifact descriptor: %+v, signature media type: %v", targetDesc, opts.SignatureMediaType) - _, _, err = repo.PushSignature(ctx, opts.SignatureMediaType, sig, targetDesc, annotations) + logger.Debugf("Pushing signature of artifact descriptor: %+v, signature media type: %v", targetDesc, remoteOpts.SignatureMediaType) + _, _, err = repo.PushSignature(ctx, remoteOpts.SignatureMediaType, sig, targetDesc, annotations) if err != nil { logger.Error("Failed to push the signature") return ocispec.Descriptor{}, ErrorPushSignatureFailed{Msg: err.Error()} @@ -117,6 +133,32 @@ func Sign(ctx context.Context, signer Signer, repo registry.Repository, opts Sig return targetDesc, nil } +func addUserMetadataToDescriptor(ctx context.Context, desc ocispec.Descriptor, userMetadata map[string]string) (ocispec.Descriptor, error) { + logger := log.GetLogger(ctx) + + if desc.Annotations == nil && len(userMetadata) > 0 { + desc.Annotations = map[string]string{} + } + + for k, v := range userMetadata { + logger.Debugf("Adding metadata %v=%v to annotations", k, v) + + for _, reservedPrefix := range reservedAnnotationPrefixes { + if strings.HasPrefix(k, reservedPrefix) { + return desc, fmt.Errorf("error adding user metadata: metadata key %v has reserved prefix %v", k, reservedPrefix) + } + } + + if _, ok := desc.Annotations[k]; ok { + return desc, fmt.Errorf("error adding user metadata: metadata key %v is already present in the target artifact", k) + } + + desc.Annotations[k] = v + } + + return desc, nil +} + // ValidationResult encapsulates the verification result (passed or failed) // for a verification type, including the desired verification action as // specified in the trust policy @@ -155,6 +197,24 @@ type VerificationOutcome struct { Error error } +func (outcome *VerificationOutcome) UserMetadata() (map[string]string, error) { + if outcome.EnvelopeContent == nil { + return nil, errors.New("unable to find envelope content for verification outcome") + } + + var payload envelope.Payload + err := json.Unmarshal(outcome.EnvelopeContent.Payload.Content, &payload) + if err != nil { + return nil, errors.New("failed to unmarshal the payload content in the signature blob to envelope.Payload") + } + + if payload.TargetArtifact.Annotations == nil { + return map[string]string{}, nil + } + + return payload.TargetArtifact.Annotations, nil +} + // VerifyOptions contains parameters for Verifier.Verify. type VerifyOptions struct { // ArtifactReference is the reference of the artifact that is been @@ -168,6 +228,9 @@ type VerifyOptions struct { // PluginConfig is a map of plugin configs. PluginConfig map[string]string + + // UserMetadata contains key-value pairs that must be present in the signature + UserMetadata map[string]string } // Verifier is a generic interface for verifying an artifact. @@ -193,6 +256,9 @@ type RemoteVerifyOptions struct { // will be processed for verification. If set to less than or equals // to zero, an error will be returned. MaxSignatureAttempts int + + // UserMetadata contains key-value pairs that must be present in the signature + UserMetadata map[string]string } type skipVerifier interface { @@ -212,6 +278,7 @@ func Verify(ctx context.Context, verifier Verifier, repo registry.Repository, re opts := VerifyOptions{ ArtifactReference: remoteOpts.ArtifactReference, PluginConfig: remoteOpts.PluginConfig, + UserMetadata: remoteOpts.UserMetadata, } if skipChecker, ok := verifier.(skipVerifier); ok { @@ -255,6 +322,8 @@ func Verify(ctx context.Context, verifier Verifier, repo registry.Repository, re errExceededMaxVerificationLimit := ErrorVerificationFailed{Msg: fmt.Sprintf("total number of signatures associated with an artifact should be less than: %d", remoteOpts.MaxSignatureAttempts)} numOfSignatureProcessed := 0 + var verificationFailedErr error = ErrorVerificationFailed{} + // get signature manifests logger.Debug("Fetching signature manifests using referrers API") err = repo.ListSignatures(ctx, artifactDescriptor, func(signatureManifests []ocispec.Descriptor) error { @@ -282,6 +351,11 @@ func Verify(ctx context.Context, verifier Verifier, repo registry.Repository, re logger.Error("Got nil outcome. Expecting non-nil outcome on verification failure") return err } + + if _, ok := outcome.Error.(ErrorUserMetadataVerificationFailed); ok { + verificationFailedErr = outcome.Error + } + continue } // at this point, the signature is verified successfully. Add @@ -315,7 +389,7 @@ func Verify(ctx context.Context, verifier Verifier, repo registry.Repository, re // Verification Failed if len(verificationOutcomes) == 0 { logger.Debugf("Signature verification failed for all the signatures associated with artifact %v", artifactDescriptor.Digest) - return ocispec.Descriptor{}, verificationOutcomes, ErrorVerificationFailed{} + return ocispec.Descriptor{}, verificationOutcomes, verificationFailedErr } // Verification Succeeded diff --git a/notation_test.go b/notation_test.go index d720d536..f837e70b 100644 --- a/notation_test.go +++ b/notation_test.go @@ -14,6 +14,8 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) +var expectedMetadata = map[string]string{"foo": "bar", "bar": "foo"} + func TestSignSuccess(t *testing.T) { repo := mock.NewRepository() testCases := []struct { @@ -26,10 +28,10 @@ func TestSignSuccess(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(b *testing.T) { - opts := SignOptions{ - ExpiryDuration: tc.dur, - ArtifactReference: mock.SampleArtifactUri, - } + opts := RemoteSignOptions{} + opts.ExpiryDuration = tc.dur + opts.ArtifactReference = mock.SampleArtifactUri + _, err := Sign(context.Background(), &dummySigner{}, repo, opts) if err != nil { b.Fatalf("Sign failed with error: %v", err) @@ -38,15 +40,15 @@ func TestSignSuccess(t *testing.T) { } } -func TestSignWithAnnotationsSuccess(t *testing.T) { +func TestSignSuccessWithUserMetadata(t *testing.T) { repo := mock.NewRepository() + opts := RemoteSignOptions{} + opts.ArtifactReference = mock.SampleArtifactUri + opts.UserMetadata = expectedMetadata - opts := SignOptions{ - ArtifactReference: mock.SampleArtifactUri, - } - _, err := Sign(context.Background(), &dummyPluginSigner{}, repo, opts) + _, err := Sign(context.Background(), &verifyMetadataSigner{}, repo, opts) if err != nil { - t.Fatalf("Sign failed with error: %v", err) + t.Fatalf("error: %v", err) } } @@ -61,7 +63,29 @@ func TestSignWithInvalidExpiry(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(b *testing.T) { - _, err := Sign(context.Background(), &dummySigner{}, repo, SignOptions{ExpiryDuration: tc.dur}) + opts := RemoteSignOptions{} + opts.ExpiryDuration = tc.dur + + _, err := Sign(context.Background(), &dummySigner{}, repo, opts) + if err == nil { + b.Fatalf("Expected error but not found") + } + }) + } +} + +func TestSignWithInvalidUserMetadata(t *testing.T) { + repo := mock.NewRepository() + testCases := []struct { + name string + metadata map[string]string + }{ + {"reservedAnnotationKey", map[string]string{reservedAnnotationPrefixes[0] + ".foo": "bar"}}, + {"keyConflict", map[string]string{"key": "value2"}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(b *testing.T) { + _, err := Sign(context.Background(), &dummySigner{}, repo, RemoteSignOptions{UserMetadata: tc.metadata}) if err == nil { b.Fatalf("Expected error but not found") } @@ -235,6 +259,17 @@ func (s *dummySigner) Sign(ctx context.Context, desc ocispec.Descriptor, opts Si return []byte("ABC"), &signature.SignerInfo{}, nil } +type verifyMetadataSigner struct{} + +func (s *verifyMetadataSigner) Sign(ctx context.Context, desc ocispec.Descriptor, opts SignOptions) ([]byte, *signature.SignerInfo, error) { + for k, v := range expectedMetadata { + if desc.Annotations[k] != v { + return nil, nil, errors.New("expected metadata not present in descriptor") + } + } + return []byte("ABC"), &signature.SignerInfo{}, nil +} + type dummyPluginSigner struct{} func (s *dummyPluginSigner) Sign(ctx context.Context, desc ocispec.Descriptor, opts SignOptions) ([]byte, *signature.SignerInfo, error) { diff --git a/signer/signer_test.go b/signer/signer_test.go index cfc8b930..8e9a446b 100644 --- a/signer/signer_test.go +++ b/signer/signer_test.go @@ -270,10 +270,12 @@ func generateSigningContent(tsa *timestamptest.TSA) (ocispec.Descriptor, notatio }, } sOpts := notation.SignOptions{ExpiryDuration: 24 * time.Hour} + if tsa != nil { tsaRoots := x509.NewCertPool() tsaRoots.AddCert(tsa.Certificate()) } + return desc, sOpts } diff --git a/verifier/verifier.go b/verifier/verifier.go index bbc94216..2d1ef354 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -133,6 +133,14 @@ func (v *verifier) Verify(ctx context.Context, desc ocispec.Descriptor, signatur logger.Infof("Target artifact that want to be verified: %+v", desc) outcome.Error = errors.New("content descriptor mismatch") } + + if len(opts.UserMetadata) > 0 { + err := verifyUserMetadata(logger, payload, opts.UserMetadata) + if err != nil { + outcome.Error = err + } + } + return outcome, outcome.Error } @@ -424,6 +432,20 @@ func verifyAuthenticity(ctx context.Context, trustPolicy *trustpolicy.TrustPolic } } +func verifyUserMetadata(logger log.Logger, payload *envelope.Payload, userMetadata map[string]string) error { + logger.Debugf("Verifying that metadata %v is present in signature", userMetadata) + logger.Debugf("Signature metadata: %v", payload.TargetArtifact.Annotations) + + for k, v := range userMetadata { + if got, ok := payload.TargetArtifact.Annotations[k]; !ok || got != v { + logger.Errorf("User required metadata %s=%s is not present in the signature", k, v) + return notation.ErrorUserMetadataVerificationFailed{} + } + } + + return nil +} + func verifyExpiry(outcome *notation.VerificationOutcome) *notation.ValidationResult { if expiry := outcome.EnvelopeContent.SignerInfo.SignedAttributes.Expiry; !expiry.IsZero() && !time.Now().Before(expiry) { return ¬ation.ValidationResult{ diff --git a/verifier/verifier_test.go b/verifier/verifier_test.go index aeb8adcb..e16bd9e3 100644 --- a/verifier/verifier_test.go +++ b/verifier/verifier_test.go @@ -606,6 +606,51 @@ func TestVerifyX509TrustedIdentities(t *testing.T) { } } +func TestVerifyUserMetadata(t *testing.T) { + policyDocument := dummyPolicyDocument() + policyDocument.TrustPolicies[0].SignatureVerification.VerificationLevel = trustpolicy.LevelAudit.Name + + pluginManager := mock.PluginManager{} + pluginManager.GetPluginError = errors.New("plugin should not be invoked when verification plugin is not specified in the signature") + pluginManager.PluginRunnerLoadError = errors.New("plugin should not be invoked when verification plugin is not specified in the signature") + + verifier := verifier{ + trustPolicyDoc: &policyDocument, + trustStore: truststore.NewX509TrustStore(dir.ConfigFS()), + pluginManager: pluginManager, + } + + tests := []struct { + metadata map[string]string + wantErr bool + }{ + {map[string]string{}, false}, + {map[string]string{"io.wabbit-networks.buildId": "123"}, false}, + {map[string]string{"io.wabbit-networks.buildId": "321"}, true}, + {map[string]string{"io.wabbit-networks.buildId": "123", "io.wabbit-networks.buildTime": "1672944615"}, false}, + {map[string]string{"io.wabbit-networks.buildId": "123", "io.wabbit-networks.buildTime": "1"}, true}, + } + + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + _, err := verifier.Verify( + context.Background(), + mock.MetadataSigEnvDescriptor, + mock.MockSigEnvWithMetadata, + notation.VerifyOptions{ + ArtifactReference: mock.SampleArtifactUri, + SignatureMediaType: "application/jose+json", + UserMetadata: tt.metadata, + }, + ) + + if tt.wantErr != (err != nil) { + t.Fatalf("TestVerifyUserMetadata Error: %q WantErr: %v", err, tt.wantErr) + } + }) + } +} + func TestPluginVersionCompatibility(t *testing.T) { errTemplate := "found plugin io.cncf.notary.plugin.unittest.mock with version 1.0.0 but signature verification needs plugin version greater than or equal to "