//! Signed tokens for private raw-file URLs. //! //! Token format: `"."`, where the MAC covers //! `"raw-v1:" + repo_id + ":" + expires`. use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; type HmacSha256 = Hmac; const MAC_LEN: usize = 32; fn mac_for(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> HmacSha256 { // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail. let mut mac = ::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(b"raw-v1:"); mac.update(repo_id.as_bytes()); mac.update(b":"); mac.update(expires_unix.to_string().as_bytes()); mac } /// Signs access to `repo_id`'s raw files until `expires_unix` (seconds since the epoch). pub fn sign_raw(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> String { let tag = mac_for(key, repo_id, expires_unix).finalize().into_bytes(); format!("{expires_unix}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag)) } /// Verifies a token for `repo_id` at time `now_unix`. Rejects malformed input, expired /// tokens (`now >= expires`) and bad MACs. The MAC comparison is constant time. pub fn verify_raw(key: &[u8; 32], token: &str, repo_id: &str, now_unix: i64) -> bool { let Some((expires_str, mac_str)) = token.split_once('.') else { return false; }; // Strict canonical decimal: no sign, no leading zeros, no whitespace. let Ok(expires) = expires_str.parse::() else { return false; }; if expires.to_string() != expires_str { return false; } if now_unix >= expires { return false; } let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else { return false; }; if tag.len() != MAC_LEN { return false; } mac_for(key, repo_id, expires).verify_slice(&tag).is_ok() } #[cfg(test)] mod tests { use super::*; const KEY: [u8; 32] = [7; 32]; const OTHER_KEY: [u8; 32] = [9; 32]; const NOW: i64 = 1_700_000_000; #[test] fn round_trip() { let token = sign_raw(&KEY, "repo-1", NOW + 60); assert!(verify_raw(&KEY, &token, "repo-1", NOW)); } #[test] fn token_has_expected_shape() { let token = sign_raw(&KEY, "repo-1", NOW + 60); let (expires, mac) = token.split_once('.').unwrap(); assert_eq!(expires, (NOW + 60).to_string()); assert_eq!(BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap().len(), MAC_LEN); } #[test] fn expired_is_rejected() { let token = sign_raw(&KEY, "repo-1", NOW); // now == expires is already expired. assert!(!verify_raw(&KEY, &token, "repo-1", NOW)); assert!(!verify_raw(&KEY, &token, "repo-1", NOW + 1)); assert!(verify_raw(&KEY, &token, "repo-1", NOW - 1)); } #[test] fn wrong_repo_is_rejected() { let token = sign_raw(&KEY, "repo-1", NOW + 60); assert!(!verify_raw(&KEY, &token, "repo-2", NOW)); } #[test] fn tampered_mac_is_rejected() { let token = sign_raw(&KEY, "repo-1", NOW + 60); let (expires, mac) = token.split_once('.').unwrap(); let mut tag = BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap(); tag[0] ^= 1; let tampered = format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag)); assert!(!verify_raw(&KEY, &tampered, "repo-1", NOW)); } #[test] fn extended_expiry_is_rejected() { let token = sign_raw(&KEY, "repo-1", NOW + 60); let (_, mac) = token.split_once('.').unwrap(); let forged = format!("{}.{mac}", NOW + 3600); assert!(!verify_raw(&KEY, &forged, "repo-1", NOW)); } #[test] fn garbage_is_rejected() { let good = sign_raw(&KEY, "repo-1", NOW + 60); let (expires, mac) = good.split_once('.').unwrap(); let inputs = [ String::new(), "garbage".to_owned(), ".".to_owned(), expires.to_string(), format!("{expires}."), format!(".{mac}"), format!("{expires}.{mac}.extra"), format!("+{expires}.{mac}"), format!("{expires}.!!!"), format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode([0u8; 16])), format!("{expires}.{mac}=="), format!("notanumber.{mac}"), ]; for input in &inputs { assert!( !verify_raw(&KEY, input, "repo-1", NOW), "accepted {input:?}" ); } } #[test] fn different_key_is_rejected() { let token = sign_raw(&KEY, "repo-1", NOW + 60); assert!(!verify_raw(&OTHER_KEY, &token, "repo-1", NOW)); } }