| 1 | // Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | module s3 |
| 5 | |
| 6 | import time |
| 7 | |
| 8 | // Test vectors come from the AWS-published Signature V4 reference test |
| 9 | // suite ("Examples of the complete version 4 signing process"). |
| 10 | // These secret/key pairs are the documented example values, not real |
| 11 | // credentials. |
| 12 | const aws_secret = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' |
| 13 | const aws_key_id = 'AKIAIOSFODNN7EXAMPLE' |
| 14 | |
| 15 | // SigV4 vector for `s3-get-object` (examplebucket / test.txt with a Range |
| 16 | // header). Source: AWS docs (Signature V4 examples). |
| 17 | // |
| 18 | // GET /test.txt HTTP/1.1 |
| 19 | // Host: examplebucket.s3.amazonaws.com |
| 20 | // Range: bytes=0-9 |
| 21 | // X-Amz-Date: 20130524T000000Z |
| 22 | // X-Amz-Content-SHA256: <empty-sha> |
| 23 | fn test_aws_sigv4_get_object_vector() { |
| 24 | t := time.parse_iso8601('2013-05-24T00:00:00.000Z') or { panic(err) } |
| 25 | creds := Credentials{ |
| 26 | access_key_id: aws_key_id |
| 27 | secret_access_key: aws_secret |
| 28 | region: 'us-east-1' |
| 29 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 30 | virtual_hosted_style: false // host already carries the bucket |
| 31 | } |
| 32 | req := SignRequest{ |
| 33 | method: 'GET' |
| 34 | path: '/test.txt' |
| 35 | query: '' |
| 36 | payload_hash: empty_sha256 |
| 37 | extra_headers: { |
| 38 | 'range': 'bytes=0-9' |
| 39 | } |
| 40 | sign_time: t |
| 41 | } |
| 42 | signed := sign_request(creds, req) or { panic(err) } |
| 43 | expected := 'AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41' |
| 44 | assert signed.authorization == expected, 'Got:\n${signed.authorization}\nExpected:\n${expected}' |
| 45 | } |
| 46 | |
| 47 | // SigV4 vector for `s3-put-object` (examplebucket / test$file.text with body |
| 48 | // "Welcome to Amazon S3."). |
| 49 | // |
| 50 | // PUT /test%24file.text HTTP/1.1 |
| 51 | // Host: examplebucket.s3.amazonaws.com |
| 52 | // Date: Fri, 24 May 2013 00:00:00 GMT |
| 53 | // x-amz-date: 20130524T000000Z |
| 54 | // x-amz-storage-class: REDUCED_REDUNDANCY |
| 55 | fn test_aws_sigv4_put_object_vector() { |
| 56 | t := time.parse_iso8601('2013-05-24T00:00:00.000Z') or { panic(err) } |
| 57 | creds := Credentials{ |
| 58 | access_key_id: aws_key_id |
| 59 | secret_access_key: aws_secret |
| 60 | region: 'us-east-1' |
| 61 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 62 | virtual_hosted_style: false |
| 63 | } |
| 64 | body := 'Welcome to Amazon S3.' |
| 65 | body_hash := sha256_hex(body.bytes()) |
| 66 | req := SignRequest{ |
| 67 | method: 'PUT' |
| 68 | path: '/test%24file.text' |
| 69 | query: '' |
| 70 | payload_hash: body_hash |
| 71 | extra_headers: { |
| 72 | 'date': 'Fri, 24 May 2013 00:00:00 GMT' |
| 73 | 'x-amz-storage-class': 'REDUCED_REDUNDANCY' |
| 74 | } |
| 75 | sign_time: t |
| 76 | } |
| 77 | signed := sign_request(creds, req) or { panic(err) } |
| 78 | expected := 'AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class, Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd' |
| 79 | assert signed.authorization == expected, 'Got:\n${signed.authorization}\nExpected:\n${expected}' |
| 80 | } |
| 81 | |
| 82 | // Asserts the canonical request string for a ListObjectsV2 GET. The |
| 83 | // canonical request is the most stable interop point — Authorization line |
| 84 | // formatting has wobbled across AWS doc revisions, but the canonical hash |
| 85 | // is always the same. |
| 86 | fn test_aws_sigv4_list_objects_canonical_request() { |
| 87 | headers := { |
| 88 | 'host': 'examplebucket.s3.amazonaws.com' |
| 89 | 'x-amz-content-sha256': empty_sha256 |
| 90 | 'x-amz-date': '20130524T000000Z' |
| 91 | } |
| 92 | canonical := build_canonical_request('GET', '/', 'list-type=2&prefix=foo', headers, |
| 93 | 'host;x-amz-content-sha256;x-amz-date', empty_sha256) |
| 94 | expected := 'GET\n/\nlist-type=2&prefix=foo\nhost:examplebucket.s3.amazonaws.com\nx-amz-content-sha256:${empty_sha256}\nx-amz-date:20130524T000000Z\n\nhost;x-amz-content-sha256;x-amz-date\n${empty_sha256}' |
| 95 | assert canonical == expected, 'canonical mismatch:\n${canonical}' |
| 96 | } |
| 97 | |
| 98 | // Cross-checked manually with openssl on the AWS docs example |
| 99 | // (date 20120215, region us-east-1, service iam): |
| 100 | // |
| 101 | // echo -n 'aws4_request' | openssl dgst -sha256 -mac HMAC -macopt hexkey:<kService> |
| 102 | fn test_signing_key_chain() { |
| 103 | key := derive_signing_key(aws_secret, '20120215', 'us-east-1', 'iam') |
| 104 | assert to_hex_lower(key) == '004aa806e13dae88b9032d9261bcb04c67d023afadd221e6b0d206e1760e0b5e', 'Got: ${to_hex_lower(key)}' |
| 105 | } |
| 106 | |
| 107 | fn test_normalize_header_value_collapses_whitespace() { |
| 108 | assert normalize_header_value(' hello world ') == 'hello world' |
| 109 | assert normalize_header_value('a\tb\tc') == 'a b c' |
| 110 | assert normalize_header_value('') == '' |
| 111 | } |
| 112 | |
| 113 | fn test_canonical_query_string_sorted_and_encoded() { |
| 114 | q := canonical_query_string({ |
| 115 | 'b': '2' |
| 116 | 'a': '1' |
| 117 | 'c': 'hello world' |
| 118 | }) |
| 119 | assert q == 'a=1&b=2&c=hello%20world' |
| 120 | } |
| 121 | |
| 122 | fn test_format_amz_date() { |
| 123 | t := time.parse_iso8601('2024-01-15T12:34:56.000Z') or { panic(err) } |
| 124 | assert format_amz_date(t) == '20240115T123456Z' |
| 125 | } |
| 126 | |
| 127 | fn test_signer_rejects_unsupported_method() { |
| 128 | creds := Credentials{ |
| 129 | access_key_id: 'KEY' |
| 130 | secret_access_key: 'secret' |
| 131 | region: 'us-east-1' |
| 132 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 133 | } |
| 134 | if _ := sign_request(creds, SignRequest{ method: 'PATCH', path: '/x', payload_hash: empty_sha256 }) { |
| 135 | assert false |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | fn test_signer_rejects_crlf_in_extra_headers() { |
| 140 | creds := Credentials{ |
| 141 | access_key_id: 'KEY' |
| 142 | secret_access_key: 'secret' |
| 143 | region: 'us-east-1' |
| 144 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 145 | } |
| 146 | bad := SignRequest{ |
| 147 | method: 'GET' |
| 148 | path: '/foo' |
| 149 | payload_hash: empty_sha256 |
| 150 | extra_headers: { |
| 151 | 'x-evil': 'value\r\nInjected: yes' |
| 152 | } |
| 153 | } |
| 154 | if _ := sign_request(creds, bad) { |
| 155 | assert false, 'expected CRLF rejection' |
| 156 | } else { |
| 157 | if err is S3Error { |
| 158 | assert err.code == 'InvalidHeader' || err.code == 'InvalidCredentials', 'unexpected code: ${err.code}' |
| 159 | } else { |
| 160 | assert false, 'wrong error type' |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | fn test_presign_rejects_crlf_in_extra_query() { |
| 166 | creds := Credentials{ |
| 167 | access_key_id: 'KEY' |
| 168 | secret_access_key: 'secret' |
| 169 | region: 'us-east-1' |
| 170 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 171 | } |
| 172 | bad := PresignRequest{ |
| 173 | method: 'GET' |
| 174 | path: '/foo' |
| 175 | extra_query: { |
| 176 | 'X-Evil': 'value\r\nInjected: yes' |
| 177 | } |
| 178 | } |
| 179 | if _ := presign_url(creds, bad) { |
| 180 | assert false, 'expected CRLF rejection' |
| 181 | } else { |
| 182 | assert err is S3Error |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | fn test_presign_rejects_invalid_expiry() { |
| 187 | creds := Credentials{ |
| 188 | access_key_id: 'KEY' |
| 189 | secret_access_key: 'secret' |
| 190 | region: 'us-east-1' |
| 191 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 192 | } |
| 193 | if _ := presign_url(creds, PresignRequest{ method: 'GET', path: '/x', expires_in: 0 }) { |
| 194 | assert false, 'expires_in=0 must be rejected' |
| 195 | } |
| 196 | if _ := presign_url(creds, PresignRequest{ |
| 197 | method: 'GET' |
| 198 | path: '/x' |
| 199 | expires_in: 700_000 |
| 200 | }) |
| 201 | { |
| 202 | assert false, 'expires_in > 7 days must be rejected' |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | fn test_presign_url_contains_required_query_params() { |
| 207 | creds := Credentials{ |
| 208 | access_key_id: 'KEY' |
| 209 | secret_access_key: 'secret' |
| 210 | region: 'us-east-1' |
| 211 | endpoint: 'examplebucket.s3.amazonaws.com' |
| 212 | } |
| 213 | url := presign_url(creds, PresignRequest{ |
| 214 | method: 'GET' |
| 215 | path: '/foo.txt' |
| 216 | expires_in: 300 |
| 217 | }) or { panic(err) } |
| 218 | assert url.contains('X-Amz-Algorithm=AWS4-HMAC-SHA256') |
| 219 | assert url.contains('X-Amz-Credential=') |
| 220 | assert url.contains('X-Amz-Date=') |
| 221 | assert url.contains('X-Amz-Expires=300') |
| 222 | assert url.contains('X-Amz-SignedHeaders=host') |
| 223 | assert url.contains('X-Amz-Signature=') |
| 224 | } |
| 225 | |