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

Initial KeyStore implementation #3329

Merged
merged 4 commits into from
May 14, 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
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The algorithm.go file has grown quite large, I split it in two as part of this PR.

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