diff --git a/README.md b/README.md index 8952672..7dd620a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ [![CI 🏗](https://github.com/RealImage/dyno/actions/workflows/ci.yml/badge.svg)](https://github.com/RealImage/dyno/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/RealImage/dyno.svg)](https://pkg.go.dev/github.com/RealImage/dyno) -Encrypt and decrypt DynamoDB table attribute value maps using either AWS KMS -or AES with a password. Useful for sending clients the LastEvaluatedKey from -the result of a DynamoDB Query or Scan operation, for paginating results without -leaking sensitive information. +Encrypt and decrypt DynamoDB primary key attribute values. +You can either use AWS KMS (don't manage keys; expensive) or AES with +your choice of password. + +Use it to send encrypted last evaluated key values that clients can use +as cursors to paginate through DynamoDB results. ## License diff --git a/aesitemcrypter.go b/aeskeycrypter.go similarity index 88% rename from aesitemcrypter.go rename to aeskeycrypter.go index 1bb1cf6..8c563fb 100644 --- a/aesitemcrypter.go +++ b/aeskeycrypter.go @@ -13,8 +13,9 @@ import ( "golang.org/x/crypto/pbkdf2" ) -// NewAesItemCrypter creates a new ItemCrypter that uses AES GCM encryption. -func NewAesItemCrypter(password, salt []byte) (ItemCrypter, error) { +// NewAesCrypter creates a new KeyCrypter that encrypts DynamoDB primary key attributes +// with AES GCM encryption. +func NewAesCrypter(password, salt []byte) (KeyCrypter, error) { key := pbkdf2.Key(password, salt, 4096, 32, sha1.New) block, err := aes.NewCipher(key) if err != nil { @@ -59,9 +60,9 @@ func (c *aesCryptedItem) Encrypt(ctx context.Context, // The item must have been encrypted with the same encryption context. func (c *aesCryptedItem) Decrypt( ctx context.Context, - item string, + itemStr string, ) (map[string]types.AttributeValue, error) { - nonceAndCipherText, err := base64.URLEncoding.DecodeString(item) + nonceAndCipherText, err := base64.URLEncoding.DecodeString(itemStr) if err != nil { return nil, err } diff --git a/aesitemcrypter_test.go b/aeskeycrypter_test.go similarity index 83% rename from aesitemcrypter_test.go rename to aeskeycrypter_test.go index d85d5f6..2e7bb08 100644 --- a/aesitemcrypter_test.go +++ b/aeskeycrypter_test.go @@ -11,6 +11,7 @@ import ( var testCases = []struct { name string item map[string]types.AttributeValue + err bool }{ { name: "string", @@ -35,12 +36,14 @@ var testCases = []struct { item: map[string]types.AttributeValue{ "key": &types.AttributeValueMemberBOOL{Value: true}, }, + err: true, }, { name: "null", item: map[string]types.AttributeValue{ "key": &types.AttributeValueMemberNULL{Value: true}, }, + err: true, }, { name: "list", @@ -55,6 +58,7 @@ var testCases = []struct { }, }, }, + err: true, }, { name: "map", @@ -65,14 +69,26 @@ var testCases = []struct { }, }, }, + err: true, + }, + { + name: "binarySet", + item: map[string]types.AttributeValue{ + "key": &types.AttributeValueMemberBS{ + Value: [][]byte{ + []byte("value"), + }, + }, + }, + err: true, }, } -func TestAesItemCrypter(t *testing.T) { +func TestAesCrypter(t *testing.T) { password := []byte("password") salt := []byte("saltsalt") - ic, err := NewAesItemCrypter(password, salt) + ic, err := NewAesCrypter(password, salt) if err != nil { t.Fatalf("NewAesItemCrypter() error = %v, want nil", err) } @@ -84,6 +100,16 @@ func TestAesItemCrypter(t *testing.T) { for _, tc := range testCases { t.Run("EncryptDecrypt_"+tc.name, func(t *testing.T) { cipherText, err := ic.Encrypt(context.Background(), tc.item) + + if tc.err { + if err == nil { + t.Fatalf("Encrypt() error = nil, want error") + } + + // OK + return + } + if err != nil { t.Fatalf("Encrypt() error = %v, want nil", err) } diff --git a/go.mod b/go.mod index 195429b..c6e2699 100644 --- a/go.mod +++ b/go.mod @@ -15,4 +15,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.22.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/x448/float16 v0.8.4 // indirect ) diff --git a/go.sum b/go.sum index f5b488d..e375ab6 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,14 @@ github.com/aws/smithy-go v1.17.0 h1:wWJD7LX6PBV6etBUwO0zElG0nWN9rUhp0WdYeHSHAaI= github.com/aws/smithy-go v1.17.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= diff --git a/itemcrypter.go b/keycrypter.go similarity index 62% rename from itemcrypter.go rename to keycrypter.go index 310e933..45e3a9a 100644 --- a/itemcrypter.go +++ b/keycrypter.go @@ -6,26 +6,27 @@ // // Example: // -// // Create a new CryptedItem -// cryptedItem := dyno.NewCryptedItem("alias/my-kms-key", kmsClient) +// // Create a new AesCrypter +// crypter := dyno.NewAesCrypter([]byte("encryption-password"), []byte("salt")) // // // Encrypt the lastEvaluatedKey -// encryptedLastEvaluatedKey, err := cryptedItem.Encrypt(ctx, map[string]string{ +// encryptedLastEvaluatedKey, err := crypter.Encrypt(ctx, map[string]string{ // "clientID": "1234", // }, lastEvaluatedKey) // -// // Pass the encryptedLastEvaluatedKey to the client +// // Pass the encryptedLastEvaluatedKey to the client in the response // -// // Client passes the encryptedLastEvaluatedKey back to the server +// // Client passes the encryptedLastEvaluatedKey back to the server in the next request // // // Decrypt the encryptedLastEvaluatedKey -// lastEvaluatedKey, err := cryptedItem.Decrypt(ctx, map[string]string{ +// lastEvaluatedKey, err := crypter.Decrypt(ctx, map[string]string{ // "clientID": "1234", // }, encryptedLastEvaluatedKey) package dyno import ( "context" + "encoding/base64" "encoding/json" "fmt" @@ -33,8 +34,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) -// ItemCrypter is an interface that encrypts and decrypts dynamodb items. -type ItemCrypter interface { +// KeyCrypter is an interface that encrypts and decrypts DynamoDB primary key attribute values. +type KeyCrypter interface { Encrypt(ctx context.Context, item map[string]types.AttributeValue) (string, error) Decrypt(ctx context.Context, item string) (map[string]types.AttributeValue, error) } @@ -52,13 +53,20 @@ func getEncryptionContext(ctx context.Context) (ec map[string]string, ok bool) { } func serialize(input map[string]types.AttributeValue) ([]byte, error) { + for _, v := range input { + switch v.(type) { + case *types.AttributeValueMemberS, *types.AttributeValueMemberB, *types.AttributeValueMemberN: + continue + default: + return nil, fmt.Errorf("unsupported type: %T", v) + } + } + var jsonMap map[string]any if err := attributevalue.UnmarshalMap(input, &jsonMap); err != nil { return nil, err } - fmt.Println(jsonMap) - return json.Marshal(jsonMap) } @@ -68,5 +76,19 @@ func deserialize(input []byte) (map[string]types.AttributeValue, error) { return nil, err } - return attributevalue.MarshalMap(jsonMap) + output, err := attributevalue.MarshalMap(jsonMap) + if err != nil { + return nil, err + } + + // Convert hex strings back to byte slices + for k, v := range output { + if s, ok := v.(*types.AttributeValueMemberS); ok { + if val, err := base64.StdEncoding.DecodeString(s.Value); err == nil { + output[k] = &types.AttributeValueMemberB{Value: val} + } + } + } + + return output, nil } diff --git a/kmsitemcrypter.go b/kmskeycrypter.go similarity index 83% rename from kmsitemcrypter.go rename to kmskeycrypter.go index 94d57d0..ead62ee 100644 --- a/kmsitemcrypter.go +++ b/kmskeycrypter.go @@ -8,22 +8,22 @@ import ( "github.com/aws/aws-sdk-go-v2/service/kms" ) -func NewKmsItemCrypter(kmsKeyID string, kmsClient *kms.Client) ItemCrypter { - return &kmsCryptedItem{ +func NewKmsCrypter(kmsKeyID string, kmsClient *kms.Client) KeyCrypter { + return &kmsCrypter{ kmsKeyID: kmsKeyID, kmsClient: kmsClient, } } -// kmsCryptedItem is a struct that encrypts and decrypts dynamodb items with a KMS key. -type kmsCryptedItem struct { +// kmsCrypter is a struct that encrypts and decrypts dynamodb items with a KMS key. +type kmsCrypter struct { kmsKeyID string kmsClient *kms.Client } // Encrypt encrypts a dynamodb item. If ctx contains an encryption context, it will be used // to encrypt the item. -func (c *kmsCryptedItem) Encrypt( +func (c *kmsCrypter) Encrypt( ctx context.Context, item map[string]types.AttributeValue, ) (string, error) { @@ -51,7 +51,7 @@ func (c *kmsCryptedItem) Encrypt( // Decrypt decrypts a dynamodb item. If ctx contains an encryption context, it will be used // to decrypt the item. The item must have been encrypted with the same encryption context. -func (c *kmsCryptedItem) Decrypt( +func (c *kmsCrypter) Decrypt( ctx context.Context, item string, ) (map[string]types.AttributeValue, error) {