diff --git a/jws.go b/jws.go new file mode 100644 index 00000000..1f11d4f8 --- /dev/null +++ b/jws.go @@ -0,0 +1,91 @@ +package notation + +const ( + // MediaTypeJWSEnvelope describes the media type of the JWS envelope. + MediaTypeJWSEnvelope = "application/vnd.cncf.notary.v2.jws.v1" +) + +// JWSPayload contains the set of claims used by Notary V2. +type JWSPayload struct { + // Private claim. + Subject Descriptor `json:"subject"` + + // Identifies the number of seconds since Epoch at which the signature was issued. + IssuedAt int64 `json:"iat"` + + // Identifies the number of seconds since Epoch at which the signature must not be considered valid. + ExpiresAt int64 `json:"exp,omitempty"` +} + +// JWSProtectedHeader contains the set of protected headers. +type JWSProtectedHeader struct { + // Defines which algorithm was used to generate the signature. + Algorithm string `json:"alg"` + + // Media type of the secured content (the payload). + ContentType string `json:"cty"` +} + +// JWSUnprotectedHeader contains the set of unprotected headers. +type JWSUnprotectedHeader struct { + // RFC3161 time stamp token Base64-encoded. + TimeStampToken []byte `json:"timestamp,omitempty"` + + // List of X.509 Base64-DER-encoded certificates + // as defined at https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6. + CertChain [][]byte `json:"x5c"` +} + +// JWSEnvelope is the final signature envelope. +type JWSEnvelope struct { + // JWSPayload Base64URL-encoded. + Payload string `json:"payload"` + + // JWSProtectedHeader Base64URL-encoded. + Protected string `json:"protected"` + + // Signature metadata that is not integrity protected + Header JWSUnprotectedHeader `json:"header"` + + // Base64URL-encoded signature. + Signature string `json:"signature"` +} + +// JWS returns the JWS algorithm name. +func (s SignatureAlgorithm) JWS() string { + switch s { + case RSASSA_PSS_SHA_256: + return "PS256" + case RSASSA_PSS_SHA_384: + return "PS384" + case RSASSA_PSS_SHA_512: + return "PS512" + case ECDSA_SHA_256: + return "ES256" + case ECDSA_SHA_384: + return "ES384" + case ECDSA_SHA_512: + return "ES512" + } + return "" +} + +// NewSignatureAlgorithmJWS returns the algorithm associated to alg. +// It returns an empty string if alg is not supported. +func NewSignatureAlgorithmJWS(alg string) SignatureAlgorithm { + switch alg { + case "PS256": + return RSASSA_PSS_SHA_256 + case "PS384": + return RSASSA_PSS_SHA_384 + case "PS512": + return RSASSA_PSS_SHA_512 + case "ES256": + return ECDSA_SHA_256 + case "ES384": + return ECDSA_SHA_384 + case "ES512": + return ECDSA_SHA_512 + } + return "" +} diff --git a/notation.go b/notation.go index 42c05228..825000dd 100644 --- a/notation.go +++ b/notation.go @@ -2,6 +2,7 @@ package notation import ( "context" + "crypto" "crypto/x509" "time" @@ -11,16 +12,16 @@ import ( // Descriptor describes the content signed or to be signed. type Descriptor struct { - // MediaType is the media type of the targeted content. + // The media type of the targeted content. MediaType string `json:"mediaType"` - // Digest is the digest of the targeted content. + // The digest of the targeted content. Digest digest.Digest `json:"digest"` - // Size specifies the size in bytes of the blob. + // Specifies the size in bytes of the blob. Size int64 `json:"size"` - // Annotations contains optional user defined attributes. + // Contains optional user defined attributes. Annotations map[string]string `json:"annotations,omitempty"` } @@ -42,11 +43,9 @@ type SignOptions struct { // the certificates in the fetched timestamp signature. // An empty list of `KeyUsages` in the verify options implies ExtKeyUsageTimeStamping. TSAVerifyOptions x509.VerifyOptions -} -// Validate does basic validation on SignOptions. -func (opts SignOptions) Validate() error { - return nil + // Sets or overrides the plugin configuration. + PluginConfig map[string]string } // Signer is a generic interface for signing an artifact. @@ -78,3 +77,83 @@ type Service interface { Signer Verifier } + +// KeySpec defines a key type and size. +type KeySpec string + +// One of following supported specs +// https://github.com/notaryproject/notaryproject/blob/main/signature-specification.md#algorithm-selection +const ( + RSA_2048 KeySpec = "RSA_2048" + RSA_3072 KeySpec = "RSA_3072" + RSA_4096 KeySpec = "RSA_4096" + EC_256 KeySpec = "EC_256" + EC_384 KeySpec = "EC_384" + EC_512 KeySpec = "EC_512" +) + +// SignatureAlgorithm returns the signing algorithm associated with KeyType k. +func (k KeySpec) SignatureAlgorithm() SignatureAlgorithm { + switch k { + case RSA_2048: + return RSASSA_PSS_SHA_256 + case RSA_3072: + return RSASSA_PSS_SHA_384 + case RSA_4096: + return RSASSA_PSS_SHA_512 + case EC_256: + return ECDSA_SHA_256 + case EC_384: + return ECDSA_SHA_384 + case EC_512: + return ECDSA_SHA_512 + } + return "" +} + +// HashAlgorithm algorithm associated with the key spec. +type HashAlgorithm string + +const ( + SHA256 HashAlgorithm = "SHA_256" + SHA384 HashAlgorithm = "SHA_384" + SHA512 HashAlgorithm = "SHA_512" +) + +// HashFunc returns the Hash associated k. +func (h HashAlgorithm) HashFunc() crypto.Hash { + switch h { + case SHA256: + return crypto.SHA256 + case SHA384: + return crypto.SHA384 + case SHA512: + return crypto.SHA512 + } + return 0 +} + +// SignatureAlgorithm defines the supported signature algorithms. +type SignatureAlgorithm string + +const ( + RSASSA_PSS_SHA_256 SignatureAlgorithm = "RSASSA_PSS_SHA_256" + RSASSA_PSS_SHA_384 SignatureAlgorithm = "RSASSA_PSS_SHA_384" + RSASSA_PSS_SHA_512 SignatureAlgorithm = "RSASSA_PSS_SHA_512" + ECDSA_SHA_256 SignatureAlgorithm = "ECDSA_SHA_256" + ECDSA_SHA_384 SignatureAlgorithm = "ECDSA_SHA_384" + ECDSA_SHA_512 SignatureAlgorithm = "ECDSA_SHA_512" +) + +// Hash returns the Hash associated s. +func (s SignatureAlgorithm) Hash() HashAlgorithm { + switch s { + case RSASSA_PSS_SHA_256, ECDSA_SHA_256: + return SHA256 + case RSASSA_PSS_SHA_384, ECDSA_SHA_384: + return SHA384 + case RSASSA_PSS_SHA_512, ECDSA_SHA_512: + return SHA512 + } + return "" +} diff --git a/plugin/errors.go b/plugin/errors.go index 12fdef71..ad59b75c 100644 --- a/plugin/errors.go +++ b/plugin/errors.go @@ -52,6 +52,19 @@ func (e RequestError) Unwrap() error { return e.Err } +func (e RequestError) Is(target error) bool { + if et, ok := target.(RequestError); ok { + if e.Code != et.Code { + return false + } + if e.Err == et.Err { + return true + } + return e.Err != nil && et.Err != nil && e.Err.Error() == et.Err.Error() + } + return false +} + func (e RequestError) MarshalJSON() ([]byte, error) { var msg string if e.Err != nil { @@ -69,6 +82,9 @@ func (e *RequestError) UnmarshalJSON(data []byte) error { if tmp.Code == "" && tmp.Message == "" && tmp.Metadata == nil { return errors.New("incomplete json") } - *e = RequestError{tmp.Code, errors.New(tmp.Message), tmp.Metadata} + *e = RequestError{Code: tmp.Code, Metadata: tmp.Metadata} + if tmp.Message != "" { + e.Err = errors.New(tmp.Message) + } return nil } diff --git a/plugin/errors_test.go b/plugin/errors_test.go index 4a9ffdce..1b8bda85 100644 --- a/plugin/errors_test.go +++ b/plugin/errors_test.go @@ -88,3 +88,29 @@ func TestRequestError_UnmarshalJSON(t *testing.T) { }) } } + +func TestRequestError_Is(t *testing.T) { + type args struct { + target error + } + tests := []struct { + name string + e RequestError + args args + want bool + }{ + {"nil", RequestError{}, args{nil}, false}, + {"not same type", RequestError{Err: errors.New("foo")}, args{errors.New("foo")}, false}, + {"only same code", RequestError{Code: ErrorCodeGeneric, Err: errors.New("foo")}, args{RequestError{Code: ErrorCodeGeneric, Err: errors.New("bar")}}, false}, + {"only same message", RequestError{Code: ErrorCodeTimeout, Err: errors.New("foo")}, args{RequestError{Code: ErrorCodeGeneric, Err: errors.New("foo")}}, false}, + {"same with nil message", RequestError{Code: ErrorCodeGeneric}, args{RequestError{Code: ErrorCodeGeneric}}, true}, + {"same", RequestError{Code: ErrorCodeGeneric, Err: errors.New("foo")}, args{RequestError{Code: ErrorCodeGeneric, Err: errors.New("foo")}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.e.Is(tt.args.target); got != tt.want { + t.Errorf("RequestError.Is() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugin/manager/integration_test.go b/plugin/manager/integration_test.go index f8334b11..aeecfbb1 100644 --- a/plugin/manager/integration_test.go +++ b/plugin/manager/integration_test.go @@ -1,4 +1,4 @@ -package manager +package manager_test import ( "context" @@ -7,9 +7,11 @@ import ( "os/exec" "path/filepath" "reflect" + "runtime" "testing" "github.com/notaryproject/notation-go/plugin" + "github.com/notaryproject/notation-go/plugin/manager" ) func preparePlugin(t *testing.T) string { @@ -53,7 +55,7 @@ func TestIntegration(t *testing.T) { t.Skip() } root := preparePlugin(t) - mgr := &Manager{rootedFS{os.DirFS(root), root}, execCommander{}} + mgr := manager.New(root) p, err := mgr.Get(context.Background(), "foo") if err != nil { t.Fatal(err) @@ -71,8 +73,19 @@ func TestIntegration(t *testing.T) { if !reflect.DeepEqual(list[0].Metadata, p.Metadata) { t.Errorf("Manager.List() got %v, want %v", list[0], p) } - _, err = mgr.Run(context.Background(), "foo", plugin.CommandGetMetadata, nil) + r, err := mgr.Runner("foo") if err != nil { t.Fatal(err) } + _, err = r.Run(context.Background(), plugin.GetMetadataRequest{}) + if err != nil { + t.Fatal(err) + } +} + +func addExeSuffix(s string) string { + if runtime.GOOS == "windows" { + s += ".exe" + } + return s } diff --git a/plugin/manager/manager.go b/plugin/manager/manager.go index fe83034e..deb40f2d 100644 --- a/plugin/manager/manager.go +++ b/plugin/manager/manager.go @@ -59,7 +59,7 @@ func (c execCommander) Output(ctx context.Context, name string, command string, return stdout.Bytes(), true, nil } -// rootedFS is io.FS implementation used in NewManager. +// rootedFS is io.FS implementation used in New. // root is the root of the file system tree passed to os.DirFS. type rootedFS struct { fs.FS @@ -72,17 +72,12 @@ type Manager struct { cmder commander } -// NewManager returns a new manager. -func NewManager() *Manager { - configDir, err := os.UserConfigDir() - if err != nil { - // Lets panic for now. - // Once the config is moved to notation-go we will move this code to - // the config package as a global initialization. - panic(err) - } - pluginDir := filepath.Join(configDir, "notation", "plugins") - return &Manager{rootedFS{os.DirFS(pluginDir), pluginDir}, execCommander{}} +// New returns a new manager rooted at root. +// +// root is the path of the directory where plugins are stored +// following the {root}/{plugin-name}/notation-{plugin-name}[.exe] pattern. +func New(root string) *Manager { + return &Manager{rootedFS{os.DirFS(root), root}, execCommander{}} } // Get returns a plugin on the system by its name. @@ -117,42 +112,16 @@ func (mgr *Manager) List(ctx context.Context) ([]*Plugin, error) { return plugins, nil } -// Run executes the specified command against the named plugin and waits for it to complete. +// Runner returns a plugin.Runner. // -// When the returned object is not nil, its type is guaranteed to remain always the same for a given Command. -// -// The returned error is nil if: -// - the plugin exists and is valid -// - the command runs and exits with a zero exit status -// - the command stdout contains a valid json object which can be unmarshal-ed. -// -// If the plugin is not found, the error is of type ErrNotFound. -// If the plugin metadata is not valid or stdout and stderr can't be decoded into a valid response, the error is of type ErrNotCompliant. -// If the command starts but does not complete successfully, the error is of type RequestError wrapping a *exec.ExitError. -// Other error types may be returned for other situations. -func (mgr *Manager) Run(ctx context.Context, name string, cmd plugin.Command, req interface{}) (interface{}, error) { - p, err := mgr.newPlugin(ctx, name) - if err != nil { - return nil, pluginErr(name, err) - } - if p.Err != nil { - return nil, pluginErr(name, withErr(p.Err, ErrNotCompliant)) - } - if cmd == plugin.CommandGetMetadata { - return &p.Metadata, nil - } - var data []byte - if req != nil { - data, err = json.Marshal(req) - if err != nil { - return nil, pluginErr(name, fmt.Errorf("failed to marshal request object: %w", err)) - } - } - resp, err := run(ctx, mgr.cmder, p.Path, cmd, data) - if err != nil { - return nil, pluginErr(name, err) +// If the plugin is not found or is not a valid candidate, the error is of type ErrNotFound. +func (mgr *Manager) Runner(name string) (plugin.Runner, error) { + ok := isCandidate(mgr.fsys, name) + if !ok { + return nil, ErrNotFound } - return resp, nil + + return pluginRunner{name: name, path: binPath(mgr.fsys, name), cmder: mgr.cmder}, nil } // newPlugin determines if the given candidate is valid and returns a Plugin. @@ -177,6 +146,28 @@ func (mgr *Manager) newPlugin(ctx context.Context, name string) (*Plugin, error) return p, nil } +type pluginRunner struct { + name string + path string + cmder commander +} + +func (p pluginRunner) Run(ctx context.Context, req plugin.Request) (interface{}, error) { + var data []byte + if req != nil { + var err error + data, err = json.Marshal(req) + if err != nil { + return nil, pluginErr(p.name, fmt.Errorf("failed to marshal request object: %w", err)) + } + } + resp, err := run(ctx, p.cmder, p.path, req.Command(), data) + if err != nil { + return nil, pluginErr(p.name, err) + } + return resp, nil +} + // run executes the command and decodes the response. func run(ctx context.Context, cmder commander, pluginPath string, cmd plugin.Command, req []byte) (interface{}, error) { out, ok, err := cmder.Output(ctx, pluginPath, string(cmd), req) @@ -187,7 +178,7 @@ func run(ctx context.Context, cmder commander, pluginPath string, cmd plugin.Com var re plugin.RequestError err = json.Unmarshal(out, &re) if err != nil { - return nil, withErr(plugin.RequestError{Code: plugin.ErrorCodeGeneric, Err: err}, ErrNotCompliant) + return nil, plugin.RequestError{Code: plugin.ErrorCodeGeneric, Err: fmt.Errorf("failed to decode json response: %w", ErrNotCompliant)} } return nil, re } @@ -199,13 +190,14 @@ func run(ctx context.Context, cmder commander, pluginPath string, cmd plugin.Com resp = new(plugin.GenerateSignatureResponse) case plugin.CommandGenerateEnvelope: resp = new(plugin.GenerateEnvelopeResponse) + case plugin.CommandDescribeKey: + resp = new(plugin.DescribeKeyResponse) default: return nil, fmt.Errorf("unsupported command: %s", cmd) } err = json.Unmarshal(out, resp) if err != nil { - err = fmt.Errorf("failed to decode json response: %w", err) - return nil, withErr(err, ErrNotCompliant) + return nil, fmt.Errorf("failed to decode json response: %w", ErrNotCompliant) } return resp, nil } @@ -236,7 +228,7 @@ func binName(name string) string { func binPath(fsys fs.FS, name string) string { base := binName(name) - // NewManager() always instantiate a rootedFS. + // New() always instantiate a rootedFS. // Other fs.FS implementations are only supported for testing purposes. if fsys, ok := fsys.(rootedFS); ok { return filepath.Join(fsys.root, name, base) @@ -250,28 +242,3 @@ func addExeSuffix(s string) string { } return s } - -func withErr(err, other error) error { - return unionError{err: err, other: other} -} - -type unionError struct { - err error - other error -} - -func (u unionError) Error() string { - return fmt.Sprintf("%s: %s", u.other, u.err) -} - -func (u unionError) Is(target error) bool { - return errors.Is(u.other, target) -} - -func (u unionError) As(target interface{}) bool { - return errors.As(u.other, target) -} - -func (u unionError) Unwrap() error { - return u.err -} diff --git a/plugin/manager/manager_test.go b/plugin/manager/manager_test.go index 18f85ede..235bee28 100644 --- a/plugin/manager/manager_test.go +++ b/plugin/manager/manager_test.go @@ -23,18 +23,6 @@ func (t testCommander) Output(ctx context.Context, path string, command string, return t.output, t.success, t.err } -type testMultiCommander struct { - output [][]byte - success []bool - err []error - n int -} - -func (t *testMultiCommander) Output(ctx context.Context, path string, command string, req []byte) (out []byte, success bool, err error) { - defer func() { t.n++ }() - return t.output[t.n], t.success[t.n], t.err[t.n] -} - var validMetadata = plugin.Metadata{ Name: "foo", Description: "friendly", Version: "1", URL: "example.com", SupportedContractVersions: []string{"1"}, Capabilities: []plugin.Capability{plugin.CapabilitySignatureGenerator}, @@ -229,7 +217,15 @@ func TestManager_List(t *testing.T) { } } -func TestManager_Run(t *testing.T) { +func TestManager_Runner_Run_NotFound(t *testing.T) { + mgr := &Manager{fstest.MapFS{}, nil} + _, err := mgr.Runner("foo") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("Manager.Runner() error = %v, want %v", err, ErrNotFound) + } +} + +func TestManager_Runner_Run(t *testing.T) { var errExec = errors.New("exec failed") type args struct { name string @@ -241,27 +237,19 @@ func TestManager_Run(t *testing.T) { args args err error }{ - {"empty fsys", &Manager{fstest.MapFS{}, nil}, args{"foo", plugin.CommandGenerateSignature}, ErrNotFound}, - { - "invalid plugin", &Manager{fstest.MapFS{ - "foo": &fstest.MapFile{Mode: fs.ModeDir}, - addExeSuffix("foo/notation-foo"): new(fstest.MapFile), - }, testCommander{nil, false, errors.New("err")}}, - args{"foo", plugin.CommandGenerateSignature}, ErrNotCompliant, - }, { "exec error", &Manager{fstest.MapFS{ "foo": &fstest.MapFile{Mode: fs.ModeDir}, addExeSuffix("foo/notation-foo"): new(fstest.MapFile), - }, &testMultiCommander{[][]byte{metadataJSON(validMetadata), nil}, []bool{true, false}, []error{nil, errExec}, 0}}, + }, &testCommander{nil, false, errExec}}, args{"foo", plugin.CommandGenerateSignature}, errExec, }, { - "exit error", &Manager{fstest.MapFS{ + "request error", &Manager{fstest.MapFS{ "foo": &fstest.MapFile{Mode: fs.ModeDir}, addExeSuffix("foo/notation-foo"): new(fstest.MapFile), - }, &testMultiCommander{[][]byte{metadataJSON(validMetadata), {}}, []bool{true, false}, []error{nil, nil}, 0}}, - args{"foo", plugin.CommandGenerateSignature}, ErrNotCompliant, + }, &testCommander{[]byte("{\"errorCode\": \"ERROR\"}"), false, nil}}, + args{"foo", plugin.CommandGenerateSignature}, plugin.RequestError{Code: plugin.ErrorCodeGeneric}, }, { "valid", &Manager{fstest.MapFS{ @@ -273,25 +261,35 @@ func TestManager_Run(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.mgr.Run(context.Background(), tt.args.name, tt.args.cmd, "1") + runner, err := tt.mgr.Runner(tt.args.name) + if err != nil { + t.Fatalf("Manager.Runner() error = %v, want nil", err) + } + got, err := runner.Run(context.Background(), requester(tt.args.cmd)) wantErr := tt.err != nil if (err != nil) != wantErr { - t.Fatalf("Manager.Run() error = %v, wantErr %v", err, wantErr) + t.Fatalf("Runner.Run() error = %v, wantErr %v", err, wantErr) } if wantErr { if !errors.Is(err, tt.err) { - t.Fatalf("Manager.Run() error = %v, want %v", err, tt.err) + t.Fatalf("Runner.Run() error = %v, want %v", err, tt.err) } } else if got == nil { - t.Error("Manager.Run() want non-nil output") + t.Error("Runner.Run() want non-nil output") } }) } } -func TestNewManager(t *testing.T) { - mgr := NewManager() +type requester plugin.Command + +func (r requester) Command() plugin.Command { + return plugin.Command(r) +} + +func TestNew(t *testing.T) { + mgr := New("") if mgr == nil { - t.Error("NewManager() = nil") + t.Error("New() = nil") } } diff --git a/plugin/metadata.go b/plugin/metadata.go index 08bc66fb..15b5603a 100644 --- a/plugin/metadata.go +++ b/plugin/metadata.go @@ -35,6 +35,10 @@ func (m *Metadata) Validate() error { return nil } +func (Metadata) Command() Command { + return CommandGetMetadata +} + // HasCapability return true if the metadata states that the // capability is supported. // Returns true if capability is empty. diff --git a/plugin/plugin.go b/plugin/plugin.go index 6adede2c..05a06106 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -1,8 +1,17 @@ package plugin +import ( + "context" + + "github.com/notaryproject/notation-go" +) + // Prefix is the prefix required on all plugin binary names. const Prefix = "notation-" +// ContractVersion is the . version of the plugin contract. +const ContractVersion = "1.0" + // Command is a CLI command available in the plugin contract. type Command string @@ -12,6 +21,11 @@ const ( // 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 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 capability. @@ -36,50 +50,97 @@ const ( CapabilityEnvelopeGenerator Capability = "SIGNATURE_ENVELOPE_GENERATOR" ) -// GenerateSignatureRequest contains the parameters passed in a generate-signature request. -// All parameters are required. -type GenerateSignatureRequest struct { - ContractVersion string `json:"contractVersion"` - KeyName string `json:"keyName"` - KeyID string `json:"keyId"` - Payload string `json:"payload"` +// GetMetadataRequest contains the parameters passed in a get-plugin-metadata request. +type GetMetadataRequest struct{} + +func (GetMetadataRequest) Command() Command { + return CommandGetMetadata } -// GenerateSignatureResponse is the response of a generate-signature request. -type GenerateSignatureResponse struct { +// 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 +} + +// GenerateSignatureResponse is the response of a describe-key request. +type DescribeKeyResponse struct { // The same key id as passed in the request. KeyID string `json:"keyId"` - // Base64 encoded signature. - Signature string `json:"signature"` - - // One of following supported signing algorithms: + // One of following supported key types: // https://github.com/notaryproject/notaryproject/blob/main/signature-specification.md#algorithm-selection - SigningAlgorithm string `json:"signingAlgorithm"` + KeySpec notation.KeySpec `json:"keySpec"` +} + +// GenerateSignatureRequest contains the parameters passed in a generate-signature request. +type GenerateSignatureRequest struct { + ContractVersion string `json:"contractVersion"` + KeyID string `json:"keyId"` + KeySpec notation.KeySpec `json:"keySpec"` + Hash notation.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 notation.SignatureAlgorithm `json:"signingAlgorithm"` // Ordered list of certificates starting with leaf certificate // and ending with root certificate. - CertificateChain []string `json:"certificateChain"` + CertificateChain [][]byte `json:"certificateChain"` } -// GenerateEnvelopeRequest contains the parameters passed in a generate-envelop request. -// All parameters are required. +// GenerateEnvelopeRequest contains the parameters passed in a generate-envelope request. type GenerateEnvelopeRequest struct { - ContractVersion string `json:"contractVersion"` - KeyName string `json:"keyName"` - KeyID string `json:"keyId"` - PayloadType string `json:"payloadType"` - SignatureEnvelopeType string `json:"signatureEnvelopeType"` - Payload string `json:"payload"` + ContractVersion string `json:"contractVersion"` + KeyID string `json:"keyId"` + PayloadType string `json:"payloadType"` + SignatureEnvelopeType string `json:"signatureEnvelopeType"` + Payload []byte `json:"payload"` + PluginConfig map[string]string `json:"pluginConfig,omitempty"` } -// GenerateSignatureResponse is the response of a generate-envelop request. +func (GenerateEnvelopeRequest) Command() Command { + return CommandGenerateSignature +} + +// GenerateSignatureResponse is the response of a generate-envelope request. type GenerateEnvelopeResponse struct { - // Base64 encoded signature envelope. - SignatureEnvelope string `json:"signatureEnvelope"` + SignatureEnvelope []byte `json:"signatureEnvelope"` + SignatureEnvelopeType string `json:"signatureEnvelopeType"` + Annotations map[string]string `json:"annotations,omitempty"` +} - SignatureEnvelopeType string `json:"signatureEnvelopeType"` +// Request defines a plugin request, which is always associated to a command. +type Request interface { + Command() Command +} - // Annotations to be appended to Signature Manifest annotations. - Annotations map[string]string `json:"annotations,omitempty"` +// Runner is an interface for running commands against a plugin. +type Runner interface { + // Run executes the specified command and waits for it to complete. + // + // When the returned object is not nil, its type is guaranteed to remain always the same for a given Command. + // + // The returned error is nil if: + // - the plugin exists + // - the command runs and exits with a zero exit status + // - the command stdout contains a valid json object which can be unmarshal-ed. + // + // If the command starts but does not complete successfully, the error is of type RequestError wrapping a *exec.ExitError. + // Other error types may be returned for other situations. + Run(ctx context.Context, req Request) (interface{}, error) } diff --git a/signature/jws/plugin.go b/signature/jws/plugin.go new file mode 100644 index 00000000..c966ce32 --- /dev/null +++ b/signature/jws/plugin.go @@ -0,0 +1,194 @@ +package jws + +import ( + "context" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v4" + "github.com/notaryproject/notation-go" + "github.com/notaryproject/notation-go/plugin" +) + +// PluginSigner signs artifacts and generates JWS signatures +// by delegating the one or both operations to the named plugin, +// as defined in +// https://github.com/notaryproject/notaryproject/blob/main/specs/plugin-extensibility.md#signing-interfaces. +type PluginSigner struct { + Runner plugin.Runner + KeyID string + PluginConfig map[string]string +} + +// Sign signs the artifact described by its descriptor, and returns the signature. +func (s *PluginSigner) Sign(ctx context.Context, desc notation.Descriptor, opts notation.SignOptions) ([]byte, error) { + metadata, err := s.getMetadata(ctx) + if err != nil { + return nil, err + } + if metadata.HasCapability(plugin.CapabilitySignatureGenerator) { + return s.generateSignature(ctx, desc, opts) + } else if metadata.HasCapability(plugin.CapabilityEnvelopeGenerator) { + return s.generateSignatureEnvelope(ctx, desc, opts) + } + return nil, fmt.Errorf("plugin does not have signing capabilities") +} + +func (s *PluginSigner) getMetadata(ctx context.Context) (*plugin.Metadata, error) { + out, err := s.Runner.Run(ctx, new(plugin.GetMetadataRequest)) + if err != nil { + return nil, fmt.Errorf("metadata command failed: %w", err) + } + metadata, ok := out.(*plugin.Metadata) + if !ok { + return nil, fmt.Errorf("plugin runner returned incorrect get-plugin-metadata response type '%T'", out) + } + if err := metadata.Validate(); err != nil { + return nil, fmt.Errorf("invalid plugin metadata: %w", err) + } + return metadata, nil +} + +func (s *PluginSigner) describeKey(ctx context.Context, config map[string]string) (*plugin.DescribeKeyResponse, error) { + req := &plugin.DescribeKeyRequest{ + ContractVersion: plugin.ContractVersion, + KeyID: s.KeyID, + PluginConfig: config, + } + out, err := s.Runner.Run(ctx, req) + if err != nil { + return nil, fmt.Errorf("describe-key command failed: %w", err) + } + resp, ok := out.(*plugin.DescribeKeyResponse) + if !ok { + return nil, fmt.Errorf("plugin runner returned incorrect describe-key response type '%T'", out) + } + return resp, nil +} + +func (s *PluginSigner) generateSignature(ctx context.Context, desc notation.Descriptor, opts notation.SignOptions) ([]byte, error) { + config := s.mergeConfig(opts.PluginConfig) + // Get key info. + key, err := s.describeKey(ctx, config) + if err != nil { + return nil, err + } + + // Check keyID is honored. + if s.KeyID != key.KeyID { + return nil, fmt.Errorf("keyID in describeKey response %q does not match request %q", key.KeyID, s.KeyID) + } + + // Get algorithm associated to key. + alg := key.KeySpec.SignatureAlgorithm() + if alg == "" { + return nil, fmt.Errorf("keySpec %q for key %q is not supported", key.KeySpec, key.KeyID) + } + + // Generate payload to be signed. + payload := packPayload(desc, opts) + if err := payload.Valid(); err != nil { + return nil, err + } + + // Generate signing string. + token := jwtToken(alg.JWS(), payload) + payloadToSign, err := token.SigningString() + if err != nil { + return nil, fmt.Errorf("failed to marshal signing payload: %v", err) + } + + // Execute plugin sign command. + req := &plugin.GenerateSignatureRequest{ + ContractVersion: plugin.ContractVersion, + KeyID: s.KeyID, + KeySpec: key.KeySpec, + Hash: alg.Hash(), + Payload: []byte(payloadToSign), + PluginConfig: config, + } + out, err := s.Runner.Run(ctx, req) + if err != nil { + return nil, fmt.Errorf("generate-signature command failed: %w", err) + } + resp, ok := out.(*plugin.GenerateSignatureResponse) + if !ok { + return nil, fmt.Errorf("plugin runner returned incorrect generate-signature response type '%T'", out) + } + + // Check keyID is honored. + if s.KeyID != resp.KeyID { + return nil, fmt.Errorf("keyID in generateSignature response %q does not match request %q", resp.KeyID, s.KeyID) + } + + // Check algorithm is supported. + jwsAlg := resp.SigningAlgorithm.JWS() + if jwsAlg == "" { + return nil, fmt.Errorf("signing algorithm %q in generateSignature response is not supported", resp.SigningAlgorithm) + } + + // Check certificate chain is not empty. + if len(resp.CertificateChain) == 0 { + return nil, errors.New("generateSignature response has empty certificate chain") + } + + certs, err := parseCertChain(resp.CertificateChain) + if err != nil { + return nil, err + } + + // Verify the hash of the request payload against the response signature + // using the public key of the signing certificate. + // At this point, resp.Signature is not base64-encoded, + // but verifyJWT expects a base64URL encoded string. + signed64Url := base64.RawURLEncoding.EncodeToString(resp.Signature) + err = verifyJWT(jwsAlg, payloadToSign, signed64Url, certs[0]) + if err != nil { + return nil, fmt.Errorf("signature returned by generateSignature cannot be verified: %v", err) + } + + // Check the the certificate chain conforms to the spec. + if err := verifyCertExtKeyUsage(certs[0], x509.ExtKeyUsageCodeSigning); err != nil { + return nil, fmt.Errorf("signing certificate in generateSignature response.CertificateChain does not meet the minimum requirements: %w", err) + } + + // Assemble the JWS signature envelope. + return jwsEnvelope(ctx, opts, payloadToSign+"."+signed64Url, resp.CertificateChain) +} + +func (s *PluginSigner) mergeConfig(config map[string]string) map[string]string { + c := make(map[string]string, len(s.PluginConfig)+len(config)) + // First clone s.PluginConfig. + for k, v := range s.PluginConfig { + c[k] = v + } + // Then set or override entries from config. + for k, v := range config { + c[k] = v + } + return c +} + +func (s *PluginSigner) generateSignatureEnvelope(ctx context.Context, desc notation.Descriptor, opts notation.SignOptions) ([]byte, error) { + return nil, errors.New("not implemented") +} + +func parseCertChain(certChain [][]byte) ([]*x509.Certificate, error) { + certs := make([]*x509.Certificate, len(certChain)) + for i, cert := range certChain { + cert, err := x509.ParseCertificate(cert) + if err != nil { + return nil, err + } + certs[i] = cert + } + return certs, nil +} + +func verifyJWT(sigAlg string, payload string, sig string, signingCert *x509.Certificate) error { + // Verify the hash of req.payload against resp.signature using the public key in the leaf certificate. + method := jwt.GetSigningMethod(sigAlg) + return method.Verify(payload, sig, signingCert.PublicKey) +} diff --git a/signature/jws/plugin_test.go b/signature/jws/plugin_test.go new file mode 100644 index 00000000..e8e97e70 --- /dev/null +++ b/signature/jws/plugin_test.go @@ -0,0 +1,359 @@ +package jws + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "reflect" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/notaryproject/notation-go" + "github.com/notaryproject/notation-go/plugin" +) + +var validMetadata = plugin.Metadata{ + Name: "foo", Description: "friendly", Version: "1", URL: "example.com", + SupportedContractVersions: []string{"1"}, Capabilities: []plugin.Capability{plugin.CapabilitySignatureGenerator}, +} + +type mockRunner struct { + resp []interface{} + err []error + n int +} + +func (r *mockRunner) Run(ctx context.Context, req plugin.Request) (interface{}, error) { + defer func() { r.n++ }() + return r.resp[r.n], r.err[r.n] +} + +type mockSignerPlugin struct { + KeyID string + KeySpec notation.KeySpec + Sign func(payload []byte) []byte + SigningAlg notation.SignatureAlgorithm + Cert []byte + n int +} + +func (s *mockSignerPlugin) Run(ctx context.Context, req plugin.Request) (interface{}, error) { + var chain [][]byte + if len(s.Cert) != 0 { + chain = append(chain, s.Cert) + } + if req != nil { + // Test json roundtrip. + jsonReq, err := json.Marshal(req) + if err != nil { + return nil, err + } + err = json.Unmarshal(jsonReq, req) + if err != nil { + return nil, err + } + } + defer func() { s.n++ }() + switch s.n { + case 0: + return &validMetadata, nil + case 1: + return &plugin.DescribeKeyResponse{KeyID: s.KeyID, KeySpec: s.KeySpec}, nil + case 2: + var signed []byte + if s.Sign != nil { + signed = s.Sign(req.(*plugin.GenerateSignatureRequest).Payload) + } + return &plugin.GenerateSignatureResponse{ + KeyID: s.KeyID, + SigningAlgorithm: s.SigningAlg, + Signature: signed, + CertificateChain: chain, + }, nil + } + panic("too many calls") +} + +func testPluginSignerError(t *testing.T, signer PluginSigner, wantEr string) { + t.Helper() + _, err := signer.Sign(context.Background(), notation.Descriptor{}, notation.SignOptions{}) + if err == nil || !strings.Contains(err.Error(), wantEr) { + t.Errorf("PluginSigner.Sign() error = %v, wantErr %v", err, wantEr) + } +} + +func TestPluginSigner_Sign_RunMetadataFails(t *testing.T) { + signer := PluginSigner{ + Runner: &mockRunner{[]interface{}{nil}, []error{errors.New("failed")}, 0}, + } + testPluginSignerError(t, signer, "metadata command failed") +} + +func TestPluginSigner_Sign_NoCapability(t *testing.T) { + m := validMetadata + m.Capabilities = []plugin.Capability{""} + signer := PluginSigner{ + Runner: &mockRunner{[]interface{}{&m}, []error{nil}, 0}, + } + testPluginSignerError(t, signer, "does not have signing capabilities") +} + +func TestPluginSigner_Sign_DescribeKeyFailed(t *testing.T) { + signer := PluginSigner{ + Runner: &mockRunner{[]interface{}{&validMetadata, nil}, []error{nil, errors.New("failed")}, 0}, + } + testPluginSignerError(t, signer, "describe-key command failed") +} + +func TestPluginSigner_Sign_DescribeKeyKeyIDMismatch(t *testing.T) { + signer := PluginSigner{ + Runner: &mockSignerPlugin{KeyID: "2", KeySpec: notation.RSA_2048}, + KeyID: "1", + } + testPluginSignerError(t, signer, "keyID in describeKey response \"2\" does not match request \"1\"") +} + +func TestPluginSigner_Sign_KeySpecNotSupported(t *testing.T) { + signer := PluginSigner{ + Runner: &mockSignerPlugin{KeyID: "1", KeySpec: "custom"}, + KeyID: "1", + } + testPluginSignerError(t, signer, "keySpec \"custom\" for key \"1\" is not supported") +} + +func TestPluginSigner_Sign_PayloadNotValid(t *testing.T) { + signer := PluginSigner{ + Runner: &mockRunner{[]interface{}{ + &validMetadata, + &plugin.DescribeKeyResponse{KeyID: "1", KeySpec: notation.RSA_2048}, + }, []error{nil, nil}, 0}, + KeyID: "1", + } + _, err := signer.Sign(context.Background(), notation.Descriptor{}, notation.SignOptions{Expiry: time.Now().Add(-100)}) + wantEr := "token is expired" + if err == nil || !strings.Contains(err.Error(), wantEr) { + t.Errorf("PluginSigner.Sign() error = %v, wantErr %v", err, wantEr) + } +} + +func TestPluginSigner_Sign_GenerateSignatureKeyIDMismatch(t *testing.T) { + signer := PluginSigner{ + Runner: &mockRunner{[]interface{}{ + &validMetadata, + &plugin.DescribeKeyResponse{KeyID: "1", KeySpec: notation.RSA_2048}, + &plugin.GenerateSignatureResponse{KeyID: "2"}, + }, []error{nil, nil, nil}, 0}, + KeyID: "1", + } + testPluginSignerError(t, signer, "keyID in generateSignature response \"2\" does not match request \"1\"") +} + +func TestPluginSigner_Sign_UnsuportedAlgorithm(t *testing.T) { + signer := PluginSigner{ + Runner: &mockSignerPlugin{KeyID: "1", KeySpec: notation.RSA_2048, SigningAlg: "custom"}, + KeyID: "1", + } + testPluginSignerError(t, signer, "signing algorithm \"custom\" in generateSignature response is not supported") +} + +func TestPluginSigner_Sign_NoCertChain(t *testing.T) { + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + }, + KeyID: "1", + } + testPluginSignerError(t, signer, "empty certificate chain") +} + +func TestPluginSigner_Sign_MalformedCert(t *testing.T) { + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + Cert: []byte("mocked"), + }, + KeyID: "1", + } + testPluginSignerError(t, signer, "x509: malformed certificate") +} + +func TestPluginSigner_Sign_SignatureVerifyError(t *testing.T) { + _, cert, err := generateKeyCertPair() + if err != nil { + t.Fatalf("generateKeyCertPair() error = %v", err) + } + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + Sign: func(payload []byte) []byte { return []byte("r a w") }, + Cert: cert.Raw, + }, + KeyID: "1", + } + testPluginSignerError(t, signer, "verification error") +} + +func validSign(t *testing.T, key interface{}) func([]byte) []byte { + t.Helper() + return func(payload []byte) []byte { + signed, err := jwt.SigningMethodPS256.Sign(string(payload), key) + if err != nil { + t.Fatal(err) + } + encSigned, err := base64.RawURLEncoding.DecodeString(signed) + if err != nil { + t.Fatal(err) + } + return encSigned + } +} + +func TestPluginSigner_Sign_CertWithoutDigitalSignatureBit(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{CommonName: "test"}, + KeyUsage: x509.KeyUsageEncipherOnly, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + BasicConstraintsValid: true, + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) + if err != nil { + t.Fatal(err) + } + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + Sign: validSign(t, key), + Cert: certBytes, + }, + KeyID: "1", + } + testPluginSignerError(t, signer, "keyUsage must have the bit positions for digitalSignature set") +} + +func TestPluginSigner_Sign_CertWithout_idkpcodeSigning(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{CommonName: "test"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) + if err != nil { + t.Fatal(err) + } + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + Sign: validSign(t, key), + Cert: certBytes, + }, + KeyID: "1", + } + testPluginSignerError(t, signer, "extKeyUsage must contain") +} + +func TestPluginSigner_Sign_CertBasicConstraintCA(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{CommonName: "test"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + BasicConstraintsValid: true, + IsCA: true, + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) + if err != nil { + t.Fatal(err) + } + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + Sign: validSign(t, key), + Cert: certBytes, + }, + KeyID: "1", + } + testPluginSignerError(t, signer, "if the basicConstraints extension is present, the CA field MUST be set false") +} + +func TestPluginSigner_Sign_Valid(t *testing.T) { + key, cert, err := generateKeyCertPair() + if err != nil { + t.Fatal(err) + } + signer := PluginSigner{ + Runner: &mockSignerPlugin{ + KeyID: "1", + KeySpec: notation.RSA_2048, + SigningAlg: notation.RSASSA_PSS_SHA_256, + Sign: validSign(t, key), + Cert: cert.Raw, + }, + KeyID: "1", + } + data, err := signer.Sign(context.Background(), notation.Descriptor{}, notation.SignOptions{}) + if err != nil { + t.Errorf("PluginSigner.Sign() error = %v, wantErr nil", err) + } + var got notation.JWSEnvelope + err = json.Unmarshal(data, &got) + if err != nil { + t.Fatal(err) + } + want := notation.JWSEnvelope{ + Protected: "eyJhbGciOiJQUzI1NiIsImN0eSI6ImFwcGxpY2F0aW9uL3ZuZC5jbmNmLm5vdGFyeS52Mi5qd3MudjEifQ", + Header: notation.JWSUnprotectedHeader{ + CertChain: [][]byte{cert.Raw}, + }, + } + if got.Protected != want.Protected { + t.Errorf("PluginSigner.Sign() Protected %v, want %v", got.Protected, want.Protected) + } + if _, err = base64.RawURLEncoding.DecodeString(got.Signature); err != nil { + t.Errorf("PluginSigner.Sign() Signature %v is not encoded as Base64URL", got.Signature) + } + if !reflect.DeepEqual(got.Header, want.Header) { + t.Errorf("PluginSigner.Sign() Header %v, want %v", got.Header, want.Header) + } + v := NewVerifier() + roots := x509.NewCertPool() + roots.AddCert(cert) + v.VerifyOptions.Roots = roots + if _, err := v.Verify(context.Background(), data, notation.VerifyOptions{}); err != nil { + t.Fatalf("Verify() error = %v", err) + } +} diff --git a/signature/jws/signer.go b/signature/jws/signer.go index 0ccbaf79..9281dc12 100644 --- a/signature/jws/signer.go +++ b/signature/jws/signer.go @@ -8,10 +8,10 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/golang-jwt/jwt/v4" "github.com/notaryproject/notation-go" - "github.com/notaryproject/notation-go/crypto/jwsutil" "github.com/notaryproject/notation-go/crypto/timestamp" "github.com/notaryproject/notation-go/internal/crypto/pki" ) @@ -28,13 +28,6 @@ type Signer struct { // certChain contains the X.509 public key certificate or certificate chain corresponding // to the key used to generate the signature. certChain [][]byte - - // TSA is the TimeStamp Authority to timestamp the resulted signature if present. - TSA timestamp.Timestamper - - // TSARoots is the set of trusted root certificates for verifying the fetched timestamp - // signature. If nil, the system roots or the platform verifier are used. - TSARoots *x509.CertPool } // NewSigner creates a signer with the recommended signing method and a signing key bundled @@ -75,9 +68,9 @@ func NewSignerWithCertificateChain(method jwt.SigningMethod, key crypto.PrivateK return nil, err } - rawCerts := make([][]byte, 0, len(certChain)) - for _, cert := range certChain { - rawCerts = append(rawCerts, cert.Raw) + rawCerts := make([][]byte, len(certChain)) + for i, cert := range certChain { + rawCerts[i] = cert.Raw } return &Signer{ method: method, @@ -88,57 +81,55 @@ func NewSignerWithCertificateChain(method jwt.SigningMethod, key crypto.PrivateK // Sign signs the artifact described by its descriptor, and returns the signature. func (s *Signer) Sign(ctx context.Context, desc notation.Descriptor, opts notation.SignOptions) ([]byte, error) { - if err := opts.Validate(); err != nil { - return nil, err - } - // generate JWT payload := packPayload(desc, opts) if err := payload.Valid(); err != nil { return nil, err } - token := &jwt.Token{ - Header: map[string]interface{}{ - "alg": s.method.Alg(), - "cty": MediaTypeNotationPayload, - "crit": []string{ - "cty", - }, - }, - Claims: payload, - Method: s.method, - } + token := jwtToken(s.method.Alg(), payload) + token.Method = s.method compact, err := token.SignedString(s.key) if err != nil { return nil, err } + return jwsEnvelope(ctx, opts, compact, s.certChain) +} + +func jwtToken(alg string, claims jwt.Claims) *jwt.Token { + return &jwt.Token{ + Header: map[string]interface{}{ + "alg": alg, + "cty": notation.MediaTypeJWSEnvelope, + }, + Claims: claims, + } +} - // generate unprotected header - header := unprotectedHeader{ - CertChain: s.certChain, +func jwsEnvelope(ctx context.Context, opts notation.SignOptions, compact string, certChain [][]byte) ([]byte, error) { + parts := strings.Split(compact, ".") + if len(parts) != 3 { + return nil, errors.New("invalid compact serialization") + } + envelope := notation.JWSEnvelope{ + Protected: parts[0], + Payload: parts[1], + Signature: parts[2], + Header: notation.JWSUnprotectedHeader{ + CertChain: certChain, + }, } // timestamp JWT - sig, err := jwsutil.ParseCompact(compact) - if err != nil { - return nil, err - } if opts.TSA != nil { - token, err := timestampSignature(ctx, sig.Signature.Signature, opts.TSA, opts.TSAVerifyOptions) + token, err := timestampSignature(ctx, envelope.Signature, opts.TSA, opts.TSAVerifyOptions) if err != nil { return nil, fmt.Errorf("timestamp failed: %w", err) } - header.TimeStampToken = token - } - - // finalize unprotected header - sig.Unprotected, err = json.Marshal(header) - if err != nil { - return nil, err + envelope.Header.TimeStampToken = token } // encode in flatten JWS JSON serialization - return json.Marshal(sig) + return json.Marshal(envelope) } // timestampSignature sends a request to the TSA for timestamping the signature. diff --git a/signature/jws/signer_test.go b/signature/jws/signer_test.go index 88b17b0c..38896479 100644 --- a/signature/jws/signer_test.go +++ b/signature/jws/signer_test.go @@ -66,10 +66,6 @@ func TestSignWithTimestamp(t *testing.T) { if err != nil { t.Fatalf("timestamptest.NewTSA() error = %v", err) } - s.TSA = tsa - tsaRoots := x509.NewCertPool() - tsaRoots.AddCert(tsa.Certificate()) - s.TSARoots = tsaRoots // sign content ctx := context.Background() diff --git a/signature/jws/spec.go b/signature/jws/spec.go index 9dd739f5..a881640a 100644 --- a/signature/jws/spec.go +++ b/signature/jws/spec.go @@ -4,46 +4,77 @@ package jws import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "errors" + "fmt" "time" "github.com/golang-jwt/jwt/v4" "github.com/notaryproject/notation-go" ) -// unprotectedHeader contains the header parameters that are not integrity protected. -type unprotectedHeader struct { - TimeStampToken []byte `json:"timestamp,omitempty"` - CertChain [][]byte `json:"x5c,omitempty"` -} - -// MediaTypeNotationPayload describes the media type of the payload of notation signature. -const MediaTypeNotationPayload = "application/vnd.cncf.notary.v2.jws.v1" - -// payload contains the subject manifest and other attributes that have to be integrity -// protected. -type payload struct { - Notation notationClaim `json:"notary"` +type notaryClaim struct { jwt.RegisteredClaims -} - -// notationClaim is the top level node and private claim, encapsulating the notary v2 data. -type notationClaim struct { Subject notation.Descriptor `json:"subject"` } // packPayload generates JWS payload according the signing content and options. -func packPayload(desc notation.Descriptor, opts notation.SignOptions) *payload { +func packPayload(desc notation.Descriptor, opts notation.SignOptions) jwt.Claims { var expiresAt *jwt.NumericDate if !opts.Expiry.IsZero() { expiresAt = jwt.NewNumericDate(opts.Expiry) } - return &payload{ - Notation: notationClaim{ - Subject: desc, - }, + return notaryClaim{ RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: expiresAt, + IssuedAt: jwt.NewNumericDate(time.Now()), }, + Subject: desc, + } +} + +var ( + oidExtensionKeyUsage = []int{2, 5, 29, 15} +) + +// verifyCertExtKeyUsage checks cert meets the requirements defined in +// https://github.com/notaryproject/notaryproject/blob/main/signature-specification.md#certificate-requirements. +func verifyCertExtKeyUsage(cert *x509.Certificate, extKeyUsage x509.ExtKeyUsage) error { + if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return errors.New("keyUsage must have the bit positions for digitalSignature set") + } + var hasExtKeyUsage bool + for _, ext := range cert.ExtKeyUsage { + if ext == extKeyUsage { + hasExtKeyUsage = true + break + } + } + if !hasExtKeyUsage { + return fmt.Errorf("extKeyUsage must contain %d", extKeyUsage) + } + for _, e := range cert.Extensions { + if e.Id.Equal(oidExtensionKeyUsage) { + if !e.Critical { + return errors.New("the keyUsage extension must be marked critical") + } + break + } + } + if cert.BasicConstraintsValid && cert.IsCA { + return errors.New("if the basicConstraints extension is present, the CA field MUST be set false") + } + switch key := cert.PublicKey.(type) { + case *rsa.PublicKey: + if key.N.BitLen() < 2048 { + return errors.New("RSA public key length must be 2048 bits or higher") + } + case *ecdsa.PublicKey: + if key.Params().N.BitLen() < 256 { + return errors.New("ECDSA public key length must be 256 bits or higher") + } } + return nil } diff --git a/signature/jws/verifier.go b/signature/jws/verifier.go index 66485082..6422f9b2 100644 --- a/signature/jws/verifier.go +++ b/signature/jws/verifier.go @@ -8,11 +8,11 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/golang-jwt/jwt/v4" "github.com/notaryproject/notation-go" - "github.com/notaryproject/notation-go/crypto/jwsutil" "github.com/notaryproject/notation-go/crypto/timestamp" ) @@ -55,39 +55,35 @@ func NewVerifier() *Verifier { // Verify verifies the signature and returns the verified descriptor and // metadata of the signed artifact. -func (v *Verifier) Verify(ctx context.Context, signature []byte, opts notation.VerifyOptions) (notation.Descriptor, error) { +func (v *Verifier) Verify(ctx context.Context, sig []byte, opts notation.VerifyOptions) (notation.Descriptor, error) { // unpack envelope - sig, err := openEnvelope(signature) + envelope, err := openEnvelope(sig) if err != nil { return notation.Descriptor{}, err } // verify signing identity - method, key, err := v.verifySigner(&sig.Signature) + method, key, err := v.verifySigner(envelope) if err != nil { return notation.Descriptor{}, err } // verify JWT - claim, err := v.verifyJWT(method, key, sig.SerializeCompact()) + compact := strings.Join([]string{envelope.Protected, envelope.Payload, envelope.Signature}, ".") + claim, err := v.verifyJWT(method, key, compact) if err != nil { return notation.Descriptor{}, err } - return claim.Subject, nil + return claim, nil } // verifySigner verifies the signing identity and returns the verification key. -func (v *Verifier) verifySigner(sig *jwsutil.Signature) (jwt.SigningMethod, crypto.PublicKey, error) { - var header unprotectedHeader - if err := json.Unmarshal(sig.Unprotected, &header); err != nil { - return nil, nil, err - } - - if len(header.CertChain) == 0 { +func (v *Verifier) verifySigner(sig *notation.JWSEnvelope) (jwt.SigningMethod, crypto.PublicKey, error) { + if len(sig.Header.CertChain) == 0 { return nil, nil, errors.New("signer certificates not found") } - return v.verifySignerFromCertChain(header.CertChain, header.TimeStampToken, sig.Signature) + return v.verifySignerFromCertChain(sig.Header.CertChain, sig.Header.TimeStampToken, sig.Signature) } // verifySignerFromCertChain verifies the signing identity from the provided certificate @@ -160,12 +156,12 @@ func (v *Verifier) verifyTimestamp(tokenBytes []byte, encodedSig string) (time.T // verifyJWT verifies the JWT token against the specified verification key, and // returns notation claim. -func (v *Verifier) verifyJWT(method jwt.SigningMethod, key crypto.PublicKey, tokenString string) (*notationClaim, error) { +func (v *Verifier) verifyJWT(method jwt.SigningMethod, key crypto.PublicKey, tokenString string) (notation.Descriptor, error) { // parse and verify token parser := &jwt.Parser{ ValidMethods: v.ValidMethods, } - var claims payload + var claims notaryClaim if _, err := parser.ParseWithClaims(tokenString, &claims, func(t *jwt.Token) (interface{}, error) { alg := t.Method.Alg() if expectedAlg := method.Alg(); alg != expectedAlg { @@ -176,28 +172,24 @@ func (v *Verifier) verifyJWT(method jwt.SigningMethod, key crypto.PublicKey, tok t.Method = method return key, nil }); err != nil { - return nil, err + return notation.Descriptor{}, err } // ensure required claims exist. // Note: the registered claims are already verified by parser.ParseWithClaims(). if claims.IssuedAt == nil { - return nil, errors.New("missing iat") + return notation.Descriptor{}, errors.New("missing iat") } - return &claims.Notation, nil + return claims.Subject, nil } // openEnvelope opens the signature envelope and get the embedded signature. -func openEnvelope(signature []byte) (*jwsutil.CompleteSignature, error) { - var envelope jwsutil.Envelope - if err := json.Unmarshal(signature, &envelope); err != nil { +func openEnvelope(sig []byte) (*notation.JWSEnvelope, error) { + var envelope notation.JWSEnvelope + if err := json.Unmarshal(sig, &envelope); err != nil { return nil, err } - if len(envelope.Signatures) != 1 { - return nil, errors.New("single signature envelope expected") - } - sig := envelope.Open() - return &sig, nil + return &envelope, nil } // verifyTimestamp verifies the timestamp token and returns stamped time. diff --git a/signature/jws/verifier_test.go b/signature/jws/verifier_test.go index ad1f52b6..c1277f65 100644 --- a/signature/jws/verifier_test.go +++ b/signature/jws/verifier_test.go @@ -76,10 +76,6 @@ func TestVerifyWithTimestamp(t *testing.T) { if err != nil { t.Fatalf("timestamptest.NewTSA() error = %v", err) } - s.TSA = tsa - tsaRoots := x509.NewCertPool() - tsaRoots.AddCert(tsa.Certificate()) - s.TSARoots = tsaRoots // sign content ctx := context.Background() @@ -103,7 +99,7 @@ func TestVerifyWithTimestamp(t *testing.T) { } // verify again with certificate trusted - v.TSARoots = tsaRoots + v.TSARoots = sOpts.TSAVerifyOptions.Roots got, err := v.Verify(ctx, sig, vOpts) if err != nil { t.Fatalf("Verify() error = %v", err)