diff --git a/cmd/notation/internal/errors/errors.go b/cmd/notation/internal/errors/errors.go new file mode 100644 index 000000000..18d39e234 --- /dev/null +++ b/cmd/notation/internal/errors/errors.go @@ -0,0 +1,14 @@ +package errors + +// ErrorReferrersAPINotSupported is used when the target registry does not +// support the Referrers API +type ErrorReferrersAPINotSupported struct { + Msg string +} + +func (e ErrorReferrersAPINotSupported) Error() string { + if e.Msg != "" { + return e.Msg + } + return "referrers API not supported" +} diff --git a/cmd/notation/key.go b/cmd/notation/key.go index 1ca8677a2..92e6c2530 100644 --- a/cmd/notation/key.go +++ b/cmd/notation/key.go @@ -163,7 +163,7 @@ func addKey(ctx context.Context, opts *keyAddOpts) error { // set log level ctx = opts.LoggingFlagOpts.SetLoggerLevel(ctx) - pluginConfig, err := cmd.ParseFlagPluginConfig(opts.pluginConfig) + pluginConfig, err := cmd.ParseFlagMap(opts.pluginConfig, cmd.PflagPluginConfig.Name) if err != nil { return err } @@ -228,7 +228,9 @@ func deleteKeys(ctx context.Context, opts *keyDeleteOpts) error { var deletedNames []string var prevDefault string exec := func(s *config.SigningKeys) error { - prevDefault = *s.Default + if s.Default != nil { + prevDefault = *s.Default + } var err error deletedNames, err = s.Remove(opts.names...) if err != nil { diff --git a/cmd/notation/key_test.go b/cmd/notation/key_test.go index 45313905c..fe5785157 100644 --- a/cmd/notation/key_test.go +++ b/cmd/notation/key_test.go @@ -17,7 +17,7 @@ func TestKeyAddCommand_BasicArgs(t *testing.T) { if err := cmd.ParseFlags([]string{ "--plugin", expected.plugin, "--id", expected.id, - "-c", "pluginconfig", + "--plugin-config", "pluginconfig", expected.name}); err != nil { t.Fatalf("Parse Flag failed: %v", err) } diff --git a/cmd/notation/registry.go b/cmd/notation/registry.go index 64f3d17e3..40788d003 100644 --- a/cmd/notation/registry.go +++ b/cmd/notation/registry.go @@ -8,16 +8,21 @@ import ( "github.com/notaryproject/notation-go/log" notationregistry "github.com/notaryproject/notation-go/registry" + notationerrors "github.com/notaryproject/notation/cmd/notation/internal/errors" "github.com/notaryproject/notation/internal/trace" "github.com/notaryproject/notation/internal/version" loginauth "github.com/notaryproject/notation/pkg/auth" "github.com/notaryproject/notation/pkg/configutil" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/errcode" ) +const zeroDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + func getSignatureRepository(ctx context.Context, opts *SecureFlagOpts, reference string) (notationregistry.Repository, error) { ref, err := registry.ParseReference(reference) if err != nil { @@ -25,34 +30,80 @@ func getSignatureRepository(ctx context.Context, opts *SecureFlagOpts, reference } // generate notation repository - return getRepositoryClient(ctx, opts, ref) + remoteRepo, err := getRepositoryClient(ctx, opts, ref) + if err != nil { + return nil, err + } + return notationregistry.NewRepository(remoteRepo), nil } -func getRegistryClient(ctx context.Context, opts *SecureFlagOpts, serverAddress string) (*remote.Registry, error) { - reg, err := remote.NewRegistry(serverAddress) +// getSignatureRepositoryForSign returns a registry.Repository for Sign. +// ociImageManifest denotes the type of manifest used to store signatures during +// Sign process. +// Setting ociImageManifest to true means using OCI image manifest and the +// Referrers tag schema. +// Otherwise, use OCI artifact manifest and requires the Referrers API. +func getSignatureRepositoryForSign(ctx context.Context, opts *SecureFlagOpts, reference string, ociImageManifest bool) (notationregistry.Repository, error) { + logger := log.GetLogger(ctx) + ref, err := registry.ParseReference(reference) if err != nil { return nil, err } - reg.Client, reg.PlainHTTP, err = getAuthClient(ctx, opts, reg.Reference) + // generate notation repository + remoteRepo, err := getRepositoryClient(ctx, opts, ref) if err != nil { return nil, err } - return reg, nil + + // Notation enforces the following two paths during Sign process: + // 1. OCI artifact manifest uses the Referrers API + // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#listing-referrers + // 2. OCI image manifest uses the Referrers Tag Schema + // Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0-rc1/spec.md#referrers-tag-schema + if !ociImageManifest { + logger.Info("Use OCI artifact manifest and Referrers API to store signature") + // ping Referrers API + if err := pingReferrersAPI(ctx, remoteRepo); err != nil { + return nil, err + } + logger.Info("Successfully pinged Referrers API on target registry") + } else { + logger.Info("Use OCI image manifest and Referrers Tag Schema to store signature") + if err := remoteRepo.SetReferrersCapability(false); err != nil { + return nil, err + } + } + repositoryOpts := notationregistry.RepositoryOptions{ + OCIImageManifest: ociImageManifest, + } + return notationregistry.NewRepositoryWithOptions(remoteRepo, repositoryOpts), nil } -func getRepositoryClient(ctx context.Context, opts *SecureFlagOpts, ref registry.Reference) (notationregistry.Repository, error) { +func getRepositoryClient(ctx context.Context, opts *SecureFlagOpts, ref registry.Reference) (*remote.Repository, error) { authClient, plainHTTP, err := getAuthClient(ctx, opts, ref) if err != nil { return nil, err } - remoteRepo := &remote.Repository{ + return &remote.Repository{ Client: authClient, Reference: ref, PlainHTTP: plainHTTP, + }, nil +} + +func getRegistryClient(ctx context.Context, opts *SecureFlagOpts, serverAddress string) (*remote.Registry, error) { + reg, err := remote.NewRegistry(serverAddress) + if err != nil { + return nil, err } - return notationregistry.NewRepository(remoteRepo), nil + + reg.Client, reg.PlainHTTP, err = getAuthClient(ctx, opts, reg.Reference) + if err != nil { + return nil, err + } + return reg, nil } func setHttpDebugLog(ctx context.Context, authClient *auth.Client) { @@ -127,3 +178,40 @@ func getSavedCreds(ctx context.Context, serverAddress string) (auth.Credential, return nativeStore.Get(serverAddress) } + +func pingReferrersAPI(ctx context.Context, remoteRepo *remote.Repository) error { + logger := log.GetLogger(ctx) + if err := remoteRepo.SetReferrersCapability(true); err != nil { + return err + } + var checkReferrerDesc ocispec.Descriptor + checkReferrerDesc.Digest = zeroDigest + // core process + err := remoteRepo.Referrers(ctx, checkReferrerDesc, "", func(referrers []ocispec.Descriptor) error { + return nil + }) + if err != nil { + var errResp *errcode.ErrorResponse + if !errors.As(err, &errResp) || errResp.StatusCode != http.StatusNotFound { + return err + } + if isErrorCode(errResp, errcode.ErrorCodeNameUnknown) { + // The repository is not found in the target registry. + // This is triggered when putting signatures to an empty repository. + // For notation, this path should never be triggered. + return err + } + // A 404 returned by Referrers API indicates that Referrers API is + // not supported. + logger.Infof("failed to ping Referrers API with error: %v", err) + errMsg := "Target registry does not support the Referrers API. Try the flag `--signature-manifest image` to store signatures using OCI image manifest for backwards compatibility" + return notationerrors.ErrorReferrersAPINotSupported{Msg: errMsg} + } + return nil +} + +// isErrorCode returns true if err is an Error and its Code equals to code. +func isErrorCode(err error, code string) bool { + var ec errcode.Error + return errors.As(err, &ec) && ec.Code == code +} diff --git a/cmd/notation/registry_test.go b/cmd/notation/registry_test.go new file mode 100644 index 000000000..41699d272 --- /dev/null +++ b/cmd/notation/registry_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + notationerrors "github.com/notaryproject/notation/cmd/notation/internal/errors" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/errcode" +) + +func TestRegistry_pingReferrersAPI_Success(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ "test": "TEST" }`)) + return + } + t.Errorf("unexpected access: %s %q", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + repo, err := remote.NewRepository(uri.Host + "/test") + if err != nil { + t.Fatalf("NewRepository() error = %v", err) + } + repo.PlainHTTP = true + ctx := context.Background() + err = pingReferrersAPI(ctx, repo) + if err != nil { + t.Errorf("pingReferrersAPI() expected nil error, but got error: %v", err) + } +} + +func TestRegistry_pingReferrersAPI_ReferrersAPINotSupported(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{ "errorresponse": { "method": "GET", "statuscode": 404 } }`)) + return + } + t.Errorf("unexpected access: %s %q", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + ctx := context.Background() + repo, err := remote.NewRepository(uri.Host + "/test") + if err != nil { + t.Fatalf("NewRepository() error = %v", err) + } + repo.PlainHTTP = true + err = pingReferrersAPI(ctx, repo) + var errorReferrersAPINotSupported notationerrors.ErrorReferrersAPINotSupported + if err == nil || !errors.As(err, &errorReferrersAPINotSupported) { + t.Errorf("pingReferrersAPI() expected ErrorReferrersAPINotSupported, but got: %v", err) + } +} + +func TestRegistry_pingReferrersAPI_Failed(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest { + w.WriteHeader(http.StatusOK) + return + } + t.Errorf("unexpected access: %s %q", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + ctx := context.Background() + repo, err := remote.NewRepository(uri.Host + "/test") + if err != nil { + t.Fatalf("NewRepository() error = %v", err) + } + repo.PlainHTTP = true + err = pingReferrersAPI(ctx, repo) + if err == nil { + t.Errorf("pingReferrersAPI expected to get error but got nil") + } +} + +func TestRegistry_pingReferrersAPI_RepositoryNotFound(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v2/test/referrers/"+zeroDigest { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{ "errors": [ { "code": "NAME_UNKNOWN", "message": "repository name not known to registry" } ] }`)) + return + } + t.Errorf("unexpected access: %s %q", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + uri, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("invalid test http server: %v", err) + } + ctx := context.Background() + expectedErr := errcode.Error{ + Code: errcode.ErrorCodeNameUnknown, + Message: "repository name not known to registry", + } + + repo, err := remote.NewRepository(uri.Host + "/test") + if err != nil { + t.Fatalf("NewRepository() error = %v", err) + } + repo.PlainHTTP = true + err = pingReferrersAPI(ctx, repo) + if err == nil { + t.Fatalf("pingReferrersAPI() expected error but got nil") + } + var ec errcode.Error + if !errors.As(err, &ec) { + t.Errorf("pingReferrersAPI() expected errcode.Error") + } + if !reflect.DeepEqual(ec, expectedErr) { + t.Errorf("pingReferrersAPI() expected error: %v, but got: %v", expectedErr, err) + } +} diff --git a/cmd/notation/sign.go b/cmd/notation/sign.go index 35b934496..5d8ec3f35 100644 --- a/cmd/notation/sign.go +++ b/cmd/notation/sign.go @@ -11,18 +11,28 @@ import ( notationregistry "github.com/notaryproject/notation-go/registry" "github.com/notaryproject/notation/internal/cmd" "github.com/notaryproject/notation/internal/envelope" + "github.com/notaryproject/notation/internal/slices" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" "oras.land/oras-go/v2/registry" ) +const ( + signatureManifestArtifact = "artifact" + signatureManifestImage = "image" +) + +var supportedSignatureManifest = []string{signatureManifestArtifact, signatureManifestImage} + type signOpts struct { cmd.LoggingFlagOpts cmd.SignerFlagOpts SecureFlagOpts - expiry time.Duration - pluginConfig []string - reference string + expiry time.Duration + pluginConfig []string + userMetadata []string + reference string + signatureManifest string } func signCommand(opts *signOpts) *cobra.Command { @@ -50,6 +60,9 @@ Example - Sign an OCI artifact identified by a tag (Notation will resolve tag to Example - Sign an OCI artifact stored in a registry and specify the signature expiry duration, for example 24 hours notation sign --expiry 24h /@ + +Example - Sign an OCI artifact and use OCI image manifest to store the signature, with the default JWS envelope: + notation sign --signature-manifest image /@ `, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -59,6 +72,10 @@ Example - Sign an OCI artifact stored in a registry and specify the signature ex return nil }, RunE: func(cmd *cobra.Command, args []string) error { + // sanity check + if !validateSignatureManifest(opts.signatureManifest) { + return fmt.Errorf("signature manifest must be one of the following %v but got %s", supportedSignatureManifest, opts.signatureManifest) + } return runSign(cmd, opts) }, } @@ -67,6 +84,8 @@ Example - Sign an OCI artifact stored in a registry and specify the signature ex opts.SecureFlagOpts.ApplyFlags(command.Flags()) cmd.SetPflagExpiry(command.Flags(), &opts.expiry) cmd.SetPflagPluginConfig(command.Flags(), &opts.pluginConfig) + command.Flags().StringVar(&opts.signatureManifest, "signature-manifest", signatureManifestArtifact, "manifest type for signatures. options: artifact, image") + cmd.SetPflagUserMetadata(command.Flags(), &opts.userMetadata, cmd.PflagUserMetadataSignUsage) return command } @@ -79,7 +98,8 @@ func runSign(command *cobra.Command, cmdOpts *signOpts) error { if err != nil { return err } - sigRepo, err := getSignatureRepository(ctx, &cmdOpts.SecureFlagOpts, cmdOpts.reference) + ociImageManifest := cmdOpts.signatureManifest == signatureManifestImage + sigRepo, err := getSignatureRepositoryForSign(ctx, &cmdOpts.SecureFlagOpts, cmdOpts.reference, ociImageManifest) if err != nil { return err } @@ -91,6 +111,10 @@ func runSign(command *cobra.Command, cmdOpts *signOpts) error { // core process _, err = notation.Sign(ctx, signer, sigRepo, opts) if err != nil { + var errorPushSignatureFailed notation.ErrorPushSignatureFailed + if errors.As(err, &errorPushSignatureFailed) { + return fmt.Errorf("%v. Target registry does not seem to support OCI artifact manifest. Try the flag `--signature-manifest image` to store signatures using OCI image manifest for backwards compatibility", err) + } return err } @@ -99,29 +123,39 @@ func runSign(command *cobra.Command, cmdOpts *signOpts) error { return nil } -func prepareSigningContent(ctx context.Context, opts *signOpts, sigRepo notationregistry.Repository) (notation.SignOptions, registry.Reference, error) { +func prepareSigningContent(ctx context.Context, opts *signOpts, sigRepo notationregistry.Repository) (notation.RemoteSignOptions, registry.Reference, error) { ref, err := resolveReference(ctx, &opts.SecureFlagOpts, opts.reference, sigRepo, func(ref registry.Reference, manifestDesc ocispec.Descriptor) { fmt.Fprintf(os.Stderr, "Warning: Always sign the artifact using digest(@sha256:...) rather than a tag(:%s) because tags are mutable and a tag reference can point to a different artifact than the one signed.\n", ref.Reference) }) if err != nil { - return notation.SignOptions{}, registry.Reference{}, err + return notation.RemoteSignOptions{}, registry.Reference{}, err } mediaType, err := envelope.GetEnvelopeMediaType(opts.SignerFlagOpts.SignatureFormat) if err != nil { - return notation.SignOptions{}, registry.Reference{}, err + return notation.RemoteSignOptions{}, registry.Reference{}, err } - pluginConfig, err := cmd.ParseFlagPluginConfig(opts.pluginConfig) + pluginConfig, err := cmd.ParseFlagMap(opts.pluginConfig, cmd.PflagPluginConfig.Name) if err != nil { - return notation.SignOptions{}, registry.Reference{}, err + return notation.RemoteSignOptions{}, registry.Reference{}, err } - - signOpts := notation.SignOptions{ - ArtifactReference: ref.String(), - SignatureMediaType: mediaType, - ExpiryDuration: opts.expiry, - PluginConfig: pluginConfig, + userMetadata, err := cmd.ParseFlagMap(opts.userMetadata, cmd.PflagUserMetadata.Name) + if err != nil { + return notation.RemoteSignOptions{}, registry.Reference{}, err } + signOpts := notation.RemoteSignOptions{ + SignOptions: notation.SignOptions{ + ArtifactReference: ref.String(), + SignatureMediaType: mediaType, + ExpiryDuration: opts.expiry, + PluginConfig: pluginConfig, + }, + UserMetadata: userMetadata, + } return signOpts, ref, nil } + +func validateSignatureManifest(signatureManifest string) bool { + return slices.Contains(supportedSignatureManifest, signatureManifest) +} diff --git a/cmd/notation/sign_test.go b/cmd/notation/sign_test.go index 587f9bafc..e6e69e8d2 100644 --- a/cmd/notation/sign_test.go +++ b/cmd/notation/sign_test.go @@ -23,6 +23,7 @@ func TestSignCommand_BasicArgs(t *testing.T) { Key: "key", SignatureFormat: envelope.JWS, }, + signatureManifest: "artifact", } if err := command.ParseFlags([]string{ expected.reference, @@ -53,7 +54,8 @@ func TestSignCommand_MoreArgs(t *testing.T) { Key: "key", SignatureFormat: envelope.COSE, }, - expiry: 24 * time.Hour, + expiry: 24 * time.Hour, + signatureManifest: signatureManifestImage, } if err := command.ParseFlags([]string{ expected.reference, @@ -62,7 +64,8 @@ func TestSignCommand_MoreArgs(t *testing.T) { "--key", expected.Key, "--plain-http", "--signature-format", expected.SignerFlagOpts.SignatureFormat, - "--expiry", expected.expiry.String()}); err != nil { + "--expiry", expected.expiry.String(), + "--signature-manifest", signatureManifestImage}); err != nil { t.Fatalf("Parse Flag failed: %v", err) } if err := command.Args(command, command.Flags().Args()); err != nil { @@ -80,10 +83,11 @@ func TestSignCommand_CorrectConfig(t *testing.T) { reference: "ref", SignerFlagOpts: cmd.SignerFlagOpts{ Key: "key", - SignatureFormat: envelope.JWS, + SignatureFormat: envelope.COSE, }, - expiry: 365 * 24 * time.Hour, - pluginConfig: []string{"key0=val0", "key1=val1"}, + expiry: 365 * 24 * time.Hour, + pluginConfig: []string{"key0=val0", "key1=val1"}, + signatureManifest: "artifact", } if err := command.ParseFlags([]string{ expected.reference, @@ -100,7 +104,7 @@ func TestSignCommand_CorrectConfig(t *testing.T) { if !reflect.DeepEqual(*expected, *opts) { t.Fatalf("Expect sign opts: %v, got: %v", expected, opts) } - config, err := cmd.ParseFlagPluginConfig(opts.pluginConfig) + config, err := cmd.ParseFlagMap(opts.pluginConfig, cmd.PflagPluginConfig.Name) if err != nil { t.Fatalf("Parse plugin Config flag failed: %v", err) } diff --git a/cmd/notation/verify.go b/cmd/notation/verify.go index c8c2d12c0..11bcace99 100644 --- a/cmd/notation/verify.go +++ b/cmd/notation/verify.go @@ -13,6 +13,7 @@ import ( "github.com/notaryproject/notation-go/verifier" "github.com/notaryproject/notation-go/verifier/trustpolicy" "github.com/notaryproject/notation/internal/cmd" + "github.com/notaryproject/notation/internal/ioutil" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/spf13/cobra" @@ -24,6 +25,14 @@ type verifyOpts struct { SecureFlagOpts reference string pluginConfig []string + userMetadata []string + outputFormat string +} + +type verifyOutput struct { + Reference string `json:"reference"` + UserMetadata map[string]string `json:"userMetadata,omitempty"` + Result string `json:"result"` } func verifyCommand(opts *verifyOpts) *cobra.Command { @@ -50,13 +59,19 @@ Example - Verify a signature on an OCI artifact identified by a tag (Notation w opts.reference = args[0] return nil }, - RunE: func(cmd *cobra.Command, args []string) error { - return runVerify(cmd, opts) + RunE: func(cmnd *cobra.Command, args []string) error { + if opts.outputFormat != cmd.OutputJson && opts.outputFormat != cmd.OutputPlaintext { + return fmt.Errorf("unrecognized output format: %v", opts.outputFormat) + } + + return runVerify(cmnd, opts) }, } opts.LoggingFlagOpts.ApplyFlags(command.Flags()) opts.SecureFlagOpts.ApplyFlags(command.Flags()) - command.Flags().StringArrayVarP(&opts.pluginConfig, "plugin-config", "c", nil, "{key}={value} pairs that are passed as it is to a plugin, if the verification is associated with a verification plugin, refer plugin documentation to set appropriate values") + command.Flags().StringArrayVar(&opts.pluginConfig, "plugin-config", nil, "{key}={value} pairs that are passed as it is to a plugin, if the verification is associated with a verification plugin, refer plugin documentation to set appropriate values") + cmd.SetPflagUserMetadata(command.Flags(), &opts.userMetadata, cmd.PflagUserMetadataVerifyUsage) + cmd.SetPflagOutput(command.Flags(), &opts.outputFormat, cmd.PflagOutputUsage) return command } @@ -86,7 +101,13 @@ func runVerify(command *cobra.Command, opts *verifyOpts) error { } // set up verification plugin config. - configs, err := cmd.ParseFlagPluginConfig(opts.pluginConfig) + configs, err := cmd.ParseFlagMap(opts.pluginConfig, cmd.PflagPluginConfig.Name) + if err != nil { + return err + } + + // set up user metadata + userMetadata, err := cmd.ParseFlagMap(opts.userMetadata, cmd.PflagUserMetadata.Name) if err != nil { return err } @@ -97,6 +118,7 @@ func runVerify(command *cobra.Command, opts *verifyOpts) error { // TODO: need to change MaxSignatureAttempts as a user input flag or // a field in config.json MaxSignatureAttempts: math.MaxInt64, + UserMetadata: userMetadata, } // core verify process @@ -104,7 +126,7 @@ func runVerify(command *cobra.Command, opts *verifyOpts) error { // write out on failure if err != nil || len(outcomes) == 0 { if err != nil { - var errorVerificationFailed *notation.ErrorVerificationFailed + var errorVerificationFailed notation.ErrorVerificationFailed if !errors.As(err, &errorVerificationFailed) { return fmt.Errorf("signature verification failed: %w", err) } @@ -122,12 +144,8 @@ func runVerify(command *cobra.Command, opts *verifyOpts) error { fmt.Fprintf(os.Stderr, "Warning: %v was set to %q and failed with error: %v\n", result.Type, result.Action, result.Error) } } - if reflect.DeepEqual(outcome.VerificationLevel, trustpolicy.LevelSkip) { - fmt.Println("Trust policy is configured to skip signature verification for", ref.String()) - } else { - fmt.Println("Successfully verified signature for", ref.String()) - } - return nil + + return printResult(opts.outputFormat, ref.String(), outcome) } func resolveReference(ctx context.Context, opts *SecureFlagOpts, reference string, sigRepo notationregistry.Repository, fn func(registry.Reference, ocispec.Descriptor)) (registry.Reference, error) { @@ -148,3 +166,34 @@ func resolveReference(ctx context.Context, opts *SecureFlagOpts, reference strin return ref, nil } + +func printResult(outputFormat, reference string, outcome *notation.VerificationOutcome) error { + if reflect.DeepEqual(outcome.VerificationLevel, trustpolicy.LevelSkip) { + switch outputFormat { + case cmd.OutputJson: + output := verifyOutput{Reference: reference, Result: "SkippedByTrustPolicy", UserMetadata: map[string]string{}} + return ioutil.PrintObjectAsJSON(output) + default: + fmt.Println("Trust policy is configured to skip signature verification for", reference) + return nil + } + } + + // the signature envelope is parsed as part of verification. + // since user metadata is only printed on successful verification, + // this error can be ignored + metadata, _ := outcome.UserMetadata() + + switch outputFormat { + case cmd.OutputJson: + output := verifyOutput{Reference: reference, Result: "Success", UserMetadata: metadata} + return ioutil.PrintObjectAsJSON(output) + default: + fmt.Println("Successfully verified signature for", reference) + if len(metadata) > 0 { + fmt.Println("\nThe artifact was signed with the following user metadata.") + ioutil.PrintMetadataMap(os.Stdout, metadata) + } + return nil + } +} diff --git a/cmd/notation/verify_test.go b/cmd/notation/verify_test.go index 69d879f25..70b2b683e 100644 --- a/cmd/notation/verify_test.go +++ b/cmd/notation/verify_test.go @@ -3,6 +3,8 @@ package main import ( "reflect" "testing" + + "github.com/notaryproject/notation/internal/cmd" ) func TestVerifyCommand_BasicArgs(t *testing.T) { @@ -15,6 +17,7 @@ func TestVerifyCommand_BasicArgs(t *testing.T) { Password: "password", }, pluginConfig: []string{"key1=val1"}, + outputFormat: cmd.OutputPlaintext, } if err := command.ParseFlags([]string{ expected.reference, @@ -40,12 +43,14 @@ func TestVerifyCommand_MoreArgs(t *testing.T) { PlainHTTP: true, }, pluginConfig: []string{"key1=val1", "key2=val2"}, + outputFormat: cmd.OutputJson, } if err := command.ParseFlags([]string{ expected.reference, "--plain-http", "--plugin-config", "key1=val1", - "--plugin-config", "key2=val2"}); err != nil { + "--plugin-config", "key2=val2", + "--output", "json"}); err != nil { t.Fatalf("Parse Flag failed: %v", err) } if err := command.Args(command, command.Flags().Args()); err != nil { diff --git a/go.mod b/go.mod index 2f825c0fc..7b5a1655e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/docker/docker-credential-helpers v0.7.0 github.com/notaryproject/notation-core-go v1.0.0-rc.1 - github.com/notaryproject/notation-go v1.0.0-rc.1.0.20230203031935-510def1a3f48 + github.com/notaryproject/notation-go v1.0.0-rc.1.0.20230208032042-6ef3544efa06 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0-rc2 github.com/sirupsen/logrus v1.9.0 diff --git a/go.sum b/go.sum index 2525aad58..07a799fa8 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/notaryproject/notation-core-go v1.0.0-rc.1 h1:ACi0gr6mD1bzp9+gu3P0meJ/N6iWHlyM9zgtdnooNAA= github.com/notaryproject/notation-core-go v1.0.0-rc.1/go.mod h1:n8Gbvl9sKa00KptkKEL5XKUyMTIALe74QipKauE2rj4= -github.com/notaryproject/notation-go v1.0.0-rc.1.0.20230203031935-510def1a3f48 h1:MHjaRqAn+uCBYkDuIGaVo91CnJY9MlTcZdYFfoE4yek= -github.com/notaryproject/notation-go v1.0.0-rc.1.0.20230203031935-510def1a3f48/go.mod h1:B/26FcjJ9GVXm1j7z+/pWKck80LdFi3KiX4Zu7gixB8= +github.com/notaryproject/notation-go v1.0.0-rc.1.0.20230208032042-6ef3544efa06 h1:0AuNQ3303yvINJSEzHUrLHSsJOyAEJvCGUit44GhERk= +github.com/notaryproject/notation-go v1.0.0-rc.1.0.20230208032042-6ef3544efa06/go.mod h1:B/26FcjJ9GVXm1j7z+/pWKck80LdFi3KiX4Zu7gixB8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc2 h1:2zx/Stx4Wc5pIPDvIxHXvXtQFW/7XWJGmnM7r3wg034= diff --git a/internal/cmd/flags.go b/internal/cmd/flags.go index 53512c1be..5df84003b 100644 --- a/internal/cmd/flags.go +++ b/internal/cmd/flags.go @@ -11,6 +11,11 @@ import ( "github.com/spf13/pflag" ) +const ( + OutputPlaintext = "text" + OutputJson = "json" +) + var ( PflagKey = &pflag.Flag{ Name: "key", @@ -64,12 +69,31 @@ var ( } PflagPluginConfig = &pflag.Flag{ - Name: "plugin-config", - Shorthand: "c", - Usage: "{key}={value} pairs that are passed as it is to a plugin, refer plugin's documentation to set appropriate values", + Name: "plugin-config", + Usage: "{key}={value} pairs that are passed as it is to a plugin, refer plugin's documentation to set appropriate values", } SetPflagPluginConfig = func(fs *pflag.FlagSet, p *[]string) { - fs.StringArrayVarP(p, PflagPluginConfig.Name, PflagPluginConfig.Shorthand, nil, PflagPluginConfig.Usage) + fs.StringArrayVar(p, PflagPluginConfig.Name, nil, PflagPluginConfig.Usage) + } + + PflagUserMetadata = &pflag.Flag{ + Name: "user-metadata", + Shorthand: "m", + } + + PflagUserMetadataSignUsage = "{key}={value} pairs that are added to the signature payload" + PflagUserMetadataVerifyUsage = "user defined {key}={value} pairs that must be present in the signature for successful verification if provided" + SetPflagUserMetadata = func(fs *pflag.FlagSet, p *[]string, usage string) { + fs.StringArrayVarP(p, PflagUserMetadata.Name, PflagUserMetadata.Shorthand, nil, usage) + } + + PflagOutput = &pflag.Flag{ + Name: "output", + Shorthand: "o", + } + PflagOutputUsage = fmt.Sprintf("output format, options: '%s', '%s'", OutputJson, OutputPlaintext) + SetPflagOutput = func(fs *pflag.FlagSet, p *string, usage string) { + fs.StringVarP(p, PflagOutput.Name, PflagOutput.Shorthand, OutputPlaintext, usage) } ) @@ -79,14 +103,14 @@ type KeyValueSlice interface { String() string } -func ParseFlagPluginConfig(config []string) (map[string]string, error) { - pluginConfig := make(map[string]string, len(config)) - for _, pair := range config { +func ParseFlagMap(c []string, flagName string) (map[string]string, error) { + m := make(map[string]string, len(c)) + for _, pair := range c { key, val, found := strings.Cut(pair, "=") if !found || key == "" || val == "" { - return nil, fmt.Errorf("could not parse flag %s: key-value pair requires \"=\" as separator", PflagPluginConfig.Name) + return nil, fmt.Errorf("could not parse flag %s: key-value pair requires \"=\" as separator", flagName) } - pluginConfig[key] = val + m[key] = val } - return pluginConfig, nil + return m, nil } diff --git a/internal/ioutil/print.go b/internal/ioutil/print.go index e57e17c05..96e452212 100644 --- a/internal/ioutil/print.go +++ b/internal/ioutil/print.go @@ -1,6 +1,7 @@ package ioutil import ( + "encoding/json" "fmt" "io" "text/tabwriter" @@ -32,3 +33,27 @@ func PrintKeyMap(w io.Writer, target *string, v []config.KeySuite) error { } return tw.Flush() } + +// PrintMetadataMap prints a map to a given Writer as a table +func PrintMetadataMap(w io.Writer, metadata map[string]string) error { + tw := newTabWriter(w) + fmt.Fprintln(tw, "\nKEY\tVALUE\t") + + for k, v := range metadata { + fmt.Fprintf(tw, "%v\t%v\t\n", k, v) + } + + return tw.Flush() +} + +// PrintObjectAsJSON takes an interface and prints it as an indented JSON string +func PrintObjectAsJSON(i interface{}) error { + jsonBytes, err := json.MarshalIndent(i, "", " ") + if err != nil { + return err + } + + fmt.Println(string(jsonBytes)) + + return nil +} diff --git a/internal/slices/slices.go b/internal/slices/slices.go new file mode 100644 index 000000000..0d348cdf1 --- /dev/null +++ b/internal/slices/slices.go @@ -0,0 +1,11 @@ +package slices + +// Contains reports whether v is present in s. +func Contains[E comparable](s []E, v E) bool { + for _, vs := range s { + if v == vs { + return true + } + } + return false +} diff --git a/internal/slices/slices_test.go b/internal/slices/slices_test.go new file mode 100644 index 000000000..2c19ff559 --- /dev/null +++ b/internal/slices/slices_test.go @@ -0,0 +1,25 @@ +package slices + +import ( + "testing" +) + +func TestContainerElement(t *testing.T) { + tests := []struct { + c []string + v string + want bool + }{ + {nil, "", false}, + {[]string{}, "", false}, + {[]string{"1", "2", "3"}, "4", false}, + {[]string{"1", "2", "3"}, "2", true}, + {[]string{"1", "2", "2", "3"}, "2", true}, + {[]string{"1", "2", "3", "2"}, "2", true}, + } + for _, tt := range tests { + if got := Contains(tt.c, tt.v); got != tt.want { + t.Errorf("ContainerElement() = %v, want %v", got, tt.want) + } + } +} diff --git a/cmd/notation/notation b/notation similarity index 57% rename from cmd/notation/notation rename to notation index 6cbf6e855..a9a700c6e 100755 Binary files a/cmd/notation/notation and b/notation differ diff --git a/pkg/configutil/once.go b/pkg/configutil/once.go index bae0f2e1d..26b028880 100644 --- a/pkg/configutil/once.go +++ b/pkg/configutil/once.go @@ -12,10 +12,6 @@ var ( // configInfo is the config.json data configInfo *config.Config configOnce sync.Once - - // signingKeyInfo if the signingkeys.json data - signingKeysInfo *config.SigningKeys - signingKeysOnce sync.Once ) // LoadConfigOnce returns the previously read config file. @@ -26,6 +22,9 @@ func LoadConfigOnce() (*config.Config, error) { var err error configOnce.Do(func() { configInfo, err = config.LoadConfig() + if err != nil { + return + } // set default value configInfo.SignatureFormat = strings.ToLower(configInfo.SignatureFormat) if configInfo.SignatureFormat == "" { diff --git a/pkg/configutil/testdata/config.json b/pkg/configutil/testdata/config.json new file mode 100644 index 000000000..c65496af3 --- /dev/null +++ b/pkg/configutil/testdata/config.json @@ -0,0 +1,3 @@ +{ + "insecureRegistries": ["reg1.io"] +} \ No newline at end of file diff --git a/pkg/configutil/testdata/no_default_key_signingkeys/signingkeys.json b/pkg/configutil/testdata/no_default_key_signingkeys/signingkeys.json new file mode 100644 index 000000000..7499c0414 --- /dev/null +++ b/pkg/configutil/testdata/no_default_key_signingkeys/signingkeys.json @@ -0,0 +1,9 @@ +{ + "keys": [ + { + "name": "e2e", + "keyPath": "notation/localkeys/e2e.key", + "certPath": "notation/localkeys/e2e.crt" + } + ] +} \ No newline at end of file diff --git a/pkg/configutil/testdata/valid_signingkeys/signingkeys.json b/pkg/configutil/testdata/valid_signingkeys/signingkeys.json new file mode 100644 index 000000000..587f727a0 --- /dev/null +++ b/pkg/configutil/testdata/valid_signingkeys/signingkeys.json @@ -0,0 +1,10 @@ +{ + "default": "e2e", + "keys": [ + { + "name": "e2e", + "keyPath": "notation/localkeys/e2e.key", + "certPath": "notation/localkeys/e2e.crt" + } + ] +} \ No newline at end of file diff --git a/pkg/configutil/util.go b/pkg/configutil/util.go index 312cb62ca..ef4f23bb3 100644 --- a/pkg/configutil/util.go +++ b/pkg/configutil/util.go @@ -38,6 +38,6 @@ func ResolveKey(name string) (config.KeySuite, error) { if name == "" { return signingKeys.GetDefault() } - + return signingKeys.Get(name) } diff --git a/pkg/configutil/util_test.go b/pkg/configutil/util_test.go new file mode 100644 index 000000000..b089e6926 --- /dev/null +++ b/pkg/configutil/util_test.go @@ -0,0 +1,139 @@ +package configutil + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/notaryproject/notation-go/dir" +) + +func TestIsRegistryInsecure(t *testing.T) { + configOnce = sync.Once{} + // for restore dir + defer func(oldDir string) { + dir.UserConfigDir = oldDir + configOnce = sync.Once{} + }(dir.UserConfigDir) + // update config dir + dir.UserConfigDir = "testdata" + + type args struct { + target string + } + tests := []struct { + name string + args args + want bool + }{ + {name: "hit registry", args: args{target: "reg1.io"}, want: true}, + {name: "miss registry", args: args{target: "reg2.io"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsRegistryInsecure(tt.args.target); got != tt.want { + t.Errorf("IsRegistryInsecure() = %v, want %v", got, tt.want) + } + }) + } + +} + +func TestIsRegistryInsecureMissingConfig(t *testing.T) { + configOnce = sync.Once{} + // for restore dir + defer func(oldDir string) { + dir.UserConfigDir = oldDir + configOnce = sync.Once{} + }(dir.UserConfigDir) + // update config dir + dir.UserConfigDir = "./testdata2" + + type args struct { + target string + } + tests := []struct { + name string + args args + want bool + }{ + {name: "missing config", args: args{target: "reg1.io"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsRegistryInsecure(tt.args.target); got != tt.want { + t.Errorf("IsRegistryInsecure() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsRegistryInsecureConfigPermissionError(t *testing.T) { + configDir := "./testdata" + // for restore dir + defer func(oldDir string) error { + // restore permission + dir.UserConfigDir = oldDir + configOnce = sync.Once{} + return os.Chmod(filepath.Join(configDir, "config.json"), 0644) + }(dir.UserConfigDir) + + // update config dir + dir.UserConfigDir = configDir + + // forbid reading the file + if err := os.Chmod(filepath.Join(configDir, "config.json"), 0000); err != nil { + t.Error(err) + } + + if IsRegistryInsecure("reg1.io") { + t.Error("should false because of missing config.json read permission.") + } +} + +func TestResolveKey(t *testing.T) { + defer func(oldDir string) { + dir.UserConfigDir = oldDir + }(dir.UserConfigDir) + + t.Run("valid e2e key", func(t *testing.T) { + dir.UserConfigDir = "./testdata/valid_signingkeys" + keySuite, err := ResolveKey("e2e") + if err != nil { + t.Fatal(err) + } + if keySuite.Name != "e2e" { + t.Error("key name is not correct.") + } + }) + + t.Run("key name is empty (using default key)", func(t *testing.T) { + dir.UserConfigDir = "./testdata/valid_signingkeys" + keySuite, err := ResolveKey("") + if err != nil { + t.Fatal(err) + } + if keySuite.Name != "e2e" { + t.Error("key name is not correct.") + } + }) + + t.Run("signingkeys.json without read permission", func(t *testing.T) { + dir.UserConfigDir = "./testdata/valid_signingkeys" + defer func() error { + // restore the permission + return os.Chmod(filepath.Join(dir.UserConfigDir, "signingkeys.json"), 0644) + }() + + // forbid reading the file + if err := os.Chmod(filepath.Join(dir.UserConfigDir, "signingkeys.json"), 0000); err != nil { + t.Error(err) + } + _, err := ResolveKey("") + if !strings.Contains(err.Error(), "permission denied") { + t.Error("should error with permission denied") + } + }) +} diff --git a/specs/commandline/list.md b/specs/commandline/list.md index e62c60c74..4b1b39f23 100644 --- a/specs/commandline/list.md +++ b/specs/commandline/list.md @@ -27,10 +27,12 @@ Aliases: list, ls Flags: + -d, --debug debug mode -h, --help help for list -p, --password string password for registry operations (default to $NOTATION_PASSWORD if not specified) --plain-http registry access via plain HTTP -u, --username string username for registry operations (default to $NOTATION_USERNAME if not specified) + -v, --verbose verbose mode ``` ## Usage diff --git a/specs/commandline/sign.md b/specs/commandline/sign.md index 8abf0de85..95be17a21 100644 --- a/specs/commandline/sign.md +++ b/specs/commandline/sign.md @@ -28,27 +28,29 @@ Usage: notation sign [flags] Flags: - -e, --expiry duration optional expiry that provides a "best by use" time for the artifact. The duration is specified in minutes(m) and/or hours(h). For example: 12h, 30m, 3h20m - -h, --help help for sign - --image-spec string manifest type for signatures. options: v1.1-artifact, v1.1-image (default: v1.1-artifact) - -k, --key string signing key name, for a key previously added to notation's key list. - -p, --password string password for registry operations (default to $NOTATION_PASSWORD if not specified) - --plain-http registry access via plain HTTP - --plugin-config strings {key}={value} pairs that are passed as it is to a plugin, refer plugin's documentation to set appropriate values - --signature-format string signature envelope format, options: 'jws', 'cose' (default "jws") - -u, --username string username for registry operations (default to $NOTATION_USERNAME if not specified) - -m, --user-metadata strings {key}={value} pairs that are added to the signature payload + -d, --debug debug mode + -e, --expiry duration optional expiry that provides a "best by use" time for the artifact. The duration is specified in minutes(m) and/or hours(h). For example: 12h, 30m, 3h20m + -h, --help help for sign + -k, --key string signing key name, for a key previously added to notation's key list. + -p, --password string password for registry operations (default to $NOTATION_PASSWORD if not specified) + --plain-http registry access via plain HTTP + --plugin-config stringArray {key}={value} pairs that are passed as it is to a plugin, refer plugin's documentation to set appropriate values + --signature-format string signature envelope format, options: 'jws', 'cose' (default "jws") + --signature-manifest string manifest type for signatures. options: artifact, image (default "artifact") + -u, --username string username for registry operations (default to $NOTATION_USERNAME if not specified) + -m, --user-metadata stringArray {key}={value} pairs that are added to the signature payload + -v, --verbose verbose mode ``` ## Use OCI image manifest to store signatures -By default, Notation uses [OCI artifact manifest][oci-artifact-manifest] to store signatures in registries. For backward compatibility, Notation supports using `OCI image manifest` to store signatures in registries that partially implement the [OCI Image specification v1.1][oci-image-spec]. Use flag `--image-spec v1.1-image` to force Notation to store the signatures using OCI image manifest. +By default, Notation uses [OCI artifact manifest][oci-artifact-manifest] to store signatures in registries. For backward compatibility, Notation supports using `OCI image manifest` to store signatures in registries that partially implement the [OCI Image specification v1.1][oci-image-spec]. Use flag `--signature-manifest image` to force Notation to store the signatures using OCI image manifest. Registries MAY not implement or enable the `Referrers API`, which is used by clients to fetch referrers. In the context of Notation, the referrers are signatures. Notation follows the fallback procedure defined in [OCI distribution spec][oci-backward-compatibility] if `Referrers API` is unavailable. ### Set config property for OCI image manifest -OCI image manifest requires additional property `config` of type `descriptor`, which is not required by OCI artifact manifest. Notation creates a default config descriptor for the user if flag `--image-spec v1.1-image` is used. +OCI image manifest requires additional property `config` of type `descriptor`, which is not required by OCI artifact manifest. Notation creates a default config descriptor for the user if flag `--signature-manifest image` is used. Notation uses empty JSON object `{}` as the default configuration content, and thus the default `config` property is fixed, as following: @@ -62,9 +64,9 @@ Notation uses empty JSON object `{}` as the default configuration content, and t ### When to use OCI image manifest -[Registry support][registry-support] lists registries with different compatibilities. For registries not supporting `OCI artifact manifest`, users can use flag `--image-spec v1.1-image` to sign artifacts stored in those registries. +[Registry support][registry-support] lists registries with different compatibilities. For registries not supporting `OCI artifact manifest`, users can use flag `--signature-manifest image` to sign artifacts stored in those registries. -For registries not listed in the page, users can consider using flag `--image-spec v1.1-image` by checking the error message. Note that there is no deterministic way to determine whether a registry supports `OCI artifact manifest` or not. The error message is just for reference. The following response status contained in error messages MAY indicate that the registry doesn't support `OCI artifact manifest`: +For registries not listed in the page, users can consider using flag `--signature-manifest image` by checking the error message. Note that there is no deterministic way to determine whether a registry supports `OCI artifact manifest` or not. The error message is just for reference. The following response status contained in error messages MAY indicate that the registry doesn't support `OCI artifact manifest`: - Response status `400 BAD Request` with error code `MANIFEST_INVALID` or `UNSUPPORTED` @@ -161,7 +163,7 @@ Successfully signed localhost:5000/net-monitor@sha256:b94d27b9934d3e08a52e52d7da ### Sign an artifact and store the signature using OCI image manifest ```shell -notation sign --image-spec v1.1-image /@ +notation sign --signature-manifest image /@ ``` [oci-artifact-manifest]: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc2/artifact.md diff --git a/specs/commandline/verify.md b/specs/commandline/verify.md index db5eb2d76..5659ce7c2 100644 --- a/specs/commandline/verify.md +++ b/specs/commandline/verify.md @@ -35,12 +35,15 @@ Usage: notation verify [flags] Flags: - -h, --help help for verify - -p, --password string password for registry operations (default to $NOTATION_PASSWORD if not specified) - --plain-http registry access via plain HTTP - --plugin-config strings {key}={value} pairs that are passed as it is to a plugin, if the verification is associated with a verification plugin, refer plugin documentation to set appropriate values - -u, --username string username for registry operations (default to $NOTATION_USERNAME if not specified) - -m, --user-metadata strings user defined {key}={value} pairs that must be present in the signature for successful verification if provided + -d, --debug debug mode + -h, --help help for verify + -o, --output string output format, options: 'json', 'text' (default "text") + -p, --password string password for registry operations (default to $NOTATION_PASSWORD if not specified) + --plain-http registry access via plain HTTP + --plugin-config stringArray {key}={value} pairs that are passed as it is to a plugin, if the verification is associated with a verification plugin, refer plugin documentation to set appropriate values + -m, --user-metadata stringArray user defined {key}={value} pairs that must be present in the signature for successful verification if provided + -u, --username string username for registry operations (default to $NOTATION_USERNAME if not specified) + -v, --verbose verbose mode ``` ## Usage @@ -166,3 +169,25 @@ An example of output messages for a successful verification: Warning: Always verify the artifact using digest(@sha256:...) rather than a tag(:v1) because resolved digest may not point to the same signed artifact, as tags are mutable. Successfully verified signature for localhost:5000/net-monitor@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 ``` + +### Verify signatures on an OCI artifact with json output + +Use the `--output` flag to format successful verification output in json. + +```shell +notation verify --output json localhost:5000/net-monitor@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 +``` + +An example of output messages for a successful verification: + +```text +{ + "reference": "localhost:5000/net-monitor@sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "userMetadata": { + "io.wabbit-networks.buildId": "123" + }, + "result": "Success" +} +``` + +On unsuccessful verification, nothing is written to `stdout`, and the failure is logged to `stderr`. diff --git a/test/e2e/suite/command/verify.go b/test/e2e/suite/command/verify.go index 02636be0a..9837c1f64 100644 --- a/test/e2e/suite/command/verify.go +++ b/test/e2e/suite/command/verify.go @@ -49,4 +49,45 @@ var _ = Describe("notation verify", func() { MatchKeyWords(VerifySuccessfully) }) }) + + It("with added user metadata", func() { + Host(BaseOptions(), func(notation *utils.ExecOpts, artifact *Artifact, vhost *utils.VirtualHost) { + notation.Exec("sign", artifact.ReferenceWithDigest(), "--user-metadata", "io.wabbit-networks.buildId=123"). + MatchKeyWords(SignSuccessfully) + + notation.Exec("verify", artifact.ReferenceWithTag()). + MatchKeyWords( + VerifySuccessfully, + "KEY", + "VALUE", + "io.wabbit-networks.buildId", + "123", + ) + + notation.Exec("verify", artifact.ReferenceWithDigest(), "--user-metadata", "io.wabbit-networks.buildId=123"). + MatchKeyWords( + VerifySuccessfully, + "KEY", + "VALUE", + "io.wabbit-networks.buildId", + "123", + ) + + notation.ExpectFailure().Exec("verify", artifact.ReferenceWithDigest(), "--user-metadata", "io.wabbit-networks.buildId=321"). + MatchErrKeyWords("unable to find specified metadata in the signature") + }) + }) + + It("with json output", func() { + Host(BaseOptions(), func(notation *utils.ExecOpts, artifact *Artifact, vhost *utils.VirtualHost) { + notation.Exec("sign", artifact.ReferenceWithDigest(), "--user-metadata", "io.wabbit-networks.buildId=123"). + MatchKeyWords(SignSuccessfully) + + notation.Exec("verify", artifact.ReferenceWithDigest(), "--output", "json"). + MatchContent(fmt.Sprintf("{\n \"reference\": \"%s\",\n \"userMetadata\": {\n \"io.wabbit-networks.buildId\": \"123\"\n },\n \"result\": \"Success\"\n}\n", artifact.ReferenceWithDigest())) + + notation.ExpectFailure().Exec("verify", artifact.ReferenceWithDigest(), "--user-metadata", "io.wabbit-networks.buildId=321"). + MatchErrKeyWords("unable to find specified metadata in the signature") + }) + }) })