1use hmac::{Hmac, Mac};
8use sha2::{Digest, Sha256};
9
10type HmacSha256 = Hmac<Sha256>;
11
12const DEFAULT_SERVICE: &str = "secretsmanager";
13
14pub 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
27fn 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
35pub 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
53pub 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]; 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 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 let canonical_request =
94 format!("POST\n/\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}");
95
96 let scope = format!("{}/{}/{}/aws4_request", date, req.region, req.service);
98
99 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 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
127pub 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
147pub(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
170fn 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 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 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 assert_eq!(
218 sha256_hex(b""),
219 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
220 );
221 }
222
223 #[test]
224 fn sha256_hex_nonempty() {
225 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 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}