Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kms/awskms/awskms.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type KeyManagementClient interface {
CreateKey(ctx context.Context, input *kms.CreateKeyInput, opts ...func(*kms.Options)) (*kms.CreateKeyOutput, error)
CreateAlias(ctx context.Context, input *kms.CreateAliasInput, opts ...func(*kms.Options)) (*kms.CreateAliasOutput, error)
Sign(ctx context.Context, input *kms.SignInput, opts ...func(*kms.Options)) (*kms.SignOutput, error)
Decrypt(ctx context.Context, params *kms.DecryptInput, optFns ...func(*kms.Options)) (*kms.DecryptOutput, error)
}

// customerMasterKeySpecMapping is a mapping between the step signature algorithm,
Expand Down
171 changes: 171 additions & 0 deletions kms/awskms/decrypter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package awskms

import (
"crypto"
"crypto/rsa"
"errors"
"fmt"
"io"

"github.com/aws/aws-sdk-go-v2/service/kms"
"github.com/aws/aws-sdk-go-v2/service/kms/types"

"go.step.sm/crypto/kms/apiv1"
"go.step.sm/crypto/pemutil"
)

// CreateDecrypter implements the [apiv1.Decrypter] interface and returns
// a [crypto.Decrypter] backed by a decryption key in AWS KMS.
func (k *KMS) CreateDecrypter(req *apiv1.CreateDecrypterRequest) (crypto.Decrypter, error) {
if req.DecryptionKey == "" {
return nil, errors.New("decryption key cannot be empty")
}

return NewDecrypter(k.client, req.DecryptionKey)
}

// Decrypter implements a [crypto.Decrypter] using AWS KMS.
type Decrypter struct {
client KeyManagementClient
keyID string
publicKey crypto.PublicKey
}

// NewDecrypter creates a new [crypto.Decrypter] backed by the given
// AWS KMS. decryption key.
func NewDecrypter(client KeyManagementClient, decryptionKey string) (*Decrypter, error) {
keyID, err := parseKeyID(decryptionKey)
if err != nil {
return nil, err
}

decrypter := &Decrypter{
client: client,
keyID: keyID,
}
if err := decrypter.preloadKey(); err != nil {
return nil, err
}

return decrypter, nil
}

func (d *Decrypter) preloadKey() error {
ctx, cancel := defaultContext()
defer cancel()

resp, err := d.client.GetPublicKey(ctx, &kms.GetPublicKeyInput{
KeyId: pointer(d.keyID),
})
if err != nil {
return fmt.Errorf("awskms GetPublicKey failed: %w", err)
}

d.publicKey, err = pemutil.ParseDER(resp.PublicKey)
return err
}

// Public returns the public key of this decrypter
func (d *Decrypter) Public() crypto.PublicKey {
return d.publicKey
}

// Decrypt decrypts ciphertext using the decryption key backed by AWS KMS and returns
// the plaintext bytes. An error is returned when decryption fails. AWS KMS only supports
// RSA keys with 2048, 3072 or 4096 bits and will always use OAEP. It supports SHA1 and SHA256.
// Labels are not supported. Before calling out to AWS, some validation is performed
// so that known bad parameters are detected client-side and a more meaningful error is returned
// for those cases.
//
// Also see https://docs.aws.amazon.com/kms/latest/developerguide/symm-asymm-choose-key-spec.html#key-spec-rsa.
func (d *Decrypter) Decrypt(_ io.Reader, ciphertext []byte, opts crypto.DecrypterOpts) ([]byte, error) {
algorithm, err := determineDecryptionAlgorithm(d.publicKey, opts)
if err != nil {
return nil, fmt.Errorf("failed determining decryption algorithm: %w", err)
}

req := &kms.DecryptInput{
KeyId: pointer(d.keyID),
CiphertextBlob: ciphertext,
EncryptionAlgorithm: algorithm,
}

ctx, cancel := defaultContext()
defer cancel()

response, err := d.client.Decrypt(ctx, req)
if err != nil {
return nil, fmt.Errorf("awskms Decrypt failed: %w", err)
}

return response.Plaintext, nil
}

const (
awsOaepSha1 = "RSAES_OAEP_SHA_1"
awsOaepSha256 = "RSAES_OAEP_SHA_256"
)

func determineDecryptionAlgorithm(key crypto.PublicKey, opts crypto.DecrypterOpts) (types.EncryptionAlgorithmSpec, error) {
pub, ok := key.(*rsa.PublicKey)
if !ok {
return "", fmt.Errorf("awskms does not support key type %T", key)
}

if opts == nil {
opts = &rsa.OAEPOptions{}
}

var rsaOpts *rsa.OAEPOptions
switch o := opts.(type) {
case *rsa.OAEPOptions:
if err := validateOAEPOptions(o); err != nil {
return "", err
}
rsaOpts = o
case *rsa.PKCS1v15DecryptOptions:
return "", errors.New("awskms does not support PKCS #1 v1.5 decryption")
default:
return "", fmt.Errorf("invalid decrypter options type %T", opts)
}

switch bitSize := pub.Size() * 8; bitSize {
default:
return "", fmt.Errorf("awskms does not support RSA public key size %d", bitSize)
case 2048, 3072, 4096:
switch rsaOpts.Hash {
case crypto.SHA1:
return awsOaepSha1, nil
case crypto.SHA256:
return awsOaepSha256, nil
case crypto.Hash(0):
// set a sane default hashing algorithm when it's not set. AWS KMS only supports
// SHA1 and SHA256, so using SHA256 generally shouldn't result in a decryption
// operation breaking, but it depends on the sending side whether or not this
// is the correct value. If it's not provided through opts, then there's no other
// way to determine which algorithm to use, though, so this is an optimistic attempt
// at decryption.
return awsOaepSha256, nil
default:
return "", fmt.Errorf("awskms does not support hash algorithm %q with RSA-OAEP", rsaOpts.Hash)
}
}
}

// validateOAEPOptions validates the RSA OAEP options provided.
func validateOAEPOptions(o *rsa.OAEPOptions) error {
if len(o.Label) > 0 {
return errors.New("awskms does not support RSA-OAEP label")
}

switch {
case o.Hash != 0 && o.MGFHash == 0: // assumes same hash is being used for both
break
case o.Hash != 0 && o.MGFHash != 0 && o.Hash != o.MGFHash:
return fmt.Errorf("awskms does not support using different algorithms for hashing %q and masking %q", o.Hash, o.MGFHash)
}

return nil
}

var _ apiv1.Decrypter = (*KMS)(nil)
185 changes: 185 additions & 0 deletions kms/awskms/decrypter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package awskms

import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"encoding/pem"
"fmt"
"hash"
"testing"

"github.com/aws/aws-sdk-go-v2/service/kms"
"github.com/stretchr/testify/require"

"go.step.sm/crypto/kms/apiv1"
"go.step.sm/crypto/pemutil"
)

func TestCreateDecrypter(t *testing.T) {
key, err := pemutil.ParseKey([]byte(rsaPublicKey))
require.NoError(t, err)
require.IsType(t, &rsa.PublicKey{}, key)
rsaKey := key.(*rsa.PublicKey)

k := &KMS{client: &MockClient{
getPublicKey: func(ctx context.Context, input *kms.GetPublicKeyInput, opts ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) {
block, _ := pem.Decode([]byte(rsaPublicKey))
return &kms.GetPublicKeyOutput{
KeyId: input.KeyId,
PublicKey: block.Bytes,
}, nil
},
}}

// fail with empty decryption key
d, err := k.CreateDecrypter(&apiv1.CreateDecrypterRequest{
DecryptionKey: "",
})
require.Error(t, err)
require.Nil(t, d)

// expect same public key to be returned
d, err = k.CreateDecrypter(&apiv1.CreateDecrypterRequest{
DecryptionKey: "test",
})
require.NoError(t, err)
require.NotNil(t, d)
require.True(t, rsaKey.Equal(d.Public()))
}

func TestDecrypterDecrypts(t *testing.T) {
kms, pub := createTestKMS(t, 2048)
fail1024KMS, _ := createTestKMS(t, 1024)

// prepare encrypted contents
encSHA256, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, pub, []byte("test"), nil)
require.NoError(t, err)
encSHA1, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, pub, []byte("test"), nil)
require.NoError(t, err)

// create a decrypter, identified by "test-sha256", and check the public key
d256, err := kms.CreateDecrypter(&apiv1.CreateDecrypterRequest{
DecryptionKey: "test-sha256",
})
require.NoError(t, err)
require.NotNil(t, d256)
require.True(t, pub.Equal(d256.Public()))

// create a decrypter, identified by "test-sha1", and check the public key
d1, err := kms.CreateDecrypter(&apiv1.CreateDecrypterRequest{
DecryptionKey: "test-sha1",
})
require.NoError(t, err)
require.NotNil(t, d1)
require.True(t, pub.Equal(d1.Public()))

t.Run("ok/sha256", func(t *testing.T) {
// successful decryption using OAEP with SHA-256
plain, err := d256.Decrypt(nil, encSHA256, &rsa.OAEPOptions{Hash: crypto.SHA256})
require.NoError(t, err)
require.Equal(t, []byte("test"), plain)
})

t.Run("ok/sha1", func(t *testing.T) {
// successful decryption using OAEP with SHA-1
plain, err := d1.Decrypt(nil, encSHA1, &rsa.OAEPOptions{Hash: crypto.SHA1})
require.NoError(t, err)
require.Equal(t, []byte("test"), plain)
})

t.Run("ok/default-options", func(t *testing.T) {
// successful decryption, defaulting to OAEP with SHA-256
plain, err := d256.Decrypt(nil, encSHA256, nil)
require.NoError(t, err)
require.Equal(t, []byte("test"), plain)
})

t.Run("fail/hash", func(t *testing.T) {
plain, err := d256.Decrypt(nil, encSHA256, &rsa.OAEPOptions{Hash: crypto.SHA384})
require.EqualError(t, err, `failed determining decryption algorithm: awskms does not support hash algorithm "SHA-384" with RSA-OAEP`)
require.Empty(t, plain)
})

t.Run("fail/label", func(t *testing.T) {
plain, err := d256.Decrypt(nil, encSHA256, &rsa.OAEPOptions{Hash: crypto.SHA256, Label: []byte{1, 2, 3, 4}})
require.EqualError(t, err, "failed determining decryption algorithm: awskms does not support RSA-OAEP label")
require.Empty(t, plain)
})

t.Run("fail/hash-mismatch", func(t *testing.T) {
plain, err := d256.Decrypt(nil, encSHA256, &rsa.OAEPOptions{Hash: crypto.SHA256, MGFHash: crypto.SHA384})
require.EqualError(t, err, `failed determining decryption algorithm: awskms does not support using different algorithms for hashing "SHA-256" and masking "SHA-384"`)
require.Empty(t, plain)
})

t.Run("fail/pkcs15", func(t *testing.T) {
plain, err := d256.Decrypt(nil, encSHA256, &rsa.PKCS1v15DecryptOptions{})
require.EqualError(t, err, "failed determining decryption algorithm: awskms does not support PKCS #1 v1.5 decryption")
require.Empty(t, plain)
})

t.Run("fail/invalid-options", func(t *testing.T) {
plain, err := d256.Decrypt(nil, encSHA256, struct{}{})
require.EqualError(t, err, "failed determining decryption algorithm: invalid decrypter options type struct {}")
require.Empty(t, plain)
})

t.Run("fail/invalid-key", func(t *testing.T) {
failingDecrypter, err := fail1024KMS.CreateDecrypter(&apiv1.CreateDecrypterRequest{
DecryptionKey: "fail",
})
require.NoError(t, err)

_, err = failingDecrypter.Decrypt(nil, nil, nil)
require.EqualError(t, err, "failed determining decryption algorithm: awskms does not support RSA public key size 1024")
})
}

func createTestKMS(t *testing.T, bitSize int) (*KMS, *rsa.PublicKey) {
t.Helper()

key, err := rsa.GenerateKey(rand.Reader, bitSize)
require.NoError(t, err)

k := &KMS{client: &MockClient{
getPublicKey: func(ctx context.Context, input *kms.GetPublicKeyInput, opts ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) {
block, _ := pemutil.Serialize(key.Public())
return &kms.GetPublicKeyOutput{
KeyId: input.KeyId,
PublicKey: block.Bytes,
}, nil
},
decrypt: func(ctx context.Context, params *kms.DecryptInput, optFns ...func(*kms.Options)) (*kms.DecryptOutput, error) {
var h hash.Hash
switch *params.KeyId {
case "test-sha256":
if params.EncryptionAlgorithm != "RSAES_OAEP_SHA_256" {
return nil, fmt.Errorf("invalid encryption algorithm %q", params.EncryptionAlgorithm)
}
h = sha256.New()
case "test-sha1":
if params.EncryptionAlgorithm != "RSAES_OAEP_SHA_1" {
return nil, fmt.Errorf("invalid encryption algorithm %q", params.EncryptionAlgorithm)
}
h = sha1.New()
default:
return nil, fmt.Errorf("invalid key ID %q", *params.KeyId)
}

dec, err := rsa.DecryptOAEP(h, nil, key, params.CiphertextBlob, nil)
if err != nil {
return nil, err
}
return &kms.DecryptOutput{
KeyId: params.KeyId,
Plaintext: dec,
}, nil
},
}}

return k, &key.PublicKey
}
Loading