diff --git a/internal/crypto/algorithms/aes256cfb.go b/internal/crypto/algorithms/aes256cfb.go index 8a026e9fde..b28da434af 100644 --- a/internal/crypto/algorithms/aes256cfb.go +++ b/internal/crypto/algorithms/aes256cfb.go @@ -35,8 +35,8 @@ var legacySalt = []byte("somesalt") // Encrypt encrypts a row of data. func (a *AES256CFBAlgorithm) Encrypt(plaintext []byte, key []byte) ([]byte, error) { - if len(plaintext) > maxSize { - return nil, status.Errorf(codes.InvalidArgument, "data is too large (>32MB)") + if len(plaintext) > maxPlaintextSize { + return nil, ErrExceedsMaxSize } block, err := aes.NewCipher(a.deriveKey(key)) if err != nil { diff --git a/internal/crypto/algorithms/aes256gcm.go b/internal/crypto/algorithms/aes256gcm.go new file mode 100644 index 0000000000..13f9161804 --- /dev/null +++ b/internal/crypto/algorithms/aes256gcm.go @@ -0,0 +1,90 @@ +// Copyright 2024 Stacklok, Inc +// +// 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. + +// Adapted from: https://github.com/gtank/cryptopasta/blob/bc3a108a5776376aa811eea34b93383837994340/encrypt.go +// cryptopasta - basic cryptography examples +// +// Written in 2015 by George Tankersley +// +// To the extent possible under law, the author(s) have dedicated all copyright +// and related and neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along +// with this software. If not, see // . + +package algorithms + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" +) + +// AES256GCMAlgorithm provides symmetric authenticated encryption using 256-bit AES-GCM with a random nonce. +type AES256GCMAlgorithm struct{} + +// Encrypt encrypts data using 256-bit AES-GCM. This both hides the content of +// the data and provides a check that it hasn't been altered. Output takes the +// form nonce|ciphertext|tag where '|' indicates concatenation. +func (_ *AES256GCMAlgorithm) Encrypt(plaintext []byte, key []byte) ([]byte, error) { + if len(plaintext) > maxPlaintextSize { + return nil, ErrExceedsMaxSize + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +// Decrypt decrypts data using 256-bit AES-GCM. This both hides the content of +// the data and provides a check that it hasn't been altered. Expects input +// form nonce|ciphertext|tag where '|' indicates concatenation. +func (_ *AES256GCMAlgorithm) Decrypt(ciphertext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(ciphertext) < gcm.NonceSize() { + return nil, errors.New("malformed ciphertext") + } + + return gcm.Open(nil, + ciphertext[:gcm.NonceSize()], + ciphertext[gcm.NonceSize():], + nil, + ) +} diff --git a/internal/crypto/algorithms/aes256gcm_test.go b/internal/crypto/algorithms/aes256gcm_test.go new file mode 100644 index 0000000000..7bda9aaab5 --- /dev/null +++ b/internal/crypto/algorithms/aes256gcm_test.go @@ -0,0 +1,119 @@ +// Copyright 2024 Stacklok, Inc +// +// 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 algorithms_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stacklok/minder/internal/crypto/algorithms" +) + +func TestGCMEncrypt(t *testing.T) { + t.Parallel() + + scenarios := []struct { + Name string + Key []byte + Plaintext []byte + ExpectedError string + }{ + { + Name: "GCM Encrypt rejects short key", + Key: []byte{0xFF}, + Plaintext: []byte(plaintext), + ExpectedError: "invalid key size", + }, + { + Name: "GCM Encrypt rejects oversized plaintext", + Key: key, + Plaintext: make([]byte, 33*1024*1024), // 33MiB + ExpectedError: algorithms.ErrExceedsMaxSize.Error(), + }, + { + Name: "GCM encrypts plaintext", + Key: key, + Plaintext: []byte(plaintext), + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + t.Parallel() + + result, err := gcm.Encrypt(scenario.Plaintext, scenario.Key) + if scenario.ExpectedError == "" { + require.NoError(t, err) + // validate by decrypting + decrypted, err := gcm.Decrypt(result, key) + require.NoError(t, err) + require.Equal(t, scenario.Plaintext, decrypted) + } else { + require.ErrorContains(t, err, scenario.ExpectedError) + } + }) + } +} + +// This doesn't test decryption - that is tested in the happy path of the encrypt test +func TestGCMDecrypt(t *testing.T) { + t.Parallel() + + scenarios := []struct { + Name string + Key []byte + Ciphertext []byte + ExpectedError string + }{ + { + Name: "GCM Decrypt rejects short key", + Key: []byte{0xFF}, + Ciphertext: []byte(plaintext), + ExpectedError: "invalid key size", + }, + { + Name: "GCM Decrypt rejects malformed ciphertext", + Key: key, + Ciphertext: make([]byte, 32), // 33MiB + ExpectedError: "message authentication failed", + }, + { + Name: "GCM Decrypt rejects undersized key", + Key: key, + Ciphertext: []byte{0xFF}, + ExpectedError: "malformed ciphertext", + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + t.Parallel() + + _, err := gcm.Decrypt(scenario.Ciphertext, scenario.Key) + require.ErrorContains(t, err, scenario.ExpectedError) + }) + } +} + +var ( + key = []byte{ + 0x5, 0x94, 0x74, 0xfd, 0xb7, 0xf9, 0x85, 0x9, 0x67, 0x8, 0x2D, 0xe8, 0x46, 0x8c, 0x76, 0xe2, + 0x7a, 0x85, 0x7f, 0xed, 0x67, 0xd4, 0xd5, 0x2c, 0x46, 0x00, 0xba, 0x44, 0x8d, 0x54, 0x20, 0xf1, + } + gcm = algorithms.AES256GCMAlgorithm{} +) + +const plaintext = "Hello world" diff --git a/internal/crypto/algorithms/algorithm.go b/internal/crypto/algorithms/algorithm.go index 127f748121..b96d4c146d 100644 --- a/internal/crypto/algorithms/algorithm.go +++ b/internal/crypto/algorithms/algorithm.go @@ -33,29 +33,40 @@ type Type string const ( // Aes256Cfb is the AES-256-CFB algorithm Aes256Cfb Type = "aes-256-cfb" + // Aes256Gcm is the AES-256-GCM algorithm + Aes256Gcm Type = "aes-256-gcm" ) -const maxSize = 32 * 1024 * 1024 +const maxPlaintextSize = 32 * 1024 * 1024 -// ErrUnknownAlgorithm is used when an incorrect algorithm name is used. var ( + // ErrUnknownAlgorithm is returned when an incorrect algorithm name is used. ErrUnknownAlgorithm = errors.New("unexpected encryption algorithm") + // ErrExceedsMaxSize is returned when the plaintext is too large. + ErrExceedsMaxSize = errors.New("plaintext is too large, limited to 32MiB") ) // TypeFromString attempts to map a string to a `Type` value. func TypeFromString(name string) (Type, error) { // TODO: use switch when we support more than once type. - if name == string(Aes256Cfb) { + switch name { + case string(Aes256Cfb): return Aes256Cfb, nil + case string(Aes256Gcm): + return Aes256Gcm, nil + default: + return "", fmt.Errorf("%w: %s", ErrUnknownAlgorithm, name) } - return "", fmt.Errorf("%w: %s", ErrUnknownAlgorithm, name) } // NewFromType instantiates an encryption algorithm by name func NewFromType(algoType Type) (EncryptionAlgorithm, error) { - // TODO: use switch when we support more than once type. - if algoType == Aes256Cfb { + switch algoType { + case Aes256Cfb: return &AES256CFBAlgorithm{}, nil + case Aes256Gcm: + return &AES256GCMAlgorithm{}, nil + default: + return nil, fmt.Errorf("%w: %s", ErrUnknownAlgorithm, algoType) } - return nil, fmt.Errorf("%w: %s", ErrUnknownAlgorithm, algoType) } diff --git a/internal/crypto/engine.go b/internal/crypto/engine.go index 7240b7b142..a0a2a447e8 100644 --- a/internal/crypto/engine.go +++ b/internal/crypto/engine.go @@ -156,7 +156,7 @@ func (e *engine) DecryptString(encryptedString EncryptedData) (string, error) { return string(decrypted), nil } -func (e *engine) encrypt(data []byte) (EncryptedData, error) { +func (e *engine) encrypt(plaintext []byte) (EncryptedData, error) { // Neither of these lookups should ever fail. algorithm, ok := e.supportedAlgorithms[e.defaultAlgorithm] if !ok { @@ -168,7 +168,7 @@ func (e *engine) encrypt(data []byte) (EncryptedData, error) { return EncryptedData{}, fmt.Errorf("unable to find preferred key with ID: %s", e.defaultKeyID) } - encrypted, err := algorithm.Encrypt(data, key) + encrypted, err := algorithm.Encrypt(plaintext, key) if err != nil { return EncryptedData{}, errors.Join(ErrEncrypt, err) } diff --git a/internal/crypto/engine_test.go b/internal/crypto/engine_test.go index dc665d9c07..789e91a2e9 100644 --- a/internal/crypto/engine_test.go +++ b/internal/crypto/engine_test.go @@ -21,8 +21,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "github.com/stacklok/minder/internal/config/server" "github.com/stacklok/minder/internal/crypto/algorithms" @@ -137,7 +135,7 @@ func TestEncryptTooLarge(t *testing.T) { require.NoError(t, err) large := make([]byte, 34000000) // More than 32 MB _, err = engine.EncryptString(string(large)) - assert.ErrorIs(t, err, status.Error(codes.InvalidArgument, "data is too large (>32MB)")) + assert.ErrorIs(t, err, algorithms.ErrExceedsMaxSize) } func TestEncryptDecryptOAuthToken(t *testing.T) {