* Fix SSE-S3 copy: preserve encryption metadata and set chunk SSE type Fixes GitHub #7562: Copying objects between encrypted buckets was failing. Root causes: 1. processMetadataBytes was re-adding SSE headers from source entry, undoing the encryption header filtering. Now uses dstEntry.Extended which is already filtered. 2. SSE-S3 streaming copy returned nil metadata. Now properly generates and returns SSE-S3 destination metadata (SeaweedFSSSES3Key, AES256 header) via ExecuteStreamingCopyWithMetadata. 3. Chunks created during streaming copy didn't have SseType set. Now sets SseType and per-chunk SseMetadata with chunk-specific IVs for SSE-S3, enabling proper decryption on GetObject. * Address review: make SSE-S3 metadata serialization failures fatal errors - In executeEncryptCopy: return error instead of just logging if SerializeSSES3Metadata fails - In createChunkFromData: return error if chunk SSE-S3 metadata serialization fails This ensures objects/chunks are never created without proper encryption metadata, preventing unreadable/corrupted data. * fmt * Refactor: reuse function names instead of creating WithMetadata variants - Change ExecuteStreamingCopy to return (*EncryptionSpec, error) directly - Remove ExecuteStreamingCopyWithMetadata wrapper - Change executeStreamingReencryptCopy to return (*EncryptionSpec, error) - Remove executeStreamingReencryptCopyWithMetadata wrapper - Update callers to ignore encryption spec with _ where not needed * Add TODO documenting large file SSE-S3 copy limitation The streaming copy approach encrypts the entire stream with a single IV but stores data in chunks with per-chunk IVs. This causes decryption issues for large files. Small inline files work correctly. This is a known architectural issue that needs separate work to fix. * Use chunk-by-chunk encryption for SSE-S3 copy (consistent with SSE-C/SSE-KMS) Instead of streaming encryption (which had IV mismatch issues for multi-chunk files), SSE-S3 now uses the same chunk-by-chunk approach as SSE-C and SSE-KMS: 1. Extended copyMultipartCrossEncryption to handle SSE-S3: - Added SSE-S3 source decryption in copyCrossEncryptionChunk - Added SSE-S3 destination encryption with per-chunk IVs - Added object-level metadata generation for SSE-S3 destinations 2. Updated routing in executeEncryptCopy/executeDecryptCopy/executeReencryptCopy to use copyMultipartCrossEncryption for all SSE-S3 scenarios 3. Removed streaming copy functions (shouldUseStreamingCopy, executeStreamingReencryptCopy) as they're no longer used 4. Added large file (1MB) integration test to verify chunk-by-chunk copy works This ensures consistent behavior across all SSE types and fixes data corruption that occurred with large files in the streaming copy approach. * fmt * fmt * Address review: fail explicitly if SSE-S3 metadata is missing Instead of silently ignoring missing SSE-S3 metadata (which could create unreadable objects), now explicitly fail the copy operation with a clear error message if: - First chunk is missing - First chunk doesn't have SSE-S3 type - First chunk has empty SSE metadata - Deserialization fails * Address review: improve comment to reflect full scope of chunk creation * Address review: fail explicitly if baseIV is empty for SSE-S3 chunk encryption If DestinationIV is not set when encrypting SSE-S3 chunks, the chunk would be created without SseMetadata, causing GetObject decryption to fail later. Now fails explicitly with a clear error message. Note: calculateIVWithOffset returns ([]byte, int) not ([]byte, error) - the int is a skip amount for intra-block alignment, not an error code. * Address review: handle 0-byte files in SSE-S3 copy For 0-byte files, there are no chunks to get metadata from. Generate an IV for the object-level metadata to ensure even empty files are properly marked as SSE-S3 encrypted. Also validate that we don't have a non-empty file with no chunks (which would indicate an internal error).
172 lines
6.6 KiB
Go
172 lines
6.6 KiB
Go
package s3api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
|
|
)
|
|
|
|
// executeUnifiedCopyStrategy executes the appropriate copy strategy based on encryption state
|
|
// Returns chunks and destination metadata that should be applied to the destination entry
|
|
func (s3a *S3ApiServer) executeUnifiedCopyStrategy(entry *filer_pb.Entry, r *http.Request, dstBucket, srcObject, dstObject string) ([]*filer_pb.FileChunk, map[string][]byte, error) {
|
|
// Detect encryption state (using entry-aware detection for multipart objects)
|
|
srcPath := fmt.Sprintf("/%s/%s", r.Header.Get("X-Amz-Copy-Source-Bucket"), srcObject)
|
|
dstPath := fmt.Sprintf("/%s/%s", dstBucket, dstObject)
|
|
state := DetectEncryptionStateWithEntry(entry, r, srcPath, dstPath)
|
|
|
|
// Debug logging for encryption state
|
|
|
|
// Apply bucket default encryption if no explicit encryption specified
|
|
if !state.IsTargetEncrypted() {
|
|
bucketMetadata, err := s3a.getBucketMetadata(dstBucket)
|
|
if err == nil && bucketMetadata != nil && bucketMetadata.Encryption != nil {
|
|
switch bucketMetadata.Encryption.SseAlgorithm {
|
|
case "aws:kms":
|
|
state.DstSSEKMS = true
|
|
case "AES256":
|
|
state.DstSSES3 = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine copy strategy
|
|
strategy, err := DetermineUnifiedCopyStrategy(state, entry.Extended, r)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
glog.V(2).Infof("Unified copy strategy for %s → %s: %v", srcPath, dstPath, strategy)
|
|
|
|
// Calculate optimized sizes for the strategy
|
|
sizeCalc := CalculateOptimizedSizes(entry, r, strategy)
|
|
glog.V(2).Infof("Size calculation: src=%d, target=%d, actual=%d, overhead=%d, preallocate=%v",
|
|
sizeCalc.SourceSize, sizeCalc.TargetSize, sizeCalc.ActualContentSize,
|
|
sizeCalc.EncryptionOverhead, sizeCalc.CanPreallocate)
|
|
|
|
// Execute strategy
|
|
switch strategy {
|
|
case CopyStrategyDirect:
|
|
chunks, err := s3a.copyChunks(entry, dstPath)
|
|
return chunks, nil, err
|
|
|
|
case CopyStrategyKeyRotation:
|
|
return s3a.executeKeyRotation(entry, r, state)
|
|
|
|
case CopyStrategyEncrypt:
|
|
return s3a.executeEncryptCopy(entry, r, state, dstBucket, dstPath)
|
|
|
|
case CopyStrategyDecrypt:
|
|
return s3a.executeDecryptCopy(entry, r, state, dstPath)
|
|
|
|
case CopyStrategyReencrypt:
|
|
return s3a.executeReencryptCopy(entry, r, state, dstBucket, dstPath)
|
|
|
|
default:
|
|
return nil, nil, fmt.Errorf("unknown unified copy strategy: %v", strategy)
|
|
}
|
|
}
|
|
|
|
// mapCopyErrorToS3Error maps various copy errors to appropriate S3 error codes
|
|
func (s3a *S3ApiServer) mapCopyErrorToS3Error(err error) s3err.ErrorCode {
|
|
if err == nil {
|
|
return s3err.ErrNone
|
|
}
|
|
|
|
// Check for read-only errors (quota enforcement)
|
|
// Uses errors.Is() to properly detect wrapped errors
|
|
if errors.Is(err, weed_server.ErrReadOnly) {
|
|
// Bucket is read-only due to quota enforcement or other configuration
|
|
// Return 403 Forbidden per S3 semantics (similar to MinIO's quota enforcement)
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
|
|
// Check for KMS errors first
|
|
if kmsErr := MapKMSErrorToS3Error(err); kmsErr != s3err.ErrInvalidRequest {
|
|
return kmsErr
|
|
}
|
|
|
|
// Check for SSE-C errors
|
|
if ssecErr := MapSSECErrorToS3Error(err); ssecErr != s3err.ErrInvalidRequest {
|
|
return ssecErr
|
|
}
|
|
|
|
// Default to internal error for unknown errors
|
|
return s3err.ErrInternalError
|
|
}
|
|
|
|
// executeKeyRotation handles key rotation for same-object copies
|
|
func (s3a *S3ApiServer) executeKeyRotation(entry *filer_pb.Entry, r *http.Request, state *EncryptionState) ([]*filer_pb.FileChunk, map[string][]byte, error) {
|
|
// For key rotation, we only need to update metadata, not re-copy chunks
|
|
// This is a significant optimization for same-object key changes
|
|
|
|
if state.SrcSSEC && state.DstSSEC {
|
|
// SSE-C key rotation - need to handle new key/IV, use reencrypt logic
|
|
return s3a.executeReencryptCopy(entry, r, state, "", "")
|
|
}
|
|
|
|
if state.SrcSSEKMS && state.DstSSEKMS {
|
|
// SSE-KMS key rotation - return existing chunks, metadata will be updated by caller
|
|
return entry.GetChunks(), nil, nil
|
|
}
|
|
|
|
// Fallback to reencrypt if we can't do metadata-only rotation
|
|
return s3a.executeReencryptCopy(entry, r, state, "", "")
|
|
}
|
|
|
|
// executeEncryptCopy handles plain → encrypted copies
|
|
func (s3a *S3ApiServer) executeEncryptCopy(entry *filer_pb.Entry, r *http.Request, state *EncryptionState, dstBucket, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) {
|
|
if state.DstSSEC {
|
|
// Use existing SSE-C copy logic
|
|
return s3a.copyChunksWithSSEC(entry, r)
|
|
}
|
|
|
|
if state.DstSSEKMS {
|
|
// Use existing SSE-KMS copy logic - metadata is now generated internally
|
|
chunks, dstMetadata, err := s3a.copyChunksWithSSEKMS(entry, r, dstBucket)
|
|
return chunks, dstMetadata, err
|
|
}
|
|
|
|
if state.DstSSES3 {
|
|
// Use chunk-by-chunk copy for SSE-S3 encryption (consistent with SSE-C and SSE-KMS)
|
|
glog.V(2).Infof("Plain→SSE-S3 copy: using unified multipart encrypt copy")
|
|
return s3a.copyMultipartCrossEncryption(entry, r, state, dstBucket, dstPath)
|
|
}
|
|
|
|
return nil, nil, fmt.Errorf("unknown target encryption type")
|
|
}
|
|
|
|
// executeDecryptCopy handles encrypted → plain copies
|
|
func (s3a *S3ApiServer) executeDecryptCopy(entry *filer_pb.Entry, r *http.Request, state *EncryptionState, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) {
|
|
// Use unified multipart-aware decrypt copy for all encryption types (consistent chunk-by-chunk)
|
|
if state.SrcSSEC || state.SrcSSEKMS || state.SrcSSES3 {
|
|
glog.V(2).Infof("Encrypted→Plain copy: using unified multipart decrypt copy")
|
|
return s3a.copyMultipartCrossEncryption(entry, r, state, "", dstPath)
|
|
}
|
|
|
|
return nil, nil, fmt.Errorf("unknown source encryption type")
|
|
}
|
|
|
|
// executeReencryptCopy handles encrypted → encrypted copies with different keys/methods
|
|
func (s3a *S3ApiServer) executeReencryptCopy(entry *filer_pb.Entry, r *http.Request, state *EncryptionState, dstBucket, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) {
|
|
// Use chunk-by-chunk approach for all cross-encryption scenarios (consistent behavior)
|
|
if state.SrcSSEC && state.DstSSEC {
|
|
return s3a.copyChunksWithSSEC(entry, r)
|
|
}
|
|
|
|
if state.SrcSSEKMS && state.DstSSEKMS {
|
|
// Use existing SSE-KMS copy logic - metadata is now generated internally
|
|
chunks, dstMetadata, err := s3a.copyChunksWithSSEKMS(entry, r, dstBucket)
|
|
return chunks, dstMetadata, err
|
|
}
|
|
|
|
// All other cross-encryption scenarios use unified multipart copy
|
|
// This includes: SSE-C↔SSE-KMS, SSE-C↔SSE-S3, SSE-KMS↔SSE-S3, SSE-S3↔SSE-S3
|
|
glog.V(2).Infof("Cross-encryption copy: using unified multipart copy")
|
|
return s3a.copyMultipartCrossEncryption(entry, r, state, dstBucket, dstPath)
|
|
}
|