R2: sign the canonical request SigV4 actually specifies - #818
Merged
Conversation
The DFlash gates-only dry run dies at "Prepare hidden correctness golden" with HTTP 403 SignatureDoesNotMatch. #817 fixed a real blank-signature 400 underneath this and uncovered the next bug: the signature is well-formed, and R2 is rejecting it because we sign a MALFORMED canonical request. SigV4 is METHOD \n URI \n QUERY \n CanonicalHeaders \n SignedHeaders \n PayloadHash and CanonicalHeaders is itself "name:value\n" per header, so a BLANK LINE separates the last header from SignedHeaders. Both scripts spelled that terminating newline inside canonical_headers' own printf: canonical_headers="$(printf 'host:%s\n...\nx-amz-date:%s\n' ...)" canonical_request="$(printf 'GET\n%s\n\n%s\n%s\n%s' ...)" Command substitution strips every trailing newline, so canonical_headers arrived without its terminator and the canonical request went on the wire one line short -- 8 lines instead of 9. R2 hashes the 9-line form for the same request, the hashes disagree, and it answers 403. That reads like a bucket-permission fault and is not one: R2 resolved the access key, the bucket and the key, and only disagreed about the signature. Move both newlines into the canonical_request format string, where nothing can strip them: one terminates the last header line, one is the blank separator. Same defect and same fix in the upload twin. Why this survived: it has never run anywhere. download_with_aws_cli() short-circuits the signer whenever `aws` is on the runner PATH, and the serial box has it -- every successful hidden-golden fetch in either repo announced "using AWS CLI S3 path-style download", never "using signed HTTPS download". M5-C's runner PATH is /usr/bin:/bin:/usr/sbin:/sbin, so it is the first box to execute this code at all, and it has now surfaced two latent bugs in a row from the same unexercised path. So also correct #817's comment claiming "the serial box worked only because OpenSSL 3 was first on its PATH." That is false -- the serial box never runs openssl in this script -- and believing it sends the next debugger to audit PATH ordering on a box that does not run the code. The existing guard could not catch this. It signs a string-to-sign ending in the literal `deadbeef`, a stand-in for the canonical-request hash, so it proves the HMAC chain and says nothing about the canonical request being hashed -- which is how #817 shipped a correct signer over a malformed input. Add theCanonicalRequestMatchesAnIndependentSigV4Implementation: it extracts the real canonical_headers/canonical_request assignments out of each shipped script, evaluates them under pinned inputs, and asserts the request is 9 lines, that line 7 is the blank separator, and that its sha256 equals botocore's own CanonicalRequest hash for the identical request. Verified: reverting either script to the pre-fix construction fails the new test with lines.count -> 8 and the exact hashes 5a4af0b9 (GET) / 6a643959 (PUT) against the expected 18ec091e / 48c8f7da. With the fix, the full Authorization header produced by the real script's own signing block byte-matches an independent Python SigV4 implementation. swift test: 531 tests in 23 suites pass. shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anupsv
had a problem deploying
to
benchmark-private-prompts-v2
July 31, 2026 00:46 — with
GitHub Actions
Failure
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The DFlash gates-only dry run keeps dying at step 36, "Prepare hidden correctness golden", with HTTP 403
SignatureDoesNotMatch. #817 fixed a real blank-signature 400 underneath this; the signature is now well-formed, and R2 still refuses it — because we sign a malformed canonical request.SigV4 is
and
CanonicalHeadersis itselfname:value\nper header, so there is a blank line between the last header andSignedHeaders. Both scripts spelled that terminating newline insidecanonical_headers' own printf:$(...)strips every trailing newline, socanonical_headersarrives without its terminator and the canonical request goes on the wire one line short — 8 lines instead of 9. R2 hashes the 9-line form for the same request, the hashes disagree, 403.That error reads like a bucket-permission fault and is not one. R2 returned
SignatureDoesNotMatch, notInvalidAccessKeyId, notAccessDenied, notNoSuchBucket— it resolved the access key, the bucket and the key, and only disagreed about the signature.Fix
Move both newlines into the
canonical_requestformat string, where nothing can strip them: one terminates the last header line, one is the blank separator. Identical latent defect and identical fix in theupload-r2-object.sh(PUT) twin.Why this survived to be found twice
It has never run anywhere.
download_with_aws_cli()short-circuits the signer wheneverawsis on the runner PATH, and the serial box has it:using AWS CLI S3 path-style download×3using AWS CLI S3 path-style download×3using signed HTTPS downloadM5-C's runner PATH is
/usr/bin:/bin:/usr/sbin:/sbin(awsexists at/opt/homebrew/bin/awsbut is off PATH), so it is the first box to execute this signer at all — and it has now surfaced two latent bugs in a row from the same unexercised path.Consequently this PR also corrects #817's comment claiming "the serial box worked only because OpenSSL 3 was first on its PATH." That is false — the serial box never runs
opensslin this script — and believing it sends the next debugger to audit PATH ordering on a box that does not run the code.Ruled out
MLXFAST_CORRECTNESS_GOLDEN_R2_PATHandMLXFAST_GPQA_R2_PATHare byte-identical to serial's in both repos.The guard that missed it
theScriptSigningChainReproducesThePinnedSignaturesigns a string-to-sign ending in the literaldeadbeef— a stand-in for the canonical-request hash. It proves the HMAC chain and says nothing about the canonical request being hashed. That is exactly how #817 shipped a correct signer over a malformed input.New test
theCanonicalRequestMatchesAnIndependentSigV4Implementationextracts the realcanonical_headers/canonical_requestassignments out of each shipped script (so it tracks the script, not a copy), evaluates them under pinned inputs, and asserts:CanonicalRequesthash for the identical request.Verification
RED — revert either script to the pre-fix construction:
GREEN — with the fix:
✔ Test run with 3 tests in 1 suite passed.Independently corroborated rather than taken on faith:
18ec091e…,48c8f7da…) exactly.Signature=01223ee7…, public AWS-docs example credentials only).swift test→ 531 tests in 23 suites pass (530 + 1 new).shellcheck -S warningclean on both scripts.Not changed, deliberately
The
head -c 400truncation of the R2 error body. R2'sSignatureDoesNotMatchdocument can echo the CanonicalRequest, which contains the host and bucket from theR2_BUCKET_ENDPOINTsecret. GitHub masks whole secret values, not substrings, so widening it would leak the endpoint into the run log.Follow-up, not blocking
curl --retry 5 --retry-all-errorsretried a non-retryable 403 six times (23:45:39 → 23:45:50 in the real log). Cosmetic; worth not retrying4xx.No secret value was printed, logged, or transmitted. All signing tests use the public AWS-docs example credential pair. No hidden prompt, golden, or GPQA answer was authored, read, or hashed.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.