diff --git a/cmd/encryption-v1.go b/cmd/encryption-v1.go index 47c801450..56b46c1f2 100644 --- a/cmd/encryption-v1.go +++ b/cmd/encryption-v1.go @@ -1074,8 +1074,16 @@ func (o *ObjectInfo) metadataDecrypter(h http.Header) objectMetaDecryptFn { return input, nil } var key []byte - if k, err := crypto.SSEC.ParseHTTP(h); err == nil { - key = k[:] + if crypto.SSECopy.IsRequested(h) { + sseCopyKey, err := crypto.SSECopy.ParseHTTP(h) + if err != nil { + return nil, err + } + key = sseCopyKey[:] + } else { + if k, err := crypto.SSEC.ParseHTTP(h); err == nil { + key = k[:] + } } key, err := decryptObjectMeta(key, o.Bucket, o.Name, o.UserDefined) if err != nil { @@ -1087,7 +1095,8 @@ func (o *ObjectInfo) metadataDecrypter(h http.Header) objectMetaDecryptFn { } } -// decryptPartsChecksums will attempt to decode checksums and return it/them if set. +// decryptPartsChecksums will attempt to decrypt and decode part checksums, and save +// only the decrypted part checksum values on ObjectInfo directly. // if part > 0, and we have the checksum for the part that will be returned. func (o *ObjectInfo) decryptPartsChecksums(h http.Header) { data := o.Checksum @@ -1112,6 +1121,23 @@ func (o *ObjectInfo) decryptPartsChecksums(h http.Header) { } } +// decryptChecksum will attempt to decrypt the ObjectInfo.Checksum, returns the decrypted value +// An error is only returned if it was encrypted and the decryption failed. +func (o *ObjectInfo) decryptChecksum(h http.Header) ([]byte, error) { + data := o.Checksum + if len(data) == 0 { + return data, nil + } + if _, encrypted := crypto.IsEncrypted(o.UserDefined); encrypted { + decrypted, err := o.metadataDecrypter(h)("object-checksum", data) + if err != nil { + return nil, err + } + data = decrypted + } + return data, nil +} + // metadataEncryptFn provides an encryption function for metadata. // Will return nil, nil if unencrypted. func (o *ObjectInfo) metadataEncryptFn(headers http.Header) (objectMetaEncryptFn, error) { diff --git a/cmd/erasure-object.go b/cmd/erasure-object.go index b8f57248b..fa5e902ab 100644 --- a/cmd/erasure-object.go +++ b/cmd/erasure-object.go @@ -1470,7 +1470,17 @@ func (er erasureObjects) putObject(ctx context.Context, bucket string, object st actualSize = n } } - if fi.Checksum == nil { + // If ServerSideChecksum is wanted for this object, it takes precedence + // over opts.WantChecksum. + if opts.WantServerSideChecksumType.IsSet() { + serverSideChecksum := r.RawServerSideChecksumResult() + if serverSideChecksum != nil { + fi.Checksum = serverSideChecksum.AppendTo(nil, nil) + if opts.EncryptFn != nil { + fi.Checksum = opts.EncryptFn("object-checksum", fi.Checksum) + } + } + } else if fi.Checksum == nil && opts.WantChecksum != nil { // Trailing headers checksums should now be filled. fi.Checksum = opts.WantChecksum.AppendTo(nil, nil) if opts.EncryptFn != nil { diff --git a/cmd/erasure-server-pool.go b/cmd/erasure-server-pool.go index ecc4e6d21..288273728 100644 --- a/cmd/erasure-server-pool.go +++ b/cmd/erasure-server-pool.go @@ -1340,12 +1340,15 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec } putOpts := ObjectOptions{ - ServerSideEncryption: dstOpts.ServerSideEncryption, - UserDefined: srcInfo.UserDefined, - Versioned: dstOpts.Versioned, - VersionID: dstOpts.VersionID, - MTime: dstOpts.MTime, - NoLock: true, + ServerSideEncryption: dstOpts.ServerSideEncryption, + UserDefined: srcInfo.UserDefined, + Versioned: dstOpts.Versioned, + VersionID: dstOpts.VersionID, + MTime: dstOpts.MTime, + NoLock: true, + EncryptFn: dstOpts.EncryptFn, + WantChecksum: dstOpts.WantChecksum, + WantServerSideChecksumType: dstOpts.WantServerSideChecksumType, } return z.serverPools[poolIdx].PutObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts) diff --git a/cmd/erasure-sets.go b/cmd/erasure-sets.go index be73b59ab..fafc2d6fc 100644 --- a/cmd/erasure-sets.go +++ b/cmd/erasure-sets.go @@ -868,11 +868,14 @@ func (s *erasureSets) CopyObject(ctx context.Context, srcBucket, srcObject, dstB } putOpts := ObjectOptions{ - ServerSideEncryption: dstOpts.ServerSideEncryption, - UserDefined: srcInfo.UserDefined, - Versioned: dstOpts.Versioned, - VersionID: dstOpts.VersionID, - MTime: dstOpts.MTime, + ServerSideEncryption: dstOpts.ServerSideEncryption, + UserDefined: srcInfo.UserDefined, + Versioned: dstOpts.Versioned, + VersionID: dstOpts.VersionID, + MTime: dstOpts.MTime, + EncryptFn: dstOpts.EncryptFn, + WantChecksum: dstOpts.WantChecksum, + WantServerSideChecksumType: dstOpts.WantServerSideChecksumType, } return dstSet.putObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts) diff --git a/cmd/logging.go b/cmd/logging.go index dc9d3b05b..d956406c6 100644 --- a/cmd/logging.go +++ b/cmd/logging.go @@ -152,6 +152,10 @@ func encLogIf(ctx context.Context, err error, errKind ...interface{}) { logger.LogIf(ctx, "encryption", err, errKind...) } +func encLogOnceIf(ctx context.Context, err error, id string, errKind ...interface{}) { + logger.LogOnceIf(ctx, "encryption", err, id, errKind...) +} + func storageLogIf(ctx context.Context, err error, errKind ...interface{}) { logger.LogIf(ctx, "storage", err, errKind...) } diff --git a/cmd/object-api-datatypes.go b/cmd/object-api-datatypes.go index de9893c68..b6672deab 100644 --- a/cmd/object-api-datatypes.go +++ b/cmd/object-api-datatypes.go @@ -654,6 +654,7 @@ type objectAttributesChecksum struct { ChecksumSHA1 string `xml:",omitempty"` ChecksumSHA256 string `xml:",omitempty"` ChecksumCRC64NVME string `xml:",omitempty"` + ChecksumType string `xml:",omitempty"` } type objectAttributesParts struct { diff --git a/cmd/object-api-interface.go b/cmd/object-api-interface.go index 1cc6782fb..501fa3a94 100644 --- a/cmd/object-api-interface.go +++ b/cmd/object-api-interface.go @@ -86,6 +86,8 @@ type ObjectOptions struct { WantChecksum *hash.Checksum // x-amz-checksum-XXX checksum sent to PutObject/ CompleteMultipartUpload. + WantServerSideChecksumType hash.ChecksumType // if set, we compute a server-side checksum of this type + NoDecryption bool // indicates if the stream must be decrypted. PreserveETag string // preserves this etag during a PUT call. NoLock bool // indicates to lower layers if the caller is expecting to hold locks. diff --git a/cmd/object-api-utils.go b/cmd/object-api-utils.go index 6275686fa..5d791ce45 100644 --- a/cmd/object-api-utils.go +++ b/cmd/object-api-utils.go @@ -1096,6 +1096,16 @@ func NewPutObjReader(rawReader *hash.Reader) *PutObjReader { return &PutObjReader{Reader: rawReader, rawReader: rawReader} } +// RawServerSideChecksumResult returns the ServerSideChecksumResult from the +// underlying rawReader, since the PutObjReader might be encrypted data and +// thus any checksum from that would be incorrect. +func (p *PutObjReader) RawServerSideChecksumResult() *hash.Checksum { + if p.rawReader != nil { + return p.rawReader.ServerSideChecksumResult + } + return nil +} + func sealETag(encKey crypto.ObjectKey, md5CurrSum []byte) []byte { var emptyKey [32]byte if bytes.Equal(encKey[:], emptyKey[:]) { diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index b9876d743..418436bd9 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -641,6 +641,7 @@ func (api objectAPIHandlers) getObjectAttributesHandler(ctx context.Context, obj ChecksumSHA1: strings.Split(chkSums["SHA1"], "-")[0], ChecksumSHA256: strings.Split(chkSums["SHA256"], "-")[0], ChecksumCRC64NVME: strings.Split(chkSums["CRC64NVME"], "-")[0], + ChecksumType: chkSums[xhttp.AmzChecksumType], } } } @@ -1465,6 +1466,46 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re targetSize, _ = srcInfo.DecryptedSize() } + // Client can request that a different type of checksum is computed server-side for the + // destination object using the x-amz-checksum-algorithm header. + headerChecksumType := hash.NewChecksumHeader(r.Header) + if headerChecksumType.IsSet() { + dstOpts.WantServerSideChecksumType = headerChecksumType.Base() + srcInfo.Reader.AddServerSideChecksumHasher(headerChecksumType) + dstOpts.WantChecksum = nil + } else { + // Check the source object for checksum. + // If Checksum is not encrypted, decryptChecksum will be a no-op and return + // the already unencrypted value. + srcChecksumDecrypted, err := srcInfo.decryptChecksum(r.Header) + if err != nil { + encLogOnceIf(GlobalContext, + fmt.Errorf("Unable to decryptChecksum for object: %s/%s, error: %w", srcBucket, srcObject, err), + "copy-object-decrypt-checksums-"+srcBucket+srcObject) + } + + // The source object has a checksum set, we need the destination to have one too. + if srcChecksumDecrypted != nil { + dstOpts.WantChecksum = hash.ChecksumFromBytes(srcChecksumDecrypted) + + // When an object is being copied from a source that is multipart, the destination will + // no longer be multipart, and thus the checksum becomes full-object instead. Since + // the CopyObject API does not require that the caller send us this final checksum, we need + // to compute it server-side, with the same type as the source object. + if dstOpts.WantChecksum != nil && dstOpts.WantChecksum.Type.IsMultipartComposite() { + dstOpts.WantServerSideChecksumType = dstOpts.WantChecksum.Type.Base() + srcInfo.Reader.AddServerSideChecksumHasher(dstOpts.WantServerSideChecksumType) + dstOpts.WantChecksum = nil + } + } else { + // S3: All copied objects without checksums and specified destination checksum algorithms + // automatically gain a CRC-64NVME checksum algorithm. + dstOpts.WantServerSideChecksumType = hash.ChecksumCRC64NVME + srcInfo.Reader.AddServerSideChecksumHasher(dstOpts.WantServerSideChecksumType) + dstOpts.WantChecksum = nil + } + } + if isTargetEncrypted { var encReader io.Reader kind, _ := crypto.IsRequested(r.Header) @@ -1498,6 +1539,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re if dstOpts.IndexCB != nil { dstOpts.IndexCB = compressionIndexEncrypter(objEncKey, dstOpts.IndexCB) } + dstOpts.EncryptFn = metadataEncrypter(objEncKey) } } @@ -1633,6 +1675,13 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re return } + // After we've checked for an invalid copy (above), if a server-side checksum type + // is requested, we need to read the source to recompute the checksum. + if dstOpts.WantServerSideChecksumType.IsSet() { + srcInfo.metadataOnly = false + } + + // Federation only. remoteCallRequired := isRemoteCopyRequired(ctx, srcBucket, dstBucket, objectAPI) var objInfo ObjectInfo diff --git a/internal/hash/checksum.go b/internal/hash/checksum.go index 1018f67a9..f2cf18c9f 100644 --- a/internal/hash/checksum.go +++ b/internal/hash/checksum.go @@ -230,6 +230,11 @@ func (c ChecksumType) FullObjectRequested() bool { return c&(ChecksumFullObject) == ChecksumFullObject || c.Is(ChecksumCRC64NVME) } +// IsMultipartComposite returns true if the checksum is multipart and full object was not requested. +func (c ChecksumType) IsMultipartComposite() bool { + return c.Is(ChecksumMultipart) && !c.FullObjectRequested() +} + // ObjType returns a string to return as x-amz-checksum-type. func (c ChecksumType) ObjType() string { if c.FullObjectRequested() { @@ -269,7 +274,7 @@ func (c ChecksumType) Trailing() bool { return c.Is(ChecksumTrailing) } -// NewChecksumFromData returns a new checksum from specified algorithm and base64 encoded value. +// NewChecksumFromData returns a new Checksum, using specified algorithm type on data. func NewChecksumFromData(t ChecksumType, data []byte) *Checksum { if !t.IsSet() { return nil @@ -311,8 +316,6 @@ func ReadCheckSums(b []byte, part int) (cs map[string]string, isMP bool) { } if !typ.FullObjectRequested() { cs = fmt.Sprintf("%s-%d", cs, t) - } else if part <= 0 { - res[xhttp.AmzChecksumType] = xhttp.AmzChecksumTypeFullObject } b = b[n:] if part > 0 { @@ -337,6 +340,13 @@ func ReadCheckSums(b []byte, part int) (cs map[string]string, isMP bool) { } if cs != "" { res[typ.String()] = cs + res[xhttp.AmzChecksumType] = typ.ObjType() + if !typ.Is(ChecksumMultipart) { + // Single part PUTs are always FULL_OBJECT checksum + // Refer https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html + // **For PutObject uploads, the checksum type is always FULL_OBJECT.** + res[xhttp.AmzChecksumType] = ChecksumFullObject.ObjType() + } } } if len(res) == 0 { @@ -468,6 +478,65 @@ func (c *Checksum) AppendTo(b []byte, parts []byte) []byte { return b } +// ChecksumFromBytes reconstructs a Checksum struct from the serialized bytes created in AppendTo() +// Returns nil if the bytes are invalid or empty. +// AppendTo() can append a serialized Checksum to another already-serialized Checksum, +// however, in practice, we only use one at a time. +// ChecksumFromBytes only returns the first one and no part checksums. +func ChecksumFromBytes(b []byte) *Checksum { + if len(b) == 0 { + return nil + } + + // Read checksum type + t, n := binary.Uvarint(b) + if n <= 0 { + return nil + } + b = b[n:] + + typ := ChecksumType(t) + length := typ.RawByteLen() + if length == 0 || len(b) < length { + return nil + } + + // Read raw checksum bytes + raw := make([]byte, length) + copy(raw, b[:length]) + b = b[length:] + + c := &Checksum{ + Type: typ, + Raw: raw, + Encoded: base64.StdEncoding.EncodeToString(raw), + } + + // Handle multipart checksums + if typ.Is(ChecksumMultipart) { + parts, n := binary.Uvarint(b) + if n <= 0 { + return nil + } + b = b[n:] + + c.WantParts = int(parts) + + if typ.Is(ChecksumIncludesMultipart) { + wantLen := int(parts) * length + if len(b) < wantLen { + return nil + } + } + } + + if !c.Valid() { + return nil + } + + return c +} + // Valid returns whether checksum is valid. func (c Checksum) Valid() bool { if c.Type == ChecksumInvalid { @@ -506,12 +575,26 @@ func (c Checksum) Matches(content []byte, parts int) error { return nil } -// AsMap returns the +// AsMap returns the checksum as a map[string]string. func (c *Checksum) AsMap() map[string]string { if c == nil || !c.Valid() { return nil } - return map[string]string{c.Type.String(): c.Encoded} + return map[string]string{ + c.Type.String(): c.Encoded, + xhttp.AmzChecksumType: c.Type.ObjType(), + } +} + +// Equal returns whether two checksum structs are equal in all their fields. +func (c *Checksum) Equal(s *Checksum) bool { + if c == nil || s == nil { + return c == s + } + return c.Type == s.Type && + c.Encoded == s.Encoded && + bytes.Equal(c.Raw, s.Raw) && + c.WantParts == s.WantParts } // TransferChecksumHeader will transfer any checksum value that has been checked. diff --git a/internal/hash/checksum_test.go b/internal/hash/checksum_test.go new file mode 100644 index 000000000..8d74bde3b --- /dev/null +++ b/internal/hash/checksum_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2015-2025 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 hash + +import ( + "net/http/httptest" + "testing" +) + +// TestChecksumAddToHeader tests that adding and retrieving a checksum on a header works +func TestChecksumAddToHeader(t *testing.T) { + tests := []struct { + name string + checksum ChecksumType + fullobj bool + }{ + {"CRC32-composite", ChecksumCRC32, false}, + {"CRC32-full-object", ChecksumCRC32, true}, + {"CRC32C-composite", ChecksumCRC32C, false}, + {"CRC32C-full-object", ChecksumCRC32C, true}, + {"CRC64NVME-full-object", ChecksumCRC64NVME, false}, // testing with false, because it always is full object. + {"ChecksumSHA1-composite", ChecksumSHA1, false}, + {"ChecksumSHA256-composite", ChecksumSHA256, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + myData := []byte("this-is-a-checksum-data-test") + chksm := NewChecksumFromData(tt.checksum, myData) + if tt.fullobj { + chksm.Type |= ChecksumFullObject + } + + w := httptest.NewRecorder() + AddChecksumHeader(w, chksm.AsMap()) + gotChksm, err := GetContentChecksum(w.Result().Header) + if err != nil { + t.Fatalf("GetContentChecksum failed: %v", err) + } + + // In the CRC64NVM case, it is always full object, so add the flag for easier equality comparison + if chksm.Type.Base().Is(ChecksumCRC64NVME) { + chksm.Type |= ChecksumFullObject + } + if !chksm.Equal(gotChksm) { + t.Fatalf("Checksum mismatch: expected %+v, got %+v", chksm, gotChksm) + } + }) + } +} + +// TestChecksumSerializeDeserialize checks AppendTo can be reversed by ChecksumFromBytes +func TestChecksumSerializeDeserialize(t *testing.T) { + myData := []byte("this-is-a-checksum-data-test") + chksm := NewChecksumFromData(ChecksumCRC32, myData) + if chksm == nil { + t.Fatal("NewChecksumFromData returned nil") + } + // Serialize the checksum to bytes + b := chksm.AppendTo(nil, nil) + if b == nil { + t.Fatal("AppendTo returned nil") + } + + // Deserialize the checksum from bytes + chksmOut := ChecksumFromBytes(b) + if chksmOut == nil { + t.Fatal("ChecksumFromBytes returned nil") + } + + // Assert new checksum matches the content + matchError := chksmOut.Matches(myData, 0) + if matchError != nil { + t.Fatalf("Checksum mismatch on chksmOut: %v", matchError) + } + + // Assert they are exactly equal + if !chksmOut.Equal(chksm) { + t.Fatalf("Checksum mismatch: expected %+v, got %+v", chksm, chksmOut) + } +} + +// TestChecksumSerializeDeserializeMultiPart checks AppendTo can be reversed by ChecksumFromBytes +// for multipart checksum +func TestChecksumSerializeDeserializeMultiPart(t *testing.T) { + // Create dummy data that we'll split into 3 parts + dummyData := []byte("The quick brown fox jumps over the lazy dog. " + + "Pack my box with five dozen brown eggs. " + + "Have another go it will all make sense in the end!") + + // Split data into 3 parts + partSize := len(dummyData) / 3 + part1Data := dummyData[0:partSize] + part2Data := dummyData[partSize : 2*partSize] + part3Data := dummyData[2*partSize:] + + // Calculate CRC32C checksum for each part using NewChecksumFromData + checksumType := ChecksumCRC32C + + part1Checksum := NewChecksumFromData(checksumType, part1Data) + part2Checksum := NewChecksumFromData(checksumType, part2Data) + part3Checksum := NewChecksumFromData(checksumType, part3Data) + + // Combine the raw checksums (this is what happens in CompleteMultipartUpload) + var checksumCombined []byte + checksumCombined = append(checksumCombined, part1Checksum.Raw...) + checksumCombined = append(checksumCombined, part2Checksum.Raw...) + checksumCombined = append(checksumCombined, part3Checksum.Raw...) + + // Create the final checksum (checksum of the combined checksums) + // Add BOTH the multipart flag AND the includes-multipart flag + finalChecksumType := checksumType | ChecksumMultipart | ChecksumIncludesMultipart + finalChecksum := NewChecksumFromData(finalChecksumType, checksumCombined) + + // Set WantParts to indicate 3 parts + finalChecksum.WantParts = 3 + + // Test AppendTo serialization + var serialized []byte + serialized = finalChecksum.AppendTo(serialized, checksumCombined) + + // Use ChecksumFromBytes to deserialize the final checksum + chksmOut := ChecksumFromBytes(serialized) + if chksmOut == nil { + t.Fatal("ChecksumFromBytes returned nil") + } + + // Assert they are exactly equal + if !chksmOut.Equal(finalChecksum) { + t.Fatalf("Checksum mismatch: expected %+v, got %+v", finalChecksum, chksmOut) + } + + // Serialize what we got from ChecksumFromBytes + serializedOut := chksmOut.AppendTo(nil, checksumCombined) + + // Read part checksums from serializedOut + readParts := ReadPartCheckSums(serializedOut) + expectedChecksums := []string{ + part1Checksum.Encoded, + part2Checksum.Encoded, + part3Checksum.Encoded, + } + for i, expected := range expectedChecksums { + if got := readParts[i][ChecksumCRC32C.String()]; got != expected { + t.Fatalf("want part%dChecksum.Encoded %s, got %s", i+1, expected, got) + } + } +} diff --git a/internal/hash/reader.go b/internal/hash/reader.go index 272452f06..ddae850a0 100644 --- a/internal/hash/reader.go +++ b/internal/hash/reader.go @@ -51,11 +51,18 @@ type Reader struct { checksum etag.ETag contentSHA256 []byte - // Content checksum + // Client-provided content checksum contentHash Checksum contentHasher hash.Hash disableMD5 bool + // Server side computed checksum. In some cases, like CopyObject, a new checksum + // needs to be computed and saved on the destination object, but the client + // does not provide it. Not calculated if client-side contentHash is set. + ServerSideChecksumType ChecksumType + ServerSideHasher hash.Hash + ServerSideChecksumResult *Checksum + trailer http.Header sha256 hash.Hash @@ -247,6 +254,16 @@ func (r *Reader) AddNonTrailingChecksum(cs *Checksum, ignoreValue bool) error { return nil } +// AddServerSideChecksumHasher adds a new hasher for computing the server-side checksum. +func (r *Reader) AddServerSideChecksumHasher(t ChecksumType) { + h := t.Hasher() + if h == nil { + return + } + r.ServerSideHasher = h + r.ServerSideChecksumType = t +} + func (r *Reader) Read(p []byte) (int, error) { n, err := r.src.Read(p) r.bytesRead += int64(n) @@ -255,6 +272,8 @@ func (r *Reader) Read(p []byte) (int, error) { } if r.contentHasher != nil { r.contentHasher.Write(p[:n]) + } else if r.ServerSideHasher != nil { + r.ServerSideHasher.Write(p[:n]) } if err == io.EOF { // Verify content SHA256, if set. @@ -293,6 +312,9 @@ func (r *Reader) Read(p []byte) (int, error) { } return n, err } + } else if r.ServerSideHasher != nil { + sum := r.ServerSideHasher.Sum(nil) + r.ServerSideChecksumResult = NewChecksumWithType(r.ServerSideChecksumType, base64.StdEncoding.EncodeToString(sum)) } } if err != nil && err != io.EOF {