Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions pkg/storage/fs/decomposeds3/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# S3 customer-provided encryption keys (SSE-C)

Both `decomposeds3` and the legacy `s3ng` driver accept an optional
`s3.sse_c_key` alongside the bucket configuration:

```toml
[grpc.services.storageprovider.drivers.decomposeds3]
"s3.endpoint" = "https://s3.example.com"
"s3.region" = "us-east-1"
"s3.bucket" = "files"
"s3.access_key" = "access-key"
"s3.secret_key" = "secret-key"
"s3.sse_c_key" = "<base64-encoded 32-byte encryption key>"
```

Generate a random key with `openssl rand -base64 32`. Keep it in secret
configuration and retain a secure backup: the same key is needed for subsequent
reads, including metadata requests and historical file versions. It is separate
from the S3 access credentials. The endpoint must use HTTPS and the S3 service
must permit SSE-C uploads.

An empty setting preserves the existing behavior. A configured key applies to
both upload paths, including multipart uploads, and to downloads and their
metadata requests. The SDK generates AES256, key and key-MD5 headers. Listing
and deleting objects do not send customer encryption headers. Incorrect or
missing keys fail without an unencrypted retry.

Setting or changing the key does not migrate or re-encrypt existing objects.
Before enabling SSE-C on an existing bucket, plan its data migration separately.
Key rotation and multiple keys within one bucket configuration are not provided.
19 changes: 16 additions & 3 deletions pkg/storage/fs/decomposeds3/blobstore/blobstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ import (

"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio-go/v7/pkg/encrypt"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/lookup"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/node"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/s3utils"
"github.com/pkg/errors"
)

// Blobstore provides an interface to an s3 compatible blobstore
type Blobstore struct {
client *minio.Client
client *minio.Client
encryption encrypt.ServerSide

defaultPutOptions Options

Expand All @@ -53,12 +56,17 @@ type Options struct {
}

// New returns a new Blobstore
func New(endpoint, region, bucket, accessKey, secretKey string, defaultPutOptions Options) (*Blobstore, error) {
// ssecKey is an optional base64-encoded 32-byte customer encryption key.
func New(endpoint, region, bucket, accessKey, secretKey, ssecKey string, defaultPutOptions Options) (*Blobstore, error) {
u, err := url.Parse(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed to parse s3 endpoint")
}

encryption, err := s3utils.NewSSEC(ssecKey, u.Scheme == "https")
if err != nil {
return nil, err
}
useSSL := u.Scheme != "http"
client, err := minio.New(u.Host, &minio.Options{
Region: region,
Expand All @@ -71,6 +79,7 @@ func New(endpoint, region, bucket, accessKey, secretKey string, defaultPutOption

return &Blobstore{
client: client,
encryption: encryption,
bucket: bucket,
defaultPutOptions: defaultPutOptions,
}, nil
Expand All @@ -86,6 +95,7 @@ func (bs *Blobstore) Upload(node *node.Node, source, _copyTarget string) error {

_, err = bs.client.PutObject(context.Background(), bs.bucket, bs.Path(node), reader, node.Blobsize, minio.PutObjectOptions{
ContentType: "application/octet-stream",
ServerSideEncryption: bs.encryption,
SendContentMd5: bs.defaultPutOptions.SendContentMd5,
ConcurrentStreamParts: bs.defaultPutOptions.ConcurrentStreamParts,
NumThreads: bs.defaultPutOptions.NumThreads,
Expand All @@ -104,6 +114,7 @@ func (bs *Blobstore) Upload(node *node.Node, source, _copyTarget string) error {
func (bs *Blobstore) UploadFromReader(node *node.Node, reader io.Reader, size int64) error {
_, err := bs.client.PutObject(context.Background(), bs.bucket, bs.Path(node), reader, size, minio.PutObjectOptions{
ContentType: "application/octet-stream",
ServerSideEncryption: bs.encryption,
SendContentMd5: bs.defaultPutOptions.SendContentMd5,
ConcurrentStreamParts: bs.defaultPutOptions.ConcurrentStreamParts,
NumThreads: bs.defaultPutOptions.NumThreads,
Expand All @@ -119,17 +130,19 @@ func (bs *Blobstore) UploadFromReader(node *node.Node, reader io.Reader, size in

// Download retrieves a blob from the blobstore for reading
func (bs *Blobstore) Download(node *node.Node) (io.ReadCloser, error) {
reader, err := bs.client.GetObject(context.Background(), bs.bucket, bs.Path(node), minio.GetObjectOptions{})
reader, err := bs.client.GetObject(context.Background(), bs.bucket, bs.Path(node), minio.GetObjectOptions{ServerSideEncryption: bs.encryption})
if err != nil {
return nil, errors.Wrapf(err, "could not download object '%s' from bucket '%s'", bs.Path(node), bs.bucket)
}

stat, err := reader.Stat()
if err != nil {
reader.Close()
return nil, errors.Wrapf(err, "blob path: %s", bs.Path(node))
}

if node.Blobsize != stat.Size {
reader.Close()
return nil, fmt.Errorf("blob has unexpected size. %d bytes expected, got %d bytes", node.Blobsize, stat.Size)
}

Expand Down
97 changes: 97 additions & 0 deletions pkg/storage/fs/decomposeds3/blobstore/blobstore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: Apache-2.0

package blobstore

import (
"bytes"
"encoding/base64"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"

"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/opencloud-eu/reva/v2/pkg/storage/pkg/decomposedfs/node"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestSSECHeaders(t *testing.T) {
source := filepath.Join(t.TempDir(), "blob")
if err := os.WriteFile(source, []byte("blob"), 0600); err != nil {
t.Fatal(err)
}
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32))
for _, tc := range []struct{ name, key string }{{"unset", ""}, {"SSE-C", key}} {
t.Run(tc.name, func(t *testing.T) {
opts := Options{DisableMultipart: true, SendContentMd5: true}
if tc.key != "" {
if _, err := New("http://s3.example.com", "us-east-1", "bucket", "ak", "sk", tc.key, opts); err == nil {
t.Fatal("accepted an HTTP endpoint with SSE-C enabled")
}
}
bs, err := New("https://s3.example.com", "us-east-1", "bucket", "ak", "sk", tc.key, opts)
if err != nil {
t.Fatal(err)
}
methods := map[string]int{}
// Intercept SDK requests to check Reva's option wiring without a
// network server or emulating multipart behavior owned by the SDK.
bs.client, err = minio.New("s3.example.com", &minio.Options{
Region: "us-east-1", Secure: true,
Creds: credentials.NewStaticV4("ak", "sk", ""),
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
methods[r.Method]++
if r.Header.Get("X-Amz-Server-Side-Encryption-Customer-Key") != tc.key {
t.Errorf("%s request has the wrong customer key", r.Method)
}
algorithm := ""
if tc.key != "" {
algorithm = "AES256"
}
if r.Header.Get("X-Amz-Server-Side-Encryption-Customer-Algorithm") != algorithm {
t.Errorf("%s request has the wrong encryption algorithm", r.Method)
}
if r.Body != nil {
defer r.Body.Close()
if _, err := io.Copy(io.Discard, r.Body); err != nil {
return nil, err
}
}
headers := http.Header{}
headers.Set("Content-Length", "4")
headers.Set("ETag", `"etag"`)
headers.Set("Last-Modified", "Mon, 26 Oct 2026 12:00:00 GMT")
return &http.Response{StatusCode: http.StatusOK, Header: headers,
ContentLength: 4, Body: io.NopCloser(strings.NewReader("blob")), Request: r}, nil
}),
})
if err != nil {
t.Fatal(err)
}
n := &node.Node{BaseNode: node.BaseNode{SpaceID: "space"}, BlobID: "blob", Blobsize: 4}
if err := bs.Upload(n, source, ""); err != nil {
t.Fatalf("Upload: %v", err)
}
if err := bs.UploadFromReader(n, strings.NewReader("blob"), 4); err != nil {
t.Fatalf("UploadFromReader: %v", err)
}
reader, err := bs.Download(n)
if err != nil {
t.Fatalf("Download: %v", err)
}
defer reader.Close()
if _, err := io.Copy(io.Discard, reader); err != nil {
t.Fatal(err)
}
if methods[http.MethodPut] != 2 || methods[http.MethodHead] != 1 || methods[http.MethodGet] != 1 {
t.Fatalf("unexpected requests: %v", methods)
}
})
}
}
2 changes: 1 addition & 1 deletion pkg/storage/fs/decomposeds3/decomposeds3.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func New(m map[string]interface{}, stream events.Stream, log *zerolog.Logger) (s
PartSize: o.PartSize,
}

bs, err := blobstore.New(o.S3Endpoint, o.S3Region, o.S3Bucket, o.S3AccessKey, o.S3SecretKey, defaultPutOptions)
bs, err := blobstore.New(o.S3Endpoint, o.S3Region, o.S3Bucket, o.S3AccessKey, o.S3SecretKey, o.S3SSECKey, defaultPutOptions)
if err != nil {
return nil, err
}
Expand Down
7 changes: 7 additions & 0 deletions pkg/storage/fs/decomposeds3/decomposeds3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ var _ = Describe("decomposeds3", func() {
Expect(err).To(MatchError("S3 configuration incomplete"))
})

It("rejects invalid SSE-C configuration before creating storage", func() {
options["s3.endpoint"] = "https://localhost:9000"
options["s3.sse_c_key"] = "invalid-secret!"
_, err := decomposeds3.New(options, nil, &zerolog.Logger{})
Expect(err).To(MatchError("SSE-C key must be base64-encoded"))
})

It("works with complete configuration", func() {
_, err := decomposeds3.New(options, nil, &zerolog.Logger{})
Expect(err).ToNot(HaveOccurred())
Expand Down
3 changes: 3 additions & 0 deletions pkg/storage/fs/decomposeds3/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ type Options struct {
// Bucket of the s3 blobstore
S3Bucket string `mapstructure:"s3.bucket"`

// Base64-encoded AES-256 customer key for server-side encryption (optional)
S3SSECKey string `mapstructure:"s3.sse_c_key"`

// Access key for the s3 blobstore
S3AccessKey string `mapstructure:"s3.access_key"`

Expand Down
2 changes: 2 additions & 0 deletions pkg/storage/fs/decomposeds3/option_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ var _ = Describe("Options", func() {
"s3.bucket": "the-bucket",
"s3.access_key": "foo",
"s3.secret_key": "bar",
"s3.sse_c_key": "customer-key",
}
o = &decomposeds3.Options{}
err := mapstructure.Decode(raw, o)
Expand All @@ -51,6 +52,7 @@ var _ = Describe("Options", func() {
Expect(o.S3Region).To(Equal("default"))
Expect(o.S3AccessKey).To(Equal("foo"))
Expect(o.S3SecretKey).To(Equal("bar"))
Expect(o.S3SSECKey).To(Equal("customer-key"))
})

Describe("S3ConfigComplete", func() {
Expand Down
19 changes: 16 additions & 3 deletions pkg/storage/fs/s3ng/blobstore/blobstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ import (

"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio-go/v7/pkg/encrypt"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/decomposedfs/lookup"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/decomposedfs/node"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/s3utils"
"github.com/pkg/errors"
)

// Blobstore provides an interface to an s3 compatible blobstore
type Blobstore struct {
client *minio.Client
client *minio.Client
encryption encrypt.ServerSide

defaultPutOptions Options

Expand All @@ -53,12 +56,17 @@ type Options struct {
}

// New returns a new Blobstore
func New(endpoint, region, bucket, accessKey, secretKey string, defaultPutOptions Options) (*Blobstore, error) {
// ssecKey is an optional base64-encoded 32-byte customer encryption key.
func New(endpoint, region, bucket, accessKey, secretKey, ssecKey string, defaultPutOptions Options) (*Blobstore, error) {
u, err := url.Parse(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed to parse s3 endpoint")
}

encryption, err := s3utils.NewSSEC(ssecKey, u.Scheme == "https")
if err != nil {
return nil, err
}
useSSL := u.Scheme != "http"
client, err := minio.New(u.Host, &minio.Options{
Region: region,
Expand All @@ -71,6 +79,7 @@ func New(endpoint, region, bucket, accessKey, secretKey string, defaultPutOption

return &Blobstore{
client: client,
encryption: encryption,
bucket: bucket,
defaultPutOptions: defaultPutOptions,
}, nil
Expand All @@ -87,6 +96,7 @@ func (bs *Blobstore) Upload(node *node.Node, source string) error {

_, err = bs.client.PutObject(context.Background(), bs.bucket, bs.Path(node), reader, node.Blobsize, minio.PutObjectOptions{
ContentType: "application/octet-stream",
ServerSideEncryption: bs.encryption,
SendContentMd5: bs.defaultPutOptions.SendContentMd5,
ConcurrentStreamParts: bs.defaultPutOptions.ConcurrentStreamParts,
NumThreads: bs.defaultPutOptions.NumThreads,
Expand All @@ -105,6 +115,7 @@ func (bs *Blobstore) Upload(node *node.Node, source string) error {
func (bs *Blobstore) UploadFromReader(node *node.Node, r io.Reader, size int64) error {
_, err := bs.client.PutObject(context.Background(), bs.bucket, bs.Path(node), r, size, minio.PutObjectOptions{
ContentType: "application/octet-stream",
ServerSideEncryption: bs.encryption,
SendContentMd5: bs.defaultPutOptions.SendContentMd5,
ConcurrentStreamParts: bs.defaultPutOptions.ConcurrentStreamParts,
NumThreads: bs.defaultPutOptions.NumThreads,
Expand All @@ -120,17 +131,19 @@ func (bs *Blobstore) UploadFromReader(node *node.Node, r io.Reader, size int64)

// Download retrieves a blob from the blobstore for reading
func (bs *Blobstore) Download(node *node.Node) (io.ReadCloser, error) {
reader, err := bs.client.GetObject(context.Background(), bs.bucket, bs.Path(node), minio.GetObjectOptions{})
reader, err := bs.client.GetObject(context.Background(), bs.bucket, bs.Path(node), minio.GetObjectOptions{ServerSideEncryption: bs.encryption})
if err != nil {
return nil, errors.Wrapf(err, "could not download object '%s' from bucket '%s'", bs.Path(node), bs.bucket)
}

stat, err := reader.Stat()
if err != nil {
reader.Close()
return nil, errors.Wrapf(err, "blob path: %s", bs.Path(node))
}

if node.Blobsize != stat.Size {
reader.Close()
return nil, fmt.Errorf("blob has unexpected size. %d bytes expected, got %d bytes", node.Blobsize, stat.Size)
}

Expand Down
Loading