Skip to main content

rustack_auth/
presigned.rs

1//! Presigned URL verification for AWS Signature Version 4.
2//!
3//! Presigned URLs carry authentication information in query parameters rather
4//! than HTTP headers. The query parameters include:
5//!
6//! - `X-Amz-Algorithm` - Must be `AWS4-HMAC-SHA256`
7//! - `X-Amz-Credential` - `AKID/date/region/service/aws4_request`
8//! - `X-Amz-Date` - ISO 8601 basic format timestamp (`YYYYMMDDTHHMMSSZ`)
9//! - `X-Amz-Expires` - Validity duration in seconds
10//! - `X-Amz-SignedHeaders` - Semicolon-separated signed header names
11//! - `X-Amz-Signature` - The hex-encoded signature
12//!
13//! For presigned URLs, the payload hash is always `UNSIGNED-PAYLOAD`.
14
15use std::collections::HashMap;
16
17use chrono::{NaiveDateTime, Utc};
18use sha2::{Digest, Sha256};
19use subtle::ConstantTimeEq;
20use tracing::debug;
21
22use crate::{
23    canonical::{
24        build_canonical_headers, build_canonical_query_string, build_canonical_uri,
25        build_signed_headers_string,
26    },
27    credentials::CredentialProvider,
28    error::AuthError,
29    sigv4::{AuthResult, build_string_to_sign, compute_signature, derive_signing_key},
30};
31
32/// The payload hash value used for all presigned URL requests.
33const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
34
35/// Parsed components from presigned URL query parameters.
36#[derive(Debug, Clone)]
37pub struct ParsedPresignedParams {
38    /// The signing algorithm (must be `AWS4-HMAC-SHA256`).
39    pub algorithm: String,
40    /// The access key ID.
41    pub access_key_id: String,
42    /// The date component of the credential scope (YYYYMMDD).
43    pub date: String,
44    /// The AWS region from the credential scope.
45    pub region: String,
46    /// The AWS service from the credential scope.
47    pub service: String,
48    /// The ISO 8601 basic format timestamp.
49    pub timestamp: String,
50    /// The URL validity duration in seconds.
51    pub expires: u64,
52    /// The list of signed header names.
53    pub signed_headers: Vec<String>,
54    /// The hex-encoded signature.
55    pub signature: String,
56}
57
58/// Parse presigned URL query parameters into their components.
59///
60/// # Errors
61///
62/// Returns [`AuthError::MissingQueryParam`] if any required parameter is absent,
63/// [`AuthError::UnsupportedAlgorithm`] if the algorithm is not `AWS4-HMAC-SHA256`,
64/// or [`AuthError::InvalidCredential`] if the credential format is invalid.
65pub fn parse_presigned_params(query: &str) -> Result<ParsedPresignedParams, AuthError> {
66    let params: HashMap<String, String> = query
67        .split('&')
68        .filter(|s| !s.is_empty())
69        .filter_map(|param| {
70            let (key, value) = param.split_once('=')?;
71            Some((key.to_owned(), url_decode(value)))
72        })
73        .collect();
74
75    let algorithm = get_required_param(&params, "X-Amz-Algorithm")?;
76    if algorithm != "AWS4-HMAC-SHA256" {
77        return Err(AuthError::UnsupportedAlgorithm(algorithm));
78    }
79
80    let credential = get_required_param(&params, "X-Amz-Credential")?;
81    let timestamp = get_required_param(&params, "X-Amz-Date")?;
82    let expires_str = get_required_param(&params, "X-Amz-Expires")?;
83    let signed_headers_str = get_required_param(&params, "X-Amz-SignedHeaders")?;
84    let signature = get_required_param(&params, "X-Amz-Signature")?;
85
86    // Parse credential: AKID/date/region/service/aws4_request
87    let cred_parts: Vec<&str> = credential.splitn(5, '/').collect();
88    if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" {
89        return Err(AuthError::InvalidCredential);
90    }
91
92    let expires: u64 = expires_str
93        .parse()
94        .map_err(|_| AuthError::MissingQueryParam("X-Amz-Expires (invalid integer)".to_owned()))?;
95
96    let signed_headers: Vec<String> = signed_headers_str
97        .split(';')
98        .map(ToOwned::to_owned)
99        .collect();
100
101    Ok(ParsedPresignedParams {
102        algorithm,
103        access_key_id: cred_parts[0].to_owned(),
104        date: cred_parts[1].to_owned(),
105        region: cred_parts[2].to_owned(),
106        service: cred_parts[3].to_owned(),
107        timestamp,
108        expires,
109        signed_headers,
110        signature,
111    })
112}
113
114/// Verify a presigned URL request.
115///
116/// This function:
117/// 1. Parses the presigned URL query parameters
118/// 2. Checks whether the URL has expired
119/// 3. Resolves the secret key via the credential provider
120/// 4. Reconstructs the canonical request (excluding `X-Amz-Signature` from query)
121/// 5. Computes the expected signature
122/// 6. Compares signatures using constant-time comparison
123///
124/// # Errors
125///
126/// Returns an [`AuthError`] if:
127/// - Required query parameters are missing or malformed
128/// - The URL has expired
129/// - The access key is not found
130/// - Required signed headers are missing
131/// - The signature does not match
132pub fn verify_presigned(
133    parts: &http::request::Parts,
134    credential_provider: &dyn CredentialProvider,
135) -> Result<AuthResult, AuthError> {
136    let query = parts.uri.query().unwrap_or("");
137    let parsed = parse_presigned_params(query)?;
138
139    debug!(
140        access_key_id = %parsed.access_key_id,
141        date = %parsed.date,
142        region = %parsed.region,
143        service = %parsed.service,
144        expires = parsed.expires,
145        "Verifying presigned URL"
146    );
147
148    // Check expiration.
149    check_expiration(&parsed.timestamp, parsed.expires)?;
150
151    // Resolve the secret key.
152    let secret_key = credential_provider.get_secret_key(&parsed.access_key_id)?;
153
154    // Build the canonical request.
155    let method = parts.method.as_str();
156    let uri = parts.uri.path();
157    let canonical_uri = build_canonical_uri(uri);
158
159    // Build the canonical query string WITHOUT X-Amz-Signature.
160    let canonical_query = build_canonical_query_string_without_signature(query);
161
162    // Collect signed headers.
163    let signed_header_refs: Vec<&str> = parsed.signed_headers.iter().map(String::as_str).collect();
164    let header_pairs: Vec<(&str, &str)> =
165        collect_signed_headers_for_presigned(parts, &signed_header_refs)?;
166
167    let canonical_headers = build_canonical_headers(&header_pairs, &signed_header_refs);
168    let signed_headers_str = build_signed_headers_string(&signed_header_refs);
169
170    // For presigned URLs, the payload hash is always UNSIGNED-PAYLOAD.
171    #[rustfmt::skip]
172    let canonical_request = format!(
173        "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n\n{signed_headers_str}\n{UNSIGNED_PAYLOAD}"
174    );
175
176    debug!(canonical_request, "Built presigned canonical request");
177
178    // Hash the canonical request.
179    let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
180
181    // Build string to sign.
182    let credential_scope = format!(
183        "{}/{}/{}/aws4_request",
184        parsed.date, parsed.region, parsed.service
185    );
186    let string_to_sign =
187        build_string_to_sign(&parsed.timestamp, &credential_scope, &canonical_hash);
188
189    debug!(string_to_sign, "Built presigned string to sign");
190
191    // Derive signing key and compute signature.
192    let signing_key =
193        derive_signing_key(&secret_key, &parsed.date, &parsed.region, &parsed.service);
194    let expected_signature = compute_signature(&signing_key, &string_to_sign);
195
196    // Constant-time comparison.
197    let provided_bytes = parsed.signature.as_bytes();
198    let expected_bytes = expected_signature.as_bytes();
199
200    if provided_bytes.ct_eq(expected_bytes).into() {
201        debug!(access_key_id = %parsed.access_key_id, "Presigned URL verification succeeded");
202        Ok(AuthResult {
203            access_key_id: parsed.access_key_id,
204            region: parsed.region,
205            service: parsed.service,
206            signed_headers: parsed.signed_headers,
207        })
208    } else {
209        debug!(
210            expected = %expected_signature,
211            provided = %parsed.signature,
212            "Presigned URL signature mismatch"
213        );
214        Err(AuthError::SignatureDoesNotMatch)
215    }
216}
217
218/// Build the canonical query string excluding the `X-Amz-Signature` parameter.
219///
220/// The remaining parameters are sorted and re-encoded per the SigV4 spec.
221fn build_canonical_query_string_without_signature(query: &str) -> String {
222    let filtered: String = query
223        .split('&')
224        .filter(|param| !param.starts_with("X-Amz-Signature="))
225        .collect::<Vec<_>>()
226        .join("&");
227    build_canonical_query_string(&filtered)
228}
229
230/// Check whether the presigned URL has expired.
231fn check_expiration(timestamp: &str, expires: u64) -> Result<(), AuthError> {
232    let request_time = NaiveDateTime::parse_from_str(timestamp, "%Y%m%dT%H%M%SZ")
233        .map_err(|_| AuthError::MissingQueryParam("X-Amz-Date (invalid format)".to_owned()))?;
234
235    let expiry_time = request_time
236        + chrono::Duration::seconds(i64::try_from(expires).map_err(|_| AuthError::RequestExpired)?);
237
238    let now = Utc::now().naive_utc();
239    if now > expiry_time {
240        return Err(AuthError::RequestExpired);
241    }
242
243    Ok(())
244}
245
246/// Collect header values for the signed headers from the request.
247fn collect_signed_headers_for_presigned<'a>(
248    parts: &'a http::request::Parts,
249    signed_headers: &[&'a str],
250) -> Result<Vec<(&'a str, &'a str)>, AuthError> {
251    let mut result = Vec::with_capacity(signed_headers.len());
252
253    for &name in signed_headers {
254        let value = parts
255            .headers
256            .get(name)
257            .ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
258            .to_str()
259            .map_err(|_| AuthError::MissingHeader(name.to_owned()))?;
260        result.push((name, value));
261    }
262
263    Ok(result)
264}
265
266/// Perform basic percent-decoding of a URL-encoded string.
267fn url_decode(input: &str) -> String {
268    percent_encoding::percent_decode_str(input)
269        .decode_utf8_lossy()
270        .into_owned()
271}
272
273/// Extract a required query parameter, returning an error if missing.
274fn get_required_param(params: &HashMap<String, String>, name: &str) -> Result<String, AuthError> {
275    params
276        .get(name)
277        .cloned()
278        .ok_or_else(|| AuthError::MissingQueryParam(name.to_owned()))
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::credentials::StaticCredentialProvider;
285
286    const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
287    const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
288
289    fn test_credential_provider() -> StaticCredentialProvider {
290        StaticCredentialProvider::new(vec![(
291            TEST_ACCESS_KEY.to_owned(),
292            TEST_SECRET_KEY.to_owned(),
293        )])
294    }
295
296    #[test]
297    fn test_should_parse_presigned_params() {
298        #[rustfmt::skip]
299        let query = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404";
300
301        let parsed = parse_presigned_params(query).unwrap();
302        assert_eq!(parsed.algorithm, "AWS4-HMAC-SHA256");
303        assert_eq!(parsed.access_key_id, "AKIAIOSFODNN7EXAMPLE");
304        assert_eq!(parsed.date, "20130524");
305        assert_eq!(parsed.region, "us-east-1");
306        assert_eq!(parsed.service, "s3");
307        assert_eq!(parsed.timestamp, "20130524T000000Z");
308        assert_eq!(parsed.expires, 86400);
309        assert_eq!(parsed.signed_headers, vec!["host"]);
310        assert_eq!(
311            parsed.signature,
312            "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
313        );
314    }
315
316    #[test]
317    fn test_should_reject_missing_algorithm_param() {
318        let query = "X-Amz-Credential=AKID%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
319                     X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&\
320                     X-Amz-Signature=abc";
321
322        let result = parse_presigned_params(query);
323        assert!(matches!(result, Err(AuthError::MissingQueryParam(_))));
324    }
325
326    #[test]
327    fn test_should_reject_expired_presigned_url() {
328        // A timestamp far in the past with a short expiry.
329        let result = check_expiration("20130524T000000Z", 86400);
330        assert!(matches!(result, Err(AuthError::RequestExpired)));
331    }
332
333    #[test]
334    fn test_should_accept_non_expired_presigned_url() {
335        // A timestamp in the future (or now) with a large expiry.
336        let now = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
337        let result = check_expiration(&now, 86400);
338        assert!(result.is_ok());
339    }
340
341    #[test]
342    fn test_should_build_query_string_without_signature() {
343        let query = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKID%2F20130524%\
344                     2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&\
345                     X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123";
346
347        let result = build_canonical_query_string_without_signature(query);
348        assert!(!result.contains("X-Amz-Signature"));
349        assert!(result.contains("X-Amz-Algorithm"));
350        assert!(result.contains("X-Amz-Expires"));
351    }
352
353    #[test]
354    fn test_should_verify_presigned_url_matching_aws_example() {
355        // Use the AWS test vector for presigned URL verification.
356        // The AWS example uses date 20130524T000000Z which is in the past,
357        // so we must test the signature computation separately from expiration.
358
359        // Verify the signature computation is correct by manually
360        // replicating the presigned URL flow.
361        let signing_key = derive_signing_key(TEST_SECRET_KEY, "20130524", "us-east-1", "s3");
362
363        // Build canonical request for the presigned URL test vector.
364        let canonical_request = "GET\n/test.txt\nX-Amz-Algorithm=AWS4-HMAC-SHA256&\
365                                 X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%\
366                                 2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&\
367                                 X-Amz-Expires=86400&X-Amz-SignedHeaders=host\nhost:examplebucket.\
368                                 s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD";
369
370        let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
371        assert_eq!(
372            canonical_hash,
373            "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04"
374        );
375
376        let string_to_sign = build_string_to_sign(
377            "20130524T000000Z",
378            "20130524/us-east-1/s3/aws4_request",
379            &canonical_hash,
380        );
381
382        let signature = compute_signature(&signing_key, &string_to_sign);
383        assert_eq!(
384            signature,
385            "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
386        );
387    }
388
389    #[test]
390    fn test_should_verify_presigned_url_with_live_timestamp() {
391        // Test full presigned URL verification with a non-expired timestamp.
392        let provider = test_credential_provider();
393        let now = Utc::now();
394        let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
395        let date = now.format("%Y%m%d").to_string();
396
397        let credential = format!("{TEST_ACCESS_KEY}/{date}/us-east-1/s3/aws4_request");
398
399        // Build the canonical request components.
400        let canonical_uri = "/test.txt";
401        let query_without_sig = format!(
402            "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={}&X-Amz-Date={timestamp}&\
403             X-Amz-Expires=86400&X-Amz-SignedHeaders=host",
404            percent_encoding::utf8_percent_encode(&credential, percent_encoding::NON_ALPHANUMERIC)
405        );
406
407        let canonical_query = build_canonical_query_string(&query_without_sig);
408
409        #[rustfmt::skip]
410        let canonical_request = format!(
411            "GET\n{canonical_uri}\n{canonical_query}\nhost:examplebucket.s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
412        );
413
414        let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
415        let credential_scope = format!("{date}/us-east-1/s3/aws4_request");
416        let string_to_sign = build_string_to_sign(&timestamp, &credential_scope, &canonical_hash);
417
418        let signing_key = derive_signing_key(TEST_SECRET_KEY, &date, "us-east-1", "s3");
419        let signature = compute_signature(&signing_key, &string_to_sign);
420
421        // Build the full query with signature.
422        let full_query = format!("{query_without_sig}&X-Amz-Signature={signature}");
423        let uri = format!("http://examplebucket.s3.amazonaws.com/test.txt?{full_query}");
424
425        let (parts, _body) = http::Request::builder()
426            .method("GET")
427            .uri(&uri)
428            .header("host", "examplebucket.s3.amazonaws.com")
429            .body(())
430            .unwrap()
431            .into_parts();
432
433        let result = verify_presigned(&parts, &provider);
434        assert!(result.is_ok());
435
436        let auth_result = result.unwrap();
437        assert_eq!(auth_result.access_key_id, TEST_ACCESS_KEY);
438        assert_eq!(auth_result.region, "us-east-1");
439        assert_eq!(auth_result.service, "s3");
440    }
441}