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 20, 2023
1 parent a95c146 commit 9711baa
Show file tree
Hide file tree
Showing 8 changed files with 445 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
}
150 changes: 150 additions & 0 deletions controllers/stores/s3_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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) {
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: "test",
},
}, "token")
require.NoError(t, err)
require.Equal(t, []byte("token"), mm.get("bucket", "test"))
})

t.Run("encrypted", func(t *testing.T) {
privk1, pubk1, err := generateKeyPair("test1", "test1@test.ch", "test", "rsa", 2048)
require.NoError(t, err)
privk2, pubk2, err := generateKeyPair("test2", "test2@test.ch", "test", "rsa", 2048)
require.NoError(t, err)
privk3, pubk3, err := generateKeyPair("test3", "test3@test.ch", "test", "rsa", 2048)
require.NoError(t, err)

_ = []string{privk1, privk2, privk3}

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: "test",
},
}, "token")
require.NoError(t, err)
requireDecryptAll(t, string(mm.get("bucket", "test")), []string{privk1, privk2, privk3})
})
}

func requireDecryptAll(t *testing.T, token 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, keys)
}
}

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

for _, key := range keys {
msg, err := helper.DecryptMessageArmored(key, []byte("test"), encrypted)
if err == nil {
require.Equal(t, "token", string(msg))
return
}
}
require.Fail(t, "unable to decrypt")
}

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) {
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)
}
16 changes: 15 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ module github.com/appuio/emergency-credentials-controller
go 1.21.5

require (
github.com/ProtonMail/gopenpgp/v2 v2.7.4
github.com/go-logr/logr v1.3.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/minio/minio-go/v7 v7.0.66
github.com/prometheus/client_golang v1.17.0
github.com/stretchr/testify v1.8.4
go.uber.org/multierr v1.11.0
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848
k8s.io/api v0.29.0
k8s.io/apimachinery v0.29.0
Expand All @@ -18,9 +21,13 @@ require (
)

require (
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch v5.7.0+incompatible // indirect
github.com/evanphx/json-patch/v5 v5.7.0 // indirect
Expand All @@ -44,10 +51,14 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
Expand All @@ -57,12 +68,14 @@ require (
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/oauth2 v0.15.0 // indirect
Expand All @@ -76,6 +89,7 @@ require (
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/evanphx/json-patch.v5 v5.7.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.29.0 // indirect
Expand Down
Loading

0 comments on commit 9711baa

Please sign in to comment.