-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
445 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.