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 Drop for SignedUrlKeySet {
174 fn drop(&mut self) {
175 use zeroize::Zeroize;
176 for (_, secret) in &mut self.keys {
177 secret.zeroize();
178 }
179 }
180}
181
182impl core::fmt::Debug for SignedUrlKeySet {
186 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
187 f.debug_struct("SignedUrlKeySet")
188 .field(
189 "kids",
190 &self
191 .keys
192 .iter()
193 .map(|(k, _)| k.as_str())
194 .collect::<Vec<_>>(),
195 )
196 .finish()
197 }
198}
199
200fn canonical_string(path: &str, exp: u64, ip: Option<IpAddr>) -> String {
205 match ip {
206 Some(ip) => format!("{path}\n{exp}\n{ip}"),
207 None => format!("{path}\n{exp}\n"),
208 }
209}
210
211fn hmac_sha256(secret: &[u8], message: &str) -> Vec<u8> {
216 let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(secret)
217 .expect("HMAC accepts a key of any length");
218 mac.update(message.as_bytes());
219 mac.finalize().into_bytes().to_vec()
220}
221
222fn split_path_and_query(uri: &str) -> (&str, &str) {
225 match uri.split_once('?') {
226 Some((path, query)) => (path, query),
227 None => (uri, ""),
228 }
229}
230
231fn query_get<'q>(query: &'q str, key: &str) -> Option<Cow<'q, str>> {
234 form_urlencoded::parse(query.as_bytes())
235 .find(|(k, _)| k == key)
236 .map(|(_, v)| v)
237}
238
239fn current_unix_time() -> u64 {
244 std::time::SystemTime::now()
245 .duration_since(std::time::UNIX_EPOCH)
246 .map(|d| d.as_secs())
247 .unwrap_or(0)
248}
249
250pub(crate) fn verify(ctx: &RequestContext<'_>, keys: &SignedUrlKeySet) -> bool {
262 let (path, query) = split_path_and_query(ctx.uri);
263
264 let Some(exp) = query_get(query, "exp").and_then(|s| s.parse::<u64>().ok()) else {
265 return false;
266 };
267 let Some(kid) = query_get(query, "kid").filter(|s| !s.is_empty()) else {
268 return false;
269 };
270 let Some(sig) = query_get(query, "sig").filter(|s| !s.is_empty()) else {
271 return false;
272 };
273 let Some(secret) = keys.secret_for(&kid) else {
274 return false;
275 };
276 let ip: Option<IpAddr> = match query_get(query, "ip") {
277 Some(raw) => match raw.parse::<IpAddr>() {
278 Ok(ip) => Some(ip),
279 Err(_) => return false,
280 },
281 None => None,
282 };
283 let Ok(decoded_sig) = URL_SAFE_NO_PAD.decode(sig.as_bytes()) else {
284 return false;
285 };
286
287 let expected = hmac_sha256(secret, &canonical_string(path, exp, ip));
288 let sig_ok = bool::from(decoded_sig.as_slice().ct_eq(expected.as_slice()));
289 let not_expired = current_unix_time() <= exp;
290 let ip_ok = match ip {
291 Some(bound_ip) => ctx.peer_addr.map(|p| p.ip()) == Some(bound_ip),
292 None => true,
293 };
294
295 sig_ok && not_expired && ip_ok
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::{AuthResult, Verifier};
302
303 const SECRET_A: &[u8; 32] = b"01234567890123456789012345678901";
304 const SECRET_B: &[u8; 32] = b"abcdefghijabcdefghijabcdefghij01";
305
306 fn keyset() -> SignedUrlKeySet {
307 SignedUrlKeySet::new([
308 ("key-a".to_string(), SECRET_A.to_vec()),
309 ("key-b".to_string(), SECRET_B.to_vec()),
310 ])
311 .unwrap()
312 }
313
314 fn far_future() -> u64 {
315 current_unix_time() + 3600
316 }
317
318 fn ctx_for(uri: &str) -> RequestContext<'_> {
319 RequestContext::new("GET", uri)
320 }
321
322 #[test]
325 fn new_rejects_secret_shorter_than_min_len() {
326 let err = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 31])]).unwrap_err();
327 assert!(matches!(
328 err,
329 Error::SignedUrlKeyTooShort {
330 min: 32,
331 actual: 31,
332 ..
333 }
334 ));
335 }
336
337 #[test]
338 fn new_accepts_exactly_min_len_secret() {
339 SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
340 }
341
342 #[test]
345 fn valid_signature_and_unexpired_is_allowed() {
346 let keys = keyset();
347 let verifier = Verifier::signed_url(keyset());
348 let exp = far_future();
349 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
350 let uri = format!("/stream/media.m3u8?{query}");
351 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Ok);
352 }
353
354 #[test]
357 fn valid_signature_but_expired_is_denied() {
358 let keys = keyset();
359 let verifier = Verifier::signed_url(keyset());
360 let exp = current_unix_time().saturating_sub(60);
361 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
362 let uri = format!("/stream/media.m3u8?{query}");
363 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
364 }
365
366 #[test]
371 fn signature_minted_for_one_path_is_rejected_on_another_path() {
372 let keys = keyset();
373 let verifier = Verifier::signed_url(keyset());
374 let exp = far_future();
375 let query = keys
376 .sign("key-a", "/route-a/media.m3u8", exp, None)
377 .unwrap();
378 let uri = format!("/route-b/media.m3u8?{query}");
380 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
381 }
382
383 #[test]
386 fn extending_exp_without_resigning_is_denied() {
387 let keys = keyset();
388 let verifier = Verifier::signed_url(keyset());
389 let original_exp = far_future();
390 let query = keys
391 .sign("key-a", "/stream/media.m3u8", original_exp, None)
392 .unwrap();
393 let tampered = query.replacen(
394 &format!("exp={original_exp}"),
395 &format!("exp={}", original_exp + 1_000_000),
396 1,
397 );
398 assert_ne!(query, tampered, "test setup: exp must actually change");
399 let uri = format!("/stream/media.m3u8?{tampered}");
400 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
401 }
402
403 #[test]
406 fn unknown_kid_is_denied() {
407 let keys = keyset();
408 let verifier = Verifier::signed_url(keyset());
409 let exp = far_future();
410 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
411 let tampered = query.replacen("kid=key-a", "kid=nonexistent-key", 1);
412 let uri = format!("/stream/media.m3u8?{tampered}");
413 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
414 }
415
416 #[test]
419 fn ip_scoped_token_from_different_peer_is_denied() {
420 let keys = keyset();
421 let verifier = Verifier::signed_url(keyset());
422 let exp = far_future();
423 let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
424 let query = keys
425 .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
426 .unwrap();
427 let uri = format!("/stream/media.m3u8?{query}");
428 let other_peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
429 let ctx = ctx_for(&uri).with_peer_addr(other_peer);
430 assert_eq!(verifier.verify(&ctx), AuthResult::Unauthorized);
431 }
432
433 #[test]
434 fn ip_scoped_token_from_correct_peer_is_allowed() {
435 let keys = keyset();
436 let verifier = Verifier::signed_url(keyset());
437 let exp = far_future();
438 let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
439 let query = keys
440 .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
441 .unwrap();
442 let uri = format!("/stream/media.m3u8?{query}");
443 let same_ip_peer: std::net::SocketAddr = "203.0.113.7:54321".parse().unwrap();
445 let ctx = ctx_for(&uri).with_peer_addr(same_ip_peer);
446 assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
447 }
448
449 #[test]
450 fn unscoped_token_is_allowed_regardless_of_peer() {
451 let keys = keyset();
452 let verifier = Verifier::signed_url(keyset());
453 let exp = far_future();
454 let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
455 let uri = format!("/stream/media.m3u8?{query}");
456 let peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
457 let ctx = ctx_for(&uri).with_peer_addr(peer);
458 assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
459 }
460
461 #[test]
464 fn missing_sig_is_denied() {
465 let verifier = Verifier::signed_url(keyset());
466 let uri = format!("/stream/media.m3u8?exp={}&kid=key-a", far_future());
467 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
468 }
469
470 #[test]
471 fn empty_sig_is_denied() {
472 let verifier = Verifier::signed_url(keyset());
473 let uri = format!("/stream/media.m3u8?exp={}&kid=key-a&sig=", far_future());
474 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
475 }
476
477 #[test]
478 fn malformed_base64_sig_is_denied_not_panicking() {
479 let verifier = Verifier::signed_url(keyset());
480 let uri = format!(
481 "/stream/media.m3u8?exp={}&kid=key-a&sig=not-valid-base64!!!",
482 far_future()
483 );
484 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
485 }
486
487 #[test]
488 fn missing_exp_is_denied() {
489 let verifier = Verifier::signed_url(keyset());
490 let uri = "/stream/media.m3u8?kid=key-a&sig=AAAA".to_string();
491 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
492 }
493
494 #[test]
495 fn unparseable_exp_is_denied() {
496 let verifier = Verifier::signed_url(keyset());
497 let uri = "/stream/media.m3u8?exp=not-a-number&kid=key-a&sig=AAAA".to_string();
498 assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
499 }
500
501 #[test]
502 fn empty_query_string_is_denied_not_panicking() {
503 let verifier = Verifier::signed_url(keyset());
504 assert_eq!(
505 verifier.verify(&ctx_for("/stream/media.m3u8")),
506 AuthResult::Unauthorized
507 );
508 assert_eq!(
509 verifier.verify(&ctx_for("/stream/media.m3u8?")),
510 AuthResult::Unauthorized
511 );
512 }
513
514 #[test]
517 fn both_active_keys_are_accepted_until_one_is_retired() {
518 let keys = keyset();
519 let exp = far_future();
520 let query_a = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
521 let query_b = keys.sign("key-b", "/stream/media.m3u8", exp, None).unwrap();
522
523 let verifier = Verifier::signed_url(keyset());
524 assert_eq!(
525 verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
526 AuthResult::Ok,
527 "key-a must verify while both keys are active"
528 );
529 assert_eq!(
530 verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
531 AuthResult::Ok,
532 "key-b must verify while both keys are active"
533 );
534
535 let retired_keyset =
537 SignedUrlKeySet::new([("key-a".to_string(), SECRET_A.to_vec())]).unwrap();
538 let verifier_after_rotation = Verifier::signed_url(retired_keyset);
539 assert_eq!(
540 verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
541 AuthResult::Ok,
542 "key-a must still verify after key-b is retired"
543 );
544 assert_eq!(
545 verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
546 AuthResult::Unauthorized,
547 "a token signed by the retired key-b must now be rejected"
548 );
549 }
550
551 #[test]
554 fn sign_with_unknown_kid_is_a_structured_error() {
555 let keys = keyset();
556 let err = keys
557 .sign("no-such-key", "/stream/media.m3u8", far_future(), None)
558 .unwrap_err();
559 assert!(matches!(err, Error::UnknownSignedUrlKeyId(k) if k == "no-such-key"));
560 }
561
562 #[test]
565 fn split_path_and_query_handles_no_query() {
566 assert_eq!(split_path_and_query("/a/b"), ("/a/b", ""));
567 assert_eq!(split_path_and_query("/a/b?x=1"), ("/a/b", "x=1"));
568 }
569
570 #[test]
571 fn query_get_skips_malformed_pairs_without_panicking() {
572 assert_eq!(query_get("a=1&garbage&b=2", "b").as_deref(), Some("2"));
573 assert_eq!(query_get("a=1&garbage&b=2", "garbage").as_deref(), Some(""));
574 assert_eq!(query_get("", "a"), None);
575 }
576
577 #[test]
578 fn debug_never_leaks_secret_bytes() {
579 let keys = keyset();
580 let debug = format!("{keys:?}");
581 assert!(debug.contains("key-a"), "kid should render: {debug}");
582 assert!(debug.contains("key-b"), "kid should render: {debug}");
583 assert!(
584 !debug.contains(std::str::from_utf8(SECRET_A).unwrap()),
585 "secret leaked: {debug}"
586 );
587 }
588}