//! Short-lived signed tokens for Git LFS transfer actions (see docs/wave-b-interfaces.md). //! //! Token format: `".."`, where the MAC covers //! `"lfs\0" + repo_id + "\0" + op + "\0" + expires`. `op` is `download`, `upload` or `any`. use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use subtle::ConstantTimeEq; type HmacSha256 = Hmac; const MAC_LEN: usize = 32; /// The operation a token grants. `Any` grants both download and upload. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LfsOp { Download, Upload, Any, } impl LfsOp { pub fn as_str(self) -> &'static str { match self { LfsOp::Download => "download", LfsOp::Upload => "upload", LfsOp::Any => "any", } } fn parse(s: &str) -> Option { match s { "download" => Some(LfsOp::Download), "upload" => Some(LfsOp::Upload), "any" => Some(LfsOp::Any), _ => None, } } } fn mac_for(key: &[u8; 32], repo_id: &str, op: &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"lfs\0"); mac.update(repo_id.as_bytes()); mac.update(b"\0"); mac.update(op.as_bytes()); mac.update(b"\0"); mac.update(expires_unix.to_string().as_bytes()); mac } /// Issues a token for `repo_id`, valid for `op` until `expires_unix` (seconds since the epoch). pub fn issue(key: &[u8; 32], repo_id: &str, op: LfsOp, expires_unix: i64) -> String { let tag = mac_for(key, repo_id, op.as_str(), expires_unix) .finalize() .into_bytes(); format!( "{expires_unix}.{}.{}", op.as_str(), BASE64_URL_SAFE_NO_PAD.encode(tag) ) } /// Verifies `token` for `repo_id` and the wanted operation at time `now_unix`. /// /// Rejects malformed input, expired tokens (`now >= expires`) and bad MACs. The MAC /// comparison is constant time. An `Any` token satisfies `Download` and `Upload`; a /// `Download` token never satisfies `Upload`, and only an `Any` token satisfies `Any`. pub fn verify(key: &[u8; 32], token: &str, repo_id: &str, wanted: LfsOp, now_unix: i64) -> bool { let mut parts = token.splitn(3, '.'); let (Some(expires_str), Some(op_str), Some(mac_str)) = (parts.next(), parts.next(), parts.next()) 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 Some(token_op) = LfsOp::parse(op_str) else { return false; }; let satisfies = token_op == LfsOp::Any || token_op == wanted; if !satisfies { return false; } let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else { return false; }; if tag.len() != MAC_LEN { return false; } let expected = mac_for(key, repo_id, op_str, expires) .finalize() .into_bytes(); bool::from(expected.as_slice().ct_eq(&tag)) } #[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_each_op() { let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60); assert!(verify(&KEY, &dl, "repo-1", LfsOp::Download, NOW)); let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60); assert!(verify(&KEY, &ul, "repo-1", LfsOp::Upload, NOW)); let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); assert!(verify(&KEY, &any, "repo-1", LfsOp::Any, NOW)); } #[test] fn token_has_expected_shape() { let token = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60); let mut parts = token.splitn(3, '.'); assert_eq!(parts.next().unwrap(), (NOW + 60).to_string()); assert_eq!(parts.next().unwrap(), "upload"); assert_eq!( BASE64_URL_SAFE_NO_PAD .decode(parts.next().unwrap()) .unwrap() .len(), MAC_LEN ); } #[test] fn expired_is_rejected() { let token = issue(&KEY, "repo-1", LfsOp::Any, NOW); assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW)); let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 1); assert!(verify(&KEY, &token, "repo-1", LfsOp::Download, NOW)); assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW + 1)); } #[test] fn wrong_repo_is_rejected() { let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); assert!(!verify(&KEY, &token, "repo-2", LfsOp::Download, NOW)); } #[test] fn wrong_key_is_rejected() { let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); assert!(!verify(&OTHER_KEY, &token, "repo-1", LfsOp::Download, NOW)); } #[test] fn wrong_op_is_rejected() { let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60); assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Upload, NOW)); assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Any, NOW)); let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60); assert!(!verify(&KEY, &ul, "repo-1", LfsOp::Download, NOW)); } #[test] fn any_satisfies_download_and_upload() { let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); assert!(verify(&KEY, &any, "repo-1", LfsOp::Download, NOW)); assert!(verify(&KEY, &any, "repo-1", LfsOp::Upload, NOW)); } #[test] fn tampering_is_rejected() { let token = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60); // Upgrading the op in the clear must fail the MAC check. let upgraded = token.replacen(".download.", ".any.", 1); assert!(!verify(&KEY, &upgraded, "repo-1", LfsOp::Upload, NOW)); // Extending expiry must fail too. let (exp, rest) = token.split_once('.').unwrap(); let extended = format!("{}.{rest}", exp.parse::().unwrap() + 1000); assert!(!verify(&KEY, &extended, "repo-1", LfsOp::Download, NOW)); // Flipping a MAC character must fail. let mut bytes = token.into_bytes(); let last = bytes.len() - 1; bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' }; let flipped = String::from_utf8(bytes).unwrap(); assert!(!verify(&KEY, &flipped, "repo-1", LfsOp::Download, NOW)); } #[test] fn malformed_is_rejected() { assert!(!verify(&KEY, "", "repo-1", LfsOp::Any, NOW)); assert!(!verify(&KEY, "garbage", "repo-1", LfsOp::Any, NOW)); assert!(!verify(&KEY, "1.any", "repo-1", LfsOp::Any, NOW)); assert!(!verify( &KEY, "+1700000060.any.AAAA", "repo-1", LfsOp::Any, NOW )); assert!(!verify( &KEY, "1700000060.bogus.AAAA", "repo-1", LfsOp::Any, NOW )); } }