Skip to content

Instantly share code, notes, and snippets.

@Madrigal
Created July 24, 2026 18:34
Show Gist options
  • Select an option

  • Save Madrigal/27ccd834d4623bf5179f789199a90807 to your computer and use it in GitHub Desktop.

Select an option

Save Madrigal/27ccd834d4623bf5179f789199a90807 to your computer and use it in GitHub Desktop.
module sse-c-repro
go 1.24.1
require (
github.com/aws/aws-sdk-go-v2 v1.43.0
github.com/aws/aws-sdk-go-v2/config v1.32.31
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
)
replace (
github.com/aws/aws-sdk-go-v2 => /tmp/aws-sdk-go-v2
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream => /tmp/aws-sdk-go-v2/aws/protocol/eventstream
github.com/aws/aws-sdk-go-v2/config => /tmp/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials => /tmp/aws-sdk-go-v2/credentials
github.com/aws/aws-sdk-go-v2/feature/ec2/imds => /tmp/aws-sdk-go-v2/feature/ec2/imds
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager => /tmp/aws-sdk-go-v2/feature/s3/transfermanager
github.com/aws/aws-sdk-go-v2/internal/configsources => /tmp/aws-sdk-go-v2/internal/configsources
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 => /tmp/aws-sdk-go-v2/internal/endpoints/v2
github.com/aws/aws-sdk-go-v2/internal/v4a => /tmp/aws-sdk-go-v2/internal/v4a
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding => /tmp/aws-sdk-go-v2/service/internal/accept-encoding
github.com/aws/aws-sdk-go-v2/service/internal/checksum => /tmp/aws-sdk-go-v2/service/internal/checksum
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url => /tmp/aws-sdk-go-v2/service/internal/presigned-url
github.com/aws/aws-sdk-go-v2/service/internal/s3shared => /tmp/aws-sdk-go-v2/service/internal/s3shared
github.com/aws/aws-sdk-go-v2/service/s3 => /tmp/aws-sdk-go-v2/service/s3
github.com/aws/aws-sdk-go-v2/service/sso => /tmp/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/ssooidc => /tmp/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/sts => /tmp/aws-sdk-go-v2/service/sts
)
// Reproduction for https://github.com/aws/aws-sdk-go-v2/issues/3490
//
// The S3 transfer manager's GetObject() calls HeadObject internally to discover
// object size before issuing parallel ranged GETs. These HeadObject calls were
// not forwarding SSE-C parameters from the user's input, causing S3 to reject
// the request for SSE-C encrypted objects.
//
// This program:
// 1. Generates a 256-bit AES key (or loads one from .sse-key if re-running)
// 2. Creates an S3 bucket with SSE-C unblocked (blocked by default since April 2026)
// 3. Uploads a 20MB object encrypted with SSE-C
// 4. Attempts to download it via transfermanager.GetObject in GetObjectRanges mode
//
// Without the fix, step 4 fails because the internal HeadObject call is missing
// the SSE-C headers and S3 returns 400 Bad Request.
//
// The SSE-C key is persisted to .sse-key so re-runs skip the upload step.
package main
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager/types"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
const (
bucket = "sdk-sse-c-repro-891377375035"
key = "test-sse-c-object"
region = "us-east-1"
keyFile = ".sse-key"
objectMB = 20 // large enough to trigger parallel ranged GETs
)
// sseKey holds the base64-encoded encryption key and its MD5,
// persisted to disk so subsequent runs can skip re-uploading.
type sseKey struct {
Key string `json:"key"`
KeyMD5 string `json:"key_md5"`
}
// generateAndSaveKey creates a random 32-byte AES key, computes its MD5,
// and writes both (base64-encoded) to .sse-key for reuse.
func generateAndSaveKey() sseKey {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
log.Fatal("failed to generate key:", err)
}
sum := md5.Sum(raw)
k := sseKey{
Key: base64.StdEncoding.EncodeToString(raw),
KeyMD5: base64.StdEncoding.EncodeToString(sum[:]),
}
data, _ := json.MarshalIndent(k, "", " ")
if err := os.WriteFile(keyFile, data, 0600); err != nil {
log.Fatal("failed to save key file:", err)
}
fmt.Printf("Generated new SSE-C key and saved to %s\n\n", keyFile)
return k
}
// loadKey reads a previously-generated key from .sse-key.
func loadKey() (sseKey, bool) {
data, err := os.ReadFile(keyFile)
if err != nil {
return sseKey{}, false
}
var k sseKey
if err := json.Unmarshal(data, &k); err != nil {
return sseKey{}, false
}
fmt.Printf("Loaded existing SSE-C key from %s\n\n", keyFile)
return k, true
}
// ensureBucket creates the bucket and unblocks SSE-C.
// Since April 2026, S3 blocks SSE-C by default on new buckets. We must
// explicitly set BlockedEncryptionTypes to NONE via PutBucketEncryption.
func ensureBucket(ctx context.Context, s3Client *s3.Client) {
_, err := s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucket),
})
if err != nil && !strings.Contains(err.Error(), "BucketAlreadyOwnedByYou") &&
!strings.Contains(err.Error(), "BucketAlreadyExists") {
log.Fatal("CreateBucket failed:", err)
}
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html
// This was trickier to do in 2026
fmt.Println("==> Enabling SSE-C on bucket (blocked by default since April 2026)...")
_, err = s3Client.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
Bucket: aws.String(bucket),
ServerSideEncryptionConfiguration: &s3types.ServerSideEncryptionConfiguration{
Rules: []s3types.ServerSideEncryptionRule{
{
ApplyServerSideEncryptionByDefault: &s3types.ServerSideEncryptionByDefault{
SSEAlgorithm: s3types.ServerSideEncryptionAes256,
},
BucketKeyEnabled: aws.Bool(false),
BlockedEncryptionTypes: &s3types.BlockedEncryptionTypes{
EncryptionType: []s3types.EncryptionType{s3types.EncryptionTypeNone},
},
},
},
},
})
if err != nil {
log.Fatal("PutBucketEncryption failed:", err)
}
}
// uploadTestObject puts a 20MB object encrypted with the given SSE-C key.
// The size is chosen to exceed the transfer manager's default part size (8MB),
// ensuring parallel ranged GETs are triggered.
func uploadTestObject(ctx context.Context, s3Client *s3.Client, k sseKey) {
fmt.Printf("==> Uploading %dMB SSE-C encrypted object to s3://%s/%s ...\n", objectMB, bucket, key)
body := strings.NewReader(strings.Repeat("A", objectMB*1024*1024))
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: body,
SSECustomerAlgorithm: aws.String("AES256"),
SSECustomerKey: aws.String(k.Key),
SSECustomerKeyMD5: aws.String(k.KeyMD5),
})
if err != nil {
log.Fatal("PutObject failed:", err)
}
fmt.Println(" Upload successful.")
}
// objectExists checks whether the SSE-C object is accessible with the given key.
// Used to skip re-uploading on subsequent runs.
func objectExists(ctx context.Context, s3Client *s3.Client, k sseKey) bool {
_, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
SSECustomerAlgorithm: aws.String("AES256"),
SSECustomerKey: aws.String(k.Key),
SSECustomerKeyMD5: aws.String(k.KeyMD5),
})
return err == nil
}
// runRepro exercises the bug: it calls transfermanager.GetObject with SSE-C
// params in GetObjectRanges mode. The transfer manager internally calls
// HeadObject to discover the object size. Without the fix, HeadObject doesn't
// receive the SSE-C headers and S3 rejects the request.
func runRepro(ctx context.Context, s3Client *s3.Client, k sseKey) {
fmt.Println("\n==> Running transfermanager.GetObject with SSE-C params (GetObjectRanges mode)...")
fmt.Println(" Internally this calls HeadObject without forwarding SSE-C params.")
fmt.Println(" Expected result: 403 AccessDenied\n")
tmClient := transfermanager.New(s3Client, func(o *transfermanager.Options) {
o.GetObjectType = types.GetObjectRanges
})
out, err := tmClient.GetObject(ctx, &transfermanager.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
SSECustomerAlgorithm: aws.String("AES256"),
SSECustomerKey: aws.String(k.Key),
SSECustomerKeyMD5: aws.String(k.KeyMD5),
})
if err != nil {
fmt.Printf("[BUG REPRODUCED] transfermanager.GetObject failed:\n %v\n", err)
return
}
n, _ := io.Copy(io.Discard, out.Body)
fmt.Printf("[NOT REPRODUCED] GetObject succeeded — bug may already be fixed. Bytes read: %d\n", n)
}
func main() {
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
if err != nil {
log.Fatal("failed to load AWS config:", err)
}
s3Client := s3.NewFromConfig(cfg)
// Load or generate the SSE-C key. On first run we generate and persist it;
// on subsequent runs we load it from disk to avoid re-uploading.
k, exists := loadKey()
if !exists {
k = generateAndSaveKey()
ensureBucket(ctx, s3Client)
uploadTestObject(ctx, s3Client, k)
} else if !objectExists(ctx, s3Client, k) {
// Key exists but object is gone (e.g. bucket was cleaned up) — re-upload
fmt.Println("==> Object not found, re-uploading...")
ensureBucket(ctx, s3Client)
uploadTestObject(ctx, s3Client, k)
} else {
fmt.Printf("==> Object already exists at s3://%s/%s, skipping upload.\n\n", bucket, key)
}
runRepro(ctx, s3Client, k)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment