Skip to main content

rustack_auth/
sigv2.rs

1//! AWS Signature Version 2 verification.
2//!
3//! SigV2 is an older signing mechanism that uses HMAC-SHA1. The `Authorization`
4//! header has the format:
5//!
6//! ```text
7//! AWS <AWSAccessKeyId>:<Signature>
8//! ```
9//!
10//! Where `Signature = Base64(HMAC-SHA1(SecretKey, StringToSign))` and:
11//!
12//! ```text
13//! StringToSign = HTTP-Verb + "\n" +
14//!                Content-MD5 + "\n" +
15//!                Content-Type + "\n" +
16//!                Date + "\n" +
17//!                CanonicalizedAmzHeaders +
18//!                CanonicalizedResource
19//! ```
20
21use std::collections::BTreeMap;
22
23use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
24use hmac::{Hmac, KeyInit, Mac};
25use sha1::Sha1;
26use subtle::ConstantTimeEq;
27use tracing::debug;
28
29use crate::{credentials::CredentialProvider, error::AuthError, sigv4::AuthResult};
30
31type HmacSha1 = Hmac<Sha1>;
32
33/// Check whether the `Authorization` header uses SigV2 format (`AWS AKID:sig`).
34#[must_use]
35pub fn is_sigv2(auth_header: &str) -> bool {
36    auth_header.starts_with("AWS ") && !auth_header.starts_with("AWS4-")
37}
38
39/// Verify an AWS SigV2-signed HTTP request.
40///
41/// # Errors
42///
43/// Returns an [`AuthError`] if the header is malformed, the access key is not
44/// found, or the signature does not match.
45pub fn verify_sigv2(
46    parts: &http::request::Parts,
47    credential_provider: &dyn CredentialProvider,
48) -> Result<AuthResult, AuthError> {
49    let auth_header = parts
50        .headers
51        .get(http::header::AUTHORIZATION)
52        .ok_or(AuthError::MissingAuthHeader)?
53        .to_str()
54        .map_err(|_| AuthError::InvalidAuthHeader)?;
55
56    let (access_key_id, provided_signature) = parse_sigv2_header(auth_header)?;
57
58    debug!(access_key_id = %access_key_id, "Verifying SigV2 signature");
59
60    let secret_key = credential_provider.get_secret_key(&access_key_id)?;
61
62    let string_to_sign = build_string_to_sign(parts);
63
64    debug!(string_to_sign = ?string_to_sign, "Built SigV2 string to sign");
65
66    let expected_signature = compute_sigv2_signature(&secret_key, &string_to_sign);
67
68    if provided_signature
69        .as_bytes()
70        .ct_eq(expected_signature.as_bytes())
71        .into()
72    {
73        debug!(access_key_id = %access_key_id, "SigV2 verification succeeded");
74        Ok(AuthResult {
75            access_key_id,
76            region: String::new(),
77            service: "s3".to_owned(),
78            signed_headers: Vec::new(),
79        })
80    } else {
81        debug!(
82            expected = %expected_signature,
83            provided = %provided_signature,
84            "SigV2 signature mismatch"
85        );
86        Err(AuthError::SignatureDoesNotMatch)
87    }
88}
89
90/// Parse a SigV2 `Authorization` header: `AWS AKID:Signature`.
91fn parse_sigv2_header(header: &str) -> Result<(String, String), AuthError> {
92    let rest = header
93        .strip_prefix("AWS ")
94        .ok_or(AuthError::InvalidAuthHeader)?;
95
96    let (access_key_id, signature) = rest.split_once(':').ok_or(AuthError::InvalidAuthHeader)?;
97
98    if access_key_id.is_empty() || signature.is_empty() {
99        return Err(AuthError::InvalidAuthHeader);
100    }
101
102    Ok((access_key_id.to_owned(), signature.to_owned()))
103}
104
105/// Build the SigV2 string to sign from the request parts.
106///
107/// ```text
108/// HTTP-Verb + "\n" +
109/// Content-MD5 + "\n" +
110/// Content-Type + "\n" +
111/// Date + "\n" +
112/// CanonicalizedAmzHeaders +
113/// CanonicalizedResource
114/// ```
115fn build_string_to_sign(parts: &http::request::Parts) -> String {
116    let method = parts.method.as_str();
117    let content_md5 = header_value(parts, "content-md5");
118    let content_type = header_value(parts, "content-type");
119
120    // Use x-amz-date if present, otherwise use Date header.
121    let date = if parts.headers.contains_key("x-amz-date") {
122        "" // When x-amz-date is present, Date field in StringToSign is empty.
123    } else {
124        &header_value(parts, "date")
125    };
126
127    let amz_headers = build_canonicalized_amz_headers(parts);
128    let resource = build_canonicalized_resource(parts);
129
130    format!("{method}\n{content_md5}\n{content_type}\n{date}\n{amz_headers}{resource}")
131}
132
133/// Build the CanonicalizedAmzHeaders string.
134///
135/// All x-amz-* headers are lowercased, sorted, and joined with newlines.
136/// Each header is formatted as `name:value\n`.
137fn build_canonicalized_amz_headers(parts: &http::request::Parts) -> String {
138    let mut amz_headers: BTreeMap<String, Vec<String>> = BTreeMap::new();
139
140    for (name, value) in &parts.headers {
141        let name_str = name.as_str();
142        if name_str.starts_with("x-amz-") {
143            let val = value.to_str().unwrap_or("").trim().to_owned();
144            amz_headers
145                .entry(name_str.to_owned())
146                .or_default()
147                .push(val);
148        }
149    }
150
151    let mut result = String::new();
152    for (name, values) in &amz_headers {
153        result.push_str(name);
154        result.push(':');
155        result.push_str(&values.join(","));
156        result.push('\n');
157    }
158
159    result
160}
161
162/// Build the CanonicalizedResource string.
163///
164/// This is the URI path plus any sub-resource query parameters (sorted).
165fn build_canonicalized_resource(parts: &http::request::Parts) -> String {
166    // S3 sub-resources that must be included in the canonical resource.
167    const SUB_RESOURCES: &[&str] = &[
168        "acl",
169        "cors",
170        "delete",
171        "lifecycle",
172        "location",
173        "logging",
174        "notification",
175        "partNumber",
176        "policy",
177        "requestPayment",
178        "response-cache-control",
179        "response-content-disposition",
180        "response-content-encoding",
181        "response-content-language",
182        "response-content-type",
183        "response-expires",
184        "restore",
185        "tagging",
186        "torrent",
187        "uploadId",
188        "uploads",
189        "versionId",
190        "versioning",
191        "versions",
192        "website",
193    ];
194
195    let path = parts.uri.path();
196    let query = parts.uri.query().unwrap_or("");
197    let mut sub_params: Vec<(String, Option<String>)> = Vec::new();
198
199    if !query.is_empty() {
200        for param in query.split('&') {
201            let (key, value) = param.split_once('=').map_or((param, None), |(k, v)| {
202                let decoded = percent_encoding::percent_decode_str(v)
203                    .decode_utf8_lossy()
204                    .into_owned();
205                // Treat empty values the same as absent values for sub-resources.
206                let value = if decoded.is_empty() {
207                    None
208                } else {
209                    Some(decoded)
210                };
211                (k, value)
212            });
213            if SUB_RESOURCES.contains(&key) {
214                sub_params.push((key.to_owned(), value));
215            }
216        }
217    }
218
219    sub_params.sort_by(|a, b| a.0.cmp(&b.0));
220
221    if sub_params.is_empty() {
222        path.to_owned()
223    } else {
224        let params_str: Vec<String> = sub_params
225            .iter()
226            .map(|(k, v)| match v {
227                Some(val) => format!("{k}={val}"),
228                None => k.clone(),
229            })
230            .collect();
231        format!("{path}?{}", params_str.join("&"))
232    }
233}
234
235/// Compute the SigV2 signature: Base64(HMAC-SHA1(secret, string_to_sign)).
236fn compute_sigv2_signature(secret_key: &str, string_to_sign: &str) -> String {
237    let mut mac =
238        HmacSha1::new_from_slice(secret_key.as_bytes()).expect("HMAC can accept any key length");
239    mac.update(string_to_sign.as_bytes());
240    let result = mac.finalize().into_bytes();
241    BASE64.encode(result)
242}
243
244/// Extract a header value as a string, returning empty string if missing.
245fn header_value(parts: &http::request::Parts, name: &str) -> String {
246    parts
247        .headers
248        .get(name)
249        .and_then(|v| v.to_str().ok())
250        .unwrap_or("")
251        .to_owned()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::credentials::StaticCredentialProvider;
258
259    const TEST_ACCESS_KEY: &str = "minioadmin";
260    const TEST_SECRET_KEY: &str = "minioadmin";
261
262    fn test_credential_provider() -> StaticCredentialProvider {
263        StaticCredentialProvider::new(vec![(
264            TEST_ACCESS_KEY.to_owned(),
265            TEST_SECRET_KEY.to_owned(),
266        )])
267    }
268
269    #[test]
270    fn test_should_detect_sigv2_header() {
271        assert!(is_sigv2("AWS AKID:signature"));
272        assert!(!is_sigv2("AWS4-HMAC-SHA256 Credential=..."));
273        assert!(!is_sigv2("Bearer token"));
274    }
275
276    #[test]
277    fn test_should_parse_sigv2_header() {
278        let (akid, sig) = parse_sigv2_header("AWS mykey:mysignature").unwrap();
279        assert_eq!(akid, "mykey");
280        assert_eq!(sig, "mysignature");
281    }
282
283    #[test]
284    fn test_should_reject_invalid_sigv2_header() {
285        assert!(parse_sigv2_header("AWS :sig").is_err());
286        assert!(parse_sigv2_header("AWS key:").is_err());
287        assert!(parse_sigv2_header("AWS noseparator").is_err());
288        assert!(parse_sigv2_header("NOTAWS key:sig").is_err());
289    }
290
291    #[test]
292    fn test_should_compute_sigv2_signature() {
293        let sig = compute_sigv2_signature("secret", "data");
294        assert!(!sig.is_empty());
295        // Base64(HMAC-SHA1) produces a deterministic output.
296        let sig2 = compute_sigv2_signature("secret", "data");
297        assert_eq!(sig, sig2);
298    }
299
300    #[test]
301    fn test_should_verify_sigv2_roundtrip() {
302        let provider = test_credential_provider();
303
304        // Build a simple GET request.
305        let date = "Sat, 28 Feb 2026 12:00:00 GMT";
306        let string_to_sign = format!("GET\n\n\n{date}\n/test-bucket/");
307        let signature = compute_sigv2_signature(TEST_SECRET_KEY, &string_to_sign);
308
309        let auth_header = format!("AWS {TEST_ACCESS_KEY}:{signature}");
310
311        let (parts, ()) = http::Request::builder()
312            .method("GET")
313            .uri("http://localhost:4566/test-bucket/")
314            .header("host", "localhost:4566")
315            .header("date", date)
316            .header(http::header::AUTHORIZATION, &auth_header)
317            .body(())
318            .unwrap()
319            .into_parts();
320
321        let result = verify_sigv2(&parts, &provider);
322        assert!(result.is_ok(), "verify_sigv2 failed: {result:?}");
323        assert_eq!(result.unwrap().access_key_id, TEST_ACCESS_KEY);
324    }
325}