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.
This commit is contained in:
Otto Bittner 2023-10-02 09:00:38 +02:00
parent a7ceda37ea
commit 887dcda78b
15 changed files with 414 additions and 71 deletions

View file

@ -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",

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
})
}
}