* Add S3 volume encryption support with -s3.encryptVolumeData flag This change adds volume-level encryption support for S3 uploads, similar to the existing -filer.encryptVolumeData option. Each chunk is encrypted with its own auto-generated CipherKey when the flag is enabled. Changes: - Add -s3.encryptVolumeData flag to weed s3, weed server, and weed mini - Wire Cipher option through S3ApiServer and ChunkedUploadOption - Add integration tests for multi-chunk range reads with encryption - Tests verify encryption works across chunk boundaries Usage: weed s3 -encryptVolumeData weed server -s3 -s3.encryptVolumeData weed mini -s3.encryptVolumeData Integration tests: go test -v -tags=integration -timeout 5m ./test/s3/sse/... * Add GitHub Actions CI for S3 volume encryption tests - Add test-volume-encryption target to Makefile that starts server with -s3.encryptVolumeData - Add s3-volume-encryption job to GitHub Actions workflow - Tests run with integration build tag and 10m timeout - Server logs uploaded on failure for debugging * Fix S3 client credentials to use environment variables The test was using hardcoded credentials "any"/"any" but the Makefile sets AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY to "some_access_key1"/ "some_secret_key1". Updated getS3Client() to read from environment variables with fallback to "any"/"any" for manual testing. * Change bucket creation errors from skip to fatal Tests should fail, not skip, when bucket creation fails. This ensures that credential mismatches and other configuration issues are caught rather than silently skipped. * Make copy and multipart test jobs fail instead of succeed Changed exit 0 to exit 1 for s3-sse-copy-operations and s3-sse-multipart jobs. These jobs document known limitations but should fail to ensure the issues are tracked and addressed, not silently ignored. * Hardcode S3 credentials to match Makefile Changed from environment variables to hardcoded credentials "some_access_key1"/"some_secret_key1" to match the Makefile configuration. This ensures tests work reliably. * fix Double Encryption * fix Chunk Size Mismatch * Added IsCompressed * is gzipped * fix copying * only perform HEAD request when len(cipherKey) > 0 * Revert "Make copy and multipart test jobs fail instead of succeed" This reverts commit bc34a7eb3c103ae7ab2000da2a6c3925712eb226. * fix security vulnerability * fix security * Update s3api_object_handlers_copy.go * Update s3api_object_handlers_copy.go * jwt to get content length
294 lines
9.8 KiB
Go
294 lines
9.8 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
)
|
|
|
|
// rotateSSECKey handles SSE-C key rotation for same-object copies
|
|
func (s3a *S3ApiServer) rotateSSECKey(entry *filer_pb.Entry, r *http.Request) ([]*filer_pb.FileChunk, error) {
|
|
// Parse source and destination SSE-C keys
|
|
sourceKey, err := ParseSSECCopySourceHeaders(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse SSE-C copy source headers: %w", err)
|
|
}
|
|
|
|
destKey, err := ParseSSECHeaders(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse SSE-C destination headers: %w", err)
|
|
}
|
|
|
|
// Validate that we have both keys
|
|
if sourceKey == nil {
|
|
return nil, fmt.Errorf("source SSE-C key required for key rotation")
|
|
}
|
|
|
|
if destKey == nil {
|
|
return nil, fmt.Errorf("destination SSE-C key required for key rotation")
|
|
}
|
|
|
|
// Check if keys are actually different
|
|
if sourceKey.KeyMD5 == destKey.KeyMD5 {
|
|
glog.V(2).Infof("SSE-C key rotation: keys are identical, using direct copy")
|
|
return entry.GetChunks(), nil
|
|
}
|
|
|
|
glog.V(2).Infof("SSE-C key rotation: rotating from key %s to key %s",
|
|
sourceKey.KeyMD5[:8], destKey.KeyMD5[:8])
|
|
|
|
// For SSE-C key rotation, we need to re-encrypt all chunks
|
|
// This cannot be a metadata-only operation because the encryption key changes
|
|
return s3a.rotateSSECChunks(entry, sourceKey, destKey)
|
|
}
|
|
|
|
// rotateSSEKMSKey handles SSE-KMS key rotation for same-object copies
|
|
func (s3a *S3ApiServer) rotateSSEKMSKey(entry *filer_pb.Entry, r *http.Request) ([]*filer_pb.FileChunk, error) {
|
|
// Get source and destination key IDs
|
|
srcKeyID, srcEncrypted := GetSourceSSEKMSInfo(entry.Extended)
|
|
if !srcEncrypted {
|
|
return nil, fmt.Errorf("source object is not SSE-KMS encrypted")
|
|
}
|
|
|
|
dstKeyID := r.Header.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId)
|
|
if dstKeyID == "" {
|
|
// Use default key if not specified
|
|
dstKeyID = "default"
|
|
}
|
|
|
|
// Check if keys are actually different
|
|
if srcKeyID == dstKeyID {
|
|
glog.V(2).Infof("SSE-KMS key rotation: keys are identical, using direct copy")
|
|
return entry.GetChunks(), nil
|
|
}
|
|
|
|
glog.V(2).Infof("SSE-KMS key rotation: rotating from key %s to key %s", srcKeyID, dstKeyID)
|
|
|
|
// For SSE-KMS, we can potentially do metadata-only rotation
|
|
// if the KMS service supports key aliasing and the data encryption key can be re-wrapped
|
|
if s3a.canDoMetadataOnlyKMSRotation(srcKeyID, dstKeyID) {
|
|
return s3a.rotateSSEKMSMetadataOnly(entry, srcKeyID, dstKeyID)
|
|
}
|
|
|
|
// Fallback to full re-encryption
|
|
return s3a.rotateSSEKMSChunks(entry, srcKeyID, dstKeyID, r)
|
|
}
|
|
|
|
// canDoMetadataOnlyKMSRotation determines if KMS key rotation can be done metadata-only
|
|
func (s3a *S3ApiServer) canDoMetadataOnlyKMSRotation(srcKeyID, dstKeyID string) bool {
|
|
// For now, we'll be conservative and always re-encrypt
|
|
// In a full implementation, this would check if:
|
|
// 1. Both keys are in the same KMS instance
|
|
// 2. The KMS supports key re-wrapping
|
|
// 3. The user has permissions for both keys
|
|
return false
|
|
}
|
|
|
|
// rotateSSEKMSMetadataOnly performs metadata-only SSE-KMS key rotation
|
|
func (s3a *S3ApiServer) rotateSSEKMSMetadataOnly(entry *filer_pb.Entry, srcKeyID, dstKeyID string) ([]*filer_pb.FileChunk, error) {
|
|
// This would re-wrap the data encryption key with the new KMS key
|
|
// For now, return an error since we don't support this yet
|
|
return nil, fmt.Errorf("metadata-only KMS key rotation not yet implemented")
|
|
}
|
|
|
|
// rotateSSECChunks re-encrypts all chunks with new SSE-C key
|
|
func (s3a *S3ApiServer) rotateSSECChunks(entry *filer_pb.Entry, sourceKey, destKey *SSECustomerKey) ([]*filer_pb.FileChunk, error) {
|
|
// Get IV from entry metadata
|
|
iv, err := GetSSECIVFromMetadata(entry.Extended)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get SSE-C IV from metadata: %w", err)
|
|
}
|
|
|
|
var rotatedChunks []*filer_pb.FileChunk
|
|
|
|
for _, chunk := range entry.GetChunks() {
|
|
rotatedChunk, err := s3a.rotateSSECChunk(chunk, sourceKey, destKey, iv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rotate SSE-C chunk: %w", err)
|
|
}
|
|
rotatedChunks = append(rotatedChunks, rotatedChunk)
|
|
}
|
|
|
|
// Generate new IV for the destination and store it in entry metadata
|
|
newIV := make([]byte, s3_constants.AESBlockSize)
|
|
if _, err := io.ReadFull(rand.Reader, newIV); err != nil {
|
|
return nil, fmt.Errorf("generate new IV: %w", err)
|
|
}
|
|
|
|
// Update entry metadata with new IV and SSE-C headers
|
|
if entry.Extended == nil {
|
|
entry.Extended = make(map[string][]byte)
|
|
}
|
|
StoreSSECIVInMetadata(entry.Extended, newIV)
|
|
entry.Extended[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte("AES256")
|
|
entry.Extended[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(destKey.KeyMD5)
|
|
|
|
return rotatedChunks, nil
|
|
}
|
|
|
|
// rotateSSEKMSChunks re-encrypts all chunks with new SSE-KMS key
|
|
func (s3a *S3ApiServer) rotateSSEKMSChunks(entry *filer_pb.Entry, srcKeyID, dstKeyID string, r *http.Request) ([]*filer_pb.FileChunk, error) {
|
|
var rotatedChunks []*filer_pb.FileChunk
|
|
|
|
// Parse encryption context and bucket key settings
|
|
_, encryptionContext, bucketKeyEnabled, err := ParseSSEKMSCopyHeaders(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse SSE-KMS copy headers: %w", err)
|
|
}
|
|
|
|
for _, chunk := range entry.GetChunks() {
|
|
rotatedChunk, err := s3a.rotateSSEKMSChunk(chunk, srcKeyID, dstKeyID, encryptionContext, bucketKeyEnabled)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("rotate SSE-KMS chunk: %w", err)
|
|
}
|
|
rotatedChunks = append(rotatedChunks, rotatedChunk)
|
|
}
|
|
|
|
return rotatedChunks, nil
|
|
}
|
|
|
|
// rotateSSECChunk rotates a single SSE-C encrypted chunk
|
|
func (s3a *S3ApiServer) rotateSSECChunk(chunk *filer_pb.FileChunk, sourceKey, destKey *SSECustomerKey, iv []byte) (*filer_pb.FileChunk, error) {
|
|
// Create new chunk with same properties
|
|
newChunk := &filer_pb.FileChunk{
|
|
Offset: chunk.Offset,
|
|
Size: chunk.Size,
|
|
ModifiedTsNs: chunk.ModifiedTsNs,
|
|
ETag: chunk.ETag,
|
|
}
|
|
|
|
// Assign new volume for the rotated chunk
|
|
assignResult, err := s3a.assignNewVolume("")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assign new volume: %w", err)
|
|
}
|
|
|
|
// Set file ID on new chunk
|
|
if err := s3a.setChunkFileId(newChunk, assignResult); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get source chunk data
|
|
fileId := chunk.GetFileIdString()
|
|
srcUrl, err := s3a.lookupVolumeUrl(fileId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup source volume: %w", err)
|
|
}
|
|
|
|
// Download encrypted data
|
|
encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download chunk data: %w", err)
|
|
}
|
|
|
|
// Decrypt with source key using provided IV
|
|
decryptedReader, err := CreateSSECDecryptedReader(bytes.NewReader(encryptedData), sourceKey, iv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create decrypted reader: %w", err)
|
|
}
|
|
|
|
decryptedData, err := io.ReadAll(decryptedReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt data: %w", err)
|
|
}
|
|
|
|
// Re-encrypt with destination key
|
|
encryptedReader, _, err := CreateSSECEncryptedReader(bytes.NewReader(decryptedData), destKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create encrypted reader: %w", err)
|
|
}
|
|
|
|
// Note: IV will be handled at the entry level by the calling function
|
|
|
|
reencryptedData, err := io.ReadAll(encryptedReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("re-encrypt data: %w", err)
|
|
}
|
|
|
|
// Update chunk size to include new IV
|
|
newChunk.Size = uint64(len(reencryptedData))
|
|
|
|
// Upload re-encrypted data
|
|
if err := s3a.uploadChunkData(reencryptedData, assignResult, false); err != nil {
|
|
return nil, fmt.Errorf("upload re-encrypted data: %w", err)
|
|
}
|
|
|
|
return newChunk, nil
|
|
}
|
|
|
|
// rotateSSEKMSChunk rotates a single SSE-KMS encrypted chunk
|
|
func (s3a *S3ApiServer) rotateSSEKMSChunk(chunk *filer_pb.FileChunk, srcKeyID, dstKeyID string, encryptionContext map[string]string, bucketKeyEnabled bool) (*filer_pb.FileChunk, error) {
|
|
// Create new chunk with same properties
|
|
newChunk := &filer_pb.FileChunk{
|
|
Offset: chunk.Offset,
|
|
Size: chunk.Size,
|
|
ModifiedTsNs: chunk.ModifiedTsNs,
|
|
ETag: chunk.ETag,
|
|
}
|
|
|
|
// Assign new volume for the rotated chunk
|
|
assignResult, err := s3a.assignNewVolume("")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assign new volume: %w", err)
|
|
}
|
|
|
|
// Set file ID on new chunk
|
|
if err := s3a.setChunkFileId(newChunk, assignResult); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get source chunk data
|
|
fileId := chunk.GetFileIdString()
|
|
srcUrl, err := s3a.lookupVolumeUrl(fileId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup source volume: %w", err)
|
|
}
|
|
|
|
// Download data (this would be encrypted with the old KMS key)
|
|
chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download chunk data: %w", err)
|
|
}
|
|
|
|
// For now, we'll just re-upload the data as-is
|
|
// In a full implementation, this would:
|
|
// 1. Decrypt with old KMS key
|
|
// 2. Re-encrypt with new KMS key
|
|
// 3. Update metadata accordingly
|
|
|
|
// Upload data with new key (placeholder implementation)
|
|
if err := s3a.uploadChunkData(chunkData, assignResult, false); err != nil {
|
|
return nil, fmt.Errorf("upload rotated data: %w", err)
|
|
}
|
|
|
|
return newChunk, nil
|
|
}
|
|
|
|
// IsSameObjectCopy determines if this is a same-object copy operation
|
|
func IsSameObjectCopy(r *http.Request, srcBucket, srcObject, dstBucket, dstObject string) bool {
|
|
return srcBucket == dstBucket && srcObject == dstObject
|
|
}
|
|
|
|
// NeedsKeyRotation determines if the copy operation requires key rotation
|
|
func NeedsKeyRotation(entry *filer_pb.Entry, r *http.Request) bool {
|
|
// Check for SSE-C key rotation
|
|
if IsSSECEncrypted(entry.Extended) && IsSSECRequest(r) {
|
|
return true // Assume different keys for safety
|
|
}
|
|
|
|
// Check for SSE-KMS key rotation
|
|
if IsSSEKMSEncrypted(entry.Extended) && IsSSEKMSRequest(r) {
|
|
srcKeyID, _ := GetSourceSSEKMSInfo(entry.Extended)
|
|
dstKeyID := r.Header.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId)
|
|
return srcKeyID != dstKeyID
|
|
}
|
|
|
|
return false
|
|
}
|