Skip to main content

didbot_dns/route53/
sigv4.rs

1//! AWS Signature Version 4, the subset Route53 needs: no unsigned payload,
2//! and headers limited to `host`, `x-amz-date` and (when present)
3//! `x-amz-security-token`.
4//!
5//! See <https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html>
6//! for the algorithm this implements. Each step is a function of its own,
7//! and the tests check each against AWS's published answers.
8
9use super::{Hmac, Mac, Route53Credentials, Sha256};
10
11/// The region and service a signature is scoped to.
12#[derive(Clone, Copy)]
13pub(super) struct Scope {
14    region: &'static str,
15    service: &'static str,
16}
17
18/// Route53 is a global service; every request is signed against this fixed
19/// region regardless of where the caller runs.
20pub(super) const ROUTE53: Scope = Scope {
21    region: "us-east-1",
22    service: "route53",
23};
24
25impl Scope {
26    /// The credential scope of a signature made at `amz_date`.
27    fn at(self, amz_date: &str) -> String {
28        let date_stamp = &amz_date[..8];
29        format!("{date_stamp}/{}/{}/aws4_request", self.region, self.service)
30    }
31}
32
33/// The request [`sign`] signs.
34pub(super) struct Request<'a> {
35    pub(super) method: &'a str,
36    /// The value the request sends in its own `host` header.
37    /// [`host_header`] is what produces it.
38    pub(super) host: &'a str,
39    pub(super) path: &'a str,
40    pub(super) query: &'a [(String, String)],
41    pub(super) body: &'a str,
42}
43
44pub(super) struct Signed {
45    pub(super) amz_date: String,
46    pub(super) authorization: String,
47}
48
49pub(super) fn host_header(endpoint: &str) -> String {
50    endpoint
51        .trim_start_matches("https://")
52        .trim_start_matches("http://")
53        .trim_end_matches('/')
54        .to_string()
55}
56
57/// Signs `request` over the headers `HttpTransport` sends with it: `host`,
58/// `x-amz-date` and, for a temporary credential, `x-amz-security-token`.
59pub(super) fn sign(
60    credentials: &Route53Credentials,
61    scope: Scope,
62    request: &Request,
63    now: time::OffsetDateTime,
64) -> Signed {
65    let amz_date = format!(
66        "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
67        now.year(),
68        u8::from(now.month()),
69        now.day(),
70        now.hour(),
71        now.minute(),
72        now.second()
73    );
74    let mut headers = vec![("host", request.host), ("x-amz-date", amz_date.as_str())];
75    if let Some(token) = &credentials.session_token {
76        headers.push(("x-amz-security-token", token.as_str()));
77    }
78
79    let canonical_request = canonical_request(
80        request.method,
81        request.path,
82        request.query,
83        &headers,
84        request.body,
85    );
86    let string_to_sign = string_to_sign(scope, &amz_date, &canonical_request);
87    let signature = signature(
88        &credentials.secret_access_key,
89        scope,
90        &amz_date,
91        &string_to_sign,
92    );
93    let authorization = format!(
94        "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={signature}",
95        credentials.access_key_id,
96        scope.at(&amz_date),
97        signed_headers(&headers),
98    );
99
100    Signed {
101        amz_date,
102        authorization,
103    }
104}
105
106/// The canonical request. `headers` are the headers the signature covers,
107/// each name in lower case, in any order.
108fn canonical_request(
109    method: &str,
110    path: &str,
111    query: &[(String, String)],
112    headers: &[(&str, &str)],
113    body: &str,
114) -> String {
115    let mut sorted = headers.to_vec();
116    sorted.sort_unstable();
117    let canonical_headers: String = sorted
118        .iter()
119        .map(|(name, value)| format!("{name}:{value}\n"))
120        .collect();
121    format!(
122        "{method}\n{}\n{}\n{canonical_headers}\n{}\n{}",
123        encode_path(path),
124        canonical_query(query),
125        signed_headers(headers),
126        hex_sha256(body.as_bytes()),
127    )
128}
129
130/// The string to sign: the algorithm, the moment, the credential scope and
131/// the canonical request's hash.
132fn string_to_sign(scope: Scope, amz_date: &str, canonical_request: &str) -> String {
133    format!(
134        "AWS4-HMAC-SHA256\n{amz_date}\n{}\n{}",
135        scope.at(amz_date),
136        hex_sha256(canonical_request.as_bytes())
137    )
138}
139
140/// The signature: an HMAC of the string to sign, under a key derived from
141/// the secret, the date and the scope.
142fn signature(
143    secret_access_key: &str,
144    scope: Scope,
145    amz_date: &str,
146    string_to_sign: &str,
147) -> String {
148    let date_stamp = &amz_date[..8];
149    let k_date = hmac_bytes(
150        format!("AWS4{secret_access_key}").as_bytes(),
151        date_stamp.as_bytes(),
152    );
153    let k_region = hmac_bytes(&k_date, scope.region.as_bytes());
154    let k_service = hmac_bytes(&k_region, scope.service.as_bytes());
155    let k_signing = hmac_bytes(&k_service, b"aws4_request");
156    hex::encode(hmac_bytes(&k_signing, string_to_sign.as_bytes()))
157}
158
159/// The `SignedHeaders` list: every name in `headers`, sorted.
160fn signed_headers(headers: &[(&str, &str)]) -> String {
161    let mut names: Vec<&str> = headers.iter().map(|(name, _)| *name).collect();
162    names.sort_unstable();
163    names.join(";")
164}
165
166fn hmac_bytes(key: &[u8], data: &[u8]) -> Vec<u8> {
167    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("hmac accepts any key length");
168    mac.update(data);
169    mac.finalize().into_bytes().to_vec()
170}
171
172fn hex_sha256(data: &[u8]) -> String {
173    use sha2::Digest as _;
174    hex::encode(Sha256::digest(data))
175}
176
177/// AWS's URI-encoding rules: unreserved characters pass through
178/// unescaped, `/` is left alone in a path, everything else becomes
179/// uppercase-hex `%XX`.
180pub(super) fn uri_encode(input: &str, encode_slash: bool) -> String {
181    let mut out = String::new();
182    for byte in input.bytes() {
183        let c = byte as char;
184        let unreserved = c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~');
185        let literal_slash = c == '/' && !encode_slash;
186        if unreserved || literal_slash {
187            out.push(c);
188        } else {
189            out.push_str(&format!("%{byte:02X}"));
190        }
191    }
192    out
193}
194
195fn encode_path(path: &str) -> String {
196    path.split('/')
197        .map(|segment| uri_encode(segment, true))
198        .collect::<Vec<_>>()
199        .join("/")
200}
201
202pub(super) fn canonical_query(query: &[(String, String)]) -> String {
203    let mut pairs: Vec<(String, String)> = query
204        .iter()
205        .map(|(k, v)| (uri_encode(k, true), uri_encode(v, true)))
206        .collect();
207    pairs.sort();
208    pairs
209        .into_iter()
210        .map(|(k, v)| format!("{k}={v}"))
211        .collect::<Vec<_>>()
212        .join("&")
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn sigv4_canonical_query_is_sorted_and_encoded() {
221        let query = vec![
222            ("name".to_string(), "a b.example".to_string()),
223            ("type".to_string(), "TXT".to_string()),
224        ];
225        assert_eq!(canonical_query(&query), "name=a%20b.example&type=TXT");
226    }
227
228    #[test]
229    fn sigv4_uri_encode_leaves_unreserved_characters_alone() {
230        assert_eq!(uri_encode("abc-DEF_123.~", false), "abc-DEF_123.~");
231        assert_eq!(uri_encode("a/b", false), "a/b");
232        assert_eq!(uri_encode("a/b", true), "a%2Fb");
233        assert_eq!(uri_encode(" ", false), "%20");
234    }
235
236    // The known answers below come from AWS's signing test suite, the copy
237    // AWS's own SDKs check their signers against:
238    // https://github.com/awslabs/aws-c-auth/tree/v1.0.0/tests/aws-signing-test-suite/v4
239    // Each case copies its `header-canonical-request.txt`,
240    // `header-string-to-sign.txt` and `header-signature.txt`. Every case
241    // signs with the credential, scope and moment below.
242
243    const SUITE_SCOPE: Scope = Scope {
244        region: "us-east-1",
245        service: "service",
246    };
247    const SUITE_SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
248    const SUITE_HOST: &str = "example.amazonaws.com";
249    const SUITE_AMZ_DATE: &str = "20150830T123600Z";
250    const SUITE_MOMENT: time::OffsetDateTime = time::macros::datetime!(2015-08-30 12:36:00 UTC);
251
252    fn suite_credentials(session_token: Option<&str>) -> Route53Credentials {
253        Route53Credentials {
254            access_key_id: "AKIDEXAMPLE".to_string(),
255            secret_access_key: SUITE_SECRET.to_string(),
256            session_token: session_token.map(str::to_string),
257        }
258    }
259
260    /// A `GET /` of the suite's host, with no query and no body.
261    const SUITE_GET: Request<'static> = Request {
262        method: "GET",
263        host: SUITE_HOST,
264        path: "/",
265        query: &[],
266        body: "",
267    };
268
269    /// Checks one case step by step: the canonical request, the string to
270    /// sign made from ours, and the signature made from that.
271    fn check_steps(
272        method: &str,
273        headers: &[(&str, &str)],
274        body: &str,
275        expected_canonical_request: &str,
276        expected_string_to_sign: &str,
277        expected_signature: &str,
278    ) {
279        let canonical_request = canonical_request(method, "/", &[], headers, body);
280        assert_eq!(canonical_request, expected_canonical_request);
281        let string_to_sign = string_to_sign(SUITE_SCOPE, SUITE_AMZ_DATE, &canonical_request);
282        assert_eq!(string_to_sign, expected_string_to_sign);
283        let signature = signature(SUITE_SECRET, SUITE_SCOPE, SUITE_AMZ_DATE, &string_to_sign);
284        assert_eq!(signature, expected_signature);
285    }
286
287    /// `get-vanilla`: a `GET /` signed with a long-lived credential.
288    #[test]
289    fn signs_the_suites_get_vanilla() {
290        check_steps(
291            "GET",
292            &[("host", SUITE_HOST), ("x-amz-date", SUITE_AMZ_DATE)],
293            "",
294            concat!(
295                "GET\n",
296                "/\n",
297                "\n",
298                "host:example.amazonaws.com\n",
299                "x-amz-date:20150830T123600Z\n",
300                "\n",
301                "host;x-amz-date\n",
302                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
303            ),
304            concat!(
305                "AWS4-HMAC-SHA256\n",
306                "20150830T123600Z\n",
307                "20150830/us-east-1/service/aws4_request\n",
308                "bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63",
309            ),
310            "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31",
311        );
312
313        // `sign` picks the same headers and stamps the same moment.
314        let signed = sign(
315            &suite_credentials(None),
316            SUITE_SCOPE,
317            &SUITE_GET,
318            SUITE_MOMENT,
319        );
320        assert_eq!(signed.amz_date, SUITE_AMZ_DATE);
321        assert_eq!(
322            signed.authorization,
323            concat!(
324                "AWS4-HMAC-SHA256 ",
325                "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, ",
326                "SignedHeaders=host;x-amz-date, ",
327                "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31",
328            )
329        );
330    }
331
332    /// `get-vanilla-with-session-token`: the same request signed with a
333    /// temporary credential, the kind an instance profile hands out.
334    #[test]
335    fn signs_the_suites_get_vanilla_with_session_token() {
336        let token = "6e86291e8372ff2a2260956d9b8aae1d763fbf315fa00fa31553b73ebf194267";
337        check_steps(
338            "GET",
339            &[
340                ("host", SUITE_HOST),
341                ("x-amz-date", SUITE_AMZ_DATE),
342                ("x-amz-security-token", token),
343            ],
344            "",
345            concat!(
346                "GET\n",
347                "/\n",
348                "\n",
349                "host:example.amazonaws.com\n",
350                "x-amz-date:20150830T123600Z\n",
351                "x-amz-security-token:",
352                "6e86291e8372ff2a2260956d9b8aae1d763fbf315fa00fa31553b73ebf194267\n",
353                "\n",
354                "host;x-amz-date;x-amz-security-token\n",
355                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
356            ),
357            concat!(
358                "AWS4-HMAC-SHA256\n",
359                "20150830T123600Z\n",
360                "20150830/us-east-1/service/aws4_request\n",
361                "067b36aa60031588cea4a4cde1f21215227a047690c72247f1d70b32fbbfad2b",
362            ),
363            "07ec1639c89043aa0e3e2de82b96708f198cceab042d4a97044c66dd9f74e7f8",
364        );
365
366        let signed = sign(
367            &suite_credentials(Some(token)),
368            SUITE_SCOPE,
369            &SUITE_GET,
370            SUITE_MOMENT,
371        );
372        assert_eq!(
373            signed.authorization,
374            concat!(
375                "AWS4-HMAC-SHA256 ",
376                "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, ",
377                "SignedHeaders=host;x-amz-date;x-amz-security-token, ",
378                "Signature=07ec1639c89043aa0e3e2de82b96708f198cceab042d4a97044c66dd9f74e7f8",
379            )
380        );
381    }
382
383    /// `post-x-www-form-urlencoded`: a `POST /` with a body. The suite signs
384    /// more headers here than `sign` picks, so this case checks the steps
385    /// alone. The headers are given in the order the request carries them.
386    #[test]
387    fn signs_the_suites_post_with_a_body() {
388        check_steps(
389            "POST",
390            &[
391                ("content-type", "application/x-www-form-urlencoded"),
392                ("host", SUITE_HOST),
393                ("content-length", "13"),
394                ("x-amz-date", SUITE_AMZ_DATE),
395                (
396                    "x-amz-content-sha256",
397                    "9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e",
398                ),
399            ],
400            "Param1=value1",
401            concat!(
402                "POST\n",
403                "/\n",
404                "\n",
405                "content-length:13\n",
406                "content-type:application/x-www-form-urlencoded\n",
407                "host:example.amazonaws.com\n",
408                "x-amz-content-sha256:",
409                "9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e\n",
410                "x-amz-date:20150830T123600Z\n",
411                "\n",
412                "content-length;content-type;host;x-amz-content-sha256;x-amz-date\n",
413                "9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e",
414            ),
415            concat!(
416                "AWS4-HMAC-SHA256\n",
417                "20150830T123600Z\n",
418                "20150830/us-east-1/service/aws4_request\n",
419                "b1edd1d03544c25390e32085d55b57acc9a3961bb59415ff86c45c3d89d16cfb",
420            ),
421            "d3875051da38690788ef43de4db0d8f280229d82040bfac253562e56c3f20e0b",
422        );
423    }
424}