Skip to main content

rs_ali_sts/
sign.rs

1//! Signature computation for Alibaba Cloud STS API.
2
3use std::collections::BTreeMap;
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD as BASE64;
7use hmac::{Hmac, Mac};
8use sha1::Sha1;
9use sha2::Sha256;
10
11use crate::error::{Result, StsError};
12
13type HmacSha1 = Hmac<Sha1>;
14type HmacSha256 = Hmac<Sha256>;
15
16/// Signature version enum for Alibaba Cloud STS API.
17///
18/// - V1_0: HMAC-SHA1 signature (version 1.0) - default, compatible with Alibaba Cloud STS
19/// - V2_0: HMAC-SHA256 signature (version 2.0) - more secure but may not be supported
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SignatureVersion {
22    /// HMAC-SHA1 signature (version 1.0) - compatible with Alibaba Cloud STS API
23    #[default]
24    V1_0 = 1,
25    /// HMAC-SHA256 signature (version 2.0) - more secure but may not be supported by all regions
26    V2_0 = 2,
27}
28
29impl SignatureVersion {
30    /// Returns the signature method string for the API request.
31    pub fn as_method_str(&self) -> &'static str {
32        match self {
33            SignatureVersion::V1_0 => "HMAC-SHA1",
34            SignatureVersion::V2_0 => "HMAC-SHA256",
35        }
36    }
37
38    /// Returns the version string for the API request.
39    pub fn as_version_str(&self) -> &'static str {
40        match self {
41            SignatureVersion::V1_0 => "1.0",
42            SignatureVersion::V2_0 => "2.0",
43        }
44    }
45}
46
47/// Percent-encodes a string per Alibaba Cloud's rules (RFC 3986 variant).
48///
49/// Unreserved characters (A-Z, a-z, 0-9, '-', '.', '_', '~') are NOT encoded.
50/// All other characters are encoded as `%XX` (uppercase hex).
51/// Spaces become `%20` (NOT `+`).
52///
53/// This implementation uses a precomputed lookup table for hex digits
54/// and avoids temporary allocations for each encoded character.
55pub(crate) fn percent_encode(s: &str) -> String {
56    // Precompute hex digits for O(1) lookup
57    const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
58
59    // Calculate capacity: worst case is 3x (each byte becomes %XX)
60    let mut encoded = String::with_capacity(s.len() * 3);
61
62    let mut buf = [0u8; 3];
63    for byte in s.bytes() {
64        match byte {
65            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
66                // SAFETY: The byte being pushed is a valid ASCII character
67                // (unreserved character per RFC 3986), which is always valid UTF-8.
68                unsafe {
69                    encoded.as_mut_vec().push(byte);
70                }
71            }
72            _ => {
73                // Use a pre-allocated buffer to avoid temporary String allocation
74                buf[0] = b'%';
75                buf[1] = HEX_DIGITS[(byte >> 4) as usize];
76                buf[2] = HEX_DIGITS[(byte & 0x0F) as usize];
77                // SAFETY: buf contains '%' followed by two uppercase hex digits,
78                // all of which are valid ASCII and therefore valid UTF-8.
79                unsafe {
80                    encoded.push_str(std::str::from_utf8_unchecked(&buf));
81                }
82            }
83        }
84    }
85    encoded
86}
87
88/// Computes the Alibaba Cloud signature for a set of request parameters.
89///
90/// Steps:
91/// 1. Sort params by key (BTreeMap provides this).
92/// 2. Build canonicalized query string: `key1=val1&key2=val2&...` (percent-encoded).
93/// 3. Build StringToSign: `{method}&%2F&{percent_encode(canonical_query)}`.
94/// 4. HMAC-SHA256 with key = `{access_key_secret}&`.
95/// 5. Base64 encode the HMAC result.
96///
97/// # Arguments
98///
99/// * `params` - The request parameters sorted by key
100/// * `access_key_secret` - The Alibaba Cloud access key secret
101/// * `http_method` - The HTTP method (GET, POST, etc.)
102/// * `version` - The signature version to use (V2_0 recommended)
103pub(crate) fn sign_request(
104    params: &BTreeMap<String, String>,
105    access_key_secret: &str,
106    http_method: &str,
107    version: SignatureVersion,
108) -> Result<String> {
109    // Step 1-2: Build canonicalized query string
110    let canonical_query: String = params
111        .iter()
112        .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
113        .collect::<Vec<_>>()
114        .join("&");
115
116    // Step 3: Build StringToSign
117    let string_to_sign = format!(
118        "{}&{}&{}",
119        http_method,
120        percent_encode("/"),
121        percent_encode(&canonical_query)
122    );
123
124    // Step 4: HMAC based on version
125    let signing_key = format!("{}&", access_key_secret);
126
127    let signature = match version {
128        SignatureVersion::V1_0 => {
129            let mut mac = HmacSha1::new_from_slice(signing_key.as_bytes())
130                .map_err(|e| StsError::Signature(format!("HMAC-SHA1 key error: {}", e)))?;
131            mac.update(string_to_sign.as_bytes());
132            BASE64.encode(mac.finalize().into_bytes())
133        }
134        SignatureVersion::V2_0 => {
135            let mut mac = HmacSha256::new_from_slice(signing_key.as_bytes())
136                .map_err(|e| StsError::Signature(format!("HMAC-SHA256 key error: {}", e)))?;
137            mac.update(string_to_sign.as_bytes());
138            BASE64.encode(mac.finalize().into_bytes())
139        }
140    };
141
142    Ok(signature)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn percent_encode_unreserved_chars() {
151        assert_eq!(percent_encode("abcXYZ019"), "abcXYZ019");
152        assert_eq!(percent_encode("-._~"), "-._~");
153    }
154
155    #[test]
156    fn percent_encode_spaces() {
157        assert_eq!(percent_encode("hello world"), "hello%20world");
158    }
159
160    #[test]
161    fn percent_encode_special_chars() {
162        assert_eq!(percent_encode("/"), "%2F");
163        assert_eq!(percent_encode("="), "%3D");
164        assert_eq!(percent_encode("&"), "%26");
165        assert_eq!(percent_encode("+"), "%2B");
166        assert_eq!(percent_encode("*"), "%2A");
167    }
168
169    #[test]
170    fn percent_encode_chinese() {
171        let encoded = percent_encode("中文");
172        assert_eq!(encoded, "%E4%B8%AD%E6%96%87");
173    }
174
175    #[test]
176    fn percent_encode_all_unreserved() {
177        // Test all unreserved characters
178        let unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
179        assert_eq!(percent_encode(unreserved), unreserved);
180    }
181
182    #[test]
183    fn percent_encode_empty() {
184        assert_eq!(percent_encode(""), "");
185    }
186
187    #[test]
188    fn percent_encode_reserved_chars() {
189        assert_eq!(percent_encode("!"), "%21");
190        assert_eq!(percent_encode("\""), "%22");
191        assert_eq!(percent_encode("#"), "%23");
192        assert_eq!(percent_encode("$"), "%24");
193        assert_eq!(percent_encode("%"), "%25");
194        assert_eq!(percent_encode("&"), "%26");
195        assert_eq!(percent_encode("'"), "%27");
196        assert_eq!(percent_encode("("), "%28");
197        assert_eq!(percent_encode(")"), "%29");
198        assert_eq!(percent_encode("*"), "%2A");
199        assert_eq!(percent_encode("+"), "%2B");
200        assert_eq!(percent_encode(","), "%2C");
201        assert_eq!(percent_encode(":"), "%3A");
202        assert_eq!(percent_encode(";"), "%3B");
203        assert_eq!(percent_encode("<"), "%3C");
204        assert_eq!(percent_encode(">"), "%3E");
205        assert_eq!(percent_encode("?"), "%3F");
206        assert_eq!(percent_encode("@"), "%40");
207        assert_eq!(percent_encode("["), "%5B");
208        assert_eq!(percent_encode("]"), "%5D");
209    }
210
211    #[test]
212    fn percent_encode_mixed() {
213        assert_eq!(percent_encode("test@example.com"), "test%40example.com");
214        assert_eq!(percent_encode("a/b c"), "a%2Fb%20c");
215        assert_eq!(percent_encode("100%"), "100%25");
216    }
217
218    #[test]
219    fn percent_encode_uppercase_hex() {
220        // Ensure hex digits are uppercase
221        let encoded = percent_encode("\x00");
222        assert_eq!(encoded, "%00");
223        let encoded = percent_encode("\u{00FF}");
224        assert_eq!(encoded, "%C3%BF");
225    }
226
227    #[test]
228    fn percent_encode_byte_0x7f() {
229        // DEL character (0x7F)
230        assert_eq!(percent_encode("\x7F"), "%7F");
231    }
232
233    #[test]
234    fn percent_encode_performance_no_realloc() {
235        // Test that we don't reallocate for typical inputs
236        let input = "Action=AssumeRole&Version=2015-04-01";
237        let encoded = percent_encode(input);
238        // Verify encoding correctness
239        assert!(encoded.contains("Action"));
240        assert!(encoded.contains("%3D")); // =
241        assert!(encoded.contains("%26")); // &
242    }
243
244    #[test]
245    fn percent_encode_multibyte_sequences() {
246        // Japanese characters (テスト)
247        assert_eq!(percent_encode("テスト"), "%E3%83%86%E3%82%B9%E3%83%88");
248        // Arabic (السلام)
249        assert_eq!(
250            percent_encode("السلام"),
251            "%D8%A7%D9%84%D8%B3%D9%84%D8%A7%D9%85"
252        );
253    }
254
255    #[test]
256    fn sign_request_sha256_deterministic() {
257        let mut params = BTreeMap::new();
258        params.insert("Action".to_string(), "AssumeRole".to_string());
259        params.insert("Format".to_string(), "JSON".to_string());
260        params.insert("Version".to_string(), "2015-04-01".to_string());
261        params.insert("AccessKeyId".to_string(), "testid".to_string());
262        params.insert("SignatureMethod".to_string(), "HMAC-SHA256".to_string());
263        params.insert("SignatureVersion".to_string(), "2.0".to_string());
264        params.insert("SignatureNonce".to_string(), "fixed-nonce".to_string());
265        params.insert("Timestamp".to_string(), "2024-01-01T00:00:00Z".to_string());
266        params.insert(
267            "RoleArn".to_string(),
268            "acs:ram::123456:role/test".to_string(),
269        );
270        params.insert("RoleSessionName".to_string(), "session".to_string());
271
272        let sig1 = sign_request(&params, "testsecret", "POST", SignatureVersion::V2_0).unwrap();
273        let sig2 = sign_request(&params, "testsecret", "POST", SignatureVersion::V2_0).unwrap();
274        assert_eq!(sig1, sig2, "SHA-256 signature must be deterministic");
275        assert!(!sig1.is_empty());
276    }
277
278    #[test]
279    fn sign_request_different_secrets_differ() {
280        let mut params = BTreeMap::new();
281        params.insert("Action".to_string(), "GetCallerIdentity".to_string());
282        params.insert("Format".to_string(), "JSON".to_string());
283
284        let sig1 = sign_request(&params, "secret1", "POST", SignatureVersion::V2_0).unwrap();
285        let sig2 = sign_request(&params, "secret2", "POST", SignatureVersion::V2_0).unwrap();
286        assert_ne!(sig1, sig2);
287    }
288
289    #[test]
290    fn sign_request_different_methods_differ() {
291        let mut params = BTreeMap::new();
292        params.insert("Action".to_string(), "GetCallerIdentity".to_string());
293
294        let sig_post = sign_request(&params, "secret", "POST", SignatureVersion::V2_0).unwrap();
295        let sig_get = sign_request(&params, "secret", "GET", SignatureVersion::V2_0).unwrap();
296        assert_ne!(sig_post, sig_get);
297    }
298
299    #[test]
300    fn sign_request_is_base64() {
301        let mut params = BTreeMap::new();
302        params.insert("Action".to_string(), "Test".to_string());
303
304        let sig = sign_request(&params, "key", "POST", SignatureVersion::V2_0).unwrap();
305        assert!(BASE64.decode(&sig).is_ok());
306    }
307
308    #[test]
309    fn signature_version_default() {
310        assert_eq!(SignatureVersion::default(), SignatureVersion::V1_0);
311    }
312
313    #[test]
314    fn signature_version_strings() {
315        assert_eq!(SignatureVersion::V1_0.as_method_str(), "HMAC-SHA1");
316        assert_eq!(SignatureVersion::V1_0.as_version_str(), "1.0");
317        assert_eq!(SignatureVersion::V2_0.as_method_str(), "HMAC-SHA256");
318        assert_eq!(SignatureVersion::V2_0.as_version_str(), "2.0");
319    }
320
321    #[test]
322    fn sign_request_sha1_deterministic() {
323        let mut params = BTreeMap::new();
324        params.insert("Action".to_string(), "AssumeRole".to_string());
325        params.insert("Format".to_string(), "JSON".to_string());
326        params.insert("Version".to_string(), "2015-04-01".to_string());
327        params.insert("AccessKeyId".to_string(), "testid".to_string());
328        params.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
329        params.insert("SignatureVersion".to_string(), "1.0".to_string());
330        params.insert("SignatureNonce".to_string(), "fixed-nonce".to_string());
331        params.insert("Timestamp".to_string(), "2024-01-01T00:00:00Z".to_string());
332        params.insert(
333            "RoleArn".to_string(),
334            "acs:ram::123456:role/test".to_string(),
335        );
336        params.insert("RoleSessionName".to_string(), "session".to_string());
337
338        let sig1 = sign_request(&params, "testsecret", "POST", SignatureVersion::V1_0).unwrap();
339        let sig2 = sign_request(&params, "testsecret", "POST", SignatureVersion::V1_0).unwrap();
340        assert_eq!(sig1, sig2, "SHA-1 signature must be deterministic");
341        assert!(!sig1.is_empty());
342    }
343}