Skip to content

fix(s3): ETag was SHA-256, which broke client-side hash validation in AWS SDKs - #106

Merged
deblasis merged 3 commits into
mainfrom
pr/103-etag-md5
Oct 1, 2026
Merged

deblasis merged 3 commits into
mainfrom
pr/103-etag-md5

Conversation

@deblasis

@deblasis deblasis commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #103.

Real S3 uses the MD5 digest of the object bytes as the ETag on non-multipart uploads. We returned SHA-256, so a 64-character hex digest went out where SDKs expect 32. GetObjectAsync then threw on every read with AmazonClientException: Expected hash not equal to calculated hash (via HashStream.CalculateHash), even though the body itself was byte-correct. Any client that validates the ETag against the content could not read an object back.

Found by @jeremydixon22 while running the AWS .NET SDK (AWSSDK.S3 4.0.102.4) against the aws-s3-style adapter, and isolated to the ETag value by rewriting only that header in a reverse proxy: the same SDK read succeeded against all four pinned S3/Core version combinations.

The stated reason for the SHA-256 stand-in was that the crypto module has no MD5. That became inaccurate in b6ca853 (2026-08-22), which added crypto.md5 for protocol checksums; adapters/sqs-style/scripts/lib.star:656-662 has used it since. This adapter was never updated.

What changed

  • Single-part and UploadPart ETags are now crypto.md5(raw).
  • Multipart ETags are hex(MD5(binary-concat(part MD5s))) + "-" + N, matching real S3. Starlark's chr() emits UTF-8, so hex-decoding part digests inside Starlark would corrupt any byte >= 0x80; the binary concatenation runs in a new total builtin, crypto.md5_hex_concat (internal/starlark/cryptomod.go:44). It returns None on any shape mismatch rather than raising, since a Starlark error surfaces as an unhandled 500 and handlers have no try.
  • CompleteMultipartUpload compares part ETags case-insensitively and strips quotes and a W/ prefix on both sides, which also closes a bypass where an empty <ETag></ETag> skipped the comparison entirely.

Breaking

ETags change from 64 hex characters to 32. Fixtures and assertions pinning the old digests need regenerating. Multipart uploads started before this lands and completed afterward will fail 400 InvalidPart, because their part digests were computed under the previous scheme; abort and restart those. Recorded in CHANGELOG.md.

Verification

$ env -u GOROOT go test -race ./internal/starlark -run TestMD5HexConcat -v
--- PASS: TestMD5HexConcat
$ env -u GOROOT go test -race ./internal/engine -run 'TestAwsS3|TestAWSS3' -v
--- PASS: TestAwsS3StyleAdapter
--- PASS: TestAwsS3StyleSigV4Verification
--- PASS: TestAwsS3StyleMultipartUpload
--- PASS: TestAWSS3StyleBinaryRoundTrip

Through AWSSDK.S3 4.0.102.4 and a hand-signed raw HTTP request, MD5 of hello:

{ "etagIsSha256": false, "plainUploadSdkReadError": null, "sdkReadError": null }

stunt adapter lint adapters/aws-s3-style is clean and just conformance-matrix regenerates with no drift.

@deblasis
deblasis merged commit 8961c94 into main Oct 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ETag was SHA-256, which broke client-side hash validation in AWS SDKs

1 participant