From b73581b05d4f739284b41ed577a3460ce63b0938 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Sat, 29 Aug 2026 17:26:09 +0800 Subject: [PATCH] fix: authenticate SSE-C keys on zero-byte reads Unseal supplied SSE-C and copy-source keys after range and request preconditions when a zero-byte read cannot build a decryptor. Preserve internal no-decryption, replication, restore, and absent-header reads. Signed-off-by: Feng Ruohang --- cmd/encryption-v1.go | 18 +++ cmd/erasure-object.go | 14 +- cmd/object-ssec-zero-byte_test.go | 226 ++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 cmd/object-ssec-zero-byte_test.go diff --git a/cmd/encryption-v1.go b/cmd/encryption-v1.go index 848f8cee1..49271803f 100644 --- a/cmd/encryption-v1.go +++ b/cmd/encryption-v1.go @@ -574,6 +574,24 @@ func DecryptCopyRequestR(client io.Reader, h http.Header, bucket, object string, return newDecryptReader(client, key, bucket, object, seqNumber, metadata) } +// checkSSECReadKey authenticates a supplied SSE-C read key against the sealed +// object key when a read has no data from which to build a decryptor. +func checkSSECReadKey(h http.Header, oi ObjectInfo, opts ObjectOptions) error { + if opts.NoDecryption || opts.Transition.RestoreRequest != nil || !crypto.SSEC.IsEncrypted(oi.UserDefined) { + return nil + } + switch { + case crypto.SSECopy.IsRequested(h): + _, err := crypto.SSECopy.UnsealObjectKey(h, oi.UserDefined, oi.Bucket, oi.Name) + return err + case crypto.SSEC.IsRequested(h): + _, err := crypto.SSEC.UnsealObjectKey(h, oi.UserDefined, oi.Bucket, oi.Name) + return err + default: + return nil + } +} + func newDecryptReader(client io.Reader, key []byte, bucket, object string, seqNumber uint32, metadata map[string]string) (io.Reader, error) { objectEncryptionKey, err := decryptObjectMeta(key, bucket, object, metadata) if err != nil { diff --git a/cmd/erasure-object.go b/cmd/erasure-object.go index 2364c45b9..79d22cc6f 100644 --- a/cmd/erasure-object.go +++ b/cmd/erasure-object.go @@ -266,9 +266,19 @@ func (er erasureObjects) GetObjectNInfo(ctx context.Context, bucket, object stri ObjInfo: objInfo, }, err } - // Zero byte objects don't even need to further initialize pipes etc. - return NewGetObjectReaderFromReader(bytes.NewReader(nil), objInfo, opts) + gr, err = NewGetObjectReaderFromReader(bytes.NewReader(nil), objInfo, opts) + if err != nil { + return gr, err + } + // With no data, the reader above cannot authenticate an SSE-C key the + // way NewGetObjectReader does. Check it after the preconditions so zero + // and non-zero reads preserve the same error ordering. + if err := checkSSECReadKey(h, objInfo, opts); err != nil { + gr.Close() + return nil, err + } + return gr, nil } if objInfo.IsRemote() { diff --git a/cmd/object-ssec-zero-byte_test.go b/cmd/object-ssec-zero-byte_test.go new file mode 100644 index 000000000..787c11893 --- /dev/null +++ b/cmd/object-ssec-zero-byte_test.go @@ -0,0 +1,226 @@ +// Copyright (c) 2015-2026 MinIO, Inc. +// +// 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 . + +package cmd + +import ( + "bytes" + "crypto/md5" + "encoding/base64" + "encoding/xml" + "net/http" + "net/http/httptest" + "testing" + + "github.com/minio/minio/internal/auth" + xhttp "github.com/minio/minio/internal/http" +) + +func TestAPIZeroByteSSECAuthenticatesKey(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPIZeroByteSSECAuthenticatesKey, + endpoints: []string{"CopyObject", "CopyObjectPart", "PutObject", "GetObject", "HeadObject", "NewMultipart"}, + }) +} + +func testAPIZeroByteSSECAuthenticatesKey(obj ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, credentials auth.Credentials, t *testing.T, +) { + previousTLS := globalIsTLS + globalIsTLS = true + defer func() { globalIsTLS = previousTLS }() + + object := "ssec/zero-byte" + oldKey := bytes.Repeat([]byte{0x11}, 32) + oldMD5 := md5.Sum(oldKey) + wrongKey := bytes.Repeat([]byte{0x22}, 32) + wrongMD5 := md5.Sum(wrongKey) + putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, nil, map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]), + }) + + correctHeaders := map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]), + } + wrongHeaders := map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(wrongKey), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]), + } + + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, correctHeaders); rec.Code != http.StatusOK || rec.Body.Len() != 0 { + t.Fatalf("%s: correct-key GET returned %d with %d bytes: %s", instanceType, rec.Code, rec.Body.Len(), rec.Body.String()) + } + headRec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodHead, bucketName, object, correctHeaders) + if headRec.Code != http.StatusOK { + t.Fatalf("%s: correct-key HEAD returned %d", instanceType, headRec.Code) + } + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, wrongHeaders); rec.Code != http.StatusForbidden { + t.Fatalf("%s: wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String()) + } + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodHead, bucketName, object, wrongHeaders); rec.Code != http.StatusForbidden { + t.Fatalf("%s: wrong-key HEAD returned %d, want %d", instanceType, rec.Code, http.StatusForbidden) + } + conditionalHeaders := make(map[string]string, len(wrongHeaders)+1) + for key, value := range wrongHeaders { + conditionalHeaders[key] = value + } + conditionalInfo, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + conditionalRequest := httptest.NewRequest(http.MethodGet, getGetObjectURL("", bucketName, object), nil) + for key, value := range wrongHeaders { + conditionalRequest.Header.Set(key, value) + } + if _, err := DecryptObjectInfo(&conditionalInfo, conditionalRequest); err != nil { + t.Fatal(err) + } + conditionalHeaders[xhttp.IfNoneMatch] = conditionalInfo.ETag + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, conditionalHeaders); rec.Code != http.StatusNotModified { + t.Fatalf("%s: conditional wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusNotModified, rec.Body.String()) + } + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, nil); rec.Code != http.StatusBadRequest { + t.Fatalf("%s: missing-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusBadRequest, rec.Body.String()) + } + + nonEmptyObject := "ssec/one-byte" + putCopyChecksumSource(t, apiRouter, credentials, bucketName, nonEmptyObject, []byte{1}, correctHeaders) + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, nonEmptyObject, wrongHeaders); rec.Code != http.StatusForbidden { + t.Fatalf("%s: one-byte wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String()) + } + + plainObject := "ssec/plain-zero-byte" + putCopyChecksumSource(t, apiRouter, credentials, bucketName, plainObject, nil, nil) + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, plainObject, wrongHeaders); rec.Code != http.StatusBadRequest { + t.Fatalf("%s: unencrypted wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusBadRequest, rec.Body.String()) + } + + destination := "ssec/zero-byte-copy" + rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, destination, map[string]string{ + xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey), + xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]), + }) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s: wrong-key CopyObject returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String()) + } + if _, err := obj.GetObjectInfo(t.Context(), bucketName, destination, ObjectOptions{}); !isErrObjectNotFound(err) { + t.Fatalf("%s: rejected CopyObject created the destination: %v", instanceType, err) + } + + rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object, map[string]string{ + xhttp.AmzStorageClass: "REDUCED_REDUNDANCY", + xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey), + xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]), + }) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s: wrong-key storage-class CopyObject returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String()) + } + + multipartObject := "ssec/zero-byte-multipart-copy" + req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, multipartObject), + 0, nil, 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("%s: NewMultipartUpload returned %d: %s", instanceType, rec.Code, rec.Body.String()) + } + var initiated InitiateMultipartUploadResponse + if err := xml.Unmarshal(rec.Body.Bytes(), &initiated); err != nil { + t.Fatal(err) + } + req, err = newTestSignedRequestV4(http.MethodPut, + getCopyObjectPartURL("", bucketName, multipartObject, initiated.UploadID, "1"), + 0, nil, credentials.AccessKey, credentials.SecretKey, map[string]string{ + xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey), + xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]), + }) + if err != nil { + t.Fatal(err) + } + req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(bucketName, object)) + rec = httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("%s: wrong-key UploadPartCopy returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String()) + } + parts, err := obj.ListObjectParts(t.Context(), bucketName, multipartObject, initiated.UploadID, 0, 1000, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + if len(parts.Parts) != 0 { + t.Fatalf("%s: rejected UploadPartCopy stored %d parts", instanceType, len(parts.Parts)) + } + if err := obj.AbortMultipartUpload(t.Context(), bucketName, multipartObject, initiated.UploadID, ObjectOptions{}); err != nil { + t.Fatal(err) + } + + wrongHeader := http.Header{} + for key, value := range wrongHeaders { + wrongHeader.Set(key, value) + } + for _, test := range []struct { + header http.Header + opts ObjectOptions + }{ + {header: nil, opts: ObjectOptions{}}, + {header: wrongHeader, opts: ObjectOptions{NoDecryption: true}}, + {header: wrongHeader, opts: ObjectOptions{ReplicationRequest: true}}, + {header: wrongHeader, opts: ObjectOptions{Transition: TransitionOptions{RestoreRequest: &RestoreObjectRequest{}}}}, + } { + gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, test.header, test.opts) + if err != nil { + t.Fatalf("%s: internal zero-byte read with opts %+v failed: %v", instanceType, test.opts, err) + } + gr.Close() + } + + rangeHeaders := make(map[string]string, len(wrongHeaders)+1) + for key, value := range wrongHeaders { + rangeHeaders[key] = value + } + rangeHeaders[xhttp.Range] = "bytes=0-0" + if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, rangeHeaders); rec.Code != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("%s: ranged wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusRequestedRangeNotSatisfiable, rec.Body.String()) + } +} + +func ssecZeroByteRequest(t *testing.T, apiRouter http.Handler, credentials auth.Credentials, + method, bucket, object string, headers map[string]string, +) *httptest.ResponseRecorder { + t.Helper() + req, err := newTestSignedRequestV4(method, getGetObjectURL("", bucket, object), + 0, nil, credentials.AccessKey, credentials.SecretKey, headers) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + return rec +}