From efc7879852e2c827af3e18755e0963bc8a8fa49b Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Wed, 2 Aug 2023 10:41:23 +0100 Subject: [PATCH] :sparkles: Added module `[encryption]` for simple cryptography utilities (#296) ### Description - Added module `[encryption]` for simple cryptography utilities - Support more hashing algorithm ### Test Coverage - [x] This change is covered by existing or additional automated tests. - [ ] Manual testing has been performed (and evidence provided) as automated testing was not feasible. - [ ] Additional tests are not required for this change (e.g. documentation update). --- .secrets.baseline | 8 +- changes/20230801110107.feature | 1 + changes/20230801144705.feature | 1 + utils/encryption/encryption.go | 147 ++++++++++++++++++++++++++++ utils/encryption/encryption_test.go | 96 ++++++++++++++++++ utils/encryption/interface.go | 20 ++++ utils/hashing/hash.go | 55 ++++++++--- utils/hashing/hash_test.go | 13 +++ utils/mocks/mock_encryption.go | 105 ++++++++++++++++++++ 9 files changed, 428 insertions(+), 18 deletions(-) create mode 100644 changes/20230801110107.feature create mode 100644 changes/20230801144705.feature create mode 100644 utils/encryption/encryption.go create mode 100644 utils/encryption/encryption_test.go create mode 100644 utils/encryption/interface.go create mode 100644 utils/mocks/mock_encryption.go diff --git a/.secrets.baseline b/.secrets.baseline index b939d05046..85475cf004 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -189,21 +189,21 @@ "filename": "utils/hashing/hash_test.go", "hashed_secret": "4028a0e356acc947fcd2bfbf00cef11e128d484a", "is_verified": false, - "line_number": 26 + "line_number": 28 }, { "type": "Hex High Entropy String", "filename": "utils/hashing/hash_test.go", "hashed_secret": "1f35be0c58b01a2fddd3aded671f0f7efed3ff62", "is_verified": false, - "line_number": 29 + "line_number": 31 }, { "type": "Hex High Entropy String", "filename": "utils/hashing/hash_test.go", "hashed_secret": "30f0cbefb37316806a7024caee994baf8365fa53", "is_verified": false, - "line_number": 114 + "line_number": 116 } ], "utils/sharedcache/common.go": [ @@ -248,5 +248,5 @@ } ] }, - "generated_at": "2023-07-24T13:50:03Z" + "generated_at": "2023-08-01T15:57:07Z" } diff --git a/changes/20230801110107.feature b/changes/20230801110107.feature new file mode 100644 index 0000000000..81e9606735 --- /dev/null +++ b/changes/20230801110107.feature @@ -0,0 +1 @@ +:sparkles: `[hashing]` Added support for [blake2b](https://www.blake2.net/) hashing algorithm diff --git a/changes/20230801144705.feature b/changes/20230801144705.feature new file mode 100644 index 0000000000..a569a2660e --- /dev/null +++ b/changes/20230801144705.feature @@ -0,0 +1 @@ +:sparkles: Added module `[encryption]` for simple cryptography utilities diff --git a/utils/encryption/encryption.go b/utils/encryption/encryption.go new file mode 100644 index 0000000000..6fe58be38b --- /dev/null +++ b/utils/encryption/encryption.go @@ -0,0 +1,147 @@ +package encryption + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + + "golang.org/x/crypto/nacl/box" + + "github.com/ARM-software/golang-utils/utils/commonerrors" +) + +const KeySize = 32 + +var ( + errKeySize = fmt.Errorf("%w: recipient key has invalid size", commonerrors.ErrInvalid) +) + +type keyPair struct { + public string + private string `json:"-"` +} + +func (k *keyPair) String() string { + return fmt.Sprintf("{Public: %v}", k.GetPublicKey()) +} + +func (k *keyPair) GoString() string { + return fmt.Sprintf("KeyPair(%q)", k.String()) +} + +func (k *keyPair) MarshalJSON() ([]byte, error) { + json := fmt.Sprintf("{\"Public\": %q}", k.GetPublicKey()) + return []byte(json), nil +} + +func (k *keyPair) GetPublicKey() string { + return k.public +} + +func (k *keyPair) GetPrivateKey() string { + return k.private +} + +func newKeyPair(public, private *[32]byte) (IKeyPair, error) { + if public == nil || private == nil { + return nil, fmt.Errorf("%w: missing key", commonerrors.ErrUndefined) + } + return newBasicKeyPair(base64.StdEncoding.EncodeToString((*public)[:]), base64.StdEncoding.EncodeToString((*private)[:])), nil +} + +func newBasicKeyPair(public, private string) IKeyPair { + return &keyPair{ + public: public, + private: private, + } +} + +// GenerateKeyPair generates a asymmetric key pair suitable for use with encryption utilities. Works with [NaCl box](https://nacl.cr.yp.to/box.html.) +func GenerateKeyPair() (pair IKeyPair, err error) { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + err = fmt.Errorf("%w: could not generate keys: %v", commonerrors.ErrUnexpected, err.Error()) + return + } + + pair, err = newKeyPair(pub, priv) + return +} + +// EncryptWithPublicKey encrypts small messages using a 32-byte public key (See https://libsodium.gitbook.io/doc/public-key_cryptography/sealed_boxes) +func EncryptWithPublicKey(base64EncodedPublicKey string, message string) (encryptedBase64Message string, err error) { + decodedPublicKey, err := base64.StdEncoding.DecodeString(base64EncodedPublicKey) + if err != nil { + err = base64DecodingError(err) + return + } + if len(decodedPublicKey) != KeySize { + err = errKeySize + return + } + + recipientKey := [KeySize]byte{} + copy(recipientKey[:], decodedPublicKey) + + secretBytes := []byte(message) + + encryptedBytes, err := box.SealAnonymous([]byte{}, secretBytes, &recipientKey, rand.Reader) + if err != nil { + err = fmt.Errorf("%w: box.SealAnonymous failed with error: %v", commonerrors.ErrUnexpected, err.Error()) + return + } + + encryptedBase64Message = base64.StdEncoding.EncodeToString(encryptedBytes) + + return +} + +func base64DecodingError(err error) error { + return fmt.Errorf("%w: base64.StdEncoding.DecodeString was unable to decode string: %v", commonerrors.ErrInvalid, err.Error()) +} + +// DecryptWithKeyPair decrypts small base64 encoded messages +func DecryptWithKeyPair(keys IKeyPair, base64EncodedEncryptedMessage string) (decryptedMessage string, err error) { + if keys == nil { + err = fmt.Errorf("%w: missing keys", commonerrors.ErrUndefined) + return + } + decodedPublicKey, err := base64.StdEncoding.DecodeString(keys.GetPublicKey()) + if err != nil { + err = base64DecodingError(err) + return + } + if len(decodedPublicKey) != KeySize { + err = errKeySize + return + } + decodedPrivateKey, err := base64.StdEncoding.DecodeString(keys.GetPrivateKey()) + if err != nil { + err = base64DecodingError(err) + return + } + if len(decodedPrivateKey) != KeySize { + err = errKeySize + return + } + + decodedMessage, err := base64.StdEncoding.DecodeString(base64EncodedEncryptedMessage) + if err != nil { + err = base64DecodingError(err) + return + } + + publicKey := [KeySize]byte{} + copy(publicKey[:], decodedPublicKey) + + privateKey := [KeySize]byte{} + copy(privateKey[:], decodedPrivateKey) + + message, ok := box.OpenAnonymous([]byte{}, decodedMessage, &publicKey, &privateKey) + if !ok { + err = fmt.Errorf("%w: message could not be decrypted", commonerrors.ErrInvalid) + return + } + decryptedMessage = string(message) + return +} diff --git a/utils/encryption/encryption_test.go b/utils/encryption/encryption_test.go new file mode 100644 index 0000000000..9e786f637e --- /dev/null +++ b/utils/encryption/encryption_test.go @@ -0,0 +1,96 @@ +package encryption + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "github.com/bxcodec/faker/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/commonerrors/errortest" + "github.com/ARM-software/golang-utils/utils/logs/logstest" +) + +func TestGenerate(t *testing.T) { + pair, err := GenerateKeyPair() + require.NoError(t, err) + assert.NotEmpty(t, pair.GetPublicKey()) + assert.NotEmpty(t, pair.GetPrivateKey()) + + b, err := base64.StdEncoding.DecodeString(pair.GetPublicKey()) + require.NoError(t, err) + assert.Equal(t, 32, len(b)) + b, err = base64.StdEncoding.DecodeString(pair.GetPrivateKey()) + require.NoError(t, err) + assert.Equal(t, 32, len(b)) +} + +func TestEncryptDecrypt(t *testing.T) { + message := faker.Paragraph() + pair, err := GenerateKeyPair() + require.NoError(t, err) + + encrypted, err := EncryptWithPublicKey(pair.GetPublicKey(), message) + require.NoError(t, err) + decryptedMessage, err := DecryptWithKeyPair(pair, encrypted) + require.NoError(t, err) + assert.Equal(t, message, decryptedMessage) +} + +func TestKeyPrint(t *testing.T) { + // Test to make sure the private key does not get printing in the logs by mistake. + pair, err := GenerateKeyPair() + require.NoError(t, err) + + fmtString := fmt.Sprintf("test: %v", pair) + assert.NotContains(t, fmtString, pair.GetPrivateKey()) + fmt.Println(fmtString) + + fmtString = fmt.Sprintf("test: %+v", pair) + assert.NotContains(t, fmtString, pair.GetPrivateKey()) + fmt.Println(fmtString) + + fmtString = fmt.Sprintf("test: %q", pair) + assert.NotContains(t, fmtString, pair.GetPrivateKey()) + fmt.Println(fmtString) + + fmtJSON, err := json.Marshal(pair) + require.NoError(t, err) + fmtString = string(fmtJSON) + assert.NotContains(t, fmtString, pair.GetPrivateKey()) + fmt.Println(fmtString) + logger := logstest.NewTestLogger(t) + logger.Info("test", "key", pair) +} + +func TestEncryptDecrypt_Failures(t *testing.T) { + pair, err := GenerateKeyPair() + require.NoError(t, err) + + _, err = EncryptWithPublicKey(faker.Name(), faker.Word()) + require.Error(t, err) + errortest.AssertError(t, err, commonerrors.ErrInvalid) + + invalidPair := newBasicKeyPair(faker.Name(), faker.Name()) + _, err = DecryptWithKeyPair(invalidPair, faker.Word()) + require.Error(t, err) + errortest.AssertError(t, err, commonerrors.ErrInvalid) + + invalidPair = newBasicKeyPair(pair.GetPublicKey(), faker.Name()) + _, err = DecryptWithKeyPair(invalidPair, faker.Word()) + require.Error(t, err) + errortest.AssertError(t, err, commonerrors.ErrInvalid) + + invalidPair = newBasicKeyPair(faker.Name(), pair.GetPrivateKey()) + _, err = DecryptWithKeyPair(invalidPair, faker.Word()) + require.Error(t, err) + errortest.AssertError(t, err, commonerrors.ErrInvalid) + + _, err = DecryptWithKeyPair(pair, faker.Word()) + require.Error(t, err) + errortest.AssertError(t, err, commonerrors.ErrInvalid) +} diff --git a/utils/encryption/interface.go b/utils/encryption/interface.go new file mode 100644 index 0000000000..01be2d69a6 --- /dev/null +++ b/utils/encryption/interface.go @@ -0,0 +1,20 @@ +// Package encryption defines utilities with regards to cryptography. +package encryption + +import ( + "encoding/json" + "fmt" +) + +//go:generate mockgen -destination=../mocks/mock_$GOPACKAGE.go -package=mocks github.com/ARM-software/golang-utils/utils/$GOPACKAGE IKeyPair + +// IKeyPair defines an asymmetric key pair for cryptography. +type IKeyPair interface { + fmt.Stringer + fmt.GoStringer + json.Marshaler + // GetPublicKey returns the public key (base64 encoded) + GetPublicKey() string + // GetPrivateKey returns the private key (base64 encoded) + GetPrivateKey() string +} diff --git a/utils/hashing/hash.go b/utils/hashing/hash.go index 9924d27207..ff07e5b536 100644 --- a/utils/hashing/hash.go +++ b/utils/hashing/hash.go @@ -11,6 +11,7 @@ import ( "crypto/sha1" //nolint:gosec "crypto/sha256" "encoding/hex" + "fmt" "hash" "io" "math" @@ -18,6 +19,7 @@ import ( "github.com/OneOfOne/xxhash" "github.com/spaolacci/murmur3" + "golang.org/x/crypto/blake2b" "github.com/ARM-software/golang-utils/utils/commonerrors" "github.com/ARM-software/golang-utils/utils/reflection" @@ -26,11 +28,12 @@ import ( ) const ( - HashMd5 = "MD5" - HashSha256 = "SHA256" - HashSha1 = "SHA1" - HashMurmur = "Murmur" - HashXXHash = "xxhash" //https://github.com/OneOfOne/xxhash + HashMd5 = "MD5" + HashSha256 = "SHA256" + HashSha1 = "SHA1" + HashMurmur = "Murmur" + HashXXHash = "xxhash" // https://github.com/OneOfOne/xxhash + HashBlake2256 = "blake2b256" // https://www.blake2.net/ ) type hashingAlgo struct { @@ -60,8 +63,21 @@ func (h *hashingAlgo) GetType() string { return h.Type } +// NewBespokeHashingAlgorithm defines a bespoke hashing algorithm +func NewBespokeHashingAlgorithm(algorithm hash.Hash) (IHash, error) { + return newHashingAlgorithm("bespoke", algorithm) +} + +func newHashingAlgorithm(htype string, algorithm hash.Hash) (IHash, error) { + return &hashingAlgo{ + Hash: algorithm, + Type: htype, + }, nil +} + func NewHashingAlgorithm(htype string) (IHash, error) { var hash hash.Hash + var err error switch htype { case HashMd5: hash = md5.New() //nolint:gosec @@ -73,33 +89,44 @@ func NewHashingAlgorithm(htype string) (IHash, error) { hash = murmur3.New64() case HashXXHash: hash = xxhash.New64() + case HashBlake2256: + hash, err = blake2b.New256(nil) + } + if err != nil { + return nil, fmt.Errorf("%w: failed loading the hashing algorithm: %v", commonerrors.ErrUnexpected, err.Error()) } if hash == nil { - return nil, commonerrors.ErrNotFound + return nil, fmt.Errorf("%w: could not find the corresponding hashing algorithm", commonerrors.ErrNotFound) } - return &hashingAlgo{ - Hash: hash, - Type: htype, - }, nil + return newHashingAlgorithm(htype, hash) } func CalculateMD5Hash(text string) string { return CalculateHash(text, HashMd5) } -func CalculateHash(text, htype string) string { - hashing, err := NewHashingAlgorithm(htype) - if err != nil { +// CalculateStringHash returns the hash of some text using a particular hashing algorithm +func CalculateStringHash(hashingAlgo IHash, text string) string { + if hashingAlgo == nil { return "" } - hash, err := hashing.Calculate(strings.NewReader(text)) + hash, err := hashingAlgo.Calculate(strings.NewReader(text)) if err != nil { return "" } return hash } +// CalculateHash calculates the hash of some text using the requested htype hashing algorithm. +func CalculateHash(text, htype string) string { + hashing, err := NewHashingAlgorithm(htype) + if err != nil { + return "" + } + return CalculateStringHash(hashing, text) +} + // HasLikelyHexHashStringEntropy states whether a string has an entropy which may entail it is a hexadecimal hash // This is based on the work done by `detect-secrets` https://github.com/Yelp/detect-secrets/blob/2fc0e31f067af98d97ad0f507dac032c9506f667/detect_secrets/plugins/high_entropy_strings.py#L150 func HasLikelyHexHashStringEntropy(str string) bool { diff --git a/utils/hashing/hash_test.go b/utils/hashing/hash_test.go index 9d31647dfc..9fca15cecb 100644 --- a/utils/hashing/hash_test.go +++ b/utils/hashing/hash_test.go @@ -6,12 +6,14 @@ package hashing import ( "fmt" + "math/rand" "strings" "testing" "github.com/bxcodec/faker/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/blake2b" ) func TestHasher(t *testing.T) { @@ -122,3 +124,14 @@ func TestIsLikelyHexHashString(t *testing.T) { }) } } + +func TestBespokeHash(t *testing.T) { + size := rand.Intn(64) //nolint:gosec //causes G404: Use of weak random number generator (math/rand instead of crypto/rand) (gosec), So disable gosec as only for testing purposes + algo, err := blake2b.New(size, nil) + require.NoError(t, err) + hashing, err := NewBespokeHashingAlgorithm(algo) + require.NoError(t, err) + hash := CalculateStringHash(hashing, faker.Paragraph()) + require.NotEmpty(t, hash) + assert.Equal(t, size*2, len(hash)) +} diff --git a/utils/mocks/mock_encryption.go b/utils/mocks/mock_encryption.go new file mode 100644 index 0000000000..2a3af0e4a6 --- /dev/null +++ b/utils/mocks/mock_encryption.go @@ -0,0 +1,105 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/ARM-software/golang-utils/utils/encryption (interfaces: IKeyPair) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockIKeyPair is a mock of IKeyPair interface. +type MockIKeyPair struct { + ctrl *gomock.Controller + recorder *MockIKeyPairMockRecorder +} + +// MockIKeyPairMockRecorder is the mock recorder for MockIKeyPair. +type MockIKeyPairMockRecorder struct { + mock *MockIKeyPair +} + +// NewMockIKeyPair creates a new mock instance. +func NewMockIKeyPair(ctrl *gomock.Controller) *MockIKeyPair { + mock := &MockIKeyPair{ctrl: ctrl} + mock.recorder = &MockIKeyPairMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIKeyPair) EXPECT() *MockIKeyPairMockRecorder { + return m.recorder +} + +// GetPrivateKey mocks base method. +func (m *MockIKeyPair) GetPrivateKey() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPrivateKey") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetPrivateKey indicates an expected call of GetPrivateKey. +func (mr *MockIKeyPairMockRecorder) GetPrivateKey() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrivateKey", reflect.TypeOf((*MockIKeyPair)(nil).GetPrivateKey)) +} + +// GetPublicKey mocks base method. +func (m *MockIKeyPair) GetPublicKey() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPublicKey") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetPublicKey indicates an expected call of GetPublicKey. +func (mr *MockIKeyPairMockRecorder) GetPublicKey() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicKey", reflect.TypeOf((*MockIKeyPair)(nil).GetPublicKey)) +} + +// GoString mocks base method. +func (m *MockIKeyPair) GoString() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GoString") + ret0, _ := ret[0].(string) + return ret0 +} + +// GoString indicates an expected call of GoString. +func (mr *MockIKeyPairMockRecorder) GoString() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GoString", reflect.TypeOf((*MockIKeyPair)(nil).GoString)) +} + +// MarshalJSON mocks base method. +func (m *MockIKeyPair) MarshalJSON() ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarshalJSON") + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarshalJSON indicates an expected call of MarshalJSON. +func (mr *MockIKeyPairMockRecorder) MarshalJSON() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarshalJSON", reflect.TypeOf((*MockIKeyPair)(nil).MarshalJSON)) +} + +// String mocks base method. +func (m *MockIKeyPair) String() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "String") + ret0, _ := ret[0].(string) + return ret0 +} + +// String indicates an expected call of String. +func (mr *MockIKeyPairMockRecorder) String() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockIKeyPair)(nil).String)) +}