Skip to content

Commit

Permalink
Implement AES-256-GCM encryption
Browse files Browse the repository at this point in the history
Fixes #3317

The GCM mode is recommended by OWASP and other authorities for secret
encryption. As per @jhrozek's suggestion, use the cryptopasta implementation of
AES-256-GCM (see link/copyright in code).

This PR does not actually make use of the new algorithm yet, this will
be done in a future PR.
  • Loading branch information
dmjb committed May 20, 2024
1 parent 1ccdb36 commit b241754
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 11 deletions.
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

0 comments on commit b241754

Please sign in to comment.