Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement AES-256-GCM encryption #3367

Merged
merged 3 commits into from
May 20, 2024
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
4 changes: 2 additions & 2 deletions internal/crypto/algorithms/aes256cfb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
90 changes: 90 additions & 0 deletions internal/crypto/algorithms/aes256gcm.go
Original file line number Diff line number Diff line change
@@ -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 <george.tankersley@gmail.com>
//
// 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 // <http://creativecommons.org/publicdomain/zero/1.0/>.

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,
)
}
119 changes: 119 additions & 0 deletions internal/crypto/algorithms/aes256gcm_test.go
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 18 additions & 7 deletions internal/crypto/algorithms/algorithm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions internal/crypto/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
4 changes: 1 addition & 3 deletions internal/crypto/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down