Skip to content

Commit

Permalink
First S3 store draft
Browse files Browse the repository at this point in the history
  • Loading branch information
bastjan committed Dec 21, 2023
1 parent a95c146 commit b1e0c8f
Show file tree
Hide file tree
Showing 8 changed files with 453 additions and 3 deletions.
38 changes: 36 additions & 2 deletions api/v1beta1/emergencyaccount_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ type TokenStoreSpec struct {
// +kubebuilder:validation:Required
Name string `json:"name"`
// Type defines the type of the store to use.
// Currently `secret`` and `log` stores are supported.
// Currently `secret`, `s3`, and `log` stores are supported.
// The stores can be further configured in the corresponding storeSpec.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Enum=secret;log
// +kubebuilder:validation:Enum=secret;log;s3
Type string `json:"type"`

// SecretSpec configures the secret store.
Expand All @@ -64,6 +64,40 @@ type TokenStoreSpec struct {
// LogSpec configures the log store.
// The log store outputs the token to the log but does not store it anywhere.
LogSpec LogStoreSpec `json:"logStore,omitempty"`
// S3Spec configures the S3 store.
// The S3 store saves the tokens in an S3 bucket.
S3Spec S3StoreSpec `json:"s3Store,omitempty"`
}

// S3StoreSpec configures the S3 store.
// The S3 store saves the tokens in an S3 bucket with optional encryption using PGP public keys.
type S3StoreSpec struct {
S3 S3Spec `json:"s3"`
// Encryption defines the encryption settings for the S3 store.
// If not set, the tokens are stored unencrypted.
// +kubebuilder:validation:Optional
Encryption S3EncryptionSpec `json:"encryption,omitempty"`
}

type S3Spec struct {
EndPoint string `json:"endpoint"`
Bucket string `json:"bucket"`

AccessKeyId string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`

// Region is the AWS region to use.
Region string `json:"region,omitempty"`
Insecure bool `json:"insecure,omitempty"`
}

type S3EncryptionSpec struct {
// Encrypt defines if the tokens should be encrypted.
// If not set, the tokens are stored unencrypted.
Encrypt bool `json:"encrypt,omitempty"`
// PGPKeys is a list of PGP public keys to encrypt the tokens with.
// At least one key must be given if encryption is enabled.
PGPKeys []string `json:"pgpKeys,omitempty"`
}

// SecretStoreSpec configures the secret store.
Expand Down
118 changes: 118 additions & 0 deletions controllers/stores/s3_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package stores

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"

"github.com/ProtonMail/gopenpgp/v2/helper"
"github.com/appuio/emergency-credentials-controller/pkg/utils"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"go.uber.org/multierr"

emcv1beta1 "github.com/appuio/emergency-credentials-controller/api/v1beta1"
)

// MinioClient partially implements the minio.Client interface.
type MinioClient interface {
PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (info minio.UploadInfo, err error)
}

type S3Store struct {
minioClientFactory func(emcv1beta1.S3StoreSpec) (MinioClient, error)
spec emcv1beta1.S3StoreSpec
}

var _ TokenStorer = &S3Store{}

// NewS3Store creates a new S3Store
func NewS3Store(spec emcv1beta1.S3StoreSpec) *S3Store {
return NewS3StoreWithClientFactory(spec, DefaultClientFactory)
}

// NewS3StoreWithClientFactory creates a new S3Store with the given client factory.
func NewS3StoreWithClientFactory(spec emcv1beta1.S3StoreSpec, minioClientFactory func(emcv1beta1.S3StoreSpec) (MinioClient, error)) *S3Store {
return &S3Store{spec: spec, minioClientFactory: minioClientFactory}
}

// DefaultClientFactory is the default factory for creating a MinioClient.
func DefaultClientFactory(spec emcv1beta1.S3StoreSpec) (MinioClient, error) {
return minio.New(spec.S3.EndPoint, &minio.Options{
Creds: credentials.NewStaticV4(spec.S3.AccessKeyId, spec.S3.SecretAccessKey, ""),
Secure: !spec.S3.Insecure,
Region: spec.S3.Region,
})
}

// StoreToken stores the token in the S3 bucket.
// If encryption is enabled, the token is encrypted with the given PGP public keys.
func (ss *S3Store) StoreToken(ctx context.Context, ea emcv1beta1.EmergencyAccount, token string) (string, error) {
cli, err := ss.minioClientFactory(ss.spec)
if err != nil {
return "", fmt.Errorf("unable to create S3 client: %w", err)
}

if ss.spec.Encryption.Encrypt {
token, err = encrypt(token, ss.spec.Encryption.PGPKeys)
if err != nil {
return "", fmt.Errorf("unable to encrypt token: %w", err)
}
}

tr := strings.NewReader(token)
info, err := cli.PutObject(ctx, ss.spec.S3.Bucket, ea.Name, tr, int64(tr.Len()), minio.PutObjectOptions{})
if err != nil {
return "", fmt.Errorf("unable to store token: %w", err)
}

return info.Key, nil
}

// EncryptedToken is the JSON structure of an encrypted token.
type EncryptedToken struct {
Secrets []EncryptedTokenSecret `json:"secrets"`
}

// EncryptedTokenSecret is the JSON structure of an encrypted token secret.
type EncryptedTokenSecret struct {
Data string `json:"data"`
}

// encrypt encrypts the token with the given PGP public keys.
// The token is encrypted with each key and the resulting encrypted tokens are returned as a JSON array.
func encrypt(token string, pgpKeys []string) (string, error) {
keys := []string{}
for _, key := range pgpKeys {
sk, err := utils.SplitPublicKeyBlocks(key)
if err != nil {
return "", fmt.Errorf("unable to parse PGP public key: %w", err)
}
keys = append(keys, sk...)
}

encrypted := make([]EncryptedTokenSecret, 0, len(keys))
errs := []error{}
for _, key := range keys {
enc, err := helper.EncryptMessageArmored((key), token)
if err != nil {
errs = append(errs, err)
continue
}
encrypted = append(encrypted, EncryptedTokenSecret{Data: enc})
}
if multierr.Combine(errs...) != nil {
return "", fmt.Errorf("unable to fully encrypt token: %w", multierr.Combine(errs...))
}

s, err := json.Marshal(EncryptedToken{
Secrets: encrypted,
})
if err != nil {
return "", fmt.Errorf("unable to marshal encrypted token: %w", err)
}

return string(s), nil
}
158 changes: 158 additions & 0 deletions controllers/stores/s3_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package stores_test

import (
"context"
"encoding/json"
"io"
"strings"
"testing"

"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/gopenpgp/v2/helper"
"github.com/appuio/emergency-credentials-controller/api/v1beta1"
emcv1beta1 "github.com/appuio/emergency-credentials-controller/api/v1beta1"
"github.com/appuio/emergency-credentials-controller/controllers/stores"
"github.com/minio/minio-go/v7"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func Test_S3Store_StoreToken(t *testing.T) {
const (
token = "token"
bucket = "bucket"
object = "object"
passphrase = "passphrase"
)

t.Run("without encryption", func(t *testing.T) {
mm := &MinioMock{}
st := stores.NewS3StoreWithClientFactory(emcv1beta1.S3StoreSpec{
S3: v1beta1.S3Spec{
Bucket: bucket,
},
}, mm.ClientFactory)

_, err := st.StoreToken(context.Background(), v1beta1.EmergencyAccount{
ObjectMeta: metav1.ObjectMeta{
Name: object,
},
}, token)
require.NoError(t, err)
require.Equal(t, []byte(token), mm.get(bucket, object))
})

t.Run("encrypted", func(t *testing.T) {
privk1, pubk1, err := generateKeyPair("test1", "[email protected]", passphrase, "rsa", 2048)
require.NoError(t, err)
privk2, pubk2, err := generateKeyPair("test2", "[email protected]", passphrase, "rsa", 2048)
require.NoError(t, err)
privk3, pubk3, err := generateKeyPair("test3", "[email protected]", passphrase, "rsa", 2048)
require.NoError(t, err)

mm := &MinioMock{}
st := stores.NewS3StoreWithClientFactory(emcv1beta1.S3StoreSpec{
S3: v1beta1.S3Spec{
Bucket: bucket,
},
Encryption: emcv1beta1.S3EncryptionSpec{
Encrypt: true,
PGPKeys: []string{strings.Join([]string{pubk1, pubk2}, "\n"), pubk3},
},
}, mm.ClientFactory)

_, err = st.StoreToken(context.Background(), v1beta1.EmergencyAccount{
ObjectMeta: metav1.ObjectMeta{
Name: object,
},
}, token)
require.NoError(t, err)
requireDecryptAll(t, string(mm.get(bucket, object)), token, passphrase, []string{privk1, privk2, privk3})
})
}

func requireDecryptAll(t *testing.T, token, expectedMsg, passphrase string, keys []string) {
t.Helper()

var data stores.EncryptedToken
err := json.Unmarshal([]byte(token), &data)
require.NoError(t, err)

for _, secret := range data.Secrets {
requireDecrypt(t, secret.Data, expectedMsg, passphrase, keys)
}
}

func requireDecrypt(t *testing.T, encrypted, expectedMsg, passphrase string, keys []string) {
t.Helper()

for _, key := range keys {
msg, err := helper.DecryptMessageArmored(key, []byte(passphrase), encrypted)
if err == nil {
require.Equal(t, expectedMsg, string(msg))
return
}
}
require.Fail(t, "expected to decrypt token with one of the given private keys")
}

type MinioMock struct {
files map[string]map[string][]byte
}

// ClientFactory returns itself.
func (mm *MinioMock) ClientFactory(v1beta1.S3StoreSpec) (stores.MinioClient, error) {
return mm, nil
}

// PutObject implements the MinioClient interface.
func (mm *MinioMock) PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (info minio.UploadInfo, err error) {
info.Bucket = bucketName
info.Key = objectName

if mm.files == nil {
mm.files = make(map[string]map[string][]byte)
}
if mm.files[bucketName] == nil {
mm.files[bucketName] = make(map[string][]byte)
}
buf := make([]byte, objectSize)
_, err = reader.Read(buf)
if err != nil {
return info, err
}
mm.files[bucketName][objectName] = buf
return info, nil
}

func (mm *MinioMock) get(bucketName string, objectName string) []byte {
if mm.files == nil {
return nil
}

if mm.files[bucketName] == nil {
return nil
}

return mm.files[bucketName][objectName]
}

// generateKeyPair generates a key pair and returns the private and public key.
func generateKeyPair(name, email, passphrase string, keyType string, bits int) (privateKey string, publicKey string, err error) {
privateKey, err = helper.GenerateKey(name, email, []byte(passphrase), keyType, bits)
if err != nil {
return "", "", err
}

ring, err := crypto.NewKeyFromArmoredReader(strings.NewReader(privateKey))
if err != nil {
return "", "", err
}

publicKey, err = ring.GetArmoredPublicKey()
if err != nil {
return "", "", err
}

return privateKey, publicKey, nil
}
3 changes: 3 additions & 0 deletions controllers/stores/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ func FromSpec(sts emcv1beta1.TokenStoreSpec) (TokenStorer, error) {
if sts.Type == "log" {
return NewLogStore(sts.LogSpec), nil
}
if sts.Type == "s3" {
return NewS3Store(sts.S3Spec), nil
}
return nil, fmt.Errorf("unknown token store type %s", sts.Type)
}
Loading

0 comments on commit b1e0c8f

Please sign in to comment.