1use core::net::IpAddr;
67
68use base64::Engine;
69use base64::engine::general_purpose::URL_SAFE_NO_PAD;
70use hmac::{Hmac, Mac};
71use sha2::Sha256;
72use subtle::ConstantTimeEq;
73
74use crate::error::{Error, Result};
75use crate::request::RequestContext;
76
77pub struct SignedUrlKeySet {
85 keys: Vec<(String, Vec<u8>)>,
86}
87
88impl SignedUrlKeySet {
89 pub const MIN_SECRET_LEN: usize = 32;
93
94 pub fn new(keys: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self> {
106 let keys: Vec<(String, Vec<u8>)> = keys.into_iter().collect();
107 for (kid, secret) in &keys {
108 if secret.len() < Self::MIN_SECRET_LEN {
109 return Err(Error::SignedUrlKeyTooShort {
110 kid: kid.clone(),
111 min: Self::MIN_SECRET_LEN,
112 actual: secret.len(),
113 });
114 }
115 }
116 Ok(SignedUrlKeySet { keys })
117 }
118
119 fn secret_for(&self, kid: &str) -> Option<&[u8]> {
120 self.keys
121 .iter()
122 .find(|(k, _)| k == kid)
123 .map(|(_, s)| s.as_slice())
124 }
125
126 pub fn sign(&self, kid: &str, path: &str, exp: u64, ip: Option<IpAddr>) -> Result<String> {
146 let secret = self
147 .secret_for(kid)
148 .ok_or_else(|| Error::UnknownSignedUrlKeyId(kid.to_string()))?;
149 let sig = URL_SAFE_NO_PAD.encode(hmac_sha256(secret, &canonical_string(path, exp, ip)));
150 let mut query = format!("exp={exp}&kid={kid}&sig={sig}");
151 if let Some(ip) = ip {
152 query.push_str(&format!("&ip={ip}"));
153 }
154 Ok(query)
155 }
156}
157
158impl core::fmt::Debug for SignedUrlKeySet {
162 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
163 f.debug_struct("SignedUrlKeySet")
164 .field(
165 "kids",
166 &self
167 .keys
168 .iter()
169 .map(|(k, _)| k.as_str())
170 .collect::<Vec<_>>(),
171 )
172 .finish()
173 }
174}
175
176fn canonical_string(path: &str, exp: u64, ip: Option<IpAddr>) -> String {
181 match ip {
182 Some(ip) => format!("{path}\n{exp}\n{ip}"),
183 None => format!("{path}\n{exp}\n"),
184 }
185}
186
187fn hmac_sha256(secret: &[u8], message: &str) -> Vec<u8> {
192 let mut mac =
193 <Hmac<Sha256> as Mac>::new_from_slice(secret).expect("HMAC accepts a key of any length");
194 mac.update(message.as_bytes());
195 mac.finalize().into_bytes().to_vec()
196}
197
198fn split_path_and_query(uri: &str) -> (&str, &str) {
201 match uri.split_once('?') {
202 Some((path, query)) => (path, query),
203 None => (uri, ""),
204 }
205}
206
207fn query_get<'q>(query: &'q str, key: &str) -> Option<&'q str> {
211 query
212 .split('&')
213 .filter_map(|pair| pair.split_once('='))
214 .find(|(k, _)| *k == key)
215 .map(|(_, v)| v)
216}
217
218fn current_unix_time() -> u64 {
223 std::time::SystemTime::now()
224 .duration_since(std::time::UNIX_EPOCH)
225 .map(|d| d.as_secs())
226 .unwrap_or(0)
227}
228
229pub(crate) fn verify(ctx: &RequestContext<'_>, keys: &SignedUrlKeySet) -> bool {
241 let (path, query) = split_path_and_query(ctx.uri);
242
243 let Some(exp) = query_get(query, "exp").and_then(|s| s.parse::<u64>().ok()) else {
244 return false;
245 };
246 let Some(kid) = query_get(query, "kid").filter(|s| !s.is_empty()) else {
247 return false;
248 };
249 let Some(sig) = query_get(query, "sig").filter(|s| !s.is_empty()) else {
250 return false;
251 };
252 let Some(secret) = keys.secret_for(kid) else {
253 return false;
254 };
255 let ip: Option<IpAddr> = match query_get(query, "ip") {
256 Some(raw) => match raw.parse::<IpAddr>() {
257 Ok(ip) => Some(ip),
258 Err(_) => return false,
259 },
260 None => None,
261 };
262 let Ok(decoded_sig) = URL_SAFE_NO_PAD.decode(sig) else {
263 return false;
264 };
265
266 let expected = hmac_sha256(secret, &canonical_string(path, exp, ip));
267 let sig_ok = bool::from(decoded_sig.as_slice().ct_eq(expected.as_slice()));
268 let not_expired = current_unix_time() <= exp;
269 let ip_ok = match ip {
270 Some(bound_ip) => ctx.peer_addr.map(|p| p.ip()) == Some(bound_ip),
271 None => true,
272 };
273
274 sig_ok && not_expired && ip_ok
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::{AuthResult, Verifier};
281
282 const SECRET_A: &[u8; 32] = b"01234567890123456789012345678901";
283 const SECRET_B: &[u8; 32] = b"abcdefghijabcdefghijabcdefghij01";
284
285 fn keyset() -> SignedUrlKeySet {
286 SignedUrlKeySet::new([
287 ("key-a".to_string(), SECRET_A.to_vec()),
288 ("key-b".to_string(), SECRET_B.to_vec()),
289 ])
290 .unwrap()
291 }
292
293 fn far_future() -> u64 {
294 current_unix_time() + 3600
295 }
296
297 fn ctx_for(uri: &str) -> RequestContext<'_> {
298 RequestContext::new("GET", uri)
299 }
300
301 #[test]
304 fn new_rejects_secret_shorter_than_min_len() {
305 let err = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 31])]).unwrap_err();
306 assert!(matches!(
307 err,
308 Error::SignedUrlKeyTooShort {
309 min: 32,
310 actual: 31,
311 ..
312 }
313 ));
314 }
315
316 #[test]
317 fn new_accepts_exactly_min_len_secret() {
318 SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
319 }
320
321 #[test]
324 fn valid_signature_and_unexpired_is_allowed() {
325 let keys = keyset();
326 let verifier = Verifier::signed_url(keyset());
327 let exp = far_future();
328 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
329 let uri = format!("/stream/media.m3u8?{query}");
330 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Ok);
331 }
332
333 #[test]
336 fn valid_signature_but_expired_is_denied() {
337 let keys = keyset();
338 let verifier = Verifier::signed_url(keyset());
339 let exp = current_unix_time().saturating_sub(60);
340 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
341 let uri = format!("/stream/media.m3u8?{query}");
342 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
343 }
344
345 #[test]
350 fn signature_minted_for_one_path_is_rejected_on_another_path() {
351 let keys = keyset();
352 let verifier = Verifier::signed_url(keyset());
353 let exp = far_future();
354 let query = keys
355 .sign("key-a", "/route-a/media.m3u8", exp, None)
356 .unwrap();
357 let uri = format!("/route-b/media.m3u8?{query}");
359 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
360 }
361
362 #[test]
365 fn extending_exp_without_resigning_is_denied() {
366 let keys = keyset();
367 let verifier = Verifier::signed_url(keyset());
368 let original_exp = far_future();
369 let query = keys
370 .sign("key-a", "/stream/media.m3u8", original_exp, None)
371 .unwrap();
372 let tampered = query.replacen(
373 &format!("exp={original_exp}"),
374 &format!("exp={}", original_exp + 1_000_000),
375 1,
376 );
377 assert_ne!(query, tampered, "test setup: exp must actually change");
378 let uri = format!("/stream/media.m3u8?{tampered}");
379 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
380 }
381
382 #[test]
385 fn unknown_kid_is_denied() {
386 let keys = keyset();
387 let verifier = Verifier::signed_url(keyset());
388 let exp = far_future();
389 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
390 let tampered = query.replacen("kid=key-a", "kid=nonexistent-key", 1);
391 let uri = format!("/stream/media.m3u8?{tampered}");
392 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
393 }
394
395 #[test]
398 fn ip_scoped_token_from_different_peer_is_denied() {
399 let keys = keyset();
400 let verifier = Verifier::signed_url(keyset());
401 let exp = far_future();
402 let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
403 let query = keys
404 .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
405 .unwrap();
406 let uri = format!("/stream/media.m3u8?{query}");
407 let other_peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
408 let ctx = ctx_for(&uri).with_peer_addr(other_peer);
409 assert_eq!(verifier.verify(&ctx), AuthResult::Unauthorized);
410 }
411
412 #[test]
413 fn ip_scoped_token_from_correct_peer_is_allowed() {
414 let keys = keyset();
415 let verifier = Verifier::signed_url(keyset());
416 let exp = far_future();
417 let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
418 let query = keys
419 .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
420 .unwrap();
421 let uri = format!("/stream/media.m3u8?{query}");
422 let same_ip_peer: std::net::SocketAddr = "203.0.113.7:54321".parse().unwrap();
424 let ctx = ctx_for(&uri).with_peer_addr(same_ip_peer);
425 assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
426 }
427
428 #[test]
429 fn unscoped_token_is_allowed_regardless_of_peer() {
430 let keys = keyset();
431 let verifier = Verifier::signed_url(keyset());
432 let exp = far_future();
433 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
434 let uri = format!("/stream/media.m3u8?{query}");
435 let peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
436 let ctx = ctx_for(&uri).with_peer_addr(peer);
437 assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
438 }
439
440 #[test]
443 fn missing_sig_is_denied() {
444 let verifier = Verifier::signed_url(keyset());
445 let uri = format!("/stream/media.m3u8?exp={}&kid=key-a", far_future());
446 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
447 }
448
449 #[test]
450 fn empty_sig_is_denied() {
451 let verifier = Verifier::signed_url(keyset());
452 let uri = format!("/stream/media.m3u8?exp={}&kid=key-a&sig=", far_future());
453 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
454 }
455
456 #[test]
457 fn malformed_base64_sig_is_denied_not_panicking() {
458 let verifier = Verifier::signed_url(keyset());
459 let uri = format!(
460 "/stream/media.m3u8?exp={}&kid=key-a&sig=not-valid-base64!!!",
461 far_future()
462 );
463 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
464 }
465
466 #[test]
467 fn missing_exp_is_denied() {
468 let verifier = Verifier::signed_url(keyset());
469 let uri = "/stream/media.m3u8?kid=key-a&sig=AAAA".to_string();
470 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
471 }
472
473 #[test]
474 fn unparseable_exp_is_denied() {
475 let verifier = Verifier::signed_url(keyset());
476 let uri = "/stream/media.m3u8?exp=not-a-number&kid=key-a&sig=AAAA".to_string();
477 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
478 }
479
480 #[test]
481 fn empty_query_string_is_denied_not_panicking() {
482 let verifier = Verifier::signed_url(keyset());
483 assert_eq!(
484 verifier.verify(&ctx_for("/stream/media.m3u8")),
485 AuthResult::Unauthorized
486 );
487 assert_eq!(
488 verifier.verify(&ctx_for("/stream/media.m3u8?")),
489 AuthResult::Unauthorized
490 );
491 }
492
493 #[test]
496 fn both_active_keys_are_accepted_until_one_is_retired() {
497 let keys = keyset();
498 let exp = far_future();
499 let query_a = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
500 let query_b = keys.sign("key-b", "/stream/media.m3u8", exp, None).unwrap();
501
502 let verifier = Verifier::signed_url(keyset());
503 assert_eq!(
504 verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
505 AuthResult::Ok,
506 "key-a must verify while both keys are active"
507 );
508 assert_eq!(
509 verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
510 AuthResult::Ok,
511 "key-b must verify while both keys are active"
512 );
513
514 let retired_keyset =
516 SignedUrlKeySet::new([("key-a".to_string(), SECRET_A.to_vec())]).unwrap();
517 let verifier_after_rotation = Verifier::signed_url(retired_keyset);
518 assert_eq!(
519 verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
520 AuthResult::Ok,
521 "key-a must still verify after key-b is retired"
522 );
523 assert_eq!(
524 verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
525 AuthResult::Unauthorized,
526 "a token signed by the retired key-b must now be rejected"
527 );
528 }
529
530 #[test]
533 fn sign_with_unknown_kid_is_a_structured_error() {
534 let keys = keyset();
535 let err = keys
536 .sign("no-such-key", "/stream/media.m3u8", far_future(), None)
537 .unwrap_err();
538 assert!(matches!(err, Error::UnknownSignedUrlKeyId(k) if k == "no-such-key"));
539 }
540
541 #[test]
544 fn split_path_and_query_handles_no_query() {
545 assert_eq!(split_path_and_query("/a/b"), ("/a/b", ""));
546 assert_eq!(split_path_and_query("/a/b?x=1"), ("/a/b", "x=1"));
547 }
548
549 #[test]
550 fn query_get_skips_malformed_pairs_without_panicking() {
551 assert_eq!(query_get("a=1&garbage&b=2", "b"), Some("2"));
552 assert_eq!(query_get("a=1&garbage&b=2", "garbage"), None);
553 assert_eq!(query_get("", "a"), None);
554 }
555
556 #[test]
557 fn debug_never_leaks_secret_bytes() {
558 let keys = keyset();
559 let debug = format!("{keys:?}");
560 assert!(debug.contains("key-a"), "kid should render: {debug}");
561 assert!(debug.contains("key-b"), "kid should render: {debug}");
562 assert!(
563 !debug.contains(std::str::from_utf8(SECRET_A).unwrap()),
564 "secret leaked: {debug}"
565 );
566 }
567}