aws_sdk_cloudfront_url_signer/
sign.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::error::{ErrorKind, SigningError};
7use crate::key::PrivateKey;
8use crate::policy::Policy;
9use aws_smithy_types::DateTime;
10use std::borrow::Cow;
11use std::fmt;
12use std::time::Duration;
13
14/// CloudFront-specific base64 encoding.
15/// Standard base64 with: `+` → `-`, `=` → `_`, `/` → `~`
16fn cloudfront_base64(data: &[u8]) -> String {
17    base64_simd::STANDARD
18        .encode_to_string(data)
19        .replace('+', "-")
20        .replace('=', "_")
21        .replace('/', "~")
22}
23
24const COOKIE_POLICY: &str = "CloudFront-Policy";
25const COOKIE_SIGNATURE: &str = "CloudFront-Signature";
26const COOKIE_KEY_PAIR_ID: &str = "CloudFront-Key-Pair-Id";
27const COOKIE_EXPIRES: &str = "CloudFront-Expires";
28
29#[derive(Debug, Clone)]
30enum Expiration {
31    DateTime(DateTime),
32    Duration(Duration),
33}
34
35/// Request to sign a CloudFront URL or generate signed cookies.
36#[derive(Debug, Clone)]
37pub struct SigningRequest {
38    pub(crate) resource_url: String,
39    pub(crate) resource_pattern: Option<String>,
40    pub(crate) key_pair_id: String,
41    pub(crate) private_key: PrivateKey,
42    pub(crate) expiration: DateTime,
43    pub(crate) active_date: Option<DateTime>,
44    pub(crate) ip_range: Option<String>,
45}
46
47impl SigningRequest {
48    /// Creates a new builder for constructing a signing request.
49    pub fn builder() -> SigningRequestBuilder {
50        SigningRequestBuilder::default()
51    }
52}
53
54/// Builder for [`SigningRequest`].
55#[derive(Default, Debug)]
56pub struct SigningRequestBuilder {
57    resource_url: Option<String>,
58    resource_pattern: Option<String>,
59    key_pair_id: Option<String>,
60    private_key: Option<PrivateKey>,
61    expiration: Option<Expiration>,
62    active_date: Option<DateTime>,
63    ip_range: Option<String>,
64    time_source: Option<aws_smithy_async::time::SharedTimeSource>,
65}
66
67impl SigningRequestBuilder {
68    /// Sets the CloudFront resource URL to sign.
69    pub fn resource_url(mut self, url: impl Into<String>) -> Self {
70        self.resource_url = Some(url.into());
71        self
72    }
73
74    /// Sets a wildcard pattern for the policy's `Resource` field.
75    ///
76    /// Use this when you want the signed URL to grant access to multiple resources
77    /// matching a pattern. If not set, `resource_url` is used in the policy.
78    ///
79    /// Wildcards:
80    /// - `*` matches zero or more characters
81    /// - `?` matches exactly one character
82    ///
83    /// # Example
84    /// ```ignore
85    /// // Sign a specific URL but grant access to all files under /videos/
86    /// SigningRequest::builder()
87    ///     .resource_url("https://d111111abcdef8.cloudfront.net/videos/intro.mp4")
88    ///     .resource_pattern("https://d111111abcdef8.cloudfront.net/videos/*")
89    ///     // ...
90    /// ```
91    pub fn resource_pattern(mut self, pattern: impl Into<String>) -> Self {
92        self.resource_pattern = Some(pattern.into());
93        self
94    }
95
96    /// Sets the CloudFront key pair ID.
97    pub fn key_pair_id(mut self, id: impl Into<String>) -> Self {
98        self.key_pair_id = Some(id.into());
99        self
100    }
101
102    /// Sets the private key for signing.
103    pub fn private_key(mut self, key: PrivateKey) -> Self {
104        self.private_key = Some(key);
105        self
106    }
107
108    /// Sets an absolute expiration time.
109    pub fn expires_at(mut self, time: DateTime) -> Self {
110        self.expiration = Some(Expiration::DateTime(time));
111        self
112    }
113
114    /// Sets a relative expiration time from now.
115    pub fn expires_in(mut self, duration: Duration) -> Self {
116        self.expiration = Some(Expiration::Duration(duration));
117        self
118    }
119
120    /// Sets an activation time (not-before date) for custom policy.
121    pub fn active_at(mut self, time: DateTime) -> Self {
122        self.active_date = Some(time);
123        self
124    }
125
126    /// Sets an IP range restriction (CIDR notation) for custom policy.
127    pub fn ip_range(mut self, cidr: impl Into<String>) -> Self {
128        self.ip_range = Some(cidr.into());
129        self
130    }
131
132    /// Builds the signing request.
133    pub fn build(self) -> Result<SigningRequest, SigningError> {
134        let resource_url = self
135            .resource_url
136            .ok_or_else(|| SigningError::invalid_input("resource_url is required"))?;
137
138        let key_pair_id = self
139            .key_pair_id
140            .ok_or_else(|| SigningError::invalid_input("key_pair_id is required"))?;
141
142        let private_key = self
143            .private_key
144            .ok_or_else(|| SigningError::invalid_input("private_key is required"))?;
145
146        let expiration = self.expiration.ok_or_else(|| {
147            SigningError::invalid_input("expiration is required (use expires_at or expires_in)")
148        })?;
149
150        let expiration = match expiration {
151            Expiration::DateTime(dt) => dt,
152            Expiration::Duration(dur) => {
153                let time_source = self.time_source.unwrap_or_default();
154                let now = DateTime::from(time_source.now());
155                DateTime::from_secs(now.secs() + dur.as_secs() as i64)
156            }
157        };
158
159        // Validate that activeDate is before expirationDate
160        if let Some(active) = self.active_date {
161            if active.secs() >= expiration.secs() {
162                return Err(SigningError::invalid_input(
163                    "active_at must be before expiration",
164                ));
165            }
166        }
167
168        Ok(SigningRequest {
169            resource_url,
170            resource_pattern: self.resource_pattern,
171            key_pair_id,
172            private_key,
173            expiration,
174            active_date: self.active_date,
175            ip_range: self.ip_range,
176        })
177    }
178}
179
180/// A signed CloudFront URL.
181#[derive(Debug, Clone)]
182pub struct SignedUrl {
183    url: url::Url,
184}
185
186impl SignedUrl {
187    pub(crate) fn new(url: String) -> Result<Self, SigningError> {
188        let url = url::Url::parse(&url).map_err(|e| {
189            SigningError::new(
190                ErrorKind::InvalidInput,
191                Some(Box::new(e)),
192                Some("failed to parse URL".into()),
193            )
194        })?;
195        Ok(Self { url })
196    }
197
198    /// Returns the complete signed URL as a string.
199    pub fn as_str(&self) -> &str {
200        self.url.as_str()
201    }
202
203    /// Returns a reference to the parsed URL.
204    pub fn as_url(&self) -> &url::Url {
205        &self.url
206    }
207
208    /// Consumes self and returns the parsed URL.
209    pub fn into_url(self) -> url::Url {
210        self.url
211    }
212}
213
214impl fmt::Display for SignedUrl {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        write!(f, "{}", self.url)
217    }
218}
219
220impl AsRef<str> for SignedUrl {
221    fn as_ref(&self) -> &str {
222        self.url.as_str()
223    }
224}
225
226impl AsRef<url::Url> for SignedUrl {
227    fn as_ref(&self) -> &url::Url {
228        &self.url
229    }
230}
231
232#[cfg(feature = "http-1x")]
233impl TryFrom<SignedUrl> for http::Request<()> {
234    type Error = http::Error;
235
236    fn try_from(signed_url: SignedUrl) -> Result<Self, Self::Error> {
237        http::Request::builder()
238            .uri(signed_url.url.as_str())
239            .body(())
240    }
241}
242
243#[cfg(feature = "http-1x")]
244impl TryFrom<&SignedUrl> for http::Request<()> {
245    type Error = http::Error;
246
247    fn try_from(signed_url: &SignedUrl) -> Result<Self, Self::Error> {
248        http::Request::builder()
249            .uri(signed_url.url.as_str())
250            .body(())
251    }
252}
253
254/// Signed cookies for CloudFront.
255#[derive(Debug, Clone)]
256pub struct SignedCookies {
257    cookies: Vec<(Cow<'static, str>, String)>,
258}
259
260impl SignedCookies {
261    pub(crate) fn new(cookies: Vec<(Cow<'static, str>, String)>) -> Self {
262        Self { cookies }
263    }
264
265    /// Returns all cookies as name-value pairs.
266    pub fn cookies(&self) -> &[(Cow<'static, str>, String)] {
267        &self.cookies
268    }
269
270    /// Gets a specific cookie value by name.
271    pub fn get(&self, name: &str) -> Option<&str> {
272        self.cookies
273            .iter()
274            .find(|(n, _)| n == name)
275            .map(|(_, v)| v.as_str())
276    }
277
278    /// Returns an iterator over cookies.
279    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
280        self.cookies.iter().map(|(n, v)| (n.as_ref(), v.as_str()))
281    }
282}
283
284// Internal signing implementation
285impl SigningRequest {
286    /// Returns true if this request should use a canned policy.
287    /// Canned policy is used only when there's no resource_pattern, active_date, or ip_range.
288    fn use_canned_policy(&self) -> bool {
289        self.resource_pattern.is_none() && self.active_date.is_none() && self.ip_range.is_none()
290    }
291
292    pub(crate) fn sign_url(&self) -> Result<SignedUrl, SigningError> {
293        let policy = self.build_policy()?;
294        let policy_json = policy.to_json();
295        let signature = self.private_key.sign(policy_json.as_bytes())?;
296        let signature_b64 = cloudfront_base64(&signature);
297
298        let separator = if self.resource_url.contains('?') {
299            "&"
300        } else {
301            "?"
302        };
303
304        let signed_url = if self.use_canned_policy() {
305            format!(
306                "{}{}Expires={}&Signature={}&Key-Pair-Id={}",
307                self.resource_url,
308                separator,
309                self.expiration.secs(),
310                signature_b64,
311                self.key_pair_id
312            )
313        } else {
314            let policy_b64 = policy.to_cloudfront_base64();
315            format!(
316                "{}{}Policy={}&Signature={}&Key-Pair-Id={}",
317                self.resource_url, separator, policy_b64, signature_b64, self.key_pair_id
318            )
319        };
320
321        SignedUrl::new(signed_url)
322    }
323
324    pub(crate) fn sign_cookies(&self) -> Result<SignedCookies, SigningError> {
325        let policy = self.build_policy()?;
326        let policy_json = policy.to_json();
327        let signature = self.private_key.sign(policy_json.as_bytes())?;
328        let signature_b64 = cloudfront_base64(&signature);
329
330        let cookies = if self.use_canned_policy() {
331            vec![
332                (
333                    Cow::Borrowed(COOKIE_EXPIRES),
334                    self.expiration.secs().to_string(),
335                ),
336                (Cow::Borrowed(COOKIE_SIGNATURE), signature_b64),
337                (Cow::Borrowed(COOKIE_KEY_PAIR_ID), self.key_pair_id.clone()),
338            ]
339        } else {
340            let policy_b64 = policy.to_cloudfront_base64();
341            vec![
342                (Cow::Borrowed(COOKIE_POLICY), policy_b64),
343                (Cow::Borrowed(COOKIE_SIGNATURE), signature_b64),
344                (Cow::Borrowed(COOKIE_KEY_PAIR_ID), self.key_pair_id.clone()),
345            ]
346        };
347
348        Ok(SignedCookies::new(cookies))
349    }
350
351    fn build_policy(&self) -> Result<Policy, SigningError> {
352        // Use resource_pattern for policy if set, otherwise use resource_url
353        let policy_resource = self.resource_pattern.as_ref().unwrap_or(&self.resource_url);
354
355        let mut builder = Policy::builder()
356            .resource(policy_resource)
357            .expires_at(self.expiration);
358
359        if let Some(active) = self.active_date {
360            builder = builder.starts_at(active);
361        }
362        if let Some(ref ip) = self.ip_range {
363            builder = builder.ip_range(ip);
364        }
365
366        builder.build()
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    const TEST_RSA_KEY: &[u8] = b"-----BEGIN RSA PRIVATE KEY-----
375MIIBPAIBAAJBANW8WjQksUoX/7nwOfRDNt1XQpLCueHoXSt91MASMOSAqpbzZvXO
376g2hW2gCFUIFUPCByMXPoeRe6iUZ5JtjepssCAwEAAQJBALR7ybwQY/lKTLKJrZab
377D4BXCCt/7ZFbMxnftsC+W7UHef4S4qFW8oOOLeYfmyGZK1h44rXf2AIp4PndKUID
3781zECIQD1suunYw5U22Pa0+2dFThp1VMXdVbPuf/5k3HT2/hSeQIhAN6yX0aT/N6G
379gb1XlBKw6GQvhcM0fXmP+bVXV+RtzFJjAiAP+2Z2yeu5u1egeV6gdCvqPnUcNobC
380FmA/NMcXt9xMSQIhALEMMJEFAInNeAIXSYKeoPNdkMPDzGnD3CueuCLEZCevAiEA
381j+KnJ7pJkTvOzFwE8RfNLli9jf6/OhyYaLL4et7Ng5k=
382-----END RSA PRIVATE KEY-----";
383
384    #[test]
385    fn test_sign_url_canned_policy() {
386        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
387        let request = SigningRequest::builder()
388            .resource_url("https://d111111abcdef8.cloudfront.net/image.jpg")
389            .key_pair_id("APKAEXAMPLE")
390            .private_key(key)
391            .expires_at(DateTime::from_secs(1767290400))
392            .build()
393            .unwrap();
394
395        let signed_url = request.sign_url().unwrap();
396        let url = signed_url.as_str();
397
398        assert!(url.contains("Expires=1767290400"));
399        assert!(url.contains("Signature="));
400        assert!(url.contains("Key-Pair-Id=APKAEXAMPLE"));
401        assert!(!url.contains("Policy="));
402    }
403
404    #[test]
405    fn test_sign_url_custom_policy() {
406        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
407        let request = SigningRequest::builder()
408            .resource_url("https://d111111abcdef8.cloudfront.net/*")
409            .key_pair_id("APKAEXAMPLE")
410            .private_key(key)
411            .expires_at(DateTime::from_secs(1767290400))
412            .active_at(DateTime::from_secs(1767200000))
413            .build()
414            .unwrap();
415
416        let signed_url = request.sign_url().unwrap();
417        let url = signed_url.as_str();
418
419        assert!(url.contains("Policy="));
420        assert!(url.contains("Signature="));
421        assert!(url.contains("Key-Pair-Id=APKAEXAMPLE"));
422        assert!(!url.contains("Expires="));
423    }
424
425    #[test]
426    fn test_sign_url_with_existing_params() {
427        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
428        let request = SigningRequest::builder()
429            .resource_url("https://d111111abcdef8.cloudfront.net/image.jpg?size=large")
430            .key_pair_id("APKAEXAMPLE")
431            .private_key(key)
432            .expires_at(DateTime::from_secs(1767290400))
433            .build()
434            .unwrap();
435
436        let signed_url = request.sign_url().unwrap();
437        let url = signed_url.as_str();
438
439        assert!(url.contains("size=large"));
440        assert!(url.contains("&Expires="));
441    }
442
443    #[test]
444    fn test_sign_cookies_canned_policy() {
445        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
446        let request = SigningRequest::builder()
447            .resource_url("https://d111111abcdef8.cloudfront.net/*")
448            .key_pair_id("APKAEXAMPLE")
449            .private_key(key)
450            .expires_at(DateTime::from_secs(1767290400))
451            .build()
452            .unwrap();
453
454        let cookies = request.sign_cookies().unwrap();
455
456        assert_eq!(cookies.get("CloudFront-Expires"), Some("1767290400"));
457        assert!(cookies.get("CloudFront-Signature").is_some());
458        assert_eq!(cookies.get("CloudFront-Key-Pair-Id"), Some("APKAEXAMPLE"));
459        assert!(cookies.get("CloudFront-Policy").is_none());
460    }
461
462    #[test]
463    fn test_sign_cookies_custom_policy() {
464        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
465        let request = SigningRequest::builder()
466            .resource_url("https://d111111abcdef8.cloudfront.net/*")
467            .key_pair_id("APKAEXAMPLE")
468            .private_key(key)
469            .expires_at(DateTime::from_secs(1767290400))
470            .ip_range("192.0.2.0/24")
471            .build()
472            .unwrap();
473
474        let cookies = request.sign_cookies().unwrap();
475
476        assert!(cookies.get("CloudFront-Policy").is_some());
477        assert!(cookies.get("CloudFront-Signature").is_some());
478        assert_eq!(cookies.get("CloudFront-Key-Pair-Id"), Some("APKAEXAMPLE"));
479        assert!(cookies.get("CloudFront-Expires").is_none());
480    }
481
482    #[test]
483    fn test_sign_url_with_resource_pattern() {
484        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
485        let request = SigningRequest::builder()
486            .resource_url("https://d111111abcdef8.cloudfront.net/videos/intro.mp4")
487            .resource_pattern("https://d111111abcdef8.cloudfront.net/videos/*")
488            .key_pair_id("APKAEXAMPLE")
489            .private_key(key)
490            .expires_at(DateTime::from_secs(1767290400))
491            .build()
492            .unwrap();
493
494        let signed_url = request.sign_url().unwrap();
495        let url = signed_url.as_str();
496
497        // Should use custom policy format (Policy param) because resource_pattern is set
498        assert!(url.contains("Policy="));
499        assert!(url.contains("Signature="));
500        assert!(url.contains("Key-Pair-Id=APKAEXAMPLE"));
501        assert!(!url.contains("Expires="));
502        // The actual URL should be the resource_url, not the pattern
503        assert!(url.starts_with("https://d111111abcdef8.cloudfront.net/videos/intro.mp4?"));
504    }
505
506    #[test]
507    fn test_sign_cookies_with_resource_pattern() {
508        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
509        let request = SigningRequest::builder()
510            .resource_url("https://d111111abcdef8.cloudfront.net/videos/*")
511            .resource_pattern("https://d111111abcdef8.cloudfront.net/videos/*")
512            .key_pair_id("APKAEXAMPLE")
513            .private_key(key)
514            .expires_at(DateTime::from_secs(1767290400))
515            .build()
516            .unwrap();
517
518        let cookies = request.sign_cookies().unwrap();
519
520        // Should use custom policy format because resource_pattern is set
521        assert!(cookies.get("CloudFront-Policy").is_some());
522        assert!(cookies.get("CloudFront-Signature").is_some());
523        assert_eq!(cookies.get("CloudFront-Key-Pair-Id"), Some("APKAEXAMPLE"));
524        assert!(cookies.get("CloudFront-Expires").is_none());
525    }
526
527    #[test]
528    fn test_signed_url_accessors() {
529        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
530        let request = SigningRequest::builder()
531            .resource_url("https://d111111abcdef8.cloudfront.net/image.jpg")
532            .key_pair_id("APKAEXAMPLE")
533            .private_key(key)
534            .expires_at(DateTime::from_secs(1767290400))
535            .build()
536            .unwrap();
537
538        let signed_url = request.sign_url().unwrap();
539
540        // Test as_str()
541        let url_str = signed_url.as_str();
542        assert!(url_str.starts_with("https://d111111abcdef8.cloudfront.net/image.jpg"));
543        assert!(url_str.contains("Expires="));
544
545        // Test as_url()
546        let url_ref = signed_url.as_url();
547        assert_eq!(url_ref.scheme(), "https");
548        assert_eq!(url_ref.host_str(), Some("d111111abcdef8.cloudfront.net"));
549        assert_eq!(url_ref.path(), "/image.jpg");
550
551        // Test Display
552        let displayed = format!("{}", signed_url);
553        assert_eq!(displayed, url_str);
554
555        // Test AsRef<str>
556        let as_ref_str: &str = signed_url.as_ref();
557        assert_eq!(as_ref_str, url_str);
558
559        // Test AsRef<url::Url>
560        let as_ref_url: &url::Url = signed_url.as_ref();
561        assert_eq!(as_ref_url, url_ref);
562
563        // Test into_url()
564        let url = signed_url.into_url();
565        assert_eq!(url.scheme(), "https");
566        assert_eq!(url.host_str(), Some("d111111abcdef8.cloudfront.net"));
567    }
568
569    #[cfg(feature = "http-1x")]
570    #[test]
571    fn test_signed_url_to_http_request() {
572        let key = PrivateKey::from_pem(TEST_RSA_KEY).unwrap();
573        let request = SigningRequest::builder()
574            .resource_url("https://d111111abcdef8.cloudfront.net/image.jpg")
575            .key_pair_id("APKAEXAMPLE")
576            .private_key(key)
577            .expires_at(DateTime::from_secs(1767290400))
578            .build()
579            .unwrap();
580
581        let signed_url = request.sign_url().unwrap();
582
583        // Test TryFrom<SignedUrl>
584        let http_req: http::Request<()> = signed_url.clone().try_into().unwrap();
585        assert_eq!(http_req.method(), http::Method::GET);
586        assert!(http_req.uri().to_string().contains("Expires="));
587
588        // Test TryFrom<&SignedUrl>
589        let http_req: http::Request<()> = (&signed_url).try_into().unwrap();
590        assert_eq!(http_req.method(), http::Method::GET);
591        assert!(http_req.uri().to_string().contains("Expires="));
592    }
593}