diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..8da0130 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,217 @@ +// Package cli provides boilerplate code required to generate plugin executable. +// At high level it performs following tasks +// 1. Validate command arguments +// 2. Read and unmarshal input +// 3. Execute relevant plugin functions +// 4. marshals output + +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + + "github.com/notaryproject/notation-plugin-framework-go/internal/slices" + "github.com/notaryproject/notation-plugin-framework-go/log" + "github.com/notaryproject/notation-plugin-framework-go/plugin" +) + +type CLI struct { + name string + pl plugin.Plugin + logger log.Logger +} + +// New creates a new CLI using given plugin +func New(executableName string, pl plugin.Plugin) *CLI { + return NewWithLogger(executableName, pl, &discardLogger{}) +} + +// NewWithLogger creates a new CLI using given plugin and logger +func NewWithLogger(executableName string, pl plugin.Plugin, l log.Logger) *CLI { + return &CLI{ + name: executableName, + pl: pl, + logger: l, + } +} + +// Execute is main controller that reads/validates commands, parses input, executes relevant plugin functions +// and returns corresponding output. +func (c *CLI) Execute(ctx context.Context, args []string) { + c.validateArgs(ctx, args) + + rescueStdOut := deferStdout() + command := args[1] + var resp any + var err error + switch plugin.Command(command) { + case plugin.CommandGetMetadata: + var request plugin.GetMetadataRequest + err = c.unmarshalRequest(&request) + if err == nil { + c.logger.Debugf("executing %s plugin's GetMetadata function", reflect.TypeOf(c.pl)) + resp, err = c.pl.GetMetadata(ctx, &request) + } + case plugin.CommandGenerateEnvelope: + var request plugin.GenerateEnvelopeRequest + err = c.unmarshalRequest(&request) + if err == nil { + c.logger.Debugf("executing %s plugin's GenerateEnvelope function", reflect.TypeOf(c.pl)) + resp, err = c.pl.GenerateEnvelope(ctx, &request) + } + case plugin.CommandVerifySignature: + var request plugin.VerifySignatureRequest + err = c.unmarshalRequest(&request) + if err == nil { + c.logger.Debugf("executing %s plugin's VerifySignature function", reflect.TypeOf(c.pl)) + resp, err = c.pl.VerifySignature(ctx, &request) + } + case plugin.CommandDescribeKey: + var request plugin.DescribeKeyRequest + err = c.unmarshalRequest(&request) + if err == nil { + c.logger.Debugf("executing %s plugin's DescribeKey function", reflect.TypeOf(c.pl)) + resp, err = c.pl.DescribeKey(ctx, &request) + } + case plugin.CommandGenerateSignature: + var request plugin.VerifySignatureRequest + err = c.unmarshalRequest(&request) + if err == nil { + c.logger.Debugf("executing %s plugin's VerifySignature function", reflect.TypeOf(c.pl)) + resp, err = c.pl.VerifySignature(ctx, &request) + } + case plugin.Version: + rescueStdOut() + c.printVersion(ctx) + default: + // should never happen + rescueStdOut() + deliverError(plugin.NewGenericError("something went wrong").Error()) + } + + op, pluginErr := c.marshalResponse(resp, err) + rescueStdOut() + if pluginErr != nil { + deliverError(pluginErr.Error()) + } + fmt.Println(op) +} + +// printVersion prints version of executable +func (c *CLI) printVersion(ctx context.Context) { + md := c.getMetadata(ctx, c.pl) + + fmt.Printf("%s - %s\nVersion: %s \n", md.Name, md.Description, md.Version) + os.Exit(0) +} + +// validateArgs validate commands/arguments passed to executable. +func (c *CLI) validateArgs(ctx context.Context, args []string) { + md := c.getMetadata(ctx, c.pl) + if !(len(args) == 2 && slices.Contains(getValidArgs(md), args[1])) { + deliverError(fmt.Sprintf("Invalid command, valid choices are: %s %s", c.name, getValidArgsString(md))) + } +} + +// unmarshalRequest reads input from std.in and unmarshal it into given request struct +func (c *CLI) unmarshalRequest(request any) error { + if err := json.NewDecoder(os.Stdin).Decode(request); err != nil { + c.logger.Errorf("%s unmarshalling error :%v", reflect.TypeOf(request), err) + return plugin.NewJSONParsingError(plugin.ErrorMsgMalformedInput) + } + return nil +} + +func (c *CLI) getMetadata(ctx context.Context, p plugin.Plugin) *plugin.GetMetadataResponse { + md, err := p.GetMetadata(ctx, &plugin.GetMetadataRequest{}) + if err != nil { + c.logger.Errorf("GetMetadataRequest error :%v", err) + deliverError("Error: Failed to get plugin metadata.") + } + return md +} + +// marshalResponse marshals the given response struct into json +func (c *CLI) marshalResponse(response any, err error) (string, *plugin.Error) { + if err != nil { + c.logger.Errorf("%s error: %v", reflect.TypeOf(response), err) + if plgErr, ok := err.(*plugin.Error); ok { + return "", plgErr + } + return "", plugin.NewGenericError(err.Error()) + } + + c.logger.Debug("marshalling response") + jsonResponse, err := json.Marshal(response) + if err != nil { + c.logger.Errorf("%s marshalling error: %v", reflect.TypeOf(response), err) + return "", plugin.NewGenericErrorf(plugin.ErrorMsgMalformedOutputFmt, err.Error()) + } + + c.logger.Debugf("%s response: %s", reflect.TypeOf(response), jsonResponse) + return string(jsonResponse), nil +} + +// deferStdout is used to make sure that nothing get emitted to stdout and stderr until intentionally rescued. +// This is required to make sure that the plugin or its dependency doesn't interfere with notation <-> plugin communication +func deferStdout() func() { + // Ignoring error because we don't want plugin to fail if `os.DevNull` is misconfigured. + null, _ := os.Open(os.DevNull) + sout := os.Stdout + serr := os.Stderr + os.Stdout = null + os.Stderr = null + + return func() { + err := null.Close() + if err != nil { + return + } + os.Stdout = sout + os.Stderr = serr + } +} + +// discardLogger implements Logger but logs nothing. It is used when user +// disenabled logging option in notation, i.e. loggerKey is not in the context. +type discardLogger struct{} + +func (dl *discardLogger) Debug(_ ...interface{}) { +} + +func (dl *discardLogger) Debugf(_ string, _ ...interface{}) { +} + +func (dl *discardLogger) Debugln(_ ...interface{}) { +} + +func (dl *discardLogger) Info(_ ...interface{}) { +} + +func (dl *discardLogger) Infof(_ string, _ ...interface{}) { +} + +func (dl *discardLogger) Infoln(_ ...interface{}) { +} + +func (dl *discardLogger) Warn(_ ...interface{}) { +} + +func (dl *discardLogger) Warnf(_ string, _ ...interface{}) { +} + +func (dl *discardLogger) Warnln(_ ...interface{}) { +} + +func (dl *discardLogger) Error(_ ...interface{}) { +} + +func (dl *discardLogger) Errorf(_ string, _ ...interface{}) { +} + +func (dl *discardLogger) Errorln(_ ...interface{}) { +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..3892db0 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,165 @@ +package cli + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "reflect" + "strings" + "testing" + + "github.com/notaryproject/notation-plugin-framework-go/cli/internal/mock" + "github.com/notaryproject/notation-plugin-framework-go/plugin" +) + +var cli = New("mockCli", &mockPlugin{}) + +func TestMarshalResponse(t *testing.T) { + res := plugin.GenerateEnvelopeResponse{ + SignatureEnvelope: []byte("envelope"), + Annotations: map[string]string{"key": "value"}, + SignatureEnvelopeType: "envelopeType", + } + op, err := cli.marshalResponse(res, nil) + if err != nil { + t.Errorf("Error found in marshalResponse: %v", err) + } + + expected := "{\"signatureEnvelope\":\"ZW52ZWxvcGU=\",\"signatureEnvelopeType\":\"envelopeType\",\"annotations\":{\"key\":\"value\"}}" + if !strings.EqualFold("{\"signatureEnvelope\":\"ZW52ZWxvcGU=\",\"signatureEnvelopeType\":\"envelopeType\",\"annotations\":{\"key\":\"value\"}}", op) { + t.Errorf("Not equal: \n expected: %s\n actual : %s", expected, op) + } +} + +func TestMarshalResponseError(t *testing.T) { + + _, err := cli.marshalResponse(nil, fmt.Errorf("expected error thrown")) + assertErr(t, err, plugin.CodeGeneric) + + _, err = cli.marshalResponse(nil, plugin.NewValidationError("expected validation error thrown")) + assertErr(t, err, plugin.CodeValidation) + + _, err = cli.marshalResponse(make(chan int), nil) + assertErr(t, err, plugin.CodeGeneric) +} + +func TestUnmarshalRequest(t *testing.T) { + content := "{\"contractVersion\":\"1.0\",\"keyId\":\"someKeyId\",\"pluginConfig\":{\"pc1\":\"pk1\"}}" + closer := setupReader(content) + defer closer() + + var request plugin.DescribeKeyRequest + if err := cli.unmarshalRequest(&request); err != nil { + t.Errorf("unmarshalRequest() failed with error: %v", err) + } + + if request.ContractVersion != "1.0" || request.KeyID != "someKeyId" || request.PluginConfig["pc1"] != "pk1" { + t.Errorf("unmarshalRequest() returned incorrect struct") + } +} + +func TestUnmarshalRequestError(t *testing.T) { + closer := setupReader("InvalidJson") + defer closer() + + var request plugin.DescribeKeyRequest + err := cli.unmarshalRequest(&request) + if err == nil { + t.Fatalf("unmarshalRequest() expected error but not found") + } + + plgErr, ok := err.(*plugin.Error) + if !ok { + t.Fatalf("unmarshalRequest() expected error of type plugin.Error but found %s", reflect.TypeOf(err)) + } + + expectedErrStr := "{\"errorCode\":\"VALIDATION_ERROR\",\"errorMessage\":\"Input is not a valid JSON\"}" + if plgErr.Error() != expectedErrStr { + t.Fatalf("unmarshalRequest() expected error string to be %s but found %s", expectedErrStr, plgErr.Error()) + } +} + +func TestGetMetadata(t *testing.T) { + pl := mock.NewPlugin(false) + ctx := context.Background() + + cli.getMetadata(ctx, pl) +} + +func TestGetMetadataError(t *testing.T) { + if os.Getenv("TEST_OS_EXIT") == "1" { + pl := mock.NewPlugin(true) + ctx := context.Background() + + cli.getMetadata(ctx, pl) + return + } + cmd := exec.Command(os.Args[0], "-test.run=TestGetMetadataError") + cmd.Env = append(os.Environ(), "TEST_OS_EXIT=1") + err := cmd.Run() + if e, ok := err.(*exec.ExitError); ok && !e.Success() { + return + } + t.Fatalf("process ran with err %v, want exit status 1", err) +} + +func setupReader(content string) func() { + tmpfile, err := os.CreateTemp("", "example") + if err != nil { + log.Fatal(err) + } + + if _, err := tmpfile.Write([]byte(content)); err != nil { + log.Fatal(err) + } + + if _, err := tmpfile.Seek(0, 0); err != nil { + log.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = tmpfile + return func() { + os.Remove(tmpfile.Name()) + os.Stdin = oldStdin // Restore original Stdin + + tmpfile.Close() + } +} + +func assertErr(t *testing.T, err error, code plugin.Code) { + if plgErr, ok := err.(*plugin.Error); ok { + if reflect.DeepEqual(code, plgErr.ErrCode) { + return + } + t.Errorf("mismatch in error code: \n expected: %s\n actual : %s", code, plgErr.ErrCode) + } + + t.Errorf("expected error of type PluginError but found %s", reflect.TypeOf(err)) +} + +type mockPlugin struct { +} + +func (p *mockPlugin) DescribeKey(_ context.Context, _ *plugin.DescribeKeyRequest) (*plugin.DescribeKeyResponse, error) { + return nil, plugin.NewUnsupportedError("DescribeKey operation is not implemented by example plugin") +} + +func (p *mockPlugin) GenerateSignature(_ context.Context, _ *plugin.GenerateSignatureRequest) (*plugin.GenerateSignatureResponse, error) { + return nil, plugin.NewUnsupportedError("GenerateSignature operation is not implemented by example plugin") +} + +func (p *mockPlugin) GenerateEnvelope(_ context.Context, _ *plugin.GenerateEnvelopeRequest) (*plugin.GenerateEnvelopeResponse, error) { + return nil, plugin.NewUnsupportedError("GenerateEnvelope operation is not implemented by example plugin") +} + +func (p *mockPlugin) VerifySignature(_ context.Context, _ *plugin.VerifySignatureRequest) (*plugin.VerifySignatureResponse, error) { + return nil, plugin.NewUnsupportedError("VerifySignature operation is not implemented by example plugin") + +} + +func (p *mockPlugin) GetMetadata(_ context.Context, _ *plugin.GetMetadataRequest) (*plugin.GetMetadataResponse, error) { + return nil, plugin.NewUnsupportedError("GetMetadata operation is not implemented by mock plugin") +} diff --git a/cli/internal/mock/plugin.go b/cli/internal/mock/plugin.go new file mode 100644 index 0000000..7a3da94 --- /dev/null +++ b/cli/internal/mock/plugin.go @@ -0,0 +1,78 @@ +// Package mock contains various mock structures required for testing. +package mock + +import ( + "context" + "fmt" + + "github.com/notaryproject/notation-plugin-framework-go/plugin" +) + +// Mock plugin used only for testing. +type mockPlugin struct { + fail bool +} + +func NewPlugin(failPlugin bool) *mockPlugin { + return &mockPlugin{fail: failPlugin} +} + +func (p *mockPlugin) DescribeKey(ctx context.Context, req *plugin.DescribeKeyRequest) (*plugin.DescribeKeyResponse, error) { + return nil, plugin.NewUnsupportedError("DescribeKey operation is not implemented by example plugin") +} + +func (p *mockPlugin) GenerateSignature(ctx context.Context, req *plugin.GenerateSignatureRequest) (*plugin.GenerateSignatureResponse, error) { + return nil, plugin.NewUnsupportedError("GenerateSignature operation is not implemented by example plugin") +} + +func (p *mockPlugin) GenerateEnvelope(ctx context.Context, req *plugin.GenerateEnvelopeRequest) (*plugin.GenerateEnvelopeResponse, error) { + if p.fail { + return nil, fmt.Errorf("expected error") + } + return &plugin.GenerateEnvelopeResponse{ + SignatureEnvelope: []byte(""), + SignatureEnvelopeType: "application/jose+json", + Annotations: map[string]string{"manifestAnntnKey1": "value1"}, + }, nil +} + +func (p *mockPlugin) VerifySignature(ctx context.Context, req *plugin.VerifySignatureRequest) (*plugin.VerifySignatureResponse, error) { + if p.fail { + return nil, fmt.Errorf("expected error") + } + upAttrs := req.Signature.UnprocessedAttributes + pAttrs := make([]interface{}, len(upAttrs)) + for i := range upAttrs { + pAttrs[i] = upAttrs[i] + } + + return &plugin.VerifySignatureResponse{ + ProcessedAttributes: pAttrs, + VerificationResults: map[plugin.Capability]*plugin.VerificationResult{ + plugin.CapabilityTrustedIdentityVerifier: { + Success: true, + Reason: "Valid trusted Identity", + }, + plugin.CapabilityRevocationCheckVerifier: { + Success: true, + Reason: "Not revoked", + }, + }, + }, nil +} + +func (p *mockPlugin) GetMetadata(ctx context.Context, req *plugin.GetMetadataRequest) (*plugin.GetMetadataResponse, error) { + if p.fail { + return nil, fmt.Errorf("expected error") + } + return &plugin.GetMetadataResponse{ + Name: "Example Plugin", + Description: "This is an description of example plugin. 🍺", + URL: "https://example.com/notation/plugin", + Version: "1.0.0", + Capabilities: []plugin.Capability{ + plugin.CapabilityEnvelopeGenerator, + plugin.CapabilityTrustedIdentityVerifier, + plugin.CapabilityRevocationCheckVerifier}, + }, nil +} diff --git a/cli/utils.go b/cli/utils.go new file mode 100644 index 0000000..36dd055 --- /dev/null +++ b/cli/utils.go @@ -0,0 +1,42 @@ +package cli + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/notaryproject/notation-plugin-framework-go/internal/slices" + "github.com/notaryproject/notation-plugin-framework-go/plugin" +) + +func getValidArgsString(md *plugin.GetMetadataResponse) string { + return fmt.Sprintf(`<%s>`, strings.Join(getValidArgs(md), "|")) +} + +// getValidArgs returns list of valid arguments depending upon the plugin capabilities +func getValidArgs(md *plugin.GetMetadataResponse) []string { + opts := []string{ + "get-plugin-metadata", "version", + } + + if slices.Contains(md.Capabilities, plugin.CapabilitySignatureGenerator) { + opts = append(opts, "generate-signature", "describe-key") + } + + if slices.Contains(md.Capabilities, plugin.CapabilityEnvelopeGenerator) { + opts = append(opts, "generate-envelope") + } + + if slices.Contains(md.Capabilities, plugin.CapabilityTrustedIdentityVerifier) || slices.Contains(md.Capabilities, plugin.CapabilityRevocationCheckVerifier) { + opts = append(opts, "verify-signature") + } + sort.Strings(opts) + return opts +} + +// deliverError print to standard error and then return nonzero exit code +func deliverError(message string) { + _, _ = fmt.Fprintln(os.Stderr, message) + os.Exit(1) +} diff --git a/cli/utils_test.go b/cli/utils_test.go new file mode 100644 index 0000000..946a40b --- /dev/null +++ b/cli/utils_test.go @@ -0,0 +1,66 @@ +package cli + +import ( + "reflect" + "strings" + "testing" + + "github.com/notaryproject/notation-plugin-framework-go/plugin" +) + +func TestGetValidArgsString(t *testing.T) { + mdResp := plugin.GetMetadataResponse{ + Name: "Example Plugin", + Description: "This is an description of example plugin. 🍺", + URL: "https://example.com/notation/plugin", + Version: "1.0.0", + Capabilities: []plugin.Capability{ + plugin.CapabilityEnvelopeGenerator, + plugin.CapabilityTrustedIdentityVerifier, + plugin.CapabilityRevocationCheckVerifier}, + } + s := getValidArgsString(&mdResp) + expected := "" + if !strings.EqualFold(s, expected) { + t.Errorf("Expected %s but found %s", expected, s) + } +} + +func TestGetValidArgs(t *testing.T) { + tests := map[string]struct { + caps []plugin.Capability + args []string + }{ + "sigGeneratorOnly": { + caps: []plugin.Capability{plugin.CapabilitySignatureGenerator}, + args: []string{"describe-key", "generate-signature", "get-plugin-metadata", "version"}, + }, + "envGeneratorOnly": { + caps: []plugin.Capability{plugin.CapabilityEnvelopeGenerator}, + args: []string{"generate-envelope", "get-plugin-metadata", "version"}, + }, + "verificationOnly": { + caps: []plugin.Capability{plugin.CapabilityTrustedIdentityVerifier, plugin.CapabilityRevocationCheckVerifier}, + args: []string{"get-plugin-metadata", "verify-signature", "version"}, + }, + "envGenerator+verification": { + caps: []plugin.Capability{plugin.CapabilityEnvelopeGenerator, plugin.CapabilityTrustedIdentityVerifier, plugin.CapabilityRevocationCheckVerifier}, + args: []string{"generate-envelope", "get-plugin-metadata", "verify-signature", "version"}, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mdResp := plugin.GetMetadataResponse{ + Name: "Example Plugin", + Description: "This is an description of example plugin. 🍺", + URL: "https://example.com/notation/plugin", + Version: "1.0.0", + Capabilities: test.caps, + } + s := getValidArgs(&mdResp) + if !reflect.DeepEqual(s, test.args) { + t.Errorf("Expected %s but found %s", test.args, s) + } + }) + } +} diff --git a/example/go.mod b/example/go.mod new file mode 100644 index 0000000..a79f8c3 --- /dev/null +++ b/example/go.mod @@ -0,0 +1,7 @@ +module github.com/notaryproject/notation-plugin-framework-go/example + +go 1.20 + +require github.com/notaryproject/notation-plugin-framework-go v0.0.0-20230715005059-b8e1fc36cd93 + +replace github.com/notaryproject/notation-plugin-framework-go => ../ diff --git a/example/go.sum b/example/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..76df3cb --- /dev/null +++ b/example/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/notaryproject/notation-plugin-framework-go/cli" +) + +func main() { + ctx := context.Background() + // Initialize plugin + plugin, err := NewExamplePlugin() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to initialize plugin") + os.Exit(2) + } + + // Create executable + cli.New("example", plugin).Execute(ctx, os.Args) +} diff --git a/example/plugin.go b/example/plugin.go new file mode 100644 index 0000000..30c75a8 --- /dev/null +++ b/example/plugin.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + + "github.com/notaryproject/notation-plugin-framework-go/plugin" +) + +type ExamplePlugin struct { +} + +func NewExamplePlugin() (*ExamplePlugin, error) { + return &ExamplePlugin{}, nil +} + +func (p *ExamplePlugin) DescribeKey(ctx context.Context, req *plugin.DescribeKeyRequest) (*plugin.DescribeKeyResponse, error) { + return nil, plugin.NewUnsupportedError("DescribeKey operation is not implemented by example plugin") +} + +func (p *ExamplePlugin) GenerateSignature(ctx context.Context, req *plugin.GenerateSignatureRequest) (*plugin.GenerateSignatureResponse, error) { + return nil, plugin.NewUnsupportedError("GenerateSignature operation is not implemented by example plugin") +} + +func (p *ExamplePlugin) GenerateEnvelope(ctx context.Context, req *plugin.GenerateEnvelopeRequest) (*plugin.GenerateEnvelopeResponse, error) { + sig := "eyJwYXlsb2FkIjoiZXlKMFlYSm5aWFJCY25ScFptRmpkQ0k2ZXlKa2FXZGxjM1FpT2lKemFHRXlOVFk2Wm1VM1pUa3pNek16T1RVd05qQmpNbVkxWlRZelkyWXpObUV6" + + "T0daaVlURXdNVGMyWmpFNE0ySTBNVFl6WVRVM09UUmxNRGd4WVRRNE1HRmlZbUUxWmlJc0ltMWxaR2xoVkhsd1pTSTZJbUZ3Y0d4cFkyRjBhVzl1TDNadVpDNWtiMk5yWlh" + + "JdVpHbHpkSEpwWW5WMGFXOXVMbTFoYm1sbVpYTjBMbll5SzJwemIyNGlMQ0p6YVhwbElqbzVOREo5ZlEiLCJwcm90ZWN0ZWQiOiJleUpoYkdjaU9pSlFVekkxTmlJc0ltTn" + + "lhWFFpT2xzaWFXOHVZMjVqWmk1dWIzUmhjbmt1YzJsbmJtbHVaMU5qYUdWdFpTSXNJbWx2TG1OdVkyWXVibTkwWVhKNUxuWmxjbWxtYVdOaGRHbHZibEJzZFdkcGJrMXBib" + + "FpsY25OcGIyNGlMQ0pwYnk1amJtTm1MbTV2ZEdGeWVTNTJaWEpwWm1sallYUnBiMjVRYkhWbmFXNGlYU3dpWTNSNUlqb2lZWEJ3YkdsallYUnBiMjR2ZG01a0xtTnVZMll1" + + "Ym05MFlYSjVMbkJoZVd4dllXUXVkakVyYW5OdmJpSXNJbWx2TG1OdVkyWXVibTkwWVhKNUxuTnBaMjVwYm1kVFkyaGxiV1VpT2lKdWIzUmhjbmt1ZURVd09TSXNJbWx2TG1" + + "OdVkyWXVibTkwWVhKNUxuTnBaMjVwYm1kVWFXMWxJam9pTWpBeU15MHdNUzB4T1ZReE16b3dNem95TXkwd09Eb3dNQ0lzSW1sdkxtTnVZMll1Ym05MFlYSjVMblpsY21sbW" + + "FXTmhkR2x2YmxCc2RXZHBiaUk2SW1sdkxtTnVZMll1Ym05MFlYSjVMbkJzZFdkcGJpNTFibWwwZEdWemRDNXRiMk5ySWl3aWFXOHVZMjVqWmk1dWIzUmhjbmt1ZG1WeWFXW" + + "nBZMkYwYVc5dVVHeDFaMmx1VFdsdVZtVnljMmx2YmlJNklqRXVNQzR3TFdGc2NHaGhMbUpsZEdFaWZRIiwiaGVhZGVyIjp7Ing1YyI6WyJNSUlEVmpDQ0FqNmdBd0lCQWdJ" + + "QlVUQU5CZ2txaGtpRzl3MEJBUXNGQURCYU1Rc3dDUVlEVlFRR0V3SlZVekVMTUFrR0ExVUVDQk1DVjBFeEVEQU9CZ05WQkFjVEIxTmxZWFIwYkdVeER6QU5CZ05WQkFvVEJ" + + "rNXZkR0Z5ZVRFYk1Ca0dBMVVFQXhNU2QyRmlZbWwwTFc1bGRIZHZjbXR6TG1sdk1CNFhEVEl6TURFeE9UQTRNVGt3TjFvWERUTXpNREV4T1RBNE1Ua3dOMW93V2pFTE1Ba0" + + "dBMVVFQmhNQ1ZWTXhDekFKQmdOVkJBZ1RBbGRCTVJBd0RnWURWUVFIRXdkVFpXRjBkR3hsTVE4d0RRWURWUVFLRXdaT2IzUmhjbmt4R3pBWkJnTlZCQU1URW5kaFltSnBkQ" + + "zF1WlhSM2IzSnJjeTVwYnpDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTkhobFArU2lZN2hzR2xmMm1BRE96SlcvSjlzaXFNa2lRdlNPeDBP" + + "U00yeXhldGZWUUwvYWJpNGlxQ1hNNndrU3h2aUJlTndJb1lFczR0aE1BOE5HRWJuS29Ya3R5aDl2bWlMQjFGVzdISHI0UUx3amdMemdXSktJUVR5MUptREJlY1haaDU2ZDB" + + "mM3czWWoxSURUdmtJU2NYQ05JKzV2LzA4R1VRS2h5Qnd2N0ZxOU1ZcG8ybGZYU0k3VjMzQktLZGRYSXhQR1ZXd0tHdlBFMHNnMlZWN1dNODRaWkxkREt6Mm1xMFB0UFRIcl" + + "N3ZzNobEsvbWpuK2JsZzNnc1lRNGg5LzdaNm5OYUY5WDBTZHlFU2w4NDFaV3J0TWhBT0ZwSXpMYno5ZXRlOE5SZDNiWUNSQklyNWdzY0hXVGY2bHlVZ3k0eHpzU3dNSFBzR" + + "0xNNEErWjAwQ0F3RUFBYU1uTUNVd0RnWURWUjBQQVFIL0JBUURBZ2VBTUJNR0ExVWRKUVFNTUFvR0NDc0dBUVVGQndNRE1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQWJO" + + "MEVydTU2dVRRU0MyOFpUZjhEN1Z5Q2tZcnJXTFlpSk1ZZE9LQnp6S1Y5bUthTTBPR0YydXlXd0RhUHhwOUtUZExYbUJwOUVGcTVTWFhBckZBK25SUzdLaW5EQWUyTzdBLzl" + + "TdGQyWGpLaTkyN3JrQTJjajIzOWQ1bFJzaldYcUpYZjl2QU1WOWEyRmpVTS9pbjJFZXZscTdidmpGRTNsMjZWWENLdE9zOUVybWZ4ckwrNkVUUktTVllPT0cvclNIRnYvU0" + + "IyTWxxRGc1UXNYQzlsWmp6TDUvWC9pb2UycVpLaHA2WDVEUHBhZDFxMVE0SXRLZFROKzJFWHlNeW9IbjFCSktOYmE3Q1VVdlhmMDNFSmViVC9JbStxb3pmRWtzSmVaSlVTb" + + "FN1akFOVVBvQ3BzRVlHV1dReDVHK1ZpRzA1U3FzKzZwcEtydXQrUCtEVlBvIl0sImlvLmNuY2Yubm90YXJ5LnNpZ25pbmdBZ2VudCI6Ik5vdGF0aW9uLzEuMC4wIn0sInNp" + + "Z25hdHVyZSI6ImlKdGh0cWJ6ME81bkZ1bzVaOW5SZGRFanlacDNSRy1LT1k2U1NCM3NjOEFnREJkVDVGanA5eWx0SW9xVGwtQkxaaHJHT0FGZU8wVF8xSlZzUGJaWk14ekp" + + "xNGZiM2dQYUlQSXRyZW5ka3BpdDFtMlJhQjhmSzFEX0k2VnF1MV9yR2lZYXhEY05wYXFuMVRfaXN4cjRNVlJla2NMU05RbkczaU1kSjBrLUF0dGY4SmRDWEUwRVdLeUxCU3" + + "RNVkFmbzBKMzlTaEZjd3lJTXZPMHZtMl9UUkRWYmNLb3ZwWTB2RnJmeUUycEZJQ2huSkVDbWl2SW1kS21CTUlXNzh2RXRONnFCcktza0kzSHpBOU4xWGp4R1k0R09BdTMwa" + + "XF0TlJhbk82NW5aR25nMGxxcEpkMTViQXdVYXFqLUtEX0JBWklVVDlUMnFDZjJDT0Y5R0t2YzNOUSJ9Cg==" + return &plugin.GenerateEnvelopeResponse{ + SignatureEnvelope: []byte(sig), + SignatureEnvelopeType: "application/jose+json", + Annotations: map[string]string{"manifestAnntnKey1": "value1"}, + }, nil +} + +func (p *ExamplePlugin) VerifySignature(ctx context.Context, req *plugin.VerifySignatureRequest) (*plugin.VerifySignatureResponse, error) { + upAttrs := req.Signature.UnprocessedAttributes + pAttrs := make([]interface{}, len(upAttrs)) + for i := range upAttrs { + pAttrs[i] = upAttrs[i] + } + + return &plugin.VerifySignatureResponse{ + ProcessedAttributes: pAttrs, + VerificationResults: map[plugin.Capability]*plugin.VerificationResult{ + plugin.CapabilityTrustedIdentityVerifier: { + Success: true, + Reason: "Valid trusted Identity", + }, + plugin.CapabilityRevocationCheckVerifier: { + Success: true, + Reason: "Not revoked", + }, + }, + }, nil +} + +func (p *ExamplePlugin) GetMetadata(ctx context.Context, req *plugin.GetMetadataRequest) (*plugin.GetMetadataResponse, error) { + return &plugin.GetMetadataResponse{ + Name: "Example Plugin", + Description: "This is an description of example plugin. 🍺", + URL: "https://example.com/notation/plugin", + Version: "1.0.0", + Capabilities: []plugin.Capability{ + plugin.CapabilityEnvelopeGenerator, + plugin.CapabilityTrustedIdentityVerifier, + plugin.CapabilityRevocationCheckVerifier}, + }, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d3cb5ab --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/notaryproject/notation-plugin-framework-go + +go 1.20 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/slices/slices.go b/internal/slices/slices.go new file mode 100644 index 0000000..0d348cd --- /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/log/log.go b/log/log.go new file mode 100644 index 0000000..2e50e87 --- /dev/null +++ b/log/log.go @@ -0,0 +1,49 @@ +// Package log provides logging functionality. +// 3rd party loggers that implement log.Logger: github.com/uber-go/zap.SugaredLogger +// and github.com/sirupsen/logrus.Logger. +package log + +// Logger is implemented by users and/or 3rd party loggers. +// For example, github.com/uber-go/zap.SugaredLogger +// and github.com/sirupsen/logrus.Logger. +type Logger interface { + // Debug logs a debug level message. + Debug(args ...interface{}) + + // Debugf logs a debug level message with format. + Debugf(format string, args ...interface{}) + + // Debugln logs a debug level message. Spaces are always added between + // operands. + Debugln(args ...interface{}) + + // Info logs an info level message. + Info(args ...interface{}) + + // Infof logs an info level message with format. + Infof(format string, args ...interface{}) + + // Infoln logs an info level message. Spaces are always added between + // operands. + Infoln(args ...interface{}) + + // Warn logs a warn level message. + Warn(args ...interface{}) + + // Warnf logs a warn level message with format. + Warnf(format string, args ...interface{}) + + // Warnln logs a warn level message. Spaces are always added between + // operands. + Warnln(args ...interface{}) + + // Error logs an error level message. + Error(args ...interface{}) + + // Errorf logs an error level message with format. + Errorf(format string, args ...interface{}) + + // Errorln logs an error level message. Spaces are always added between + // operands. + Errorln(args ...interface{}) +} diff --git a/plugin/algorithm.go b/plugin/algorithm.go new file mode 100644 index 0000000..9a768d0 --- /dev/null +++ b/plugin/algorithm.go @@ -0,0 +1,43 @@ +package plugin + +// KeySpec is type of the signing algorithm, including algorithm and size. +type KeySpec string + +// KeySpec supported by notation. +// +// https://github.com/notaryproject/notaryproject/blob/main/specs/signature-specification.md#algorithm-selection +const ( + KeySpecRSA2048 KeySpec = "RSA-2048" + KeySpecRSA3072 KeySpec = "RSA-3072" + KeySpecRSA4096 KeySpec = "RSA-4096" + KeySpecEC256 KeySpec = "EC-256" + KeySpecEC384 KeySpec = "EC-384" + KeySpecEC521 KeySpec = "EC-521" +) + +// HashAlgorithm supported by notation. +type HashAlgorithm string + +// one of the following supported hash algorithm names. +// +// https://github.com/notaryproject/notaryproject/blob/main/specs/signature-specification.md#algorithm-selection +const ( + HashAlgorithmSHA256 HashAlgorithm = "SHA-256" + HashAlgorithmSHA384 HashAlgorithm = "SHA-384" + HashAlgorithmSHA512 HashAlgorithm = "SHA-512" +) + +// SignatureAlgorithm supported by notation +type SignatureAlgorithm string + +// one of the following supported signing algorithm names. +// +// https://github.com/notaryproject/notaryproject/blob/main/specs/signature-specification.md#algorithm-selection +const ( + SignatureAlgorithmECDSA_SHA256 SignatureAlgorithm = "ECDSA-SHA-256" + SignatureAlgorithmECDSA_SHA384 SignatureAlgorithm = "ECDSA-SHA-384" + SignatureAlgorithmECDSA_SHA512 SignatureAlgorithm = "ECDSA-SHA-512" + SignatureAlgorithmRSASSA_PSS_SHA256 SignatureAlgorithm = "RSASSA-PSS-SHA-256" + SignatureAlgorithmRSASSA_PSS_SHA384 SignatureAlgorithm = "RSASSA-PSS-SHA-384" + SignatureAlgorithmRSASSA_PSS_SHA512 SignatureAlgorithm = "RSASSA-PSS-SHA-512" +) diff --git a/plugin/describekey.go b/plugin/describekey.go new file mode 100644 index 0000000..785f51d --- /dev/null +++ b/plugin/describekey.go @@ -0,0 +1,22 @@ +package plugin + +// DescribeKeyRequest contains the parameters passed in a describe-key request. +type DescribeKeyRequest struct { + ContractVersion string `json:"contractVersion"` + KeyID string `json:"keyId"` + PluginConfig map[string]string `json:"pluginConfig,omitempty"` +} + +func (DescribeKeyRequest) Command() Command { + return CommandDescribeKey +} + +// DescribeKeyResponse is the response of a describe-key request. +type DescribeKeyResponse struct { + // The same key id as passed in the request. + KeyID string `json:"keyId"` + + // One of following supported key types: + // https://github.com/notaryproject/notaryproject/blob/main/specs/signature-specification.md#algorithm-selection + KeySpec KeySpec `json:"keySpec"` +} diff --git a/plugin/errors.go b/plugin/errors.go new file mode 100644 index 0000000..01459cf --- /dev/null +++ b/plugin/errors.go @@ -0,0 +1,72 @@ +package plugin + +import ( + "encoding/json" + "fmt" +) + +type Code string + +const ( + CodeValidation Code = "VALIDATION_ERROR" + CodeUnsupportedContractVersion Code = "UNSUPPORTED_CONTRACT_VERSION" + CodeAccessDenied Code = "ACCESS_DENIED" + CodeThrottled Code = "THROTTLED" + CodeGeneric Code = "ERROR" +) + +const ( + ErrorMsgMalformedInput string = "Input is not a valid JSON" + ErrorMsgMalformedOutputFmt string = "Failed to generate response. Error: %s" +) + +// Error is used when the signature associated is no longer +// valid. +type Error struct { + ErrCode Code `json:"errorCode"` + Msg string `json:"errorMessage"` +} + +func NewError(code Code, msg string) *Error { + return &Error{ + ErrCode: code, + Msg: msg, + } +} + +func NewGenericError(msg string) *Error { + return NewError(CodeGeneric, msg) +} + +func NewGenericErrorf(format string, msg string) *Error { + return NewError(CodeGeneric, fmt.Sprintf(format, msg)) +} + +func NewUnsupportedError(msg string) *Error { + return NewError(CodeValidation, msg+" is not supported") +} + +func NewValidationError(msg string) *Error { + return NewError(CodeValidation, msg) +} + +func NewValidationErrorf(format string, msg string) *Error { + return NewError(CodeValidation, fmt.Sprintf(format, msg)) +} + +func NewUnsupportedContractVersionError(version string) *Error { + return NewError(CodeUnsupportedContractVersion, fmt.Sprintf("%q is not a supported notary plugin contract version", version)) +} + +func NewJSONParsingError(msg string) *Error { + return NewValidationError(msg) +} + +// Error returns the formatted error message. +func (e *Error) Error() string { + op, err := json.Marshal(e) + if err != nil { + return "something went wrong" + } + return string(op) +} diff --git a/plugin/metadata.go b/plugin/metadata.go new file mode 100644 index 0000000..ebfb45e --- /dev/null +++ b/plugin/metadata.go @@ -0,0 +1,21 @@ +package plugin + +// GetMetadataRequest contains the parameters passed in a get-plugin-metadata +// request. +type GetMetadataRequest struct { + PluginConfig map[string]string `json:"pluginConfig,omitempty"` +} + +func (GetMetadataRequest) Command() Command { + return CommandGetMetadata +} + +// GetMetadataResponse provided by the plugin. +type GetMetadataResponse struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + URL string `json:"url"` + SupportedContractVersions []string `json:"supportedContractVersions,omitempty"` + Capabilities []Capability `json:"capabilities"` +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..207166b --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,43 @@ +// Package plugin provides the tooling to use the notation plugin. +// +// includes a CLIManager and a CLIPlugin implementation. +package plugin + +import ( + "context" +) + +// GenericPlugin is the base requirement to be a plugin. +type GenericPlugin interface { + // GetMetadata returns the metadata information of the plugin. + GetMetadata(ctx context.Context, req *GetMetadataRequest) (*GetMetadataResponse, error) +} + +// SignPlugin defines the required methods to be a SignPlugin. +type SignPlugin interface { + GenericPlugin + + // DescribeKey returns the KeySpec of a key. + DescribeKey(ctx context.Context, req *DescribeKeyRequest) (*DescribeKeyResponse, error) + + // GenerateSignature generates the raw signature based on the request. + GenerateSignature(ctx context.Context, req *GenerateSignatureRequest) (*GenerateSignatureResponse, error) + + // GenerateEnvelope generates the Envelope with signature based on the + // request. + GenerateEnvelope(ctx context.Context, req *GenerateEnvelopeRequest) (*GenerateEnvelopeResponse, error) +} + +// VerifyPlugin defines the required method to be a VerifyPlugin. +type VerifyPlugin interface { + GenericPlugin + + // VerifySignature validates the signature based on the request. + VerifySignature(ctx context.Context, req *VerifySignatureRequest) (*VerifySignatureResponse, error) +} + +// Plugin defines required methods to be a Plugin. +type Plugin interface { + SignPlugin + VerifyPlugin +} diff --git a/plugin/proto.go b/plugin/proto.go new file mode 100644 index 0000000..2c046af --- /dev/null +++ b/plugin/proto.go @@ -0,0 +1,56 @@ +// Package plugin defines the protocol layer for communication between notation +// and notation external plugin. +package plugin + +// Capability is a feature available in the plugin contract. +type Capability string + +const ( + // CapabilitySignatureGenerator is the name of the capability + // for a plugin to support generating raw signatures. + CapabilitySignatureGenerator Capability = "SIGNATURE_GENERATOR.RAW" + + // CapabilityEnvelopeGenerator is the name of the capability + // for a plugin to support generating envelope signatures. + CapabilityEnvelopeGenerator Capability = "SIGNATURE_GENERATOR.ENVELOPE" + + // CapabilityTrustedIdentityVerifier is the name of the + // capability for a plugin to support verifying trusted identities. + CapabilityTrustedIdentityVerifier Capability = "SIGNATURE_VERIFIER.TRUSTED_IDENTITY" + + // CapabilityRevocationCheckVerifier is the name of the + // capability for a plugin to support verifying revocation checks. + CapabilityRevocationCheckVerifier Capability = "SIGNATURE_VERIFIER.REVOCATION_CHECK" +) + +// Command is a CLI command available in the plugin contract. +type Command string + +const ( + // CommandGetMetadata is the name of the plugin command + // which must be supported by every plugin and returns the + // plugin metadata. + CommandGetMetadata Command = "get-plugin-metadata" + + // CommandDescribeKey is the name of the plugin command + // which must be supported by every plugin that has the + // SIGNATURE_GENERATOR.RAW capability. + CommandDescribeKey Command = "describe-key" + + // CommandGenerateSignature is the name of the plugin command + // which must be supported by every plugin that has the + // SIGNATURE_GENERATOR.RAW capability. + CommandGenerateSignature Command = "generate-signature" + + // CommandGenerateEnvelope is the name of the plugin command + // which must be supported by every plugin that has the + // SIGNATURE_GENERATOR.ENVELOPE capability. + CommandGenerateEnvelope Command = "generate-envelope" + + // CommandVerifySignature is the name of the plugin command + // which must be supported by every plugin that has + // any SIGNATURE_VERIFIER.* capability + CommandVerifySignature Command = "verify-signature" + + Version Command = "version" +) diff --git a/plugin/sign.go b/plugin/sign.go new file mode 100644 index 0000000..c8a1c06 --- /dev/null +++ b/plugin/sign.go @@ -0,0 +1,50 @@ +package plugin + +// GenerateSignatureRequest contains the parameters passed in a +// generate-signature request. +type GenerateSignatureRequest struct { + ContractVersion string `json:"contractVersion"` + KeyID string `json:"keyId"` + KeySpec KeySpec `json:"keySpec"` + Hash HashAlgorithm `json:"hashAlgorithm"` + Payload []byte `json:"payload"` + PluginConfig map[string]string `json:"pluginConfig,omitempty"` +} + +func (GenerateSignatureRequest) Command() Command { + return CommandGenerateSignature +} + +// GenerateSignatureResponse is the response of a generate-signature request. +type GenerateSignatureResponse struct { + KeyID string `json:"keyId"` + Signature []byte `json:"signature"` + SigningAlgorithm SignatureAlgorithm `json:"signingAlgorithm"` + + // Ordered list of certificates starting with leaf certificate + // and ending with root certificate. + CertificateChain [][]byte `json:"certificateChain"` +} + +// GenerateEnvelopeRequest contains the parameters passed in a generate-envelope +// request. +type GenerateEnvelopeRequest struct { + ContractVersion string `json:"contractVersion"` + KeyID string `json:"keyId"` + PayloadType string `json:"payloadType"` + SignatureEnvelopeType string `json:"signatureEnvelopeType"` + Payload []byte `json:"payload"` + ExpiryDurationInSeconds uint64 `json:"expiryDurationInSeconds,omitempty"` + PluginConfig map[string]string `json:"pluginConfig,omitempty"` +} + +func (GenerateEnvelopeRequest) Command() Command { + return CommandGenerateEnvelope +} + +// GenerateEnvelopeResponse is the response of a generate-envelope request. +type GenerateEnvelopeResponse struct { + SignatureEnvelope []byte `json:"signatureEnvelope"` + SignatureEnvelopeType string `json:"signatureEnvelopeType"` + Annotations map[string]string `json:"annotations,omitempty"` +} diff --git a/plugin/verify.go b/plugin/verify.go new file mode 100644 index 0000000..a764642 --- /dev/null +++ b/plugin/verify.go @@ -0,0 +1,53 @@ +package plugin + +import ( + "time" +) + +// VerifySignatureRequest contains the parameters passed in a verify-signature +// request. +type VerifySignatureRequest struct { + ContractVersion string `json:"contractVersion"` + Signature Signature `json:"signature"` + TrustPolicy TrustPolicy `json:"trustPolicy"` + PluginConfig map[string]string `json:"pluginConfig,omitempty"` +} + +func (VerifySignatureRequest) Command() Command { + return CommandVerifySignature +} + +// Signature represents a signature pulled from the envelope +type Signature struct { + CriticalAttributes CriticalAttributes `json:"criticalAttributes"` + UnprocessedAttributes []string `json:"unprocessedAttributes"` + CertificateChain [][]byte `json:"certificateChain"` +} + +// CriticalAttributes contains all critical attributes and +// their values in the signature envelope +type CriticalAttributes struct { + ContentType string `json:"contentType"` + SigningScheme string `json:"signingScheme"` + Expiry *time.Time `json:"expiry,omitempty"` + AuthenticSigningTime *time.Time `json:"authenticSigningTime,omitempty"` + ExtendedAttributes map[string]interface{} `json:"extendedAttributes,omitempty"` +} + +// TrustPolicy represents trusted identities that sign the artifacts +type TrustPolicy struct { + TrustedIdentities []string `json:"trustedIdentities"` + SignatureVerification []Capability `json:"signatureVerification"` +} + +// VerifySignatureResponse is the response of a verify-signature request. +type VerifySignatureResponse struct { + VerificationResults map[Capability]*VerificationResult `json:"verificationResults"` + ProcessedAttributes []interface{} `json:"processedAttributes"` +} + +// VerificationResult is the result of a verification performed by the plugin +type VerificationResult struct { + Success bool `json:"success"` + Reason string `json:"reason,omitempty"` +}