diff --git a/go.mod b/go.mod index bf296dd9..a0aa1944 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/googleapis/gax-go/v2 v2.22.0 github.com/peterbourgon/diskv/v3 v3.0.1 github.com/pkg/errors v0.9.1 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 github.com/schollz/jsonstore v1.1.0 github.com/smallstep/go-attestation v0.4.4-0.20260603212853-e1a87a0b07d9 github.com/stretchr/testify v1.11.1 @@ -102,6 +103,7 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index d840c5ee..b79977e6 100644 --- a/go.sum +++ b/go.sum @@ -749,6 +749,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E= github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -1460,6 +1462,7 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kms/apiv1/options.go b/kms/apiv1/options.go index 13570e53..8016be0f 100644 --- a/kms/apiv1/options.go +++ b/kms/apiv1/options.go @@ -187,6 +187,8 @@ const ( // PlatformKMS is the KMS implementation that uses TPMKMS on Windows and // Linux and MacKMS on macOS. PlatformKMS Type = "kms" + // ScalewayKMS is a KMS implementation using Scaleway Key Manager. + ScalewayKMS Type = "scwkms" ) // TypeOf returns the type of of the given uri. @@ -213,7 +215,7 @@ func (t Type) Validate() error { switch typ { case DefaultKMS, SoftKMS: // Go crypto based kms. return nil - case CloudKMS, AmazonKMS, AzureKMS: // Cloud based kms. + case CloudKMS, AmazonKMS, AzureKMS, ScalewayKMS: // Cloud based kms. return nil case YubiKey, PKCS11, TPMKMS: // Hardware based kms. return nil diff --git a/kms/kms_test.go b/kms/kms_test.go index e3c3b4df..a716c137 100644 --- a/kms/kms_test.go +++ b/kms/kms_test.go @@ -10,6 +10,7 @@ import ( "go.step.sm/crypto/kms/apiv1" "go.step.sm/crypto/kms/awskms" "go.step.sm/crypto/kms/cloudkms" + "go.step.sm/crypto/kms/scwkms" "go.step.sm/crypto/kms/softkms" ) @@ -40,6 +41,7 @@ func TestNew(t *testing.T) { {"uri", false, args{ctx, apiv1.Options{URI: "softkms:foo=bar"}}, &softkms.SoftKMS{}, false}, {"awskms", false, args{ctx, apiv1.Options{Type: "awskms"}}, &awskms.KMS{}, false}, {"cloudkms", true, args{ctx, apiv1.Options{Type: "cloudkms"}}, &cloudkms.CloudKMS{}, failCloudKMS}, + {"scwkms", false, args{ctx, apiv1.Options{Type: "scwkms"}}, &scwkms.ScalewayKMS{}, false}, {"fail validation", false, args{ctx, apiv1.Options{Type: "foobar"}}, nil, true}, } for _, tt := range tests { diff --git a/kms/scwkms/decrypter.go b/kms/scwkms/decrypter.go new file mode 100644 index 00000000..d35125ca --- /dev/null +++ b/kms/scwkms/decrypter.go @@ -0,0 +1,124 @@ +//go:build !noscwkms + +package scwkms + +import ( + "crypto" + "crypto/rsa" + "errors" + "fmt" + "io" + + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + + "go.step.sm/crypto/kms/apiv1" +) + +// CreateDecrypter implements the apiv1.Decrypter interface and returns a +// crypto.Decrypter backed by a Scaleway asymmetric encryption key. +// +// Scaleway only supports RSA-OAEP-SHA256 for asymmetric decryption. +func (k *ScalewayKMS) CreateDecrypter(req *apiv1.CreateDecrypterRequest) (crypto.Decrypter, error) { + if req.DecryptionKey == "" { + return nil, errors.New("scwkms CreateDecrypter: 'decryptionKey' cannot be empty") + } + return NewDecrypter(k.client, req.DecryptionKey) +} + +// Decrypter implements crypto.Decrypter using Scaleway Key Manager. +type Decrypter struct { + client KeyManagementClient + keyID string + region scw.Region + publicKey crypto.PublicKey +} + +// NewDecrypter creates a new crypto.Decrypter backed by the given Scaleway key. +func NewDecrypter(client KeyManagementClient, decryptionKey string) (*Decrypter, error) { + keyID, region := parseKeyName(decryptionKey, "") + + decrypter := &Decrypter{ + client: client, + keyID: keyID, + region: region, + } + if err := decrypter.preloadKey(); err != nil { + return nil, err + } + + return decrypter, nil +} + +func (d *Decrypter) preloadKey() error { + response, err := d.client.GetPublicKey(&km.GetPublicKeyRequest{ + Region: d.region, + KeyID: d.keyID, + }) + if err != nil { + return fmt.Errorf("scwkms GetPublicKey failed: %w", err) + } + + d.publicKey, err = parsePublicKeyPEM([]byte(response.Pem)) + return err +} + +// Public returns the public key of this decrypter. +func (d *Decrypter) Public() crypto.PublicKey { + return d.publicKey +} + +// validateOAEPOptions validates the RSA OAEP options provided. +// Scaleway only supports RSA-OAEP-SHA256; labels are not supported. +func validateOAEPOptions(o *rsa.OAEPOptions) error { + if o == nil { + return nil + } + if len(o.Label) > 0 { + return errors.New("scwkms does not support RSA-OAEP label") + } + switch o.Hash { + case crypto.Hash(0), crypto.SHA256: + return nil + default: + return fmt.Errorf("scwkms does not support hash algorithm %q with RSA-OAEP (only SHA-256 is supported)", o.Hash) + } +} + +// Decrypt decrypts the given ciphertext using the Scaleway Key Manager +// asymmetric decryption API. +// +// Only RSA-OAEP-SHA256 is supported by Scaleway. Labels are not supported. +// The opts argument must be nil, a *rsa.OAEPOptions (with Hash 0 or SHA-256 +// and no Label), or will produce an error. +func (d *Decrypter) Decrypt(_ io.Reader, ciphertext []byte, opts crypto.DecrypterOpts) ([]byte, error) { + if opts == nil { + opts = &rsa.OAEPOptions{} + } + switch o := opts.(type) { + case *rsa.OAEPOptions: + if err := validateOAEPOptions(o); err != nil { + return nil, err + } + case *rsa.PKCS1v15DecryptOptions: + return nil, errors.New("scwkms does not support PKCS#1 v1.5 decryption") + default: + return nil, errors.New("scwkms Decrypt: invalid options type") + } + + response, err := d.client.Decrypt(&km.DecryptRequest{ + Region: d.region, + KeyID: d.keyID, + Ciphertext: ciphertext, + // AssociatedData is nil: it is only used for symmetric AES-GCM keys, + // not for RSA-OAEP asymmetric decryption. + }) + if err != nil { + return nil, fmt.Errorf("scwkms Decrypt failed: %w", err) + } + + return response.Plaintext, nil +} + +// Compile-time assertion. +var _ apiv1.Decrypter = (*ScalewayKMS)(nil) diff --git a/kms/scwkms/decrypter_test.go b/kms/scwkms/decrypter_test.go new file mode 100644 index 00000000..f0830068 --- /dev/null +++ b/kms/scwkms/decrypter_test.go @@ -0,0 +1,232 @@ +package scwkms + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "fmt" + "os" + "testing" + + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/kms/apiv1" + "go.step.sm/crypto/pemutil" +) + +func TestScalewayKMS_CreateDecrypter(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/rsapub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + tests := []struct { + name string + client *MockClient + req *apiv1.CreateDecrypterRequest + wantErr bool + }{ + { + name: "ok", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + assert.Equal(t, testKeyID, req.KeyID) + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + req: &apiv1.CreateDecrypterRequest{DecryptionKey: testKeyID}, + }, + { + name: "fail/empty-key", + client: &MockClient{}, + req: &apiv1.CreateDecrypterRequest{}, + wantErr: true, + }, + { + name: "fail/api-error", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return nil, fmt.Errorf("api error") + }, + }, + req: &apiv1.CreateDecrypterRequest{DecryptionKey: testKeyID}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k := &ScalewayKMS{client: tt.client, region: testRegion} + got, err := k.CreateDecrypter(tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, pk, got.Public()) + }) + } +} + +func TestNewDecrypter(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/rsapub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + tests := []struct { + name string + client *MockClient + key string + wantErr bool + }{ + { + name: "ok", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + key: testKeyID, + }, + { + name: "fail/api-error", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return nil, fmt.Errorf("api error") + }, + }, + key: testKeyID, + wantErr: true, + }, + { + name: "fail/bad-pem", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: "not a pem"}, nil + }, + }, + key: testKeyID, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, err := NewDecrypter(tt.client, tt.key) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, pk, d.Public()) + }) + } +} + +func TestDecrypter_Decrypt(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/rsapub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + plaintext := []byte("hello") + + var nilOpts *rsa.OAEPOptions + + tests := []struct { + name string + client *MockClient + opts crypto.DecrypterOpts + wantErr bool + }{ + { + name: "ok/nil-opts", + client: &MockClient{ + decrypt: func(req *km.DecryptRequest, _ ...scw.RequestOption) (*km.DecryptResponse, error) { + assert.Nil(t, req.AssociatedData) + return &km.DecryptResponse{Plaintext: plaintext}, nil + }, + }, + }, + { + name: "ok/nil-oaep-opts", + client: &MockClient{ + decrypt: func(req *km.DecryptRequest, _ ...scw.RequestOption) (*km.DecryptResponse, error) { + return &km.DecryptResponse{Plaintext: plaintext}, nil + }, + }, + opts: nilOpts, + }, + { + name: "ok/sha256-oaep", + client: &MockClient{ + decrypt: func(req *km.DecryptRequest, _ ...scw.RequestOption) (*km.DecryptResponse, error) { + return &km.DecryptResponse{Plaintext: plaintext}, nil + }, + }, + opts: &rsa.OAEPOptions{Hash: crypto.SHA256}, + }, + { + name: "fail/label", + client: &MockClient{}, + opts: &rsa.OAEPOptions{Hash: crypto.SHA256, Label: []byte("label")}, + wantErr: true, + }, + { + name: "fail/unsupported-hash", + client: &MockClient{}, + opts: &rsa.OAEPOptions{Hash: crypto.SHA512}, + wantErr: true, + }, + { + name: "fail/pkcs1v15", + client: &MockClient{}, + opts: &rsa.PKCS1v15DecryptOptions{}, + wantErr: true, + }, + { + name: "fail/api-error-nil-opts", + client: &MockClient{ + decrypt: func(req *km.DecryptRequest, _ ...scw.RequestOption) (*km.DecryptResponse, error) { + return nil, fmt.Errorf("api error") + }, + }, + wantErr: true, + }, + { + name: "fail/decrypt-error", + client: &MockClient{ + decrypt: func(req *km.DecryptRequest, _ ...scw.RequestOption) (*km.DecryptResponse, error) { + return nil, fmt.Errorf("api error") + }, + }, + opts: &rsa.OAEPOptions{Hash: crypto.SHA256}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &Decrypter{ + client: tt.client, + keyID: testKeyID, + region: testRegion, + publicKey: pk, + } + got, err := d.Decrypt(rand.Reader, []byte("ciphertext"), tt.opts) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, plaintext, got) + }) + } +} + +// Compile-time assertion. +var _ crypto.Decrypter = (*Decrypter)(nil) diff --git a/kms/scwkms/mock_test.go b/kms/scwkms/mock_test.go new file mode 100644 index 00000000..bb34adf6 --- /dev/null +++ b/kms/scwkms/mock_test.go @@ -0,0 +1,40 @@ +package scwkms + +import ( + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +// MockClient implements KeyManagementClient for unit testing. +type MockClient struct { + createKey func(*km.CreateKeyRequest, ...scw.RequestOption) (*km.Key, error) + getKey func(*km.GetKeyRequest, ...scw.RequestOption) (*km.Key, error) + getPublicKey func(*km.GetPublicKeyRequest, ...scw.RequestOption) (*km.PublicKey, error) + sign func(*km.SignRequest, ...scw.RequestOption) (*km.SignResponse, error) + decrypt func(*km.DecryptRequest, ...scw.RequestOption) (*km.DecryptResponse, error) + deleteKey func(*km.DeleteKeyRequest, ...scw.RequestOption) error +} + +func (m *MockClient) CreateKey(req *km.CreateKeyRequest, opts ...scw.RequestOption) (*km.Key, error) { + return m.createKey(req, opts...) +} + +func (m *MockClient) GetKey(req *km.GetKeyRequest, opts ...scw.RequestOption) (*km.Key, error) { + return m.getKey(req, opts...) +} + +func (m *MockClient) GetPublicKey(req *km.GetPublicKeyRequest, opts ...scw.RequestOption) (*km.PublicKey, error) { + return m.getPublicKey(req, opts...) +} + +func (m *MockClient) Sign(req *km.SignRequest, opts ...scw.RequestOption) (*km.SignResponse, error) { + return m.sign(req, opts...) +} + +func (m *MockClient) Decrypt(req *km.DecryptRequest, opts ...scw.RequestOption) (*km.DecryptResponse, error) { + return m.decrypt(req, opts...) +} + +func (m *MockClient) DeleteKey(req *km.DeleteKeyRequest, opts ...scw.RequestOption) error { + return m.deleteKey(req, opts...) +} diff --git a/kms/scwkms/no_scwkms.go b/kms/scwkms/no_scwkms.go new file mode 100644 index 00000000..8b3c0a58 --- /dev/null +++ b/kms/scwkms/no_scwkms.go @@ -0,0 +1,20 @@ +//go:build noscwkms + +package scwkms + +import ( + "context" + "os" + "path/filepath" + + "github.com/pkg/errors" + + "go.step.sm/crypto/kms/apiv1" +) + +func init() { + apiv1.Register(apiv1.ScalewayKMS, func(ctx context.Context, opts apiv1.Options) (apiv1.KeyManager, error) { + name := filepath.Base(os.Args[0]) + return nil, errors.Errorf("unsupported kms type 'scwkms': %s is compiled without Scaleway Key Manager support", name) + }) +} diff --git a/kms/scwkms/scwkms.go b/kms/scwkms/scwkms.go new file mode 100644 index 00000000..fab77e16 --- /dev/null +++ b/kms/scwkms/scwkms.go @@ -0,0 +1,363 @@ +//go:build !noscwkms + +package scwkms + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + + "go.step.sm/crypto/kms/apiv1" + "go.step.sm/crypto/kms/uri" + "go.step.sm/crypto/pemutil" +) + +// Scheme is the scheme used in URIs, the string "scwkms". +const Scheme = string(apiv1.ScalewayKMS) + +// signatureAlgorithmMapping maps apiv1.SignatureAlgorithm (and optional RSA Bits) +// to the Scaleway KeyAlgorithmAsymmetricSigning constant. +// Scaleway only supports SHA-256 for all algorithms, P-256 and P-384 for EC. +// Unsupported: SHA384WithRSA, SHA512WithRSA, SHA384WithRSAPSS, SHA512WithRSAPSS, +// ECDSAWithSHA512, and PureEd25519. +var signatureAlgorithmMapping = map[apiv1.SignatureAlgorithm]interface{}{ + apiv1.ECDSAWithSHA256: km.KeyAlgorithmAsymmetricSigningEcP256Sha256, + apiv1.ECDSAWithSHA384: km.KeyAlgorithmAsymmetricSigningEcP384Sha384, + apiv1.SHA256WithRSA: map[int]km.KeyAlgorithmAsymmetricSigning{ + 0: km.KeyAlgorithmAsymmetricSigningRsaPkcs1_3072Sha256, + 2048: km.KeyAlgorithmAsymmetricSigningRsaPkcs1_2048Sha256, + 3072: km.KeyAlgorithmAsymmetricSigningRsaPkcs1_3072Sha256, + 4096: km.KeyAlgorithmAsymmetricSigningRsaPkcs1_4096Sha256, + }, + apiv1.SHA256WithRSAPSS: map[int]km.KeyAlgorithmAsymmetricSigning{ + 0: km.KeyAlgorithmAsymmetricSigningRsaPss3072Sha256, + 2048: km.KeyAlgorithmAsymmetricSigningRsaPss2048Sha256, + 3072: km.KeyAlgorithmAsymmetricSigningRsaPss3072Sha256, + 4096: km.KeyAlgorithmAsymmetricSigningRsaPss4096Sha256, + }, +} + +// KeyManagementClient defines the subset of the Scaleway Key Manager API used by +// this package. The narrow interface enables unit testing with a mock. +type KeyManagementClient interface { + CreateKey(req *km.CreateKeyRequest, opts ...scw.RequestOption) (*km.Key, error) + GetKey(req *km.GetKeyRequest, opts ...scw.RequestOption) (*km.Key, error) + GetPublicKey(req *km.GetPublicKeyRequest, opts ...scw.RequestOption) (*km.PublicKey, error) + Sign(req *km.SignRequest, opts ...scw.RequestOption) (*km.SignResponse, error) + Decrypt(req *km.DecryptRequest, opts ...scw.RequestOption) (*km.DecryptResponse, error) + DeleteKey(req *km.DeleteKeyRequest, opts ...scw.RequestOption) error +} + +// newKeyManagerClientFunc is the constructor for the Scaleway Key Manager API. +// It is a variable so it can be overridden in tests. +var newKeyManagerClientFunc = func(client *scw.Client) KeyManagementClient { + return km.NewAPI(client) +} + +// ScalewayKMS implements a KMS using Scaleway Key Manager. +type ScalewayKMS struct { + client KeyManagementClient + region scw.Region + projectID string +} + +// New creates a new ScalewayKMS. It reads configuration from the given +// apiv1.Options and from the Scaleway configuration file / environment variables. +// +// The URI format is: +// +// scwkms:[key-id=;region=][?access-key=...&secret-key=...&project-id=...&organization-id=...®ion=...&profile=...] +func New(_ context.Context, opts apiv1.Options) (*ScalewayKMS, error) { + var ( + accessKey string + secretKey string + projectID string + organizationID string + region scw.Region + profileName string + ) + + if opts.URI != "" { + u, err := uri.ParseWithScheme(Scheme, opts.URI) + if err != nil { + return nil, err + } + accessKey = u.Get("access-key") + secretKey = u.Get("secret-key") + projectID = u.Get("project-id") + organizationID = u.Get("organization-id") + region = scw.Region(u.Get("region")) + profileName = u.Get("profile") + } + + // opts.Region is a shared field — use it as fallback. + if region == "" && opts.Region != "" { + region = scw.Region(opts.Region) + } + + var clientOpts []scw.ClientOption + + // Load Scaleway configuration file (~/.config/scw/config.yaml). + cfg, cfgErr := scw.LoadConfig() + if cfgErr == nil && cfg != nil { + if profileName != "" { + profile, err := cfg.GetProfile(profileName) + if err != nil { + return nil, fmt.Errorf("scwkms: error loading profile %q: %w", profileName, err) + } + clientOpts = append(clientOpts, scw.WithProfile(profile)) + } else if profile, err := cfg.GetActiveProfile(); err == nil { + clientOpts = append(clientOpts, scw.WithProfile(profile)) + } + } + + // Environment variables (SCW_ACCESS_KEY, SCW_SECRET_KEY, etc.) override the profile. + clientOpts = append(clientOpts, scw.WithEnv()) + + // Explicit URI parameters override everything else. + if accessKey != "" && secretKey != "" { + clientOpts = append(clientOpts, scw.WithAuth(accessKey, secretKey)) + } + if projectID != "" { + clientOpts = append(clientOpts, scw.WithDefaultProjectID(projectID)) + } + if organizationID != "" { + clientOpts = append(clientOpts, scw.WithDefaultOrganizationID(organizationID)) + } + if region != "" { + clientOpts = append(clientOpts, scw.WithDefaultRegion(region)) + } + + scwClient, err := scw.NewClient(clientOpts...) + if err != nil { + return nil, fmt.Errorf("scwkms: error creating Scaleway client: %w", err) + } + + // Determine effective region for use in key operation requests. + if region == "" { + if r, ok := scwClient.GetDefaultRegion(); ok { + region = r + } + } + // Determine effective project ID for key creation. + if projectID == "" { + if p, ok := scwClient.GetDefaultProjectID(); ok { + projectID = p + } + } + + return &ScalewayKMS{ + client: newKeyManagerClientFunc(scwClient), + region: region, + projectID: projectID, + }, nil +} + +func init() { + apiv1.Register(apiv1.ScalewayKMS, func(ctx context.Context, opts apiv1.Options) (apiv1.KeyManager, error) { + return New(ctx, opts) + }) +} + +// NewScalewayKMS creates a ScalewayKMS with the given client. Intended for testing. +func NewScalewayKMS(client KeyManagementClient) *ScalewayKMS { + return &ScalewayKMS{ + client: client, + } +} + +// Close is a no-op: the Scaleway client has no persistent connection to close. +func (k *ScalewayKMS) Close() error { + return nil +} + +// CreateSigner returns a new crypto.Signer backed by a Scaleway asymmetric +// signing key. +func (k *ScalewayKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, error) { + if req.SigningKey == "" { + return nil, fmt.Errorf("scwkms CreateSigner: 'signingKey' cannot be empty") + } + return NewSigner(k.client, req.SigningKey) +} + +// CreateKey creates a new asymmetric key in Scaleway Key Manager. +// The key name in CreateKeyRequest is used as the Scaleway key name. +// The returned CreateKeyResponse.Name is a scwkms URI. +func (k *ScalewayKMS) CreateKey(req *apiv1.CreateKeyRequest) (*apiv1.CreateKeyResponse, error) { + if req.Name == "" { + return nil, fmt.Errorf("scwkms CreateKey: 'name' cannot be empty") + } + + usage, err := k.keyUsageFromRequest(req) + if err != nil { + return nil, err + } + + name, _ := parseKeyName(req.Name, k.region) + creq := &km.CreateKeyRequest{ + Region: k.region, + ProjectID: k.projectID, + Name: &name, + Usage: usage, + Unprotected: true, // allow DeleteKey; can be overridden by users after creation + } + + key, err := k.client.CreateKey(creq) + if err != nil { + return nil, fmt.Errorf("scwkms CreateKey failed: %w", err) + } + + // Build a scwkms URI from the returned key ID and region. + keyURI := keyIDToURI(key.ID, key.Region) + + // Retrieve the public key to include in the response. + pk, err := k.GetPublicKey(&apiv1.GetPublicKeyRequest{Name: keyURI}) + if err != nil { + return nil, fmt.Errorf("scwkms GetPublicKey after CreateKey failed: %w", err) + } + + return &apiv1.CreateKeyResponse{ + Name: keyURI, + PublicKey: pk, + CreateSignerRequest: apiv1.CreateSignerRequest{ + SigningKey: keyURI, + }, + }, nil +} + +// keyUsageFromRequest maps the apiv1.CreateKeyRequest to a Scaleway KeyUsage. +func (k *ScalewayKMS) keyUsageFromRequest(req *apiv1.CreateKeyRequest) (*km.KeyUsage, error) { + if req.SignatureAlgorithm == apiv1.UnspecifiedSignAlgorithm { + // Default: EC P-256 SHA-256 + algo := km.KeyAlgorithmAsymmetricSigningEcP256Sha256 + return &km.KeyUsage{AsymmetricSigning: &algo}, nil + } + + v, ok := signatureAlgorithmMapping[req.SignatureAlgorithm] + if !ok { + return nil, fmt.Errorf("scwkms does not support signature algorithm '%s'", req.SignatureAlgorithm) + } + + switch v := v.(type) { + case km.KeyAlgorithmAsymmetricSigning: + algo := v + return &km.KeyUsage{AsymmetricSigning: &algo}, nil + case map[int]km.KeyAlgorithmAsymmetricSigning: + algo, ok := v[req.Bits] + if !ok { + return nil, fmt.Errorf("scwkms does not support signature algorithm '%s' with %d bits", req.SignatureAlgorithm, req.Bits) + } + return &km.KeyUsage{AsymmetricSigning: &algo}, nil + default: + return nil, fmt.Errorf("scwkms: unexpected algorithm mapping type") + } +} + +// GetPublicKey retrieves the public key of the given Scaleway key. +// The name can be a bare UUID, or a scwkms URI (scwkms:key-id=...;region=...). +func (k *ScalewayKMS) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, error) { + if req.Name == "" { + return nil, fmt.Errorf("scwkms GetPublicKey: 'name' cannot be empty") + } + + keyID, region := parseKeyName(req.Name, k.region) + + response, err := k.client.GetPublicKey(&km.GetPublicKeyRequest{ + Region: region, + KeyID: keyID, + }) + if err != nil { + return nil, fmt.Errorf("scwkms GetPublicKey failed: %w", err) + } + + pk, err := parsePublicKeyPEM([]byte(response.Pem)) + if err != nil { + return nil, err + } + + return pk, nil +} + +// DeleteKey deletes a key from Scaleway Key Manager. +// +// Note: keys created with Unprotected:true (the default in this package) can +// be deleted. Keys with Protected:true or in the Locked state will return an +// error from Scaleway. +func (k *ScalewayKMS) DeleteKey(req *apiv1.DeleteKeyRequest) error { + if req.Name == "" { + return fmt.Errorf("scwkms DeleteKey: 'name' cannot be empty") + } + + keyID, region := parseKeyName(req.Name, k.region) + + if err := k.client.DeleteKey(&km.DeleteKeyRequest{ + Region: region, + KeyID: keyID, + }); err != nil { + return fmt.Errorf("scwkms DeleteKey failed: %w", err) + } + + return nil +} + +// parsePublicKeyPEM decodes a PEM-encoded public key returned by Scaleway. +// Scaleway uses non-standard PEM headers for public keys: +// - "EC PUBLIC KEY" → bytes are PKIX SubjectPublicKeyInfo; rewrite header. +// - "RSA PUBLIC KEY" → bytes are PKCS#1 RSAPublicKey; parse directly. +func parsePublicKeyPEM(pemBytes []byte) (crypto.PublicKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return pemutil.ParseKey(pemBytes) + } + switch block.Type { + case "EC PUBLIC KEY": + block.Type = "PUBLIC KEY" + return pemutil.ParseKey(pem.EncodeToMemory(block)) + case "RSA PUBLIC KEY": + return x509.ParsePKCS1PublicKey(block.Bytes) + default: + return pemutil.ParseKey(pemBytes) + } +} + +// keyIDToURI encodes a key ID and region into a scwkms URI string. +func keyIDToURI(keyID string, region scw.Region) string { + return uri.New(Scheme, map[string][]string{ + "key-id": {keyID}, + "region": {string(region)}, + }).String() +} + +// parseKeyName parses a key identifier and returns the key ID and region. +// Accepted formats: +// - bare UUID: "11111111-1111-1111-1111-111111111111" +// - scwkms URI with params: "scwkms:key-id=11111111-...;region=fr-par" +// - scwkms opaque URI: "scwkms:11111111-..." +func parseKeyName(name string, defaultRegion scw.Region) (keyID string, region scw.Region) { + if u, err := uri.ParseWithScheme(Scheme, name); err == nil { + if id := u.Get("key-id"); id != "" { + keyID = id + } else { + keyID = u.Opaque + } + if r := u.Get("region"); r != "" { + region = scw.Region(r) + } + } else { + keyID = name + } + + if region == "" { + region = defaultRegion + } + return +} + +// Compile-time assertions. +var _ apiv1.KeyManager = (*ScalewayKMS)(nil) +var _ apiv1.KeyDeleter = (*ScalewayKMS)(nil) diff --git a/kms/scwkms/scwkms_test.go b/kms/scwkms/scwkms_test.go new file mode 100644 index 00000000..5e743bb7 --- /dev/null +++ b/kms/scwkms/scwkms_test.go @@ -0,0 +1,365 @@ +package scwkms + +import ( + "context" + "fmt" + "os" + "testing" + + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/kms/apiv1" + "go.step.sm/crypto/pemutil" +) + +const testKeyID = "11111111-2222-3333-4444-555555555555" +const testRegion = scw.RegionFrPar + +func testKeyURI(keyID string) string { + return keyIDToURI(keyID, testRegion) +} + +func TestNew(t *testing.T) { + // Override the client constructor to avoid real Scaleway calls. + orig := newKeyManagerClientFunc + t.Cleanup(func() { newKeyManagerClientFunc = orig }) + newKeyManagerClientFunc = func(client *scw.Client) KeyManagementClient { + return &MockClient{} + } + + tests := []struct { + name string + opts apiv1.Options + wantErr bool + }{ + {"ok/no-uri", apiv1.Options{}, false}, + {"ok/empty-uri", apiv1.Options{URI: "scwkms:"}, false}, + {"ok/uri-with-key", apiv1.Options{URI: "scwkms:key-id=" + testKeyID + ";region=fr-par"}, false}, + {"fail/wrong-scheme", apiv1.Options{URI: "cloudkms:"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k, err := New(context.Background(), tt.opts) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.NotNil(t, k) + }) + } +} + +func TestNewScalewayKMS(t *testing.T) { + client := &MockClient{} + k := NewScalewayKMS(client) + assert.NotNil(t, k) +} + +func TestScalewayKMS_Close(t *testing.T) { + k := &ScalewayKMS{client: &MockClient{}} + assert.NoError(t, k.Close()) +} + +func TestScalewayKMS_GetPublicKey(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + keyURI := testKeyURI(testKeyID) + + tests := []struct { + name string + client *MockClient + req *apiv1.GetPublicKeyRequest + wantErr bool + }{ + { + name: "ok/key-id", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + assert.Equal(t, testKeyID, req.KeyID) + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + req: &apiv1.GetPublicKeyRequest{Name: testKeyID}, + }, + { + name: "ok/uri", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + assert.Equal(t, testKeyID, req.KeyID) + assert.Equal(t, testRegion, req.Region) + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + req: &apiv1.GetPublicKeyRequest{Name: keyURI}, + }, + { + name: "fail/empty-name", + client: &MockClient{}, + req: &apiv1.GetPublicKeyRequest{}, + wantErr: true, + }, + { + name: "fail/api-error", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return nil, fmt.Errorf("api error") + }, + }, + req: &apiv1.GetPublicKeyRequest{Name: testKeyID}, + wantErr: true, + }, + { + name: "fail/bad-pem", + client: &MockClient{ + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: "not a pem"}, nil + }, + }, + req: &apiv1.GetPublicKeyRequest{Name: testKeyID}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k := &ScalewayKMS{client: tt.client, region: testRegion} + got, err := k.GetPublicKey(tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, pk, got) + }) + } +} + +func TestScalewayKMS_CreateSigner(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + + signingAlgo := km.KeyAlgorithmAsymmetricSigningEcP256Sha256 + client := &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return &km.Key{Usage: &km.KeyUsage{AsymmetricSigning: &signingAlgo}}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + } + + k := &ScalewayKMS{client: client, region: testRegion} + signer, err := k.CreateSigner(&apiv1.CreateSignerRequest{SigningKey: testKeyID}) + assert.NoError(t, err) + assert.NotNil(t, signer) + + // Empty signing key must fail. + _, err = k.CreateSigner(&apiv1.CreateSignerRequest{}) + assert.Error(t, err) +} + +func TestScalewayKMS_CreateKey(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + tests := []struct { + name string + req *apiv1.CreateKeyRequest + client *MockClient + wantErr bool + }{ + { + name: "ok/default-algo", + req: &apiv1.CreateKeyRequest{Name: "my-key"}, + client: &MockClient{ + createKey: func(req *km.CreateKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + assert.Equal(t, "my-key", *req.Name) + assert.True(t, req.Unprotected) + return &km.Key{ID: testKeyID, Region: testRegion}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + }, + { + name: "ok/ecdsa-p256", + req: &apiv1.CreateKeyRequest{Name: "ec-key", SignatureAlgorithm: apiv1.ECDSAWithSHA256}, + client: &MockClient{ + createKey: func(req *km.CreateKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + assert.NotNil(t, req.Usage.AsymmetricSigning) + assert.Equal(t, km.KeyAlgorithmAsymmetricSigningEcP256Sha256, *req.Usage.AsymmetricSigning) + return &km.Key{ID: testKeyID, Region: testRegion}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + }, + { + name: "ok/rsa-pkcs1-3072", + req: &apiv1.CreateKeyRequest{Name: "rsa-key", SignatureAlgorithm: apiv1.SHA256WithRSA, Bits: 3072}, + client: &MockClient{ + createKey: func(req *km.CreateKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + assert.Equal(t, km.KeyAlgorithmAsymmetricSigningRsaPkcs1_3072Sha256, *req.Usage.AsymmetricSigning) + return &km.Key{ID: testKeyID, Region: testRegion}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + }, + { + name: "ok/rsa-pss-default-bits", + req: &apiv1.CreateKeyRequest{Name: "rsa-pss-key", SignatureAlgorithm: apiv1.SHA256WithRSAPSS}, + client: &MockClient{ + createKey: func(req *km.CreateKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + // Default bits (0) → 3072 + assert.Equal(t, km.KeyAlgorithmAsymmetricSigningRsaPss3072Sha256, *req.Usage.AsymmetricSigning) + return &km.Key{ID: testKeyID, Region: testRegion}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + }, + { + name: "fail/empty-name", + req: &apiv1.CreateKeyRequest{}, + client: &MockClient{}, + wantErr: true, + }, + { + name: "fail/unsupported-algo", + req: &apiv1.CreateKeyRequest{Name: "k", SignatureAlgorithm: apiv1.ECDSAWithSHA512}, + client: &MockClient{}, + wantErr: true, + }, + { + name: "fail/unsupported-bits", + req: &apiv1.CreateKeyRequest{Name: "k", SignatureAlgorithm: apiv1.SHA256WithRSA, Bits: 1024}, + client: &MockClient{}, + wantErr: true, + }, + { + name: "fail/create-key-api-error", + req: &apiv1.CreateKeyRequest{Name: "k", SignatureAlgorithm: apiv1.ECDSAWithSHA256}, + client: &MockClient{ + createKey: func(req *km.CreateKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return nil, fmt.Errorf("api error") + }, + }, + wantErr: true, + }, + { + name: "fail/get-public-key-error", + req: &apiv1.CreateKeyRequest{Name: "k", SignatureAlgorithm: apiv1.ECDSAWithSHA256}, + client: &MockClient{ + createKey: func(req *km.CreateKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return &km.Key{ID: testKeyID, Region: testRegion}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return nil, fmt.Errorf("api error") + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k := &ScalewayKMS{client: tt.client, region: testRegion} + got, err := k.CreateKey(tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, pk, got.PublicKey) + assert.Contains(t, got.Name, testKeyID) + assert.Equal(t, got.Name, got.CreateSignerRequest.SigningKey) + }) + } +} + +func TestScalewayKMS_DeleteKey(t *testing.T) { + keyURI := testKeyURI(testKeyID) + + tests := []struct { + name string + client *MockClient + req *apiv1.DeleteKeyRequest + wantErr bool + }{ + { + name: "ok", + client: &MockClient{ + deleteKey: func(req *km.DeleteKeyRequest, _ ...scw.RequestOption) error { + assert.Equal(t, testKeyID, req.KeyID) + return nil + }, + }, + req: &apiv1.DeleteKeyRequest{Name: keyURI}, + }, + { + name: "fail/empty-name", + client: &MockClient{}, + req: &apiv1.DeleteKeyRequest{}, + wantErr: true, + }, + { + name: "fail/api-error", + client: &MockClient{ + deleteKey: func(req *km.DeleteKeyRequest, _ ...scw.RequestOption) error { + return fmt.Errorf("protected key") + }, + }, + req: &apiv1.DeleteKeyRequest{Name: testKeyID}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k := &ScalewayKMS{client: tt.client, region: testRegion} + err := k.DeleteKey(tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestParseKeyName(t *testing.T) { + const defaultRegion = scw.RegionFrPar + + tests := []struct { + name string + input string + wantKeyID string + wantRegion scw.Region + }{ + {"bare-uuid", testKeyID, testKeyID, defaultRegion}, + {"uri-with-params", "scwkms:key-id=" + testKeyID + ";region=nl-ams", testKeyID, "nl-ams"}, + {"opaque-uri", "scwkms:" + testKeyID, testKeyID, defaultRegion}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyID, region := parseKeyName(tt.input, defaultRegion) + assert.Equal(t, tt.wantKeyID, keyID) + assert.Equal(t, tt.wantRegion, region) + }) + } +} diff --git a/kms/scwkms/signer.go b/kms/scwkms/signer.go new file mode 100644 index 00000000..86fa74e2 --- /dev/null +++ b/kms/scwkms/signer.go @@ -0,0 +1,170 @@ +//go:build !noscwkms + +package scwkms + +import ( + "crypto" + "crypto/ecdsa" + "crypto/x509" + "fmt" + "io" + "math/big" + + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + "golang.org/x/crypto/cryptobyte" + cryptobyteasn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +// scwKeyAlgorithmMapping maps a Scaleway asymmetric signing algorithm to the +// corresponding x509.SignatureAlgorithm. This reverse mapping is used by the +// Signer to expose the correct SignatureAlgorithm() value — critical for +// distinguishing RSA-PKCS1 from RSA-PSS when signing certificates. +var scwKeyAlgorithmMapping = map[km.KeyAlgorithmAsymmetricSigning]x509.SignatureAlgorithm{ + km.KeyAlgorithmAsymmetricSigningEcP256Sha256: x509.ECDSAWithSHA256, + km.KeyAlgorithmAsymmetricSigningEcP384Sha384: x509.ECDSAWithSHA384, + km.KeyAlgorithmAsymmetricSigningRsaPkcs1_2048Sha256: x509.SHA256WithRSA, + km.KeyAlgorithmAsymmetricSigningRsaPkcs1_3072Sha256: x509.SHA256WithRSA, + km.KeyAlgorithmAsymmetricSigningRsaPkcs1_4096Sha256: x509.SHA256WithRSA, + km.KeyAlgorithmAsymmetricSigningRsaPss2048Sha256: x509.SHA256WithRSAPSS, + km.KeyAlgorithmAsymmetricSigningRsaPss3072Sha256: x509.SHA256WithRSAPSS, + km.KeyAlgorithmAsymmetricSigningRsaPss4096Sha256: x509.SHA256WithRSAPSS, +} + +// Signer implements a crypto.Signer using Scaleway Key Manager. +type Signer struct { + client KeyManagementClient + keyID string + region scw.Region + algorithm x509.SignatureAlgorithm + publicKey crypto.PublicKey +} + +// NewSigner creates a new Signer for the given Scaleway signing key. +func NewSigner(client KeyManagementClient, signingKey string) (*Signer, error) { + keyID, region := parseKeyName(signingKey, "") + + signer := &Signer{ + client: client, + keyID: keyID, + region: region, + } + if err := signer.preloadKey(); err != nil { + return nil, err + } + + return signer, nil +} + +// preloadKey fetches both the key metadata (for algorithm) and the public key +// (for the PEM). Two calls are necessary: GetKey provides the algorithm/usage +// required to distinguish RSA-PKCS1 from RSA-PSS; GetPublicKey provides the +// raw PEM bytes. +func (s *Signer) preloadKey() error { + // GetKey → algorithm + key, err := s.client.GetKey(&km.GetKeyRequest{ + Region: s.region, + KeyID: s.keyID, + }) + if err != nil { + return fmt.Errorf("scwkms GetKey failed: %w", err) + } + + if key.Usage != nil && key.Usage.AsymmetricSigning != nil { + if alg, ok := scwKeyAlgorithmMapping[*key.Usage.AsymmetricSigning]; ok { + s.algorithm = alg + } + // Unknown algorithm → x509.UnknownSignatureAlgorithm (zero value), which is fine. + } + + // GetPublicKey → PEM + pubKeyResp, err := s.client.GetPublicKey(&km.GetPublicKeyRequest{ + Region: s.region, + KeyID: s.keyID, + }) + if err != nil { + return fmt.Errorf("scwkms GetPublicKey failed: %w", err) + } + + s.publicKey, err = parsePublicKeyPEM([]byte(pubKeyResp.Pem)) + return err +} + +// Public returns the public key of this signer. +func (s *Signer) Public() crypto.PublicKey { + return s.publicKey +} + +// Sign signs the given digest using the Scaleway Key Manager signing API. +// +// Scaleway uses digest-only signing (the digest is sent directly). ECDSA +// signatures returned by Scaleway are defensively normalised to ASN.1 DER +// format because some KMS providers return raw IEEE P-1363 (r‖s) encoding. +// Go's x509 verifiers require DER. +// +// TODO: Remove the raw→DER normalisation once Scaleway's wire format is +// confirmed via a live integration test. +func (s *Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + // Validate that the requested hash is compatible with the key algorithm. + switch opts.HashFunc() { + case crypto.SHA256, crypto.SHA384, crypto.SHA512: + // accepted + default: + return nil, fmt.Errorf("scwkms Sign: unsupported hash function %v", opts.HashFunc()) + } + + // The Scaleway SDK manages context internally via HTTP; no explicit timeout + // context is threaded through the Sign call here. + + response, err := s.client.Sign(&km.SignRequest{ + Region: s.region, + KeyID: s.keyID, + Digest: digest, + }) + if err != nil { + return nil, fmt.Errorf("scwkms Sign failed: %w", err) + } + + sig := response.Signature + + // Normalise ECDSA signatures to ASN.1 DER if the key is an ECDSA key. + if ecKey, ok := s.publicKey.(*ecdsa.PublicKey); ok { + sig, err = normalizeECDSASignature(sig, ecKey) + if err != nil { + return nil, fmt.Errorf("scwkms Sign: signature normalisation failed: %w", err) + } + } + + return sig, nil +} + +// SignatureAlgorithm returns the signature algorithm of this signer. +// This is used when signing certificates to correctly encode the algorithm +// identifier — especially important to distinguish RSA-PKCS1 from RSA-PSS. +func (s *Signer) SignatureAlgorithm() x509.SignatureAlgorithm { + return s.algorithm +} + +// normalizeECDSASignature ensures the signature bytes are in ASN.1 DER format. +// If the signature is 2×curveByteLen bytes (raw IEEE P-1363 r‖s), it is +// re-encoded as DER SEQUENCE { INTEGER r, INTEGER s }. +// If the length does not match the raw format, the bytes are returned as-is +// (assumed to already be DER). +func normalizeECDSASignature(sig []byte, pubKey *ecdsa.PublicKey) ([]byte, error) { + curveByteLen := (pubKey.Curve.Params().BitSize + 7) / 8 + if len(sig) != curveByteLen*2 { + // Not raw P-1363; assume DER and return unchanged. + return sig, nil + } + + // Raw P-1363 format: encode as DER SEQUENCE { INTEGER r, INTEGER s }. + r := new(big.Int).SetBytes(sig[:curveByteLen]) + sv := new(big.Int).SetBytes(sig[curveByteLen:]) + + var b cryptobyte.Builder + b.AddASN1(cryptobyteasn1.SEQUENCE, func(b *cryptobyte.Builder) { + b.AddASN1BigInt(r) + b.AddASN1BigInt(sv) + }) + return b.Bytes() +} diff --git a/kms/scwkms/signer_test.go b/kms/scwkms/signer_test.go new file mode 100644 index 00000000..39fa2314 --- /dev/null +++ b/kms/scwkms/signer_test.go @@ -0,0 +1,289 @@ +package scwkms + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "fmt" + "os" + "testing" + + km "github.com/scaleway/scaleway-sdk-go/api/key_manager/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.step.sm/crypto/pemutil" +) + +func TestNewSigner(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + signingAlgo := km.KeyAlgorithmAsymmetricSigningEcP256Sha256 + + tests := []struct { + name string + client *MockClient + signingKey string + wantErr bool + wantAlg x509.SignatureAlgorithm + }{ + { + name: "ok/bare-id", + client: &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + assert.Equal(t, testKeyID, req.KeyID) + return &km.Key{Usage: &km.KeyUsage{AsymmetricSigning: &signingAlgo}}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + assert.Equal(t, testKeyID, req.KeyID) + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + signingKey: testKeyID, + wantAlg: x509.ECDSAWithSHA256, + }, + { + name: "ok/uri", + client: &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + assert.Equal(t, testKeyID, req.KeyID) + return &km.Key{Usage: &km.KeyUsage{AsymmetricSigning: &signingAlgo}}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + }, + signingKey: testKeyURI(testKeyID), + wantAlg: x509.ECDSAWithSHA256, + }, + { + name: "fail/get-key-error", + client: &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return nil, fmt.Errorf("api error") + }, + }, + signingKey: testKeyID, + wantErr: true, + }, + { + name: "fail/get-public-key-error", + client: &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return &km.Key{Usage: &km.KeyUsage{AsymmetricSigning: &signingAlgo}}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return nil, fmt.Errorf("api error") + }, + }, + signingKey: testKeyID, + wantErr: true, + }, + { + name: "fail/bad-pem", + client: &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return &km.Key{Usage: &km.KeyUsage{AsymmetricSigning: &signingAlgo}}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: "not a pem"}, nil + }, + }, + signingKey: testKeyID, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signer, err := NewSigner(tt.client, tt.signingKey) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, pk, signer.Public()) + assert.Equal(t, tt.wantAlg, signer.SignatureAlgorithm()) + }) + } +} + +func TestSigner_SignatureAlgorithm(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + + algos := []struct { + algo km.KeyAlgorithmAsymmetricSigning + wantAlg x509.SignatureAlgorithm + }{ + {km.KeyAlgorithmAsymmetricSigningEcP256Sha256, x509.ECDSAWithSHA256}, + {km.KeyAlgorithmAsymmetricSigningEcP384Sha384, x509.ECDSAWithSHA384}, + {km.KeyAlgorithmAsymmetricSigningRsaPkcs1_2048Sha256, x509.SHA256WithRSA}, + {km.KeyAlgorithmAsymmetricSigningRsaPkcs1_3072Sha256, x509.SHA256WithRSA}, + {km.KeyAlgorithmAsymmetricSigningRsaPkcs1_4096Sha256, x509.SHA256WithRSA}, + {km.KeyAlgorithmAsymmetricSigningRsaPss2048Sha256, x509.SHA256WithRSAPSS}, + {km.KeyAlgorithmAsymmetricSigningRsaPss3072Sha256, x509.SHA256WithRSAPSS}, + {km.KeyAlgorithmAsymmetricSigningRsaPss4096Sha256, x509.SHA256WithRSAPSS}, + } + + for _, tc := range algos { + t.Run(string(tc.algo), func(t *testing.T) { + a := tc.algo + client := &MockClient{ + getKey: func(req *km.GetKeyRequest, _ ...scw.RequestOption) (*km.Key, error) { + return &km.Key{Usage: &km.KeyUsage{AsymmetricSigning: &a}}, nil + }, + getPublicKey: func(req *km.GetPublicKeyRequest, _ ...scw.RequestOption) (*km.PublicKey, error) { + return &km.PublicKey{Pem: string(pemBytes)}, nil + }, + } + signer, err := NewSigner(client, testKeyID) + require.NoError(t, err) + assert.Equal(t, tc.wantAlg, signer.SignatureAlgorithm()) + }) + } +} + +func TestSigner_Public(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + + s := &Signer{publicKey: pk} + assert.Equal(t, pk, s.Public()) +} + +func TestSigner_Sign(t *testing.T) { + pemBytes, err := os.ReadFile("testdata/pub.pem") + require.NoError(t, err) + pk, err := pemutil.ParseKey(pemBytes) + require.NoError(t, err) + ecPub, ok := pk.(*ecdsa.PublicKey) + require.True(t, ok) + + digest := []byte("0123456789abcdef0123456789abcdef") // 32-byte SHA-256 + + // A valid DER-encoded signature to use as mock return. + derSig := makeDERSignature(t) + + tests := []struct { + name string + client *MockClient + digest []byte + opts crypto.SignerOpts + wantErr bool + }{ + { + name: "ok/sha256", + client: &MockClient{ + sign: func(req *km.SignRequest, _ ...scw.RequestOption) (*km.SignResponse, error) { + assert.Equal(t, digest, req.Digest) + return &km.SignResponse{Signature: derSig}, nil + }, + }, + digest: digest, + opts: crypto.SHA256, + }, + { + name: "ok/raw-p1363-normalised", + client: &MockClient{ + sign: func(req *km.SignRequest, _ ...scw.RequestOption) (*km.SignResponse, error) { + // Return a raw P-1363 signature (64 bytes for P-256). + rawSig := make([]byte, 64) + copy(rawSig[:32], digest) + copy(rawSig[32:], digest) + return &km.SignResponse{Signature: rawSig}, nil + }, + }, + digest: digest, + opts: crypto.SHA256, + }, + { + name: "fail/unsupported-hash", + client: &MockClient{}, + digest: digest, + opts: crypto.MD5, + wantErr: true, + }, + { + name: "fail/api-error", + client: &MockClient{ + sign: func(req *km.SignRequest, _ ...scw.RequestOption) (*km.SignResponse, error) { + return nil, fmt.Errorf("api error") + }, + }, + digest: digest, + opts: crypto.SHA256, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Signer{ + client: tt.client, + keyID: testKeyID, + region: testRegion, + publicKey: ecPub, + algorithm: x509.ECDSAWithSHA256, + } + got, err := s.Sign(rand.Reader, tt.digest, tt.opts) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.NotEmpty(t, got) + }) + } +} + +func TestNormalizeECDSASignature(t *testing.T) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + pub := &privKey.PublicKey + + // Create a 64-byte raw P-1363 signature. + rawR := make([]byte, 32) + rawS := make([]byte, 32) + _, _ = rand.Read(rawR) + _, _ = rand.Read(rawS) + rawSig := make([]byte, 0, 64) + rawSig = append(rawSig, rawR...) + rawSig = append(rawSig, rawS...) + + // Normalise: should produce a DER sequence. + der, err := normalizeECDSASignature(rawSig, pub) + require.NoError(t, err) + assert.Greater(t, len(der), 64, "DER encoding should add overhead") + assert.Equal(t, byte(0x30), der[0], "DER sequence tag should be 0x30") + + // Already DER (wrong length for raw): should return unchanged. + shortSig := []byte{0x30, 0x44, 0x02, 0x20} // 4 bytes, not 64 + result, err := normalizeECDSASignature(shortSig, pub) + require.NoError(t, err) + assert.Equal(t, shortSig, result) +} + +// makeDERSignature creates a small valid DER ECDSA signature for testing. +func makeDERSignature(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + digest := make([]byte, 32) + _, err = rand.Read(digest) + require.NoError(t, err) + sig, err := ecdsa.SignASN1(rand.Reader, key, digest) + require.NoError(t, err) + return sig +} + +// Ensure Signer satisfies crypto.Signer at compile time. +var _ crypto.Signer = (*Signer)(nil) diff --git a/kms/scwkms/testdata/pub.pem b/kms/scwkms/testdata/pub.pem new file mode 100644 index 00000000..e31e583e --- /dev/null +++ b/kms/scwkms/testdata/pub.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5VPD/W5RXn0lrs2MdoNteTSZ+sh1 +veT13hakPZF9YzaNVZgujqK3d1nt+4jPECU+ED/WQ1GgFZiVGUo3flvB/w== +-----END PUBLIC KEY----- diff --git a/kms/scwkms/testdata/rsapub.pem b/kms/scwkms/testdata/rsapub.pem new file mode 100644 index 00000000..37276edf --- /dev/null +++ b/kms/scwkms/testdata/rsapub.pem @@ -0,0 +1,11 @@ +-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAsrAcFrWhJLBEjgrbt962 +GJULrg/FZQ+MB+KQJi+mv2VoVq6Rp7g+H7EyAi8b9a9cf1X4vFLjEpEFFhhji0e7 +BZIIeE96rOHdq9Rf8MEyy8YcqKe1eTeQhOTsyD7pletjyNUOC/DexN2saS7N80uj +vEKpEprDNf+XCTrQqDTlBO4LWNPu7e8xjm+Wvnb9P8Kizln4Z8cfRjxRUwpYRgjh +YdhHegT2a6lZodthh+iw83P5WlxJBuDNiKk+E0kIePDQLg2irjNr/gjo2O13yUNn ++ZdtdRjSjOAlrW/n1rxs6TIMYv/qEGinnj2j47cTAQqDzNdw8GwbzJcA0iajNvQj +Ge4RoO0p4jPiD+9H0qfraNf3e1VQcIUoRoohlwrisBJCUrpSiTKbp9orVCrY9ZvA +uQyUpiLnsN7//+tQUDEX3Tw4rpl0oY48TYMqzzs9mNd3lnkJnkEecM0m+dP/8wEb +Np5KtEnqmuW3UNWnvzxB9HGVIYt35fjGCHHIZl9PbnqbAgMBAAE= +-----END PUBLIC KEY-----