Skip to main content

tsafe_aws/
sigv4.rs

1//! AWS Signature Version 4 signing for AWS JSON-service HTTP requests.
2//!
3//! Only the POST-with-JSON-body case is implemented (the AWS integrations in
4//! this crate share this shape). SigV4 reference:
5//! <https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>
6
7use hmac::{Hmac, Mac};
8use sha2::{Digest, Sha256};
9
10type HmacSha256 = Hmac<Sha256>;
11
12const DEFAULT_SERVICE: &str = "secretsmanager";
13
14/// Hex-encode the SHA-256 digest of `data`.
15pub fn sha256_hex(data: &[u8]) -> String {
16    let mut h = Sha256::new();
17    h.update(data);
18    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
19}
20
21fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
22    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
23    mac.update(data);
24    mac.finalize().into_bytes().to_vec()
25}
26
27/// Derive the SigV4 signing key from the secret access key.
28fn derive_signing_key(secret_key: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
29    let k_date = hmac_sha256(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
30    let k_region = hmac_sha256(&k_date, region.as_bytes());
31    let k_service = hmac_sha256(&k_region, service.as_bytes());
32    hmac_sha256(&k_service, b"aws4_request")
33}
34
35/// Output of the signing step — the HTTP headers to include in the request.
36pub struct SigningOutput {
37    pub authorization: String,
38    pub x_amz_date: String,
39    pub x_amz_security_token: Option<String>,
40}
41
42struct SigningRequest<'a> {
43    service: &'a str,
44    region: &'a str,
45    target: &'a str,
46    body: &'a str,
47    access_key_id: &'a str,
48    secret_access_key: &'a str,
49    session_token: Option<&'a str>,
50    datetime: &'a str,
51}
52
53/// Sign a POST request to AWS Secrets Manager at a given `datetime`
54/// (format: `YYYYMMDDTHHMMSSZ`).  The `datetime` parameter is taken as an
55/// argument so tests can supply a fixed value.
56pub fn sign_at(
57    region: &str,
58    target: &str,
59    body: &str,
60    access_key_id: &str,
61    secret_access_key: &str,
62    session_token: Option<&str>,
63    datetime: &str,
64) -> SigningOutput {
65    sign_request(SigningRequest {
66        service: DEFAULT_SERVICE,
67        region,
68        target,
69        body,
70        access_key_id,
71        secret_access_key,
72        session_token,
73        datetime,
74    })
75}
76
77fn sign_request(req: SigningRequest<'_>) -> SigningOutput {
78    let date = &req.datetime[..8]; // first 8 chars are YYYYMMDD
79    let host = format!("{}.{}.amazonaws.com", req.service, req.region);
80    let content_type = "application/x-amz-json-1.1";
81    let payload_hash = sha256_hex(req.body.as_bytes());
82
83    // Build canonical headers and signed-headers list (both sorted alphabetically).
84    let (canonical_headers, signed_headers) = build_canonical_headers(
85        content_type,
86        &host,
87        req.datetime,
88        req.target,
89        req.session_token,
90    );
91
92    // Canonical request (POST + / + empty query string + headers + payload hash)
93    let canonical_request =
94        format!("POST\n/\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}");
95
96    // Credential scope
97    let scope = format!("{}/{}/{}/aws4_request", date, req.region, req.service);
98
99    // String to sign
100    let string_to_sign = format!(
101        "AWS4-HMAC-SHA256\n{}\n{}\n{}",
102        req.datetime,
103        scope,
104        sha256_hex(canonical_request.as_bytes())
105    );
106
107    // Sign
108    let signing_key = derive_signing_key(req.secret_access_key, date, req.region, req.service);
109    let signature: String = hmac_sha256(&signing_key, string_to_sign.as_bytes())
110        .iter()
111        .map(|b| format!("{b:02x}"))
112        .collect();
113
114    let authorization = format!(
115        "AWS4-HMAC-SHA256 Credential={}/{},\
116         SignedHeaders={},Signature={}",
117        req.access_key_id, scope, signed_headers, signature
118    );
119
120    SigningOutput {
121        authorization,
122        x_amz_date: req.datetime.to_string(),
123        x_amz_security_token: req.session_token.map(|s| s.to_string()),
124    }
125}
126
127/// Sign a POST request using the current UTC time.
128pub fn sign(
129    region: &str,
130    target: &str,
131    body: &str,
132    access_key_id: &str,
133    secret_access_key: &str,
134    session_token: Option<&str>,
135) -> SigningOutput {
136    sign_for_service(
137        DEFAULT_SERVICE,
138        region,
139        target,
140        body,
141        access_key_id,
142        secret_access_key,
143        session_token,
144    )
145}
146
147/// Sign a POST request for an AWS JSON service using the current UTC time.
148pub(crate) fn sign_for_service(
149    service: &str,
150    region: &str,
151    target: &str,
152    body: &str,
153    access_key_id: &str,
154    secret_access_key: &str,
155    session_token: Option<&str>,
156) -> SigningOutput {
157    let datetime = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
158    sign_request(SigningRequest {
159        service,
160        region,
161        target,
162        body,
163        access_key_id,
164        secret_access_key,
165        session_token,
166        datetime: &datetime,
167    })
168}
169
170/// Build (canonical_headers_block, signed_headers_string).
171/// Headers are sorted alphabetically by name as required by SigV4.
172///
173/// Without session token:  content-type, host, x-amz-date, x-amz-target
174/// With session token:     content-type, host, x-amz-date, x-amz-security-token, x-amz-target
175fn build_canonical_headers(
176    content_type: &str,
177    host: &str,
178    datetime: &str,
179    target: &str,
180    session_token: Option<&str>,
181) -> (String, String) {
182    if let Some(token) = session_token {
183        // x-amz-security-token sorts before x-amz-target ('s' < 't')
184        let canonical = format!(
185            "content-type:{content_type}\nhost:{host}\n\
186             x-amz-date:{datetime}\nx-amz-security-token:{token}\nx-amz-target:{target}\n"
187        );
188        let signed = "content-type;host;x-amz-date;x-amz-security-token;x-amz-target".to_string();
189        (canonical, signed)
190    } else {
191        let canonical = format!(
192            "content-type:{content_type}\nhost:{host}\n\
193             x-amz-date:{datetime}\nx-amz-target:{target}\n"
194        );
195        let signed = "content-type;host;x-amz-date;x-amz-target".to_string();
196        (canonical, signed)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    // Known-good SigV4 test vector derived from the AWS documentation.
205    // We verify our implementation produces the expected canonical request hash
206    // and a structurally valid Authorization header.
207    const FIXED_DATETIME: &str = "20150830T123600Z";
208    const REGION: &str = "us-east-1";
209    const TARGET: &str = "secretsmanager.ListSecrets";
210    const BODY: &str = r#"{"MaxResults":100}"#;
211    const KEY_ID: &str = "AKIDEXAMPLE";
212    const SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
213
214    #[test]
215    fn sha256_hex_known_value() {
216        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
217        assert_eq!(
218            sha256_hex(b""),
219            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
220        );
221    }
222
223    #[test]
224    fn sha256_hex_nonempty() {
225        // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2ec73b00361bbef0469348423f656b7c8dba (truncated for readability)
226        let h = sha256_hex(b"abc");
227        assert!(h.starts_with("ba7816bf"));
228        assert_eq!(h.len(), 64);
229    }
230
231    #[test]
232    fn sign_at_produces_valid_authorization_header() {
233        let out = sign_at(REGION, TARGET, BODY, KEY_ID, SECRET, None, FIXED_DATETIME);
234        assert!(out.authorization.starts_with("AWS4-HMAC-SHA256 "));
235        assert!(out
236            .authorization
237            .contains("Credential=AKIDEXAMPLE/20150830/"));
238        assert!(out
239            .authorization
240            .contains("SignedHeaders=content-type;host;x-amz-date;x-amz-target"));
241        assert!(out.authorization.contains("Signature="));
242        assert_eq!(out.x_amz_date, FIXED_DATETIME);
243        assert!(out.x_amz_security_token.is_none());
244    }
245
246    #[test]
247    fn sign_at_with_session_token_includes_security_token_header() {
248        let out = sign_at(
249            REGION,
250            TARGET,
251            BODY,
252            KEY_ID,
253            SECRET,
254            Some("session-token-abc"),
255            FIXED_DATETIME,
256        );
257        assert!(out.authorization.contains("x-amz-security-token"));
258        assert_eq!(
259            out.x_amz_security_token.as_deref(),
260            Some("session-token-abc")
261        );
262    }
263
264    #[test]
265    fn sign_at_deterministic_for_same_inputs() {
266        let a = sign_at(REGION, TARGET, BODY, KEY_ID, SECRET, None, FIXED_DATETIME);
267        let b = sign_at(REGION, TARGET, BODY, KEY_ID, SECRET, None, FIXED_DATETIME);
268        assert_eq!(a.authorization, b.authorization);
269    }
270
271    #[test]
272    fn sign_at_different_body_produces_different_signature() {
273        let a = sign_at(
274            REGION,
275            TARGET,
276            r#"{"MaxResults":100}"#,
277            KEY_ID,
278            SECRET,
279            None,
280            FIXED_DATETIME,
281        );
282        let b = sign_at(
283            REGION,
284            TARGET,
285            r#"{"MaxResults":50}"#,
286            KEY_ID,
287            SECRET,
288            None,
289            FIXED_DATETIME,
290        );
291        assert_ne!(a.authorization, b.authorization);
292    }
293
294    #[test]
295    fn sign_at_different_key_produces_different_signature() {
296        let a = sign_at(REGION, TARGET, BODY, KEY_ID, SECRET, None, FIXED_DATETIME);
297        let b = sign_at(
298            REGION,
299            TARGET,
300            BODY,
301            KEY_ID,
302            "different-secret",
303            None,
304            FIXED_DATETIME,
305        );
306        assert_ne!(a.authorization, b.authorization);
307    }
308
309    #[test]
310    fn canonical_headers_without_token_sorted_correctly() {
311        let (canonical, signed) = build_canonical_headers(
312            "application/x-amz-json-1.1",
313            "secretsmanager.us-east-1.amazonaws.com",
314            "20150830T123600Z",
315            "secretsmanager.ListSecrets",
316            None,
317        );
318        assert!(canonical.starts_with("content-type:"));
319        assert!(canonical.contains("\nhost:"));
320        assert!(canonical.contains("\nx-amz-date:"));
321        assert!(canonical.contains("\nx-amz-target:"));
322        assert!(!canonical.contains("x-amz-security-token"));
323        assert_eq!(signed, "content-type;host;x-amz-date;x-amz-target");
324    }
325
326    #[test]
327    fn canonical_headers_with_token_sorted_correctly() {
328        let (canonical, signed) = build_canonical_headers(
329            "application/x-amz-json-1.1",
330            "secretsmanager.us-east-1.amazonaws.com",
331            "20150830T123600Z",
332            "secretsmanager.ListSecrets",
333            Some("tok"),
334        );
335        // x-amz-security-token must appear before x-amz-target in the block
336        let st_pos = canonical.find("x-amz-security-token").unwrap();
337        let target_pos = canonical.find("x-amz-target").unwrap();
338        assert!(
339            st_pos < target_pos,
340            "x-amz-security-token must sort before x-amz-target"
341        );
342        assert_eq!(
343            signed,
344            "content-type;host;x-amz-date;x-amz-security-token;x-amz-target"
345        );
346    }
347}