Files
seaweedfs/weed/s3api/s3_sse_s3_integration_test.go
T
Chris Lu d65c568cbb fix(s3api): validate SSE-S3 chunk IV length; add multipart direct reader tests (#9218)
* fix(s3api): validate SSE-S3 chunk IV length; add multipart direct reader tests

DeserializeSSES3Metadata does not require an IV, and a corrupted or
legacy chunk without one would have flowed into cipher.NewCTR and
panicked. Validate that each per-chunk IV is exactly AESBlockSize bytes
before decryption, closing the current and any already-appended chunk
readers on error.

Factor the per-chunk decryption loop out of
createMultipartSSES3DecryptedReaderDirect into buildMultipartSSES3Reader
so it can be driven with a mock chunk fetcher, and add tests covering:
the happy path with two parts (distinct per-chunk DEKs/IVs, out-of-order
chunks) to lock in the fix from #9211; missing-IV and short-IV metadata
rejection without panic; and reader cleanup when a later chunk fails.

* address review: sort chunks copy; close encryptedStream on error

- buildMultipartSSES3Reader now sorts a copy of the chunks slice so
  callers do not observe entry.Chunks reordered (other code paths,
  e.g. ETag computation, can rely on the original order).
- createMultipartSSES3DecryptedReaderDirect now closes encryptedStream
  on the error path from buildMultipartSSES3Reader. All current
  callers pass nil, but this keeps cleanup symmetric with the
  success path.
- Extend TestBuildMultipartSSES3Reader_PerChunkKeys to assert the
  input slice is not mutated.

* address review: defer single close; extend chunk-copy + IV-guard pattern

- createMultipartSSES3DecryptedReaderDirect: collapse the duplicated
  encryptedStream.Close() calls into a single nil-guarded defer so the
  error and success paths share cleanup.
- createMultipartSSECDecryptedReaderDirect,
  createMultipartSSEKMSDecryptedReaderDirect: sort a copy of entry.Chunks
  instead of mutating the caller's slice, matching the SSE-S3 helper.
- createMultipartSSECDecryptedReaderDirect: validate per-chunk IV length
  before handing it to cipher.NewCTR; a base64-decoded empty or short
  IV from malformed/corrupt metadata would otherwise panic.
- SSE-KMS needs no IV guard: CreateSSEKMSDecryptedReader already calls
  ValidateIV before cipher.NewCTR. Note recorded in the sort comment.

* address review: close appended readers on SSE-C/SSE-KMS error paths

createMultipartSSECDecryptedReaderDirect and
createMultipartSSEKMSDecryptedReaderDirect only closed the current chunk
reader on error and leaked any chunk readers already appended to the
local readers slice, mirroring the leak previously fixed in the SSE-S3
helper. Add the same closeAppendedReaders() closure pattern to both
functions and invoke it on every error return inside the loop so failed
requests do not leak volume-server HTTP connections.

* address review: defer encryptedStream close in SSE-C/SSE-KMS; drop chunks reassignment

- Move encryptedStream.Close() to a nil-guarded defer at the top of
  createMultipartSSECDecryptedReaderDirect and
  createMultipartSSEKMSDecryptedReaderDirect so the stream is closed on
  every return path (including error returns from inside the per-chunk
  loop), mirroring the SSE-S3 helper.
- In buildMultipartSSES3Reader, iterate sortedChunks directly instead of
  reassigning chunks = sortedChunks.
2026-04-24 13:59:23 -07:00

582 lines
18 KiB
Go

package s3api
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// NOTE: These are integration tests that test the end-to-end encryption/decryption flow.
// Full HTTP handler tests (PUT -> GET) would require a complete mock server with filer,
// which is complex to set up. These tests focus on the critical decrypt path.
// TestSSES3EndToEndSmallFile tests the complete encryption->storage->decryption cycle for small inline files
// This test would have caught the IV retrieval bug for inline files
func TestSSES3EndToEndSmallFile(t *testing.T) {
// Initialize global SSE-S3 key manager
globalSSES3KeyManager = NewSSES3KeyManager()
defer func() {
globalSSES3KeyManager = NewSSES3KeyManager()
}()
// Set up the key manager with a super key for testing
keyManager := GetSSES3KeyManager()
keyManager.superKey = make([]byte, 32)
for i := range keyManager.superKey {
keyManager.superKey[i] = byte(i)
}
testCases := []struct {
name string
data []byte
}{
{"tiny file (10 bytes)", []byte("test12345")},
{"small file (50 bytes)", []byte("This is a small test file for SSE-S3 encryption")},
{"medium file (256 bytes)", bytes.Repeat([]byte("a"), 256)},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Step 1: Encrypt (simulates what happens during PUT)
sseS3Key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("Failed to generate SSE-S3 key: %v", err)
}
encryptedReader, iv, err := CreateSSES3EncryptedReader(bytes.NewReader(tc.data), sseS3Key)
if err != nil {
t.Fatalf("Failed to create encrypted reader: %v", err)
}
encryptedData, err := io.ReadAll(encryptedReader)
if err != nil {
t.Fatalf("Failed to read encrypted data: %v", err)
}
// Store IV in the key (this is critical for inline files!)
sseS3Key.IV = iv
// Serialize the metadata (this is stored in entry.Extended)
serializedMetadata, err := SerializeSSES3Metadata(sseS3Key)
if err != nil {
t.Fatalf("Failed to serialize SSE-S3 metadata: %v", err)
}
// Step 2: Simulate storage (inline file - no chunks)
// For inline files, data is in Content, metadata in Extended
mockEntry := &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.SeaweedFSSSES3Key: serializedMetadata,
s3_constants.AmzServerSideEncryption: []byte("AES256"),
},
Content: encryptedData,
Chunks: []*filer_pb.FileChunk{}, // Critical: inline files have NO chunks
}
// Step 3: Decrypt (simulates what happens during GET)
// This tests the IV retrieval path for inline files
// First, deserialize metadata from storage
retrievedKeyData := mockEntry.Extended[s3_constants.SeaweedFSSSES3Key]
retrievedKey, err := DeserializeSSES3Metadata(retrievedKeyData, keyManager)
if err != nil {
t.Fatalf("Failed to deserialize SSE-S3 metadata: %v", err)
}
// CRITICAL TEST: For inline files, IV must be in object-level metadata
var retrievedIV []byte
if len(retrievedKey.IV) > 0 {
// Success path: IV found in object-level key
retrievedIV = retrievedKey.IV
} else if len(mockEntry.GetChunks()) > 0 {
// Fallback path: would check chunks (but inline files have no chunks)
t.Fatal("Inline file should have IV in object-level metadata, not chunks")
}
if len(retrievedIV) == 0 {
// THIS IS THE BUG WE FIXED: inline files had no way to get IV!
t.Fatal("Failed to retrieve IV for inline file - this is the bug we fixed!")
}
// Now decrypt with the retrieved IV
decryptedReader, err := CreateSSES3DecryptedReader(bytes.NewReader(encryptedData), retrievedKey, retrievedIV)
if err != nil {
t.Fatalf("Failed to create decrypted reader: %v", err)
}
decryptedData, err := io.ReadAll(decryptedReader)
if err != nil {
t.Fatalf("Failed to read decrypted data: %v", err)
}
// Verify decrypted data matches original
if !bytes.Equal(decryptedData, tc.data) {
t.Errorf("Decrypted data doesn't match original.\nExpected: %q\nGot: %q", tc.data, decryptedData)
}
})
}
}
// TestSSES3EndToEndChunkedFile tests the complete flow for chunked files
func TestSSES3EndToEndChunkedFile(t *testing.T) {
// Initialize global SSE-S3 key manager
globalSSES3KeyManager = NewSSES3KeyManager()
defer func() {
globalSSES3KeyManager = NewSSES3KeyManager()
}()
keyManager := GetSSES3KeyManager()
keyManager.superKey = make([]byte, 32)
for i := range keyManager.superKey {
keyManager.superKey[i] = byte(i)
}
// Generate SSE-S3 key
sseS3Key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("Failed to generate SSE-S3 key: %v", err)
}
// Create test data for two chunks
chunk1Data := []byte("This is chunk 1 data for SSE-S3 encryption test")
chunk2Data := []byte("This is chunk 2 data for SSE-S3 encryption test")
// Encrypt chunk 1
encryptedReader1, iv1, err := CreateSSES3EncryptedReader(bytes.NewReader(chunk1Data), sseS3Key)
if err != nil {
t.Fatalf("Failed to create encrypted reader for chunk 1: %v", err)
}
encryptedChunk1, _ := io.ReadAll(encryptedReader1)
// Encrypt chunk 2
encryptedReader2, iv2, err := CreateSSES3EncryptedReader(bytes.NewReader(chunk2Data), sseS3Key)
if err != nil {
t.Fatalf("Failed to create encrypted reader for chunk 2: %v", err)
}
encryptedChunk2, _ := io.ReadAll(encryptedReader2)
// Create metadata for each chunk
chunk1Key := &SSES3Key{
Key: sseS3Key.Key,
IV: iv1,
Algorithm: sseS3Key.Algorithm,
KeyID: sseS3Key.KeyID,
}
chunk2Key := &SSES3Key{
Key: sseS3Key.Key,
IV: iv2,
Algorithm: sseS3Key.Algorithm,
KeyID: sseS3Key.KeyID,
}
serializedChunk1Meta, _ := SerializeSSES3Metadata(chunk1Key)
serializedChunk2Meta, _ := SerializeSSES3Metadata(chunk2Key)
serializedObjMeta, _ := SerializeSSES3Metadata(sseS3Key)
// Create mock entry with chunks
mockEntry := &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.SeaweedFSSSES3Key: serializedObjMeta,
s3_constants.AmzServerSideEncryption: []byte("AES256"),
},
Chunks: []*filer_pb.FileChunk{
{
FileId: "chunk1,123",
Offset: 0,
Size: uint64(len(encryptedChunk1)),
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: serializedChunk1Meta,
},
{
FileId: "chunk2,456",
Offset: int64(len(chunk1Data)),
Size: uint64(len(encryptedChunk2)),
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: serializedChunk2Meta,
},
},
}
// Verify multipart detection
sses3Chunks := 0
for _, chunk := range mockEntry.GetChunks() {
if chunk.GetSseType() == filer_pb.SSEType_SSE_S3 && len(chunk.GetSseMetadata()) > 0 {
sses3Chunks++
}
}
isMultipart := sses3Chunks > 1
if !isMultipart {
t.Error("Expected multipart SSE-S3 object detection")
}
if sses3Chunks != 2 {
t.Errorf("Expected 2 SSE-S3 chunks, got %d", sses3Chunks)
}
// Verify each chunk has valid metadata with IV
for i, chunk := range mockEntry.GetChunks() {
deserializedKey, err := DeserializeSSES3Metadata(chunk.GetSseMetadata(), keyManager)
if err != nil {
t.Errorf("Failed to deserialize chunk %d metadata: %v", i, err)
}
if len(deserializedKey.IV) == 0 {
t.Errorf("Chunk %d has no IV", i)
}
// Decrypt this chunk to verify it works
var chunkData []byte
if i == 0 {
chunkData = encryptedChunk1
} else {
chunkData = encryptedChunk2
}
decryptedReader, err := CreateSSES3DecryptedReader(bytes.NewReader(chunkData), deserializedKey, deserializedKey.IV)
if err != nil {
t.Errorf("Failed to decrypt chunk %d: %v", i, err)
continue
}
decrypted, _ := io.ReadAll(decryptedReader)
var expectedData []byte
if i == 0 {
expectedData = chunk1Data
} else {
expectedData = chunk2Data
}
if !bytes.Equal(decrypted, expectedData) {
t.Errorf("Chunk %d decryption failed", i)
}
}
}
// TestSSES3EndToEndWithDetectPrimaryType tests that type detection works correctly for different scenarios
func TestSSES3EndToEndWithDetectPrimaryType(t *testing.T) {
s3a := &S3ApiServer{}
testCases := []struct {
name string
entry *filer_pb.Entry
expectedType string
shouldBeSSES3 bool
}{
{
name: "Inline SSE-S3 file (no chunks)",
entry: &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.AmzServerSideEncryption: []byte("AES256"),
},
Attributes: &filer_pb.FuseAttributes{},
Content: []byte("encrypted data"),
Chunks: []*filer_pb.FileChunk{},
},
expectedType: s3_constants.SSETypeS3,
shouldBeSSES3: true,
},
{
name: "Single chunk SSE-S3 file",
entry: &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.AmzServerSideEncryption: []byte("AES256"),
},
Attributes: &filer_pb.FuseAttributes{},
Chunks: []*filer_pb.FileChunk{
{
FileId: "1,123",
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: []byte("metadata"),
},
},
},
expectedType: s3_constants.SSETypeS3,
shouldBeSSES3: true,
},
{
name: "SSE-KMS file (has KMS key ID)",
entry: &filer_pb.Entry{
Extended: map[string][]byte{
s3_constants.AmzServerSideEncryption: []byte("AES256"),
s3_constants.AmzServerSideEncryptionAwsKmsKeyId: []byte("kms-key-123"),
},
Attributes: &filer_pb.FuseAttributes{},
Chunks: []*filer_pb.FileChunk{},
},
expectedType: s3_constants.SSETypeKMS,
shouldBeSSES3: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
detectedType := s3a.detectPrimarySSEType(tc.entry)
if detectedType != tc.expectedType {
t.Errorf("Expected type %s, got %s", tc.expectedType, detectedType)
}
if (detectedType == s3_constants.SSETypeS3) != tc.shouldBeSSES3 {
t.Errorf("SSE-S3 detection mismatch: expected %v, got %v", tc.shouldBeSSES3, detectedType == s3_constants.SSETypeS3)
}
})
}
}
// initSSES3KeyManagerForTest resets the global SSE-S3 key manager and seeds it
// with a deterministic super key suitable for envelope-encrypt/decrypt cycles
// inside tests. Returns the freshly initialized manager.
func initSSES3KeyManagerForTest(t *testing.T) *SSES3KeyManager {
t.Helper()
globalSSES3KeyManager = NewSSES3KeyManager()
t.Cleanup(func() { globalSSES3KeyManager = NewSSES3KeyManager() })
km := GetSSES3KeyManager()
km.superKey = make([]byte, 32)
for i := range km.superKey {
km.superKey[i] = byte(i)
}
return km
}
// encryptSSES3Part encrypts data with a freshly generated DEK and returns the
// ciphertext plus serialized per-chunk metadata (DEK + IV). Mirrors what the
// multipart upload path writes into each part's chunk metadata.
func encryptSSES3Part(t *testing.T, data []byte) (ciphertext, metadata []byte) {
t.Helper()
key, err := GenerateSSES3Key()
if err != nil {
t.Fatalf("GenerateSSES3Key: %v", err)
}
encReader, iv, err := CreateSSES3EncryptedReader(bytes.NewReader(data), key)
if err != nil {
t.Fatalf("CreateSSES3EncryptedReader: %v", err)
}
ciphertext, err = io.ReadAll(encReader)
if err != nil {
t.Fatalf("ReadAll ciphertext: %v", err)
}
key.IV = iv
metadata, err = SerializeSSES3Metadata(key)
if err != nil {
t.Fatalf("SerializeSSES3Metadata: %v", err)
}
return ciphertext, metadata
}
// TestBuildMultipartSSES3Reader_PerChunkKeys locks in the fix from PR #9211:
// each multipart part has its own DEK and IV, and the direct multipart reader
// must decrypt each chunk with its own per-chunk metadata (not the entry-level
// key). Before the fix, using a shared entry key produced garbled output.
func TestBuildMultipartSSES3Reader_PerChunkKeys(t *testing.T) {
keyManager := initSSES3KeyManagerForTest(t)
// Two parts with distinct sizes (including a short final part) and
// independent DEKs/IVs to exercise the per-chunk key plumbing.
part1Plaintext := bytes.Repeat([]byte("ABCDEFGHIJKLMNOP"), 16) // 256 bytes
part2Plaintext := []byte("short tail part") // 15 bytes
cipher1, meta1 := encryptSSES3Part(t, part1Plaintext)
cipher2, meta2 := encryptSSES3Part(t, part2Plaintext)
chunks := []*filer_pb.FileChunk{
{
FileId: "1,aaa",
Offset: 0,
Size: uint64(len(cipher1)),
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: meta1,
},
{
FileId: "2,bbb",
Offset: int64(len(part1Plaintext)),
Size: uint64(len(cipher2)),
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: meta2,
},
}
// Pass chunks out of order to verify offset-based sort.
shuffled := []*filer_pb.FileChunk{chunks[1], chunks[0]}
// Snapshot the input ordering; the helper must not mutate the caller's
// slice, which is backed by entry.Chunks and relied on elsewhere (e.g.
// ETag computation).
shuffledOrderBefore := []*filer_pb.FileChunk{shuffled[0], shuffled[1]}
fetched := map[string]int{}
chunkData := map[string][]byte{
"1,aaa": cipher1,
"2,bbb": cipher2,
}
fetch := func(c *filer_pb.FileChunk) (io.ReadCloser, error) {
fetched[c.GetFileIdString()]++
data, ok := chunkData[c.GetFileIdString()]
if !ok {
return nil, fmt.Errorf("unexpected chunk %s", c.GetFileIdString())
}
return io.NopCloser(bytes.NewReader(data)), nil
}
reader, err := buildMultipartSSES3Reader(shuffled, keyManager, fetch)
if err != nil {
t.Fatalf("buildMultipartSSES3Reader: %v", err)
}
got, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("ReadAll reader: %v", err)
}
want := append(append([]byte{}, part1Plaintext...), part2Plaintext...)
if !bytes.Equal(got, want) {
t.Fatalf("decrypted output mismatch\n want (len=%d): %q\n got (len=%d): %q",
len(want), want, len(got), got)
}
if fetched["1,aaa"] != 1 || fetched["2,bbb"] != 1 {
t.Errorf("expected each chunk fetched once, got %v", fetched)
}
for i := range shuffledOrderBefore {
if shuffled[i] != shuffledOrderBefore[i] {
t.Errorf("caller's chunk slice was reordered at index %d: before=%s after=%s",
i, shuffledOrderBefore[i].GetFileIdString(), shuffled[i].GetFileIdString())
}
}
}
// TestBuildMultipartSSES3Reader_InvalidIVLength verifies that per-chunk metadata
// with a missing or short IV is rejected with a clear error instead of
// panicking inside cipher.NewCTR. The short-IV case is crafted by encoding the
// metadata JSON directly, since SerializeSSES3Metadata refuses to emit a bad
// IV; this simulates corrupted or legacy on-disk metadata.
func TestBuildMultipartSSES3Reader_InvalidIVLength(t *testing.T) {
keyManager := initSSES3KeyManagerForTest(t)
// Pre-encrypt the DEK with the current super key so Deserialize can unwrap
// it successfully and we reach the IV-length check.
dek := bytes.Repeat([]byte{0x42}, s3_constants.AESKeySize)
encryptedDEK, nonce, err := keyManager.encryptKeyWithSuperKey(dek)
if err != nil {
t.Fatalf("encryptKeyWithSuperKey: %v", err)
}
makeMetadata := func(iv []byte) []byte {
meta := map[string]string{
"algorithm": s3_constants.SSEAlgorithmAES256,
"keyId": "test-key",
"encryptedDEK": base64.StdEncoding.EncodeToString(encryptedDEK),
"nonce": base64.StdEncoding.EncodeToString(nonce),
}
if iv != nil {
meta["iv"] = base64.StdEncoding.EncodeToString(iv)
}
out, err := json.Marshal(meta)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
return out
}
cases := []struct {
name string
iv []byte
}{
{"missing IV", nil},
{"short IV", []byte("too-short")}, // 9 bytes, not 16
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
closed := false
fetch := func(c *filer_pb.FileChunk) (io.ReadCloser, error) {
return &closeTrackingReadCloser{Reader: bytes.NewReader([]byte("whatever")), closed: &closed}, nil
}
chunks := []*filer_pb.FileChunk{
{
FileId: "1,bad",
Offset: 0,
Size: 8,
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: makeMetadata(tc.iv),
},
}
_, err := buildMultipartSSES3Reader(chunks, keyManager, fetch)
if err == nil {
t.Fatal("expected error for invalid IV length, got nil")
}
if !strings.Contains(err.Error(), "invalid IV length") {
t.Errorf("expected 'invalid IV length' in error, got: %v", err)
}
if !closed {
t.Error("chunk reader for the bad chunk was not closed on error")
}
})
}
}
// TestBuildMultipartSSES3Reader_ClosesAppendedOnError verifies that when a
// later chunk fails (e.g., malformed metadata), readers already appended for
// earlier valid chunks are closed so volume-server HTTP connections do not leak.
func TestBuildMultipartSSES3Reader_ClosesAppendedOnError(t *testing.T) {
keyManager := initSSES3KeyManagerForTest(t)
// First chunk: valid SSE-S3 chunk.
cipher1, meta1 := encryptSSES3Part(t, []byte("first chunk plaintext"))
// Second chunk: missing per-chunk metadata, triggers error after first is
// already appended.
chunks := []*filer_pb.FileChunk{
{
FileId: "1,good",
Offset: 0,
Size: uint64(len(cipher1)),
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: meta1,
},
{
FileId: "2,bad",
Offset: int64(len(cipher1)),
Size: 1,
SseType: filer_pb.SSEType_SSE_S3,
SseMetadata: nil, // triggers "missing per-chunk metadata"
},
}
firstClosed := false
secondClosed := false
fetch := func(c *filer_pb.FileChunk) (io.ReadCloser, error) {
switch c.GetFileIdString() {
case "1,good":
return &closeTrackingReadCloser{Reader: bytes.NewReader(cipher1), closed: &firstClosed}, nil
case "2,bad":
return &closeTrackingReadCloser{Reader: bytes.NewReader([]byte("x")), closed: &secondClosed}, nil
}
return nil, fmt.Errorf("unexpected chunk %s", c.GetFileIdString())
}
_, err := buildMultipartSSES3Reader(chunks, keyManager, fetch)
if err == nil {
t.Fatal("expected error from missing chunk metadata, got nil")
}
if !firstClosed {
t.Error("previously appended chunk reader was not closed on error")
}
if !secondClosed {
t.Error("chunk reader for the failing chunk was not closed on error")
}
}
type closeTrackingReadCloser struct {
io.Reader
closed *bool
}
func (r *closeTrackingReadCloser) Close() error {
*r.closed = true
return nil
}