From 887dcda78b40b22541309f18bf330d09a6d3d25f Mon Sep 17 00:00:00 2001 From: Otto Bittner Date: Mon, 2 Oct 2023 09:00:38 +0200 Subject: [PATCH] s3proxy: add keyservice integration Encrypt each object with a random DEK and attach the encrypted DEK as object metadata. Encrpt the DEK with a key from the keyservice. All objects use the same KEK until a keyrotation takes place. --- bazel/toolchains/go_module_deps.bzl | 10 +++ go.mod | 2 + go.sum | 2 + s3proxy/cmd/main.go | 16 ++++- s3proxy/deploy/README.md | 3 + s3proxy/internal/crypto/BUILD.bazel | 16 +++++ s3proxy/internal/crypto/crypto.go | 98 ++++++++++++-------------- s3proxy/internal/crypto/crypto_test.go | 48 +++++++++++++ s3proxy/internal/kms/BUILD.bazel | 29 ++++++++ s3proxy/internal/kms/kms.go | 76 ++++++++++++++++++++ s3proxy/internal/kms/kms_test.go | 72 +++++++++++++++++++ s3proxy/internal/router/BUILD.bazel | 1 + s3proxy/internal/router/object.go | 31 ++++---- s3proxy/internal/router/router.go | 42 ++++++++++- s3proxy/internal/router/router_test.go | 39 ++++++++++ 15 files changed, 414 insertions(+), 71 deletions(-) create mode 100644 s3proxy/internal/crypto/crypto_test.go create mode 100644 s3proxy/internal/kms/BUILD.bazel create mode 100644 s3proxy/internal/kms/kms.go create mode 100644 s3proxy/internal/kms/kms_test.go diff --git a/bazel/toolchains/go_module_deps.bzl b/bazel/toolchains/go_module_deps.bzl index 1751bec00..e85504260 100644 --- a/bazel/toolchains/go_module_deps.bzl +++ b/bazel/toolchains/go_module_deps.bzl @@ -3050,6 +3050,7 @@ def go_dependencies(): sum = "h1:YjkZLJ7K3inKgMZ0wzCU9OHqc+UqMQyXsPXnf3Cl2as=", version = "v1.9.2", ) + go_repository( name = "com_github_hexops_gotextdiff", build_file_generation = "on", @@ -5017,6 +5018,15 @@ def go_dependencies(): sum = "h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=", version = "v1.2.0", ) + go_repository( + name = "com_github_tink_crypto_tink_go_v2", + build_file_generation = "on", + build_file_proto_mode = "disable_global", + importpath = "github.com/tink-crypto/tink-go/v2", + replace = "github.com/derpsteb/tink-go/v2", + sum = "h1:FVii9oXvddz9sFir5TRYjQKrzJLbVD/hibT+SnRSDzg=", + version = "v2.0.0-20231002051717-a808e454eed6", + ) go_repository( name = "com_github_titanous_rocacheck", diff --git a/go.mod b/go.mod index 0ced2c53d..926cbdd26 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ replace ( github.com/edgelesssys/constellation/v2/operators/constellation-node-operator/v2/api => ./operators/constellation-node-operator/api github.com/google/go-tpm => github.com/thomasten/go-tpm v0.0.0-20230629092004-f43f8e2a59eb github.com/martinjungblut/go-cryptsetup => github.com/daniel-weisse/go-cryptsetup v0.0.0-20230705150314-d8c07bd1723c + github.com/tink-crypto/tink-go/v2 v2.0.0 => github.com/derpsteb/tink-go/v2 v2.0.0-20231002051717-a808e454eed6 ) require ( @@ -108,6 +109,7 @@ require ( github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 github.com/theupdateframework/go-tuf v0.5.2 + github.com/tink-crypto/tink-go/v2 v2.0.0 go.uber.org/goleak v1.2.1 go.uber.org/zap v1.26.0 golang.org/x/crypto v0.13.0 diff --git a/go.sum b/go.sum index 1c7d9e6bc..7dbe3135b 100644 --- a/go.sum +++ b/go.sum @@ -298,6 +298,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/derpsteb/tink-go/v2 v2.0.0-20231002051717-a808e454eed6 h1:FVii9oXvddz9sFir5TRYjQKrzJLbVD/hibT+SnRSDzg= +github.com/derpsteb/tink-go/v2 v2.0.0-20231002051717-a808e454eed6/go.mod h1:QAbyq9LZncomYnScxlfaHImbV4ieNIe6bnu/Xcqqox4= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= diff --git a/s3proxy/cmd/main.go b/s3proxy/cmd/main.go index fcb469e25..5b471d8af 100644 --- a/s3proxy/cmd/main.go +++ b/s3proxy/cmd/main.go @@ -57,7 +57,10 @@ func main() { func runServer(flags cmdFlags, log *logger.Logger) error { log.With(zap.String("ip", flags.ip), zap.Int("port", defaultPort), zap.String("region", flags.region)).Infof("listening") - router := router.New(flags.region, log) + router, err := router.New(flags.region, flags.kmsEndpoint, log) + if err != nil { + return fmt.Errorf("creating router: %w", err) + } server := http.Server{ Addr: fmt.Sprintf("%s:%d", flags.ip, defaultPort), @@ -92,6 +95,7 @@ func parseFlags() (cmdFlags, error) { ip := flag.String("ip", defaultIP, "ip to listen on") region := flag.String("region", defaultRegion, "AWS region in which target bucket is located") certLocation := flag.String("cert", defaultCertLocation, "location of TLS certificate") + kmsEndpoint := flag.String("kms", "key-service.kube-system:9000", "endpoint of the KMS service to get key encryption keys from") level := flag.Int("level", defaultLogLevel, "log level") flag.Parse() @@ -107,7 +111,14 @@ func parseFlags() (cmdFlags, error) { // return cmdFlags{}, fmt.Errorf("parsing log level: %w", err) // } - return cmdFlags{noTLS: *noTLS, ip: netIP.String(), region: *region, certLocation: *certLocation, logLevel: *level}, nil + return cmdFlags{ + noTLS: *noTLS, + ip: netIP.String(), + region: *region, + certLocation: *certLocation, + kmsEndpoint: *kmsEndpoint, + logLevel: *level, + }, nil } type cmdFlags struct { @@ -115,6 +126,7 @@ type cmdFlags struct { ip string region string certLocation string + kmsEndpoint string // TODO(derpsteb): enable once we are on go 1.21. // logLevel slog.Level logLevel int diff --git a/s3proxy/deploy/README.md b/s3proxy/deploy/README.md index 57f8cf73c..e95d80206 100644 --- a/s3proxy/deploy/README.md +++ b/s3proxy/deploy/README.md @@ -1,5 +1,8 @@ # Deploying s3proxy +**Caution:** Using s3proxy outside Constellation is insecure as the connection between the key management service (KMS) and s3proxy is protected by Constellation's WireGuard VPN. +The VPN is a feature of Constellation and will not be present by default in other environments. + Disclaimer: the following steps will be automated next. - Within `constellation/build`: `bazel run //:devbuild` - Copy the container name displayed for the s3proxy image. Look for the line starting with `[@//bazel/release:s3proxy_push]`. diff --git a/s3proxy/internal/crypto/BUILD.bazel b/s3proxy/internal/crypto/BUILD.bazel index 193aef953..cf29bfe78 100644 --- a/s3proxy/internal/crypto/BUILD.bazel +++ b/s3proxy/internal/crypto/BUILD.bazel @@ -1,8 +1,24 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("//bazel/go:go_test.bzl", "go_test") go_library( name = "crypto", srcs = ["crypto.go"], importpath = "github.com/edgelesssys/constellation/v2/s3proxy/internal/crypto", visibility = ["//s3proxy:__subpackages__"], + deps = [ + "@com_github_tink_crypto_tink_go_v2//aead/subtle", + "@com_github_tink_crypto_tink_go_v2//kwp/subtle", + "@com_github_tink_crypto_tink_go_v2//subtle/random", + ], +) + +go_test( + name = "crypto_test", + srcs = ["crypto_test.go"], + embed = [":crypto"], + deps = [ + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], ) diff --git a/s3proxy/internal/crypto/crypto.go b/s3proxy/internal/crypto/crypto.go index c9c8b2aa1..bdc117a7b 100644 --- a/s3proxy/internal/crypto/crypto.go +++ b/s3proxy/internal/crypto/crypto.go @@ -7,73 +7,67 @@ SPDX-License-Identifier: AGPL-3.0-only /* Package crypto provides encryption and decryption functions for the s3proxy. It uses AES-256-GCM to encrypt and decrypt data. -A new nonce is generated for each encryption operation. */ package crypto import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "io" + "fmt" + + aeadsubtle "github.com/tink-crypto/tink-go/v2/aead/subtle" + kwpsubtle "github.com/tink-crypto/tink-go/v2/kwp/subtle" + "github.com/tink-crypto/tink-go/v2/subtle/random" ) -// Encrypt takes a 32 byte key and encrypts a plaintext using AES-256-GCM. -// Output format is 12 byte nonce + ciphertext. -func Encrypt(plaintext, key []byte) ([]byte, error) { - // Enforce AES-256 - if len(key) != 32 { - return nil, aes.KeySizeError(len(key)) - } - - // None should not be reused more often that 2^32 times: - // https://pkg.go.dev/crypto/cipher#NewGCM - // Assuming n encryption operations per second, the key has to be rotated every: - // n=1: 2^32 / (60*60*24*365*10) = 135 years. - // n=10: 2^32 / (60*60*24*365*10) = 13.5 years. - // n=100: 2^32 / (60*60*24*365*10) = 1.3 years. - // n=1000: 2^32 / (60*60*24*365*10) = 50 days. - nonce := make([]byte, 12) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, err - } - - block, err := aes.NewCipher(key) +// Encrypt generates a random key to encrypt a plaintext using AES-256-GCM. +// The generated key is encrypted using the supplied key encryption key (KEK). +// The ciphertext and encrypted data encryption key (DEK) are returned. +func Encrypt(plaintext []byte, kek [32]byte) (ciphertext []byte, encryptedDEK []byte, err error) { + dek := random.GetRandomBytes(32) + aesgcm, err := aeadsubtle.NewAESGCMSIV(dek) if err != nil { - return nil, err + return nil, nil, fmt.Errorf("getting aesgcm: %w", err) } - aesgcm, err := cipher.NewGCM(block) + + ciphertext, err = aesgcm.Encrypt(plaintext, []byte("")) if err != nil { - return nil, err + return nil, nil, fmt.Errorf("encrypting plaintext: %w", err) } - ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) - // Prepend the nonce to the ciphertext. - ciphertext = append(nonce, ciphertext...) + keywrapper, err := kwpsubtle.NewKWP(kek[:]) + if err != nil { + return nil, nil, fmt.Errorf("getting kwp: %w", err) + } - return ciphertext, nil + encryptedDEK, err = keywrapper.Wrap(dek) + if err != nil { + return nil, nil, fmt.Errorf("wrapping dek: %w", err) + } + + return ciphertext, encryptedDEK, nil } -// Decrypt takes a 32 byte key and decrypts a ciphertext using AES-256-GCM. -// ciphertext is formatted as 12 byte nonce + ciphertext. -func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { - // Enforce AES-256 - if len(key) != 32 { - return nil, aes.KeySizeError(len(key)) - } - - // Extract the nonce from the ciphertext. - nonce := ciphertext[:12] - ciphertext = ciphertext[12:] - - block, err := aes.NewCipher(key) +// Decrypt decrypts a ciphertext using AES-256-GCM. +// The encrypted DEK is decrypted using the supplied KEK. +func Decrypt(ciphertext, encryptedDEK []byte, kek [32]byte) ([]byte, error) { + keywrapper, err := kwpsubtle.NewKWP(kek[:]) if err != nil { - return nil, err - } - aesgcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err + return nil, fmt.Errorf("getting kwp: %w", err) } - return aesgcm.Open(nil, nonce, ciphertext, nil) + dek, err := keywrapper.Unwrap(encryptedDEK) + if err != nil { + return nil, fmt.Errorf("unwrapping dek: %w", err) + } + + aesgcm, err := aeadsubtle.NewAESGCMSIV(dek) + if err != nil { + return nil, fmt.Errorf("getting aesgcm: %w", err) + } + + plaintext, err := aesgcm.Decrypt(ciphertext, []byte("")) + if err != nil { + return nil, fmt.Errorf("decrypting ciphertext: %w", err) + } + + return plaintext, nil } diff --git a/s3proxy/internal/crypto/crypto_test.go b/s3proxy/internal/crypto/crypto_test.go new file mode 100644 index 000000000..4fb17e87d --- /dev/null +++ b/s3proxy/internal/crypto/crypto_test.go @@ -0,0 +1,48 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ +package crypto + +import ( + "crypto/rand" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncryptDecrypt(t *testing.T) { + tests := map[string]struct { + plaintext []byte + }{ + "simple": { + plaintext: []byte("hello, world"), + }, + "long": { + plaintext: []byte("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor."), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + kek := [32]byte{} + _, err := rand.Read(kek[:]) + require.NoError(t, err) + + ciphertext, encryptedDEK, err := Encrypt(tt.plaintext, kek) + require.NoError(t, err) + + assert.NotContains(t, ciphertext, tt.plaintext) + + // Decrypt the ciphertext using the KEK and encrypted DEK + decrypted, err := Decrypt(ciphertext, encryptedDEK, kek) + require.NoError(t, err) + + // Verify that the decrypted plaintext matches the original plaintext + assert.Equal(t, tt.plaintext, decrypted, fmt.Sprintf("expected plaintext %s, got %s", tt.plaintext, decrypted)) + }) + } +} diff --git a/s3proxy/internal/kms/BUILD.bazel b/s3proxy/internal/kms/BUILD.bazel new file mode 100644 index 000000000..e4d4d25b8 --- /dev/null +++ b/s3proxy/internal/kms/BUILD.bazel @@ -0,0 +1,29 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("//bazel/go:go_test.bzl", "go_test") + +go_library( + name = "kms", + srcs = ["kms.go"], + importpath = "github.com/edgelesssys/constellation/v2/s3proxy/internal/kms", + visibility = ["//s3proxy:__subpackages__"], + deps = [ + "//internal/logger", + "//keyservice/keyserviceproto", + "@org_golang_google_grpc//:go_default_library", + "@org_golang_google_grpc//credentials/insecure", + ], +) + +go_test( + name = "kms_test", + srcs = ["kms_test.go"], + embed = [":kms"], + deps = [ + "//internal/logger", + "//keyservice/keyserviceproto", + "@com_github_stretchr_testify//assert", + "@org_golang_google_grpc//:go_default_library", + "@org_golang_google_grpc//test/bufconn", + "@org_uber_go_goleak//:goleak", + ], +) diff --git a/s3proxy/internal/kms/kms.go b/s3proxy/internal/kms/kms.go new file mode 100644 index 000000000..24e53ed5c --- /dev/null +++ b/s3proxy/internal/kms/kms.go @@ -0,0 +1,76 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +/* +Package kms is used to interact with the Constellation keyservice. +So far it is a copy of the joinservice's kms package. +*/ +package kms + +import ( + "context" + "fmt" + + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/keyservice/keyserviceproto" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// Client interacts with Constellation's keyservice. +type Client struct { + log *logger.Logger + endpoint string + grpc grpcClient +} + +// New creates a new KMS. +func New(log *logger.Logger, endpoint string) Client { + return Client{ + log: log, + endpoint: endpoint, + grpc: client{}, + } +} + +// GetDataKey returns a data encryption key for the given UUID. +func (c Client) GetDataKey(ctx context.Context, keyID string, length int) ([]byte, error) { + log := c.log.With("keyID", keyID, "endpoint", c.endpoint) + // the KMS does not use aTLS since traffic is only routed through the Constellation cluster + // cluster internal connections are considered trustworthy + log.Infof("Connecting to KMS") + conn, err := grpc.DialContext(ctx, c.endpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + defer conn.Close() + + log.Infof("Requesting data key") + res, err := c.grpc.GetDataKey( + ctx, + &keyserviceproto.GetDataKeyRequest{ + DataKeyId: keyID, + Length: uint32(length), + }, + conn, + ) + if err != nil { + return nil, fmt.Errorf("fetching data encryption key from Constellation KMS: %w", err) + } + + log.Infof("Data key request successful") + return res.DataKey, nil +} + +type grpcClient interface { + GetDataKey(context.Context, *keyserviceproto.GetDataKeyRequest, *grpc.ClientConn) (*keyserviceproto.GetDataKeyResponse, error) +} + +type client struct{} + +func (c client) GetDataKey(ctx context.Context, req *keyserviceproto.GetDataKeyRequest, conn *grpc.ClientConn) (*keyserviceproto.GetDataKeyResponse, error) { + return keyserviceproto.NewAPIClient(conn).GetDataKey(ctx, req) +} diff --git a/s3proxy/internal/kms/kms_test.go b/s3proxy/internal/kms/kms_test.go new file mode 100644 index 000000000..e91fb34d6 --- /dev/null +++ b/s3proxy/internal/kms/kms_test.go @@ -0,0 +1,72 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package kms + +import ( + "context" + "errors" + "testing" + + "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/keyservice/keyserviceproto" + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" + "google.golang.org/grpc" + "google.golang.org/grpc/test/bufconn" +) + +type stubClient struct { + getDataKeyErr error + dataKey []byte +} + +func (c *stubClient) GetDataKey(context.Context, *keyserviceproto.GetDataKeyRequest, *grpc.ClientConn) (*keyserviceproto.GetDataKeyResponse, error) { + return &keyserviceproto.GetDataKeyResponse{DataKey: c.dataKey}, c.getDataKeyErr +} + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestGetDataKey(t *testing.T) { + testCases := map[string]struct { + client *stubClient + wantErr bool + }{ + "GetDataKey success": { + client: &stubClient{dataKey: []byte{0x1, 0x2, 0x3}}, + }, + "GetDataKey error": { + client: &stubClient{getDataKeyErr: errors.New("error")}, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + listener := bufconn.Listen(1) + defer listener.Close() + + client := New( + logger.NewTest(t), + listener.Addr().String(), + ) + + client.grpc = tc.client + + res, err := client.GetDataKey(context.Background(), "disk-uuid", 32) + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.client.dataKey, res) + } + }) + } +} diff --git a/s3proxy/internal/router/BUILD.bazel b/s3proxy/internal/router/BUILD.bazel index 646d3c14c..c60568bce 100644 --- a/s3proxy/internal/router/BUILD.bazel +++ b/s3proxy/internal/router/BUILD.bazel @@ -12,6 +12,7 @@ go_library( deps = [ "//internal/logger", "//s3proxy/internal/crypto", + "//s3proxy/internal/kms", "//s3proxy/internal/s3", "@com_github_aws_aws_sdk_go_v2_service_s3//:s3", "@org_uber_go_zap//:zap", diff --git a/s3proxy/internal/router/object.go b/s3proxy/internal/router/object.go index 6590652f3..0f58a2900 100644 --- a/s3proxy/internal/router/object.go +++ b/s3proxy/internal/router/object.go @@ -8,6 +8,7 @@ package router import ( "context" + "encoding/hex" "io" "net/http" "net/url" @@ -23,15 +24,14 @@ import ( ) const ( - // testingKey is a temporary encryption key used for testing. - // TODO (derpsteb): This key needs to be fetched from Constellation's keyservice. - testingKey = "01234567890123456789012345678901" - // encryptionTag is the key used to tag objects that are encrypted with this proxy. Presence of the key implies the object needs to be decrypted. - encryptionTag = "constellation-encryption" + // dekTag is the name of the header that holds the encrypted data encryption key for the attached object. Presence of the key implies the object needs to be decrypted. + // Use lowercase only, as AWS automatically lowercases all metadata keys. + dekTag = "constellation-dek" ) // object bundles data to implement http.Handler methods that use data from incoming requests. type object struct { + kek [32]byte client s3Client key string bucket string @@ -113,10 +113,16 @@ func (o object) get(w http.ResponseWriter, r *http.Request) { } plaintext := body - decrypt, ok := output.Metadata[encryptionTag] + rawEncryptedDEK, ok := output.Metadata[dekTag] + if ok { + encryptedDEK, err := hex.DecodeString(rawEncryptedDEK) + if err != nil { + o.log.Errorf("GetObject decoding DEK", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - if ok && decrypt == "true" { - plaintext, err = crypto.Decrypt(body, []byte(testingKey)) + plaintext, err = crypto.Decrypt(body, encryptedDEK, o.kek) if err != nil { o.log.With(zap.Error(err)).Errorf("GetObject decrypting response") http.Error(w, err.Error(), http.StatusInternalServerError) @@ -132,18 +138,13 @@ func (o object) get(w http.ResponseWriter, r *http.Request) { // put is a http.HandlerFunc that implements the PUT method for objects. func (o object) put(w http.ResponseWriter, r *http.Request) { - o.log.Debugf("putObject", "key", o.key, "host", o.bucket) - - ciphertext, err := crypto.Encrypt(o.data, []byte(testingKey)) + ciphertext, encryptedDEK, err := crypto.Encrypt(o.data, o.kek) if err != nil { o.log.With(zap.Error(err)).Errorf("PutObject") http.Error(w, err.Error(), http.StatusInternalServerError) return } - // We need to tag objects that are encrypted with this proxy, - // because there might be objects in a bucket that are not encrypted. - // GetObject needs to be able to recognize these objects and skip decryption. - o.metadata[encryptionTag] = "true" + o.metadata[dekTag] = hex.EncodeToString(encryptedDEK) output, err := o.client.PutObject(r.Context(), o.bucket, o.key, o.tags, o.contentType, o.objectLockLegalHoldStatus, o.objectLockMode, o.sseCustomerAlgorithm, o.sseCustomerKey, o.sseCustomerKeyMD5, o.objectLockRetainUntilDate, o.metadata, ciphertext) if err != nil { diff --git a/s3proxy/internal/router/router.go b/s3proxy/internal/router/router.go index 7d7766407..bd9b84427 100644 --- a/s3proxy/internal/router/router.go +++ b/s3proxy/internal/router/router.go @@ -10,11 +10,18 @@ It decides which packages to forward and which to intercept. The routing logic in this file is taken from this blog post: https://benhoyt.com/writings/go-routing/#regex-switch. We should be able to replace this once this is part of the stdlib: https://github.com/golang/go/issues/61410. + +If the router intercepts a PutObject request it will encrypt the body before forwarding it to the S3 API. +The stored object will have a tag that holds an encrypted data encryption key (DEK). +That DEK is used to encrypt the object's body. +The DEK is generated randomly for each PutObject request. +The DEK is encrypted with a key encryption key (KEK) fetched from Constellation's keyservice. */ package router import ( "bytes" + "context" "crypto/md5" "crypto/sha256" "encoding/base64" @@ -28,10 +35,17 @@ import ( "time" "github.com/edgelesssys/constellation/v2/internal/logger" + "github.com/edgelesssys/constellation/v2/s3proxy/internal/kms" "github.com/edgelesssys/constellation/v2/s3proxy/internal/s3" "go.uber.org/zap" ) +const ( + // Use a 32*8 = 256 bit key for AES-256. + kekSizeBytes = 32 + kekID = "s3proxy-kek" +) + var ( keyPattern = regexp.MustCompile("/(.+)") bucketAndKeyPattern = regexp.MustCompile("/([^/?]+)/(.+)") @@ -40,12 +54,26 @@ var ( // Router implements the interception logic for the s3proxy. type Router struct { region string + kek [32]byte log *logger.Logger } // New creates a new Router. -func New(region string, log *logger.Logger) Router { - return Router{region: region, log: log} +func New(region, endpoint string, log *logger.Logger) (Router, error) { + kms := kms.New(log, endpoint) + + // Get the key encryption key that encrypts all DEKs. + kek, err := kms.GetDataKey(context.Background(), kekID, kekSizeBytes) + if err != nil { + return Router{}, fmt.Errorf("getting KEK: %w", err) + } + + kekArray, err := byteSliceToByteArray(kek) + if err != nil { + return Router{}, fmt.Errorf("converting KEK to byte array: %w", err) + } + + return Router{region: region, kek: kekArray, log: log}, nil } // Serve implements the routing logic for the s3 proxy. @@ -243,6 +271,16 @@ func handleForwards(log *logger.Logger) http.HandlerFunc { } } +// byteSliceToByteArray casts a byte slice to a byte array of length 32. +// It does a length check to prevent the cast from panic'ing. +func byteSliceToByteArray(input []byte) ([32]byte, error) { + if len(input) != 32 { + return [32]byte{}, fmt.Errorf("input length mismatch, got: %d", len(input)) + } + + return ([32]byte)(input), nil +} + // containsBucket is a helper to recognizes cases where the bucket name is sent as part of the host. // In other cases the bucket name is sent as part of the path. func containsBucket(host string) bool { diff --git a/s3proxy/internal/router/router_test.go b/s3proxy/internal/router/router_test.go index 2a51b9da0..a690ce669 100644 --- a/s3proxy/internal/router/router_test.go +++ b/s3proxy/internal/router/router_test.go @@ -46,3 +46,42 @@ func TestValidateContentMD5(t *testing.T) { }) } } + +func TestByteSliceToByteArray(t *testing.T) { + tests := map[string]struct { + input []byte + output [32]byte + wantErr bool + }{ + "empty input": { + input: []byte{}, + output: [32]byte{}, + }, + "successful input": { + input: []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + output: [32]byte{0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41}, + }, + "input too short": { + input: []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + output: [32]byte{0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41}, + wantErr: true, + }, + "input too long": { + input: []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + output: [32]byte{0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41}, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := byteSliceToByteArray(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.Equal(t, tc.output, result) + }) + } +}