mirror of
https://github.com/pgsty/minio.git
synced 2026-09-06 18:46:17 +03:00
35bd75948a
With compression allow_encryption=on an SSE-C object is stored as encrypt(s2(plaintext)), while replication reads it raw (NoDecryption at cmd/erasure-object.go:257) and putReplicationOpts drops the internal compression and actual-size headers (cmd/bucket-replication.go:786). The replica keeps the source seal with no compression marker, so a GET with the correct customer key returns HTTP 200 and the raw S2 stream instead of the object, and the source records the transfer as COMPLETED. Widen the one condition in excludeForCompression (cmd/object-api-utils.go:613) so SSE-C is never compressed, whatever allow_encryption says. This covers all four producers at once, PutObject, NewMultipartUpload, CopyObject and PutObjectExtract, plus any future caller of isCompressible. crypto.SSEC.IsRequested ignores copy-source headers, so a copy is judged on its destination key only, and a raw SSE-C replica write is unaffected because it carries no public SSE-C headers. allow_encryption keeps its meaning for SSE-S3 and SSE-KMS, where the server owns the key and decompresses before replicating. Tests: TestAPISSECCompressionReplicaStaysReadable (single PUT and multipart), TestAPISSECCompressionProducerMatrix, TestAPISSECCompressionSkippedOnCopyObject, TestAPISSECCompressionSkippedOnSnowballExtract and the control TestSSECBatchReplicationCannotRead in cmd/compression-ssec_test.go. Two existing expectations pinned the removed shape and are updated: TestAPICopyObjectSSECKeyRotationNullVersionCompressesRewrite is renamed TestAPICopyObjectSSECKeyRotationNullVersionSkipsCompression and now expects an uncompressed rewrite, keeping its body, checksum, version and ETag assertions; the SSE-C compressed-encrypted variant of TestAPICopyObjectServerSideChecksumEncryption becomes compressible-extension and expects an uncompressed destination, its SSE-S3 sibling keeping the compressed coverage. Compatibility: a deliberate behaviour change. Deployments with allow_encryption=on no longer store new or rewritten SSE-C data compressed, so those writes cost more space; objects already stored compressed keep working on the source and are the concern of pgsty/silo#109, which rejects them at replication time. Multipart uploads initiated before this change keep compressing their parts from the metadata saved at initiation. Upstream468a9fae8refused this combination at PUT time anda2cab0255removed the guard; upstream master is still unguarded, so this is a deliberate divergence. Fixes pgsty/silo#118 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L7qJqWwy8oFA6aCXWRzXQe Signed-off-by: Feng Ruohang <rh@vonng.com>
739 lines
28 KiB
Go
739 lines
28 KiB
Go
// Copyright (c) 2015-2026 MinIO, Inc.
|
|
// Copyright (c) 2026 PGSTY
|
|
//
|
|
// This file is part of MinIO Object Storage stack
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/xml"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/klauspost/compress/s2"
|
|
"github.com/minio/minio/internal/auth"
|
|
"github.com/minio/minio/internal/crypto"
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
"github.com/minio/minio/internal/kms"
|
|
)
|
|
|
|
// disableCompression turns the global compression config off and returns a
|
|
// restore func. It is the replication destination that applies no transform of
|
|
// its own; setCopyChecksumCompression covers the enabled cases.
|
|
func disableCompression() func() {
|
|
globalCompressConfigMu.Lock()
|
|
previous := globalCompressConfig
|
|
globalCompressConfig.Enabled = false
|
|
globalCompressConfigMu.Unlock()
|
|
|
|
return func() {
|
|
globalCompressConfigMu.Lock()
|
|
globalCompressConfig = previous
|
|
globalCompressConfigMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// ssecTestHeaders builds the customer key headers for a key made of the given
|
|
// repeated byte.
|
|
func ssecTestHeaders(b byte) map[string]string {
|
|
key := bytes.Repeat([]byte{b}, 32)
|
|
keyMD5 := md5.Sum(key)
|
|
return map[string]string{
|
|
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
|
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
|
|
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
|
|
}
|
|
}
|
|
|
|
// assertStoredSSECUncompressed requires the stored object to be SSE-C sealed
|
|
// and to carry no compression marker, so that "not compressed" is never
|
|
// reported for an object that is not encrypted either.
|
|
func assertStoredSSECUncompressed(t *testing.T, obj ObjectLayer, bucketName, object string) ObjectInfo {
|
|
t.Helper()
|
|
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, sealed := info.UserDefined[crypto.MetaSealedKeySSEC]; !sealed {
|
|
t.Fatalf("%s is not SSE-C sealed, the fixture proves nothing (userDefined=%v)", object, info.UserDefined)
|
|
}
|
|
if marker, compressed := info.UserDefined[ReservedMetadataPrefix+"compression"]; compressed {
|
|
t.Errorf("%s was stored as a compressed SSE-C object (compression=%q); such an object cannot be replicated",
|
|
object, marker)
|
|
}
|
|
return info
|
|
}
|
|
|
|
// assertSSECPlaintext GETs an SSE-C object with its customer key and requires
|
|
// the body to equal the plaintext.
|
|
func assertSSECPlaintext(t *testing.T, apiRouter http.Handler, credentials auth.Credentials,
|
|
bucketName, object string, sseHeaders map[string]string, want []byte,
|
|
) {
|
|
t.Helper()
|
|
req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
|
|
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s status %d: %s", object, rec.Code, rec.Body.String())
|
|
}
|
|
if bytes.Equal(rec.Body.Bytes(), want) {
|
|
return
|
|
}
|
|
body := rec.Body.Bytes()
|
|
head := body
|
|
if len(head) > 16 {
|
|
head = head[:16]
|
|
}
|
|
t.Errorf("GET %s returned %d bytes, want the %d byte plaintext; first bytes % x",
|
|
object, len(body), len(want), head)
|
|
// Name the failure mode: a body that s2-decodes to the plaintext is the raw
|
|
// S2 stream of a compressed source shipped without its compression marker.
|
|
if decoded, derr := io.ReadAll(s2.NewReader(bytes.NewReader(body))); derr == nil && bytes.Equal(decoded, want) {
|
|
t.Errorf("the returned body is the raw S2 stream: s2-decoding it yields the %d byte plaintext", len(want))
|
|
}
|
|
}
|
|
|
|
// TestAPISSECCompressionReplicaStaysReadable replicates an SSE-C object written
|
|
// with compression enabled and allow_encryption=on, and requires the replica to
|
|
// read back as the source plaintext.
|
|
//
|
|
// The source object is read the way the replication worker reads it
|
|
// (ReplicationRequest, hence NoDecryption), its wire headers come from the
|
|
// production option builder putReplicationOpts, and the replica is written the
|
|
// way a destination that applies no transform of its own stores it: compression
|
|
// off and no default encryption.
|
|
//
|
|
// Before the SSE-C compression exclusion the source was stored as
|
|
// encrypt(s2(plaintext)) while putReplicationOpts dropped
|
|
// X-Minio-Internal-compression, so the replica decrypted to an S2 stream and a
|
|
// correct-key GET returned HTTP 200 with the wrong body.
|
|
func TestAPISSECCompressionReplicaStaysReadable(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPISSECCompressionReplicaStaysReadable,
|
|
})
|
|
}
|
|
|
|
func testAPISSECCompressionReplicaStaysReadable(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
previousTLS := globalIsTLS
|
|
globalIsTLS = true
|
|
defer func() { globalIsTLS = previousTLS }()
|
|
|
|
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:GetObject","s3:ReplicateObject"`)
|
|
sseHeaders := ssecTestHeaders(0x42)
|
|
// Highly compressible and comfortably above minCompressibleSize (4096).
|
|
data := bytes.Repeat([]byte("silo compressed ssec replication payload "), 8192)
|
|
|
|
t.Run(instanceType+"/single-put", func(t *testing.T) {
|
|
object := "replication/ssec-single.txt"
|
|
|
|
// --- Source side: compression ON with allow_encryption ON. ---
|
|
restore := setCopyChecksumCompression(true)
|
|
srcReq, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
|
|
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, sseHeaders)
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
srcRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(srcRec, srcReq)
|
|
if srcRec.Code != http.StatusOK {
|
|
restore()
|
|
t.Fatalf("source PUT status %d: %s", srcRec.Code, srcRec.Body.String())
|
|
}
|
|
sourceInfo := assertStoredSSECUncompressed(t, obj, bucketName, object)
|
|
t.Logf("source: stored size=%d compression=%q plaintext=%d", sourceInfo.Size,
|
|
sourceInfo.UserDefined[ReservedMetadataPrefix+"compression"], len(data))
|
|
|
|
// The replication worker's read: raw stored bytes, no decryption.
|
|
gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{},
|
|
ObjectOptions{ReplicationRequest: true})
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
sourceInfo = gr.ObjInfo
|
|
raw, err := io.ReadAll(gr)
|
|
gr.Close()
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
|
|
replicationOpts, isMP, err := putReplicationOpts(t.Context(), "", sourceInfo)
|
|
if err != nil {
|
|
restore()
|
|
t.Fatalf("putReplicationOpts rejected the source: %v", err)
|
|
}
|
|
if isMP {
|
|
restore()
|
|
t.Fatal("single PUT source classified as multipart")
|
|
}
|
|
headers := map[string]string{}
|
|
for name, values := range replicationOpts.Header() {
|
|
if len(values) > 0 {
|
|
headers[name] = values[0]
|
|
}
|
|
}
|
|
restore()
|
|
|
|
// --- Destination side: NO compression, NO default encryption. ---
|
|
restoreDst := disableCompression()
|
|
defer restoreDst()
|
|
|
|
replReq, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
|
|
int64(len(raw)), bytes.NewReader(raw), replicator.AccessKey, replicator.SecretKey, headers)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(replRec, replReq)
|
|
if replRec.Code != http.StatusOK {
|
|
t.Fatalf("replica PUT status %d: %s", replRec.Code, replRec.Body.String())
|
|
}
|
|
|
|
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("replica: stored size=%d compression=%q actual-size=%q", info.Size,
|
|
info.UserDefined[ReservedMetadataPrefix+"compression"],
|
|
info.UserDefined[ReservedMetadataPrefix+"actual-size"])
|
|
assertSSECPlaintext(t, apiRouter, credentials, bucketName, object, sseHeaders, data)
|
|
})
|
|
|
|
t.Run(instanceType+"/multipart", func(t *testing.T) {
|
|
object := "replication/ssec-mpu.txt"
|
|
|
|
restore := setCopyChecksumCompression(true)
|
|
newReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
|
|
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
newRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(newRec, newReq)
|
|
if newRec.Code != http.StatusOK {
|
|
restore()
|
|
t.Fatalf("source NewMultipart status %d: %s", newRec.Code, newRec.Body.String())
|
|
}
|
|
var sourceInit InitiateMultipartUploadResponse
|
|
if err = xmlDecoder(newRec.Body, &sourceInit, int64(newRec.Body.Len())); err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
partReq, err := newTestSignedRequestV4(http.MethodPut,
|
|
getPutObjectPartURL("", bucketName, object, sourceInit.UploadID, "1"),
|
|
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, sseHeaders)
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
partRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(partRec, partReq)
|
|
if partRec.Code != http.StatusOK {
|
|
restore()
|
|
t.Fatalf("source PutPart status %d: %s", partRec.Code, partRec.Body.String())
|
|
}
|
|
completeBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{
|
|
{PartNumber: 1, ETag: canonicalizeETag(partRec.Header()[xhttp.ETag][0])},
|
|
}})
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
completeReq, err := newTestSignedRequestV4(http.MethodPost,
|
|
getCompleteMultipartUploadURL("", bucketName, object, sourceInit.UploadID),
|
|
int64(len(completeBody)), bytes.NewReader(completeBody), credentials.AccessKey, credentials.SecretKey, sseHeaders)
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
completeRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(completeRec, completeReq)
|
|
if completeRec.Code != http.StatusOK {
|
|
restore()
|
|
t.Fatalf("source Complete status %d: %s", completeRec.Code, completeRec.Body.String())
|
|
}
|
|
assertStoredSSECUncompressed(t, obj, bucketName, object)
|
|
|
|
gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{},
|
|
ObjectOptions{ReplicationRequest: true})
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
sourceInfo := gr.ObjInfo
|
|
rawPart, err := io.ReadAll(gr)
|
|
gr.Close()
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
actualSize, err := sourceInfo.GetActualSize()
|
|
if err != nil {
|
|
restore()
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("source mpu: stored size=%d actual-size=%d rawRead=%d plaintext=%d compression=%q",
|
|
sourceInfo.Size, actualSize, len(rawPart), len(data),
|
|
sourceInfo.UserDefined[ReservedMetadataPrefix+"compression"])
|
|
|
|
replicationOpts, isMP, err := putReplicationOpts(t.Context(), "", sourceInfo)
|
|
if err != nil {
|
|
restore()
|
|
t.Fatalf("putReplicationOpts rejected the source: %v", err)
|
|
}
|
|
if !isMP {
|
|
restore()
|
|
t.Fatal("SSE-C multipart source not recognized as multipart")
|
|
}
|
|
replicationOpts.Internal.SourceMTime = time.Time{}
|
|
headers := map[string]string{}
|
|
for name, values := range replicationOpts.Header() {
|
|
if len(values) > 0 {
|
|
headers[name] = values[0]
|
|
}
|
|
}
|
|
restore()
|
|
|
|
// --- Destination: no compression, no default encryption. ---
|
|
restoreDst := disableCompression()
|
|
defer restoreDst()
|
|
|
|
replNewReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
|
|
0, nil, replicator.AccessKey, replicator.SecretKey, headers)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replNewRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(replNewRec, replNewReq)
|
|
if replNewRec.Code != http.StatusOK {
|
|
t.Fatalf("replica NewMultipart status %d: %s", replNewRec.Code, replNewRec.Body.String())
|
|
}
|
|
var replicaInit InitiateMultipartUploadResponse
|
|
if err = xmlDecoder(replNewRec.Body, &replicaInit, int64(replNewRec.Body.Len())); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replPartReq, err := newTestSignedRequestV4(http.MethodPut,
|
|
getPutObjectPartURL("", bucketName, object, replicaInit.UploadID, "1"),
|
|
int64(len(rawPart)), bytes.NewReader(rawPart), replicator.AccessKey, replicator.SecretKey,
|
|
map[string]string{xhttp.MinIOSourceReplicationRequest: "true"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replPartRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(replPartRec, replPartReq)
|
|
if replPartRec.Code != http.StatusOK {
|
|
t.Fatalf("replica PutPart status %d: %s", replPartRec.Code, replPartRec.Body.String())
|
|
}
|
|
replCompleteBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{
|
|
{PartNumber: 1, ETag: canonicalizeETag(replPartRec.Header()[xhttp.ETag][0])},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replCompleteReq, err := newTestSignedRequestV4(http.MethodPost,
|
|
getCompleteMultipartUploadURL("", bucketName, object, replicaInit.UploadID),
|
|
int64(len(replCompleteBody)), bytes.NewReader(replCompleteBody), replicator.AccessKey, replicator.SecretKey,
|
|
map[string]string{
|
|
xhttp.MinIOSourceReplicationRequest: "true",
|
|
xhttp.MinIOSourceMTime: sourceInfo.ModTime.Format(time.RFC3339Nano),
|
|
xhttp.MinIOSourceETag: sourceInfo.ETag,
|
|
xhttp.MinIOReplicationActualObjectSize: strconv.FormatInt(actualSize, 10),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replCompleteRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(replCompleteRec, replCompleteReq)
|
|
if replCompleteRec.Code != http.StatusOK {
|
|
t.Fatalf("replica Complete status %d: %s", replCompleteRec.Code, replCompleteRec.Body.String())
|
|
}
|
|
|
|
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reportedActual, aerr := info.GetActualSize()
|
|
t.Logf("replica mpu: stored size=%d compression=%q actual-size=%q GetActualSize=%d(err=%v)",
|
|
info.Size, info.UserDefined[ReservedMetadataPrefix+"compression"],
|
|
info.UserDefined[ReservedMetadataPrefix+"actual-size"], reportedActual, aerr)
|
|
assertSSECPlaintext(t, apiRouter, credentials, bucketName, object, sseHeaders, data)
|
|
})
|
|
}
|
|
|
|
// TestAPISSECCompressionProducerMatrix pins the scope of the exclusion across
|
|
// the PutObject and NewMultipartUpload producers: SSE-C is never compressed,
|
|
// while plaintext, SSE-S3 and SSE-KMS keep following allow_encryption.
|
|
func TestAPISSECCompressionProducerMatrix(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPISSECCompressionProducerMatrix,
|
|
})
|
|
}
|
|
|
|
func testAPISSECCompressionProducerMatrix(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
previousTLS := globalIsTLS
|
|
globalIsTLS = true
|
|
defer func() { globalIsTLS = previousTLS }()
|
|
|
|
ssecHeaders := ssecTestHeaders(0x5a)
|
|
sseS3Headers := map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionAES}
|
|
sseKMSHeaders := map[string]string{
|
|
xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionKMS,
|
|
xhttp.AmzServerSideEncryptionKmsID: "compressed-ssec-producer-matrix",
|
|
}
|
|
previousKMS := GlobalKMS
|
|
GlobalKMS = kms.NewStub("compressed-ssec-producer-matrix")
|
|
defer func() { GlobalKMS = previousKMS }()
|
|
|
|
big := bytes.Repeat([]byte("silo producer matrix payload "), 8192)
|
|
small := bytes.Repeat([]byte("s"), 1024) // below minCompressibleSize
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
allowEncrypted bool
|
|
headers map[string]string
|
|
body []byte
|
|
wantCompressed bool
|
|
}{
|
|
// SSE-C is excluded from compression in both configurations, because the
|
|
// replication wire cannot carry the compression state.
|
|
{"ssec+allow_encryption-on+large", true, ssecHeaders, big, false},
|
|
{"ssec+allow_encryption-on+small", true, ssecHeaders, small, false},
|
|
{"ssec+allow_encryption-off+large", false, ssecHeaders, big, false},
|
|
// Plaintext still compresses in both configurations.
|
|
{"plain+allow_encryption-on+large", true, nil, big, true},
|
|
{"plain+allow_encryption-off+large", false, nil, big, true},
|
|
// SSE-S3 and SSE-KMS are the reason allow_encryption exists: the server
|
|
// owns the key, so the source decompresses before replicating.
|
|
{"sse-s3+allow_encryption-on+large", true, sseS3Headers, big, true},
|
|
{"sse-s3+allow_encryption-off+large", false, sseS3Headers, big, false},
|
|
{"sse-kms+allow_encryption-on+large", true, sseKMSHeaders, big, true},
|
|
{"sse-kms+allow_encryption-off+large", false, sseKMSHeaders, big, false},
|
|
} {
|
|
t.Run(instanceType+"/put/"+tc.name, func(t *testing.T) {
|
|
restore := setCopyChecksumCompression(tc.allowEncrypted)
|
|
defer restore()
|
|
object := "producer/" + tc.name + ".txt"
|
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
|
|
int64(len(tc.body)), bytes.NewReader(tc.body), credentials.AccessKey, credentials.SecretKey, tc.headers)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT status %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, compressed := info.UserDefined[ReservedMetadataPrefix+"compression"]
|
|
if compressed != tc.wantCompressed {
|
|
t.Errorf("compressed=%v, want %v (userDefined=%v)", compressed, tc.wantCompressed, info.UserDefined)
|
|
}
|
|
})
|
|
}
|
|
|
|
// NewMultipartUpload has no size gate, so the exclusion turns on the SSE-C
|
|
// and allow_encryption combination alone.
|
|
for _, tc := range []struct {
|
|
name string
|
|
allowEncrypted bool
|
|
headers map[string]string
|
|
wantCompressed bool
|
|
}{
|
|
{"ssec+allow_encryption-on", true, ssecHeaders, false},
|
|
{"ssec+allow_encryption-off", false, ssecHeaders, false},
|
|
{"plain+allow_encryption-on", true, nil, true},
|
|
} {
|
|
t.Run(instanceType+"/mpu/"+tc.name, func(t *testing.T) {
|
|
restore := setCopyChecksumCompression(tc.allowEncrypted)
|
|
defer restore()
|
|
object := "producer/mpu-" + tc.name + ".txt"
|
|
req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
|
|
0, nil, credentials.AccessKey, credentials.SecretKey, tc.headers)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("NewMultipartUpload status %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
var init InitiateMultipartUploadResponse
|
|
if err = xmlDecoder(rec.Body, &init, int64(rec.Body.Len())); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mi, err := obj.GetMultipartInfo(t.Context(), bucketName, object, init.UploadID, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, compressed := mi.UserDefined[ReservedMetadataPrefix+"compression"]
|
|
if compressed != tc.wantCompressed {
|
|
t.Errorf("upload compressed=%v, want %v", compressed, tc.wantCompressed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAPISSECCompressionSkippedOnCopyObject covers the third producer:
|
|
// CopyObjectHandler decides compression before it encrypts, so a copy with a
|
|
// destination customer key and allow_encryption=on used to store a compressed
|
|
// SSE-C object from a plaintext source. It also covers the reverse direction,
|
|
// where only copy-source customer headers are present and compression must
|
|
// still apply.
|
|
func TestAPISSECCompressionSkippedOnCopyObject(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPISSECCompressionSkippedOnCopyObject,
|
|
endpoints: []string{"CopyObject", "PutObject", "GetObject"},
|
|
})
|
|
}
|
|
|
|
func testAPISSECCompressionSkippedOnCopyObject(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
previousTLS := globalIsTLS
|
|
globalIsTLS = true
|
|
defer func() { globalIsTLS = previousTLS }()
|
|
|
|
ssecHeaders := ssecTestHeaders(0x7c)
|
|
data := bytes.Repeat([]byte("copy object compressed ssec payload "), 8192)
|
|
|
|
restore := setCopyChecksumCompression(true)
|
|
defer restore()
|
|
|
|
// An unencrypted source, stored compressed because compression is on. Only
|
|
// the copy adds encryption, so only the copy can change the decision.
|
|
src := "copysrc/plain.txt"
|
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, src),
|
|
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("source PUT status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
dst := "copydst/ssec.txt"
|
|
copyHeaders := map[string]string{"X-Amz-Copy-Source": SlashSeparator + bucketName + SlashSeparator + src}
|
|
for k, v := range ssecHeaders {
|
|
copyHeaders[k] = v
|
|
}
|
|
copyReq, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucketName, dst),
|
|
0, nil, credentials.AccessKey, credentials.SecretKey, copyHeaders)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
copyRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(copyRec, copyReq)
|
|
if copyRec.Code != http.StatusOK {
|
|
t.Fatalf("CopyObject status %d: %s", copyRec.Code, copyRec.Body.String())
|
|
}
|
|
|
|
assertStoredSSECUncompressed(t, obj, bucketName, dst)
|
|
assertSSECPlaintext(t, apiRouter, credentials, bucketName, dst, ssecHeaders, data)
|
|
|
|
// The plaintext source is untouched by the copy and stays compressed.
|
|
srcInfo, err := obj.GetObjectInfo(t.Context(), bucketName, src, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, compressed := srcInfo.UserDefined[ReservedMetadataPrefix+"compression"]; !compressed {
|
|
t.Errorf("the plaintext copy source lost compression (userDefined=%v)", srcInfo.UserDefined)
|
|
}
|
|
|
|
// The reverse direction: a copy-source customer key is not a destination
|
|
// key, so copying the SSE-C object on to a plaintext destination still
|
|
// compresses. crypto.SSEC.IsRequested ignores the copy-source headers.
|
|
plain := "copydst/decrypted.txt"
|
|
decryptHeaders := map[string]string{
|
|
"X-Amz-Copy-Source": SlashSeparator + bucketName + SlashSeparator + dst,
|
|
xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
|
|
xhttp.AmzServerSideEncryptionCopyCustomerKey: ssecHeaders[xhttp.AmzServerSideEncryptionCustomerKey],
|
|
xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: ssecHeaders[xhttp.AmzServerSideEncryptionCustomerKeyMD5],
|
|
}
|
|
decryptReq, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucketName, plain),
|
|
0, nil, credentials.AccessKey, credentials.SecretKey, decryptHeaders)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
decryptRec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(decryptRec, decryptReq)
|
|
if decryptRec.Code != http.StatusOK {
|
|
t.Fatalf("CopyObject to a plaintext destination status %d: %s", decryptRec.Code, decryptRec.Body.String())
|
|
}
|
|
plainInfo, err := obj.GetObjectInfo(t.Context(), bucketName, plain, ObjectOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, sealed := plainInfo.UserDefined[crypto.MetaSealedKeySSEC]; sealed {
|
|
t.Fatalf("%s is still SSE-C sealed, the fixture proves nothing", plain)
|
|
}
|
|
if _, compressed := plainInfo.UserDefined[ReservedMetadataPrefix+"compression"]; !compressed {
|
|
t.Errorf("a copy carrying only copy-source SSE-C headers was not compressed (userDefined=%v)", plainInfo.UserDefined)
|
|
}
|
|
}
|
|
|
|
// TestAPISSECCompressionSkippedOnSnowballExtract covers the fourth producer:
|
|
// PutObjectExtractHandler decides compression per entry before it encrypts, so
|
|
// a tar extract carrying customer key headers used to store every entry as a
|
|
// compressed SSE-C object.
|
|
func TestAPISSECCompressionSkippedOnSnowballExtract(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testAPISSECCompressionSkippedOnSnowballExtract,
|
|
})
|
|
}
|
|
|
|
func testAPISSECCompressionSkippedOnSnowballExtract(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
previousTLS := globalIsTLS
|
|
globalIsTLS = true
|
|
defer func() { globalIsTLS = previousTLS }()
|
|
|
|
entry := "extracted/entry.txt"
|
|
payload := bytes.Repeat([]byte("snowball compressed ssec entry "), 4096)
|
|
|
|
var body bytes.Buffer
|
|
tw := tar.NewWriter(&body)
|
|
if err := tw.WriteHeader(&tar.Header{Name: entry, Mode: 0o600, Size: int64(len(payload))}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tw.Write(payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restore := setCopyChecksumCompression(true)
|
|
defer restore()
|
|
|
|
ssecHeaders := ssecTestHeaders(0x2d)
|
|
headers := map[string]string{xhttp.AmzSnowballExtract: "true"}
|
|
for k, v := range ssecHeaders {
|
|
headers[k] = v
|
|
}
|
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, "snowball.tar"),
|
|
int64(body.Len()), bytes.NewReader(body.Bytes()), credentials.AccessKey, credentials.SecretKey, headers)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("snowball extract status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
assertStoredSSECUncompressed(t, obj, bucketName, entry)
|
|
assertSSECPlaintext(t, apiRouter, credentials, bucketName, entry, ssecHeaders, payload)
|
|
}
|
|
|
|
// TestSSECBatchReplicationCannotRead is the control for the corruption path:
|
|
// batch replication reads without ReplicationRequest, so NoDecryption is never
|
|
// set and a non-empty SSE-C source fails at read time. Batch replication
|
|
// therefore cannot reach the replica shape in
|
|
// TestAPISSECCompressionReplicaStaysReadable; it cannot replicate a non-empty
|
|
// SSE-C object at all, compressed or not. A zero-byte object takes the reader
|
|
// shortcut, whose key check passes without a customer key.
|
|
func TestSSECBatchReplicationCannotRead(t *testing.T) {
|
|
defer DetectTestLeak(t)()
|
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
|
t: t,
|
|
objAPITest: testSSECBatchReplicationCannotRead,
|
|
})
|
|
}
|
|
|
|
func testSSECBatchReplicationCannotRead(obj ObjectLayer, instanceType, bucketName string,
|
|
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
|
|
) {
|
|
previousTLS := globalIsTLS
|
|
globalIsTLS = true
|
|
defer func() { globalIsTLS = previousTLS }()
|
|
|
|
ssecHeaders := ssecTestHeaders(0x6b)
|
|
data := bytes.Repeat([]byte("batch ssec payload "), 8192)
|
|
object := "batch/ssec-plain.txt"
|
|
|
|
restore := disableCompression()
|
|
defer restore()
|
|
|
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
|
|
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, ssecHeaders)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
apiRouter.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("source PUT status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// The read shape used by BatchJobReplicateV1.ReplicateToTarget and
|
|
// writeAsArchive: no ReplicationRequest, so NoDecryption is never set.
|
|
gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{}, ObjectOptions{})
|
|
if err == nil {
|
|
gr.Close()
|
|
t.Fatal("batch-shaped read of an SSE-C object unexpectedly succeeded")
|
|
}
|
|
t.Logf("batch-shaped read of an SSE-C object fails as expected: %v", err)
|
|
|
|
// Control: the replication worker's read shape succeeds and yields ciphertext.
|
|
gr2, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{},
|
|
ObjectOptions{ReplicationRequest: true})
|
|
if err != nil {
|
|
t.Fatalf("replication-shaped read failed: %v", err)
|
|
}
|
|
raw, err := io.ReadAll(gr2)
|
|
gr2.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Equal(raw, data) {
|
|
t.Fatal("replication-shaped read returned plaintext")
|
|
}
|
|
t.Logf("replication-shaped read returns %d bytes of ciphertext (plaintext %d)", len(raw), len(data))
|
|
}
|