1use core::net::IpAddr;
77use std::borrow::Cow;
78
79use base64::Engine;
80use base64::engine::general_purpose::URL_SAFE_NO_PAD;
81use hmac::{Hmac, KeyInit, Mac};
82use sha2::Sha256;
83use subtle::ConstantTimeEq;
84
85use crate::error::{Error, Result};
86use crate::request::RequestContext;
87
88pub struct SignedUrlKeySet {
96 keys: Vec<(String, Vec<u8>)>,
97}
98
99impl SignedUrlKeySet {
100 pub const MIN_SECRET_LEN: usize = 32;
104
105 pub fn new(keys: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self> {
117 let keys: Vec<(String, Vec<u8>)> = keys.into_iter().collect();
118 for (kid, secret) in &keys {
119 if secret.len() < Self::MIN_SECRET_LEN {
120 return Err(Error::SignedUrlKeyTooShort {
121 kid: kid.clone(),
122 min: Self::MIN_SECRET_LEN,
123 actual: secret.len(),
124 });
125 }
126 }
127 Ok(SignedUrlKeySet { keys })
128 }
129
130 fn secret_for(&self, kid: &str) -> Option<&[u8]> {
131 self.keys
132 .iter()
133 .find(|(k, _)| k == kid)
134 .map(|(_, s)| s.as_slice())
135 }
136
137 pub fn sign(&self, kid: &str, path: &str, exp: u64, ip: Option<IpAddr>) -> Result<String> {
157 let secret = self
158 .secret_for(kid)
159 .ok_or_else(|| Error::UnknownSignedUrlKeyId(kid.to_string()))?;
160 let sig = URL_SAFE_NO_PAD.encode(hmac_sha256(secret, &canonical_string(path, exp, ip)));
161 let mut query = form_urlencoded::Serializer::new(String::new());
162 query.append_pair("exp", &exp.to_string());
163 query.append_pair("kid", kid);
164 query.append_pair("sig", &sig);
165 if let Some(ip) = ip {
166 query.append_pair("ip", &ip.to_string());
167 }
168 Ok(query.finish())
169 }
170}
171
172impl core::fmt::Debug for SignedUrlKeySet {
176 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177 f.debug_struct("SignedUrlKeySet")
178 .field(
179 "kids",
180 &self
181 .keys
182 .iter()
183 .map(|(k, _)| k.as_str())
184 .collect::<Vec<_>>(),
185 )
186 .finish()
187 }
188}
189
190fn canonical_string(path: &str, exp: u64, ip: Option<IpAddr>) -> String {
195 match ip {
196 Some(ip) => format!("{path}\n{exp}\n{ip}"),
197 None => format!("{path}\n{exp}\n"),
198 }
199}
200
201fn hmac_sha256(secret: &[u8], message: &str) -> Vec<u8> {
206 let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(secret)
207 .expect("HMAC accepts a key of any length");
208 mac.update(message.as_bytes());
209 mac.finalize().into_bytes().to_vec()
210}
211
212fn split_path_and_query(uri: &str) -> (&str, &str) {
215 match uri.split_once('?') {
216 Some((path, query)) => (path, query),
217 None => (uri, ""),
218 }
219}
220
221fn query_get<'q>(query: &'q str, key: &str) -> Option<Cow<'q, str>> {
224 form_urlencoded::parse(query.as_bytes())
225 .find(|(k, _)| k == key)
226 .map(|(_, v)| v)
227}
228
229fn current_unix_time() -> u64 {
234 std::time::SystemTime::now()
235 .duration_since(std::time::UNIX_EPOCH)
236 .map(|d| d.as_secs())
237 .unwrap_or(0)
238}
239
240pub(crate) fn verify(ctx: &RequestContext<'_>, keys: &SignedUrlKeySet) -> bool {
252 let (path, query) = split_path_and_query(ctx.uri);
253
254 let Some(exp) = query_get(query, "exp").and_then(|s| s.parse::<u64>().ok()) else {
255 return false;
256 };
257 let Some(kid) = query_get(query, "kid").filter(|s| !s.is_empty()) else {
258 return false;
259 };
260 let Some(sig) = query_get(query, "sig").filter(|s| !s.is_empty()) else {
261 return false;
262 };
263 let Some(secret) = keys.secret_for(&kid) else {
264 return false;
265 };
266 let ip: Option<IpAddr> = match query_get(query, "ip") {
267 Some(raw) => match raw.parse::<IpAddr>() {
268 Ok(ip) => Some(ip),
269 Err(_) => return false,
270 },
271 None => None,
272 };
273 let Ok(decoded_sig) = URL_SAFE_NO_PAD.decode(sig.as_bytes()) else {
274 return false;
275 };
276
277 let expected = hmac_sha256(secret, &canonical_string(path, exp, ip));
278 let sig_ok = bool::from(decoded_sig.as_slice().ct_eq(expected.as_slice()));
279 let not_expired = current_unix_time() <= exp;
280 let ip_ok = match ip {
281 Some(bound_ip) => ctx.peer_addr.map(|p| p.ip()) == Some(bound_ip),
282 None => true,
283 };
284
285 sig_ok && not_expired && ip_ok
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::{AuthResult, Verifier};
292
293 const SECRET_A: &[u8; 32] = b"01234567890123456789012345678901";
294 const SECRET_B: &[u8; 32] = b"abcdefghijabcdefghijabcdefghij01";
295
296 fn keyset() -> SignedUrlKeySet {
297 SignedUrlKeySet::new([
298 ("key-a".to_string(), SECRET_A.to_vec()),
299 ("key-b".to_string(), SECRET_B.to_vec()),
300 ])
301 .unwrap()
302 }
303
304 fn far_future() -> u64 {
305 current_unix_time() + 3600
306 }
307
308 fn ctx_for(uri: &str) -> RequestContext<'_> {
309 RequestContext::new("GET", uri)
310 }
311
312 #[test]
315 fn new_rejects_secret_shorter_than_min_len() {
316 let err = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 31])]).unwrap_err();
317 assert!(matches!(
318 err,
319 Error::SignedUrlKeyTooShort {
320 min: 32,
321 actual: 31,
322 ..
323 }
324 ));
325 }
326
327 #[test]
328 fn new_accepts_exactly_min_len_secret() {
329 SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
330 }
331
332 #[test]
335 fn valid_signature_and_unexpired_is_allowed() {
336 let keys = keyset();
337 let verifier = Verifier::signed_url(keyset());
338 let exp = far_future();
339 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
340 let uri = format!("/stream/media.m3u8?{query}");
341 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Ok);
342 }
343
344 #[test]
347 fn valid_signature_but_expired_is_denied() {
348 let keys = keyset();
349 let verifier = Verifier::signed_url(keyset());
350 let exp = current_unix_time().saturating_sub(60);
351 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
352 let uri = format!("/stream/media.m3u8?{query}");
353 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
354 }
355
356 #[test]
361 fn signature_minted_for_one_path_is_rejected_on_another_path() {
362 let keys = keyset();
363 let verifier = Verifier::signed_url(keyset());
364 let exp = far_future();
365 let query = keys
366 .sign("key-a", "/route-a/media.m3u8", exp, None)
367 .unwrap();
368 let uri = format!("/route-b/media.m3u8?{query}");
370 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
371 }
372
373 #[test]
376 fn extending_exp_without_resigning_is_denied() {
377 let keys = keyset();
378 let verifier = Verifier::signed_url(keyset());
379 let original_exp = far_future();
380 let query = keys
381 .sign("key-a", "/stream/media.m3u8", original_exp, None)
382 .unwrap();
383 let tampered = query.replacen(
384 &format!("exp={original_exp}"),
385 &format!("exp={}", original_exp + 1_000_000),
386 1,
387 );
388 assert_ne!(query, tampered, "test setup: exp must actually change");
389 let uri = format!("/stream/media.m3u8?{tampered}");
390 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
391 }
392
393 #[test]
396 fn unknown_kid_is_denied() {
397 let keys = keyset();
398 let verifier = Verifier::signed_url(keyset());
399 let exp = far_future();
400 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
401 let tampered = query.replacen("kid=key-a", "kid=nonexistent-key", 1);
402 let uri = format!("/stream/media.m3u8?{tampered}");
403 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
404 }
405
406 #[test]
409 fn ip_scoped_token_from_different_peer_is_denied() {
410 let keys = keyset();
411 let verifier = Verifier::signed_url(keyset());
412 let exp = far_future();
413 let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
414 let query = keys
415 .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
416 .unwrap();
417 let uri = format!("/stream/media.m3u8?{query}");
418 let other_peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
419 let ctx = ctx_for(&uri).with_peer_addr(other_peer);
420 assert_eq!(verifier.verify(&ctx), AuthResult::Unauthorized);
421 }
422
423 #[test]
424 fn ip_scoped_token_from_correct_peer_is_allowed() {
425 let keys = keyset();
426 let verifier = Verifier::signed_url(keyset());
427 let exp = far_future();
428 let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
429 let query = keys
430 .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
431 .unwrap();
432 let uri = format!("/stream/media.m3u8?{query}");
433 let same_ip_peer: std::net::SocketAddr = "203.0.113.7:54321".parse().unwrap();
435 let ctx = ctx_for(&uri).with_peer_addr(same_ip_peer);
436 assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
437 }
438
439 #[test]
440 fn unscoped_token_is_allowed_regardless_of_peer() {
441 let keys = keyset();
442 let verifier = Verifier::signed_url(keyset());
443 let exp = far_future();
444 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
445 let uri = format!("/stream/media.m3u8?{query}");
446 let peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
447 let ctx = ctx_for(&uri).with_peer_addr(peer);
448 assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
449 }
450
451 #[test]
454 fn missing_sig_is_denied() {
455 let verifier = Verifier::signed_url(keyset());
456 let uri = format!("/stream/media.m3u8?exp={}&kid=key-a", far_future());
457 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
458 }
459
460 #[test]
461 fn empty_sig_is_denied() {
462 let verifier = Verifier::signed_url(keyset());
463 let uri = format!("/stream/media.m3u8?exp={}&kid=key-a&sig=", far_future());
464 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
465 }
466
467 #[test]
468 fn malformed_base64_sig_is_denied_not_panicking() {
469 let verifier = Verifier::signed_url(keyset());
470 let uri = format!(
471 "/stream/media.m3u8?exp={}&kid=key-a&sig=not-valid-base64!!!",
472 far_future()
473 );
474 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
475 }
476
477 #[test]
478 fn missing_exp_is_denied() {
479 let verifier = Verifier::signed_url(keyset());
480 let uri = "/stream/media.m3u8?kid=key-a&sig=AAAA".to_string();
481 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
482 }
483
484 #[test]
485 fn unparseable_exp_is_denied() {
486 let verifier = Verifier::signed_url(keyset());
487 let uri = "/stream/media.m3u8?exp=not-a-number&kid=key-a&sig=AAAA".to_string();
488 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
489 }
490
491 #[test]
492 fn empty_query_string_is_denied_not_panicking() {
493 let verifier = Verifier::signed_url(keyset());
494 assert_eq!(
495 verifier.verify(&ctx_for("/stream/media.m3u8")),
496 AuthResult::Unauthorized
497 );
498 assert_eq!(
499 verifier.verify(&ctx_for("/stream/media.m3u8?")),
500 AuthResult::Unauthorized
501 );
502 }
503
504 #[test]
507 fn both_active_keys_are_accepted_until_one_is_retired() {
508 let keys = keyset();
509 let exp = far_future();
510 let query_a = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
511 let query_b = keys.sign("key-b", "/stream/media.m3u8", exp, None).unwrap();
512
513 let verifier = Verifier::signed_url(keyset());
514 assert_eq!(
515 verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
516 AuthResult::Ok,
517 "key-a must verify while both keys are active"
518 );
519 assert_eq!(
520 verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
521 AuthResult::Ok,
522 "key-b must verify while both keys are active"
523 );
524
525 let retired_keyset =
527 SignedUrlKeySet::new([("key-a".to_string(), SECRET_A.to_vec())]).unwrap();
528 let verifier_after_rotation = Verifier::signed_url(retired_keyset);
529 assert_eq!(
530 verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
531 AuthResult::Ok,
532 "key-a must still verify after key-b is retired"
533 );
534 assert_eq!(
535 verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
536 AuthResult::Unauthorized,
537 "a token signed by the retired key-b must now be rejected"
538 );
539 }
540
541 #[test]
544 fn sign_with_unknown_kid_is_a_structured_error() {
545 let keys = keyset();
546 let err = keys
547 .sign("no-such-key", "/stream/media.m3u8", far_future(), None)
548 .unwrap_err();
549 assert!(matches!(err, Error::UnknownSignedUrlKeyId(k) if k == "no-such-key"));
550 }
551
552 #[test]
555 fn split_path_and_query_handles_no_query() {
556 assert_eq!(split_path_and_query("/a/b"), ("/a/b", ""));
557 assert_eq!(split_path_and_query("/a/b?x=1"), ("/a/b", "x=1"));
558 }
559
560 #[test]
561 fn query_get_skips_malformed_pairs_without_panicking() {
562 assert_eq!(query_get("a=1&garbage&b=2", "b").as_deref(), Some("2"));
563 assert_eq!(query_get("a=1&garbage&b=2", "garbage").as_deref(), Some(""));
564 assert_eq!(query_get("", "a"), None);
565 }
566
567 #[test]
568 fn debug_never_leaks_secret_bytes() {
569 let keys = keyset();
570 let debug = format!("{keys:?}");
571 assert!(debug.contains("key-a"), "kid should render: {debug}");
572 assert!(debug.contains("key-b"), "kid should render: {debug}");
573 assert!(
574 !debug.contains(std::str::from_utf8(SECRET_A).unwrap()),
575 "secret leaked: {debug}"
576 );
577 }
578}