Skip to content

Commit

Permalink
Initial KeyStore implementation (#3329)
Browse files Browse the repository at this point in the history
Relates to #3304

Implement an interface which hides the specifics of loading an
encryption key. This allows us to use a secret store or a key store
directly in future.

Right now, the implementation uses a single key loaded from the config.
In my next PR, I will generalize it to support multiple keys. This will
require a new config structure.
  • Loading branch information
dmjb committed May 14, 2024
1 parent fcf857b commit e7f9914
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 80 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,54 +12,28 @@
// See the License for the specific language governing permissions and
// limitations under the License.

package crypto
package algorithms

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"io"

"golang.org/x/crypto/argon2"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// EncryptionAlgorithm represents a crypto algorithm used by the Engine
type EncryptionAlgorithm interface {
Encrypt(data []byte, salt []byte) ([]byte, error)
Decrypt(data []byte, salt []byte) ([]byte, error)
}

// EncryptionAlgorithmType is an enum of supported encryption algorithms
type EncryptionAlgorithmType string

const (
// Aes256Cfb is the AES-256-CFB algorithm
Aes256Cfb EncryptionAlgorithmType = "aes-256-cfb"
)

const maxSize = 32 * 1024 * 1024

// ErrUnknownAlgorithm is used when an incorrect algorithm name is used.
var ErrUnknownAlgorithm = errors.New("unexpected encryption algorithm")

func newAlgorithm(key []byte) EncryptionAlgorithm {
// TODO: Make the type of algorithm selectable
return &aesCFBSAlgorithm{encryptionKey: key}
}

type aesCFBSAlgorithm struct {
encryptionKey []byte
}
// AES256CFBAlgorithm implements the AES-256-CFB algorithm
type AES256CFBAlgorithm struct{}

// Encrypt encrypts a row of data.
func (a *aesCFBSAlgorithm) Encrypt(data []byte, salt []byte) ([]byte, error) {
func (a *AES256CFBAlgorithm) Encrypt(data []byte, key []byte, salt []byte) ([]byte, error) {
if len(data) > maxSize {
return nil, status.Errorf(codes.InvalidArgument, "data is too large (>32MB)")
}
block, err := aes.NewCipher(a.deriveKey(salt))
block, err := aes.NewCipher(a.deriveKey(key, salt))
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to create cipher: %s", err)
}
Expand All @@ -78,8 +52,8 @@ func (a *aesCFBSAlgorithm) Encrypt(data []byte, salt []byte) ([]byte, error) {
}

// Decrypt decrypts a row of data.
func (a *aesCFBSAlgorithm) Decrypt(ciphertext []byte, salt []byte) ([]byte, error) {
block, err := aes.NewCipher(a.deriveKey(salt))
func (a *AES256CFBAlgorithm) Decrypt(ciphertext []byte, key []byte, salt []byte) ([]byte, error) {
block, err := aes.NewCipher(a.deriveKey(key, salt))
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to create cipher: %s", err)
}
Expand All @@ -95,6 +69,6 @@ func (a *aesCFBSAlgorithm) Decrypt(ciphertext []byte, salt []byte) ([]byte, erro
}

// Function to derive a key from a passphrase using Argon2
func (a *aesCFBSAlgorithm) deriveKey(salt []byte) []byte {
return argon2.IDKey(a.encryptionKey, salt, 1, 64*1024, 4, 32)
func (_ *AES256CFBAlgorithm) deriveKey(key []byte, salt []byte) []byte {
return argon2.IDKey(key, salt, 1, 64*1024, 4, 32)
}
61 changes: 61 additions & 0 deletions internal/crypto/algorithms/algorithm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// 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 contains implementations of various crypto algorithms
// for the crypto engine.
package algorithms

import (
"errors"
"fmt"
)

// EncryptionAlgorithm represents a crypto algorithm used by the Engine
type EncryptionAlgorithm interface {
Encrypt(data []byte, key []byte, salt []byte) ([]byte, error)
Decrypt(data []byte, key []byte, salt []byte) ([]byte, error)
}

// Type is an enum of supported encryption algorithms
type Type string

const (
// Aes256Cfb is the AES-256-CFB algorithm
Aes256Cfb Type = "aes-256-cfb"
)

const maxSize = 32 * 1024 * 1024

// ErrUnknownAlgorithm is used when an incorrect algorithm name is used.
var (
ErrUnknownAlgorithm = errors.New("unexpected encryption algorithm")
)

// 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) {
return Aes256Cfb, nil
}
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 {
return &AES256CFBAlgorithm{}, nil
}
return nil, fmt.Errorf("%w: %s", ErrUnknownAlgorithm, algoType)
}
92 changes: 67 additions & 25 deletions internal/crypto/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ import (
"encoding/json"
"errors"
"fmt"
"path/filepath"

"golang.org/x/oauth2"

serverconfig "github.com/stacklok/minder/internal/config/server"
"github.com/stacklok/minder/internal/crypto/algorithms"
"github.com/stacklok/minder/internal/crypto/keystores"
)

//go:generate go run go.uber.org/mock/mockgen -package mock_$GOPACKAGE -destination=./mock/$GOFILE -source=./$GOFILE
Expand All @@ -50,27 +53,44 @@ var (
ErrEncrypt = errors.New("unable to encrypt")
)

type algorithmsByName map[algorithms.Type]algorithms.EncryptionAlgorithm

type engine struct {
algorithm EncryptionAlgorithm
keystore keystores.KeyStore
supportedAlgorithms algorithmsByName
defaultKeyID string
defaultAlgorithm algorithms.Type
}

// NewEngineFromAuthConfig creates a new crypto engine from an auth config
func NewEngineFromAuthConfig(authConfig *serverconfig.AuthConfig) (Engine, error) {
if authConfig == nil {
// NewEngineFromAuthConfig creates a new crypto engine from the service config
// TODO: modify to support multiple keys/algorithms
func NewEngineFromAuthConfig(config *serverconfig.AuthConfig) (Engine, error) {
if config == nil {
return nil, errors.New("auth config is nil")
}

keyBytes, err := authConfig.GetTokenKey()
keystore, err := keystores.NewKeyStoreFromConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to read token key file: %s", err)
}

return NewEngine(keyBytes), nil
}
aes, err := algorithms.NewFromType(algorithms.Aes256Cfb)
if err != nil {
return nil, err
}
supportedAlgorithms := map[algorithms.Type]algorithms.EncryptionAlgorithm{
algorithms.Aes256Cfb: aes,
}

// NewEngine creates the engine based on the specified algorithm and key.
func NewEngine(key []byte) Engine {
return &engine{algorithm: newAlgorithm(key)}
return &engine{
keystore: keystore,
supportedAlgorithms: supportedAlgorithms,
defaultAlgorithm: algorithms.Aes256Cfb,
// Use the key filename as the key ID.
// This will be cleaned up in a future PR
// Right now, by the time we get here, this should return a valid result
defaultKeyID: filepath.Base(config.TokenKey),
}, nil
}

func (e *engine) EncryptOAuthToken(token *oauth2.Token) (EncryptedData, error) {
Expand All @@ -83,7 +103,7 @@ func (e *engine) EncryptOAuthToken(token *oauth2.Token) (EncryptedData, error) {
// Encrypt the JSON.
encrypted, err := e.encrypt(jsonData)
if err != nil {
return EncryptedData{}, fmt.Errorf("unable to encrypt token: %w", err)
return EncryptedData{}, err
}
return encrypted, nil
}
Expand Down Expand Up @@ -114,38 +134,60 @@ func (e *engine) EncryptString(data string) (EncryptedData, error) {
func (e *engine) DecryptString(encryptedString EncryptedData) (string, error) {
decrypted, err := e.decrypt(encryptedString)
if err != nil {
return "", fmt.Errorf("%w: %w", ErrDecrypt, err)
return "", err
}
return string(decrypted), nil
}

func (e *engine) encrypt(data []byte) (EncryptedData, error) {
encrypted, err := e.algorithm.Encrypt(data, legacySalt)
// Neither of these lookups should ever fail.
algorithm, ok := e.supportedAlgorithms[e.defaultAlgorithm]
if !ok {
return EncryptedData{}, fmt.Errorf("unable to find preferred algorithm: %s", e.defaultAlgorithm)
}

key, err := e.keystore.GetKey(e.defaultKeyID)
if err != nil {
return EncryptedData{}, err
return EncryptedData{}, fmt.Errorf("unable to find preferred key with ID: %s", e.defaultKeyID)
}

encrypted, err := algorithm.Encrypt(data, key, legacySalt)
if err != nil {
return EncryptedData{}, errors.Join(ErrEncrypt, err)
}

encoded := base64.StdEncoding.EncodeToString(encrypted)
// TODO:
// 1. when we support more than one algorithm, remove hard-coding.
// 2. Allow salt to be randomly generated per secret.
// 3. Set key version.
return NewBackwardsCompatibleEncryptedData(encoded), nil
// TODO: Allow salt to be randomly generated per secret.
return EncryptedData{
Algorithm: e.defaultAlgorithm,
EncodedData: encoded,
Salt: legacySalt,
KeyVersion: e.defaultKeyID,
}, nil
}

func (e *engine) decrypt(data EncryptedData) ([]byte, error) {
// TODO: Select algorithm based on Algorithm field when we support
// more than one algorithm.
if data.Algorithm != Aes256Cfb {
return nil, fmt.Errorf("%w: %s", ErrUnknownAlgorithm, data.Algorithm)
algorithm, ok := e.supportedAlgorithms[data.Algorithm]
if !ok {
return nil, fmt.Errorf("%w: %s", algorithms.ErrUnknownAlgorithm, e.defaultAlgorithm)
}

key, err := e.keystore.GetKey(e.defaultKeyID)
if err != nil {
// error from keystore is good enough - we do not need more context
return nil, err
}

// base64 decode the string
encrypted, err := base64.StdEncoding.DecodeString(data.EncodedData)
if err != nil {
return nil, err
return nil, fmt.Errorf("error decoding secret: %w", err)
}

// decrypt the data
return e.algorithm.Decrypt(encrypted, data.Salt)
result, err := algorithm.Decrypt(encrypted, key, data.Salt)
if err != nil {
return nil, errors.Join(ErrDecrypt, err)
}
return result, nil
}
70 changes: 70 additions & 0 deletions internal/crypto/keystores/keystore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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 keystores contains logic for loading encryption keys from a keystores
package keystores

import (
"errors"
"fmt"
"path/filepath"

serverconfig "github.com/stacklok/minder/internal/config/server"
)

//go:generate go run go.uber.org/mock/mockgen -package mock_$GOPACKAGE -destination=./mock/$GOFILE -source=./$GOFILE

// KeyStore represents a struct which stores or can fetch encryption keys.
type KeyStore interface {
// GetKey retrieves the key for the specified algorithm by key ID.
GetKey(id string) ([]byte, error)
}

// ErrUnknownKeyID is returned when the Key ID cannot be found by the keystore.
var ErrUnknownKeyID = errors.New("unknown key id")

// This structure is used by the keystore implementation to manage keys.
type keysByID map[string][]byte

// NewKeyStoreFromConfig creates an instance of a KeyStore based on the
// AuthConfig in Minder.
// Since our only implementation is based on reading from the local disk, do
// all key loading during construction of the struct.
// TODO: allow support for multiple keys/algos
func NewKeyStoreFromConfig(config *serverconfig.AuthConfig) (KeyStore, error) {
key, err := config.GetTokenKey()
if err != nil {
return nil, fmt.Errorf("unable to read encryption key from %s: %w", config.TokenKey, err)
}
// Use the key filename as the key ID.
name := filepath.Base(config.TokenKey)
keys := map[string][]byte{
name: key,
}
return &localFileKeyStore{
keys: keys,
}, nil
}

type localFileKeyStore struct {
keys keysByID
}

func (l *localFileKeyStore) GetKey(id string) ([]byte, error) {
key, ok := l.keys[id]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownKeyID, id)
}
return key, nil
}
6 changes: 4 additions & 2 deletions internal/crypto/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ package crypto
import (
"encoding/json"
"fmt"

"github.com/stacklok/minder/internal/crypto/algorithms"
)

// EncryptedData represents the structure we use to store encrypted data in the
// database.
type EncryptedData struct {
// The type of encryption used.
Algorithm EncryptionAlgorithmType
Algorithm algorithms.Type
// The encrypted data represented as a base64 encoded string.
EncodedData string
// The salt used in the encryption.
Expand All @@ -44,7 +46,7 @@ func (e *EncryptedData) Serialize() (json.RawMessage, error) {
// and should be removed once we migrate to the new encryption model.
func NewBackwardsCompatibleEncryptedData(encryptedData string) EncryptedData {
return EncryptedData{
Algorithm: Aes256Cfb,
Algorithm: algorithms.Aes256Cfb,
EncodedData: encryptedData,
Salt: legacySalt,
KeyVersion: "",
Expand Down
Loading

0 comments on commit e7f9914

Please sign in to comment.