aws_sdk_cloudfront_url_signer/
sign.rs1use 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
14fn 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#[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 pub fn builder() -> SigningRequestBuilder {
50 SigningRequestBuilder::default()
51 }
52}
53
54#[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 pub fn resource_url(mut self, url: impl Into<String>) -> Self {
70 self.resource_url = Some(url.into());
71 self
72 }
73
74 pub fn resource_pattern(mut self, pattern: impl Into<String>) -> Self {
92 self.resource_pattern = Some(pattern.into());
93 self
94 }
95
96 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 pub fn private_key(mut self, key: PrivateKey) -> Self {
104 self.private_key = Some(key);
105 self
106 }
107
108 pub fn expires_at(mut self, time: DateTime) -> Self {
110 self.expiration = Some(Expiration::DateTime(time));
111 self
112 }
113
114 pub fn expires_in(mut self, duration: Duration) -> Self {
116 self.expiration = Some(Expiration::Duration(duration));
117 self
118 }
119
120 pub fn active_at(mut self, time: DateTime) -> Self {
122 self.active_date = Some(time);
123 self
124 }
125
126 pub fn ip_range(mut self, cidr: impl Into<String>) -> Self {
128 self.ip_range = Some(cidr.into());
129 self
130 }
131
132 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 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#[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 pub fn as_str(&self) -> &str {
200 self.url.as_str()
201 }
202
203 pub fn as_url(&self) -> &url::Url {
205 &self.url
206 }
207
208 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#[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 pub fn cookies(&self) -> &[(Cow<'static, str>, String)] {
267 &self.cookies
268 }
269
270 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 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
284impl SigningRequest {
286 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 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 assert!(url.contains("Policy="));
499 assert!(url.contains("Signature="));
500 assert!(url.contains("Key-Pair-Id=APKAEXAMPLE"));
501 assert!(!url.contains("Expires="));
502 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 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 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 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 let displayed = format!("{}", signed_url);
553 assert_eq!(displayed, url_str);
554
555 let as_ref_str: &str = signed_url.as_ref();
557 assert_eq!(as_ref_str, url_str);
558
559 let as_ref_url: &url::Url = signed_url.as_ref();
561 assert_eq!(as_ref_url, url_ref);
562
563 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 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 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}