fix(sigv4): collapse interior header-value spaces per SigV4 canonicalization
ober
8d3e16b0dbe4357acc745a9e272bb714d7272bef
--- a/lib/jerboa-aws/sigv4.sls +++ b/lib/jerboa-aws/sigv4.sls @@ -31,13 +31,13 @@ (lambda (a b) (string<? (string-downcase (car a)) (string-downcase (car b)))) headers)] - ;; Build canonical headers - [canonical-headers - (apply string-append - (map (lambda (h) - (string-append (string-downcase (car h)) ":" - (string-trim (cdr h)) "\n")) - sorted-headers))] + ;; Build canonical headers + [canonical-headers + (apply string-append + (map (lambda (h) + (string-append (string-downcase (car h)) ":" + (canonical-header-value (cdr h)) "\n")) + sorted-headers))] ;; Build signed headers list [signed-headers (string-join @@ -80,6 +80,26 @@ ;; --- Helpers --- + ;; Canonical header value per SigV4: trim leading/trailing whitespace AND + ;; collapse runs of interior spaces to a single space. Without the collapse, + ;; a header value carrying two consecutive spaces is signed differently from + ;; the value AWS reconstructs for verification, yielding SignatureDoesNotMatch. + (define (canonical-header-value str) + (collapse-spaces (string-trim str))) + + (define (collapse-spaces str) + (let ([len (string-length str)] + [out (open-output-string)]) + (let loop ([i 0] [prev-space? #f]) + (cond + [(= i len) (get-output-string out)] + [(char=? (string-ref str i) #\space) + (unless prev-space? (write-char #\space out)) + (loop (+ i 1) #t)] + [else + (write-char (string-ref str i) out) + (loop (+ i 1) #f)])))) + (define (string-trim str) (let* ([len (string-length str)] [start (let loop ([i 0]) --- a/test/test-all.ss +++ b/test/test-all.ss @@ -3,6 +3,8 @@ (jerboa-aws s3 objects) (jerboa-aws ec2 params) (jerboa-aws json) + (jerboa-aws sigv4) + (only (jerboa-aws crypto) sha256 hex-encode) (only (jerboa-aws pssm) parse-pssm-args pssm-terminal-sanitize @@ -90,6 +92,49 @@ (cons "a" ""))) "a=&a%20b=x%2Fy&z=last") +;; --- SigV4 signing ---------------------------------------------------- +;; AWS SigV4 test-suite "get-vanilla" known-answer vector: validates the +;; canonical request, string-to-sign, key derivation and signature end to end. +(check + "sigv4 get-vanilla known vector" + (sigv4-sign "GET" "/" "" + '(("Host" . "example.amazonaws.com") + ("X-Amz-Date" . "20150830T123600Z")) + (hex-encode (sha256 "")) + "us-east-1" "service" + "AKIDEXAMPLE" + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" + "20150830T123600Z" "20150830") + (string-append + "AWS4-HMAC-SHA256 " + "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, " + "SignedHeaders=host;x-amz-date, " + "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31")) + +;; SigV4 canonical header values collapse runs of interior spaces to a single +;; space. A header value with two consecutive spaces must therefore sign +;; identically to the same value with one space; otherwise AWS reconstructs a +;; different canonical request and rejects the call (SignatureDoesNotMatch). +(let ([sign (lambda (v) + (sigv4-sign "GET" "/" "" + `(("Host" . "example.amazonaws.com") + ("X-Amz-Date" . "20150830T123600Z") + ("X-Custom" . ,v)) + (hex-encode (sha256 "")) + "us-east-1" "service" + "AKIDEXAMPLE" + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" + "20150830T123600Z" "20150830"))]) + (check "sigv4 collapses interior header spaces" + (sign "a b") + (sign "a b")) + (check "sigv4 collapses a long run of header spaces" + (sign "a b") + (sign "a b")) + (check "sigv4 trims leading/trailing header whitespace" + (sign " value ") + (sign "value"))) + (let ([target (s3-build-request-target path-client "bucket"