162 changes: 162 additions & 0 deletions internal/etm/etm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package etm

import (
"bytes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"regexp"
"testing"
)

var _ cipher.AEAD = &etmAEAD{}

var whitespace = regexp.MustCompile(`[\s]+`)

func decode(s string) []byte {
b, err := hex.DecodeString(whitespace.ReplaceAllString(s, ""))
if err != nil {
panic(err)
}
return b
}

func TestOverhead(t *testing.T) {
aead, err := NewAES256SHA512(make([]byte, 64))
if err != nil {
t.Fatal(err)
}

expected := 72
actual := aead.Overhead()
if actual != expected {
t.Errorf("Expected %v but was %v", expected, actual)
}
}

func TestNonceSize(t *testing.T) {
aead, err := NewAES256SHA512(make([]byte, 64))
if err != nil {
t.Fatal(err)
}

expected := 16
actual := aead.NonceSize()
if actual != expected {
t.Errorf("Expected %v but was %v", expected, actual)
}
}

func TestBadKeySizes(t *testing.T) {
aead, err := NewAES256SHA512(nil)
if err == nil {
t.Errorf("No error for 256/512, got %v instead", aead)
}
}

func TestBadMessage(t *testing.T) {
aead, err := NewAES256SHA512(make([]byte, 64))
if err != nil {
t.Fatal(err)
}

input := make([]byte, 100)
output := aead.Seal(nil, make([]byte, aead.NonceSize()), input, nil)
output[91] ^= 3

b, err := aead.Open(nil, make([]byte, aead.NonceSize()), output, nil)
if err == nil {
t.Errorf("Expected error but got %v", b)
}
}

func TestAEAD_AES_256_CBC_HMAC_SHA_512(t *testing.T) {
aead, err := NewAES256SHA512(decode(`
20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f
30 31 32 33 34 35 36 37 38 39 3a 3b 3c 3d 3e 3f
00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f
`))
if err != nil {
t.Fatal(err)
}

p := decode(`
41 20 63 69 70 68 65 72 20 73 79 73 74 65 6d 20
6d 75 73 74 20 6e 6f 74 20 62 65 20 72 65 71 75
69 72 65 64 20 74 6f 20 62 65 20 73 65 63 72 65
74 2c 20 61 6e 64 20 69 74 20 6d 75 73 74 20 62
65 20 61 62 6c 65 20 74 6f 20 66 61 6c 6c 20 69
6e 74 6f 20 74 68 65 20 68 61 6e 64 73 20 6f 66
20 74 68 65 20 65 6e 65 6d 79 20 77 69 74 68 6f
75 74 20 69 6e 63 6f 6e 76 65 6e 69 65 6e 63 65
`)

iv := decode(`
1a f3 8c 2d c2 b9 6f fd d8 66 94 09 23 41 bc 04
`)

a := decode(`
54 68 65 20 73 65 63 6f 6e 64 20 70 72 69 6e 63
69 70 6c 65 20 6f 66 20 41 75 67 75 73 74 65 20
4b 65 72 63 6b 68 6f 66 66 73
`)

expected := decode(`
1a f3 8c 2d c2 b9 6f fd d8 66 94 09 23 41 bc 04
4a ff aa ad b7 8c 31 c5 da 4b 1b 59 0d 10 ff bd
3d d8 d5 d3 02 42 35 26 91 2d a0 37 ec bc c7 bd
82 2c 30 1d d6 7c 37 3b cc b5 84 ad 3e 92 79 c2
e6 d1 2a 13 74 b7 7f 07 75 53 df 82 94 10 44 6b
36 eb d9 70 66 29 6a e6 42 7e a7 5c 2e 08 46 a1
1a 09 cc f5 37 0d c8 0b fe cb ad 28 c7 3f 09 b3
a3 b7 5e 66 2a 25 94 41 0a e4 96 b2 e2 e6 60 9e
31 e6 e0 2c c8 37 f0 53 d2 1f 37 ff 4f 51 95 0b
be 26 38 d0 9d d7 a4 93 09 30 80 6d 07 03 b1 f6
4d d3 b4 c0 88 a7 f4 5c 21 68 39 64 5b 20 12 bf
2e 62 69 a8 c5 6a 81 6d bc 1b 26 77 61 95 5b c5
`)

c := aead.Seal(nil, iv, p, a)
if !bytes.Equal(expected, c) {
t.Errorf("Expected \n%x\n but was \n%x", expected, c)
}

p2, err := aead.Open(nil, iv, c, a)
if err != nil {
t.Fatal(err)
}

if !bytes.Equal(p, p2) {
t.Error("Bad round-trip")
}
}

func Example() {
key := []byte("yellow submarine was a love song hunt for red october was a film")
plaintext := []byte("this is a secret value")
data := []byte("this is a public value")

aead, err := NewAES256SHA512(key)
if err != nil {
fmt.Println(err)
return
}

nonce := make([]byte, aead.NonceSize())
_, _ = io.ReadFull(rand.Reader, nonce)

ciphertext := aead.Seal(nil, nonce, plaintext, data)

secret, err := aead.Open(nil, nil, ciphertext, data)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(string(secret))
// Output:
// this is a secret value
}
135 changes: 135 additions & 0 deletions pkg/transforms/aesprotection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// Copyright (c) 2021 One Track Consulting
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

package transforms

import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/edgexfoundry/app-functions-sdk-go/v2/internal/etm"
"github.com/edgexfoundry/app-functions-sdk-go/v2/pkg/interfaces"
"github.com/edgexfoundry/app-functions-sdk-go/v2/pkg/util"

"github.com/edgexfoundry/go-mod-core-contracts/v2/common"
)

type AESProtection struct {
SecretPath string
SecretName string
EncryptionKey string
}

// NewAESProtection creates, initializes and returns a new instance of AESProtection configured
// to retrieve the encryption key from the Secret Store
func NewAESProtection(secretPath string, secretName string) AESProtection {
return AESProtection{
SecretPath: secretPath,
SecretName: secretName,
}
}

// Encrypt encrypts a string, []byte, or json.Marshaller type using AES 256 encryption.
// It also signs the data using a SHA512 hash.
// It will return a Base64 encode []byte of the encrypted data.
func (protection AESProtection) Encrypt(ctx interfaces.AppFunctionContext, data interface{}) (bool, interface{}) {
if data == nil {
return false, fmt.Errorf("function Encrypt in pipeline '%s': No Data Received", ctx.PipelineId())
}

ctx.LoggingClient().Debugf("Encrypting with AES256 in pipeline '%s'", ctx.PipelineId())

byteData, err := util.CoerceType(data)
if err != nil {
return false, err
}

key, err := protection.getKey(ctx)

if err != nil {
return false, err
}

if len(key) == 0 {
return false, fmt.Errorf("AES256 encryption key not set in pipeline '%s'", ctx.PipelineId())
}

aead, err := etm.NewAES256SHA512(key)

if err != nil {
return false, err
}

nonce := make([]byte, aead.NonceSize())
_, err = rand.Read(nonce)

if err != nil {
return false, err
}

dst := make([]byte, 0)

encrypted := aead.Seal(dst, nonce, byteData, nil)

clearKey(key)

encodedData := []byte(base64.StdEncoding.EncodeToString(encrypted))

// Set response "content-type" header to "text/plain"
ctx.SetResponseContentType(common.ContentTypeText)

return true, encodedData
}

func (protection *AESProtection) getKey(ctx interfaces.AppFunctionContext) ([]byte, error) {
// If using Secret Store for the encryption key
if len(protection.SecretPath) != 0 && len(protection.SecretName) != 0 {
// Note secrets are cached so this call doesn't result in unneeded calls to SecretStore Service and
// the cache is invalidated when StoreSecrets is used.
secretData, err := ctx.GetSecret(protection.SecretPath, protection.SecretName)
if err != nil {
return nil, fmt.Errorf(
"unable to retieve encryption key at secret path=%s and name=%s in pipeline '%s'",
protection.SecretPath,
protection.SecretName,
ctx.PipelineId())
}

key, ok := secretData[protection.SecretName]
if !ok {
return nil, fmt.Errorf(
"unable find encryption key in secret data for name=%s in pipeline '%s'",
protection.SecretName,
ctx.PipelineId())
}

ctx.LoggingClient().Debugf(
"Using encryption key from Secret Store at path=%s & name=%s in pipeline '%s'",
protection.SecretPath,
protection.SecretName,
ctx.PipelineId())

return hex.DecodeString(key)
}
return nil, fmt.Errorf("No key configured")
}

func clearKey(key []byte) {
for i := range key {
key[i] = 0
}
}
177 changes: 177 additions & 0 deletions pkg/transforms/aesprotection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//
// Copyright (c) 2021 One Track Consulting
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

package transforms

import (
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/edgexfoundry/app-functions-sdk-go/v2/internal/etm"
"github.com/edgexfoundry/app-functions-sdk-go/v2/pkg/interfaces/mocks"
"github.com/edgexfoundry/app-functions-sdk-go/v2/pkg/util"
"github.com/edgexfoundry/go-mod-core-contracts/v2/clients/logger"
"github.com/edgexfoundry/go-mod-core-contracts/v2/common"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)

func TestNewAESProtection(t *testing.T) {
secretPath := uuid.NewString()
secretName := uuid.NewString()

sut := NewAESProtection(secretPath, secretName)

assert.Equal(t, secretPath, sut.SecretPath)
assert.Equal(t, secretName, sut.SecretName)
}

func TestAESProtection_clearKey(t *testing.T) {
key := []byte(uuid.NewString())

clearKey(key)

for _, v := range key {
assert.Equal(t, byte(0), v)
}
}

func TestAESProtection_getKey(t *testing.T) {
secretPath := uuid.NewString()
secretName := uuid.NewString()
pipelineId := uuid.NewString()
key := "217A24432646294A404E635266556A586E3272357538782F413F442A472D4B6150645367566B59703373367639792442264529482B4D6251655468576D5A7134"

type fields struct {
SecretPath string
SecretName string
EncryptionKey string
}
tests := []struct {
name string
fields fields
ctxSetup func(ctx *mocks.AppFunctionContext)
wantErr bool
}{
{name: "no key", wantErr: true},
{
name: "secret error",
fields: fields{SecretPath: secretPath, SecretName: secretName},
ctxSetup: func(ctx *mocks.AppFunctionContext) {
ctx.On("GetSecret", secretPath, secretName).Return(nil, fmt.Errorf("secret error"))
},
wantErr: true,
},
{
name: "secret not in map",
fields: fields{SecretPath: secretPath, SecretName: secretName},
ctxSetup: func(ctx *mocks.AppFunctionContext) {
ctx.On("GetSecret", secretPath, secretName).Return(map[string]string{}, nil)
},
wantErr: true,
},
{
name: "happy",
fields: fields{SecretPath: secretPath, SecretName: secretName},
ctxSetup: func(ctx *mocks.AppFunctionContext) {
ctx.On("SetResponsesContentType", common.ContentTypeText).Return()
ctx.On("GetSecret", secretPath, secretName).Return(map[string]string{secretName: key}, nil)
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aesData := &AESProtection{
SecretPath: tt.fields.SecretPath,
SecretName: tt.fields.SecretName,
EncryptionKey: tt.fields.EncryptionKey,
}

ctx := &mocks.AppFunctionContext{}
ctx.On("PipelineId").Return(pipelineId)
ctx.On("LoggingClient").Return(logger.NewMockClient())

if tt.ctxSetup != nil {
tt.ctxSetup(ctx)
}

if k, err := aesData.getKey(ctx); (err != nil) != tt.wantErr {
t.Errorf("getKey() error = %v, wantErr %v", err, tt.wantErr)

if !tt.wantErr {
assert.Equal(t, key, k)
}
}
})
}
}

func TestAESProtection_Encrypt(t *testing.T) {
secretPath := uuid.NewString()
secretName := uuid.NewString()
key := "217A24432646294A404E635266556A586E3272357538782F413F442A472D4B6150645367566B59703373367639792442264529482B4D6251655468576D5A7134"

ctx := &mocks.AppFunctionContext{}
ctx.On("SetResponseContentType", common.ContentTypeText).Return()
ctx.On("PipelineId").Return("pipeline-id")
ctx.On("LoggingClient").Return(logger.NewMockClient())
ctx.On("GetSecret", secretPath, secretName).Return(map[string]string{secretName: key}, nil)

enc := NewAESProtection(secretPath, secretName)

continuePipeline, encrypted := enc.Encrypt(ctx, []byte(plainString))
assert.True(t, continuePipeline)

ebytes, err := util.CoerceType(encrypted)

require.NoError(t, err)

//output is base64 encoded
dbytes, err := base64.StdEncoding.DecodeString(string(ebytes))

if err != nil {
panic(err)
}

decrypted := aes256Decrypt(t, dbytes, key)

assert.Equal(t, plainString, string(decrypted))
}

func aes256Decrypt(t *testing.T, dbytes []byte, key string) []byte {
k, err := hex.DecodeString(key)

if err != nil {
panic(err)
}

//internally we are leaning heavily on ETM logic
//do not want to re-implement here
etm, err := etm.NewAES256SHA512(k)

require.NoError(t, err)

dst := make([]byte, 0)

res, err := etm.Open(dst, nil, dbytes, nil)

require.NoError(t, err)

return res
}
6 changes: 6 additions & 0 deletions pkg/transforms/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/edgexfoundry/go-mod-core-contracts/v2/common"
)

// Deprecated: use AESProtection
type Encryption struct {
SecretPath string
SecretName string
Expand All @@ -39,6 +40,7 @@ type Encryption struct {
}

// NewEncryption creates, initializes and returns a new instance of Encryption
// Deprecated: use NewAESProtection
func NewEncryption(encryptionKey string, initializationVector string) Encryption {
return Encryption{
EncryptionKey: encryptionKey,
Expand All @@ -48,6 +50,7 @@ func NewEncryption(encryptionKey string, initializationVector string) Encryption

// NewEncryptionWithSecrets creates, initializes and returns a new instance of Encryption configured
// to retrieve the encryption key from the Secret Store
// Deprecated: use NewAESProtection
func NewEncryptionWithSecrets(secretPath string, secretName string, initializationVector string) Encryption {
return Encryption{
SecretPath: secretPath,
Expand All @@ -67,11 +70,14 @@ func pkcs5Padding(ciphertext []byte, blockSize int) []byte {

// EncryptWithAES encrypts a string, []byte, or json.Marshaller type using AES encryption.
// It will return a Base64 encode []byte of the encrypted data.
// Deprecated: use AESProtection.Encrypt
func (aesData Encryption) EncryptWithAES(ctx interfaces.AppFunctionContext, data interface{}) (bool, interface{}) {
if data == nil {
return false, fmt.Errorf("function EncryptWithAES in pipeline '%s': No Data Received", ctx.PipelineId())
}

ctx.LoggingClient().Warnf("EncryptWithAES has been deprecated - please use the new AESProtection.Encrypt in pipeline '%s'", ctx.PipelineId())

ctx.LoggingClient().Debugf("Encrypting with AES in pipeline '%s'", ctx.PipelineId())

byteData, err := util.CoerceType(data)
Expand Down