| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; |
| 7 | use hmac::{Hmac, KeyInit, Mac}; |
| 8 | use sha2::Sha256; |
| 9 | |
| 10 | type HmacSha256 = Hmac<Sha256>; |
| 11 | |
| 12 | const MAC_LEN: usize = 32; |
| 13 | |
| 14 | fn mac_for(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> HmacSha256 { |
| 15 | |
| 16 | let mut mac = |
| 17 | <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length"); |
| 18 | mac.update(b"raw-v1:"); |
| 19 | mac.update(repo_id.as_bytes()); |
| 20 | mac.update(b":"); |
| 21 | mac.update(expires_unix.to_string().as_bytes()); |
| 22 | mac |
| 23 | } |
| 24 | |
| 25 | |
| 26 | pub fn sign_raw(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> String { |
| 27 | let tag = mac_for(key, repo_id, expires_unix).finalize().into_bytes(); |
| 28 | format!("{expires_unix}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag)) |
| 29 | } |
| 30 | |
| 31 | |
| 32 | |
| 33 | pub fn verify_raw(key: &[u8; 32], token: &str, repo_id: &str, now_unix: i64) -> bool { |
| 34 | let Some((expires_str, mac_str)) = token.split_once('.') else { |
| 35 | return false; |
| 36 | }; |
| 37 | |
| 38 | let Ok(expires) = expires_str.parse::<i64>() else { |
| 39 | return false; |
| 40 | }; |
| 41 | if expires.to_string() != expires_str { |
| 42 | return false; |
| 43 | } |
| 44 | if now_unix >= expires { |
| 45 | return false; |
| 46 | } |
| 47 | let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else { |
| 48 | return false; |
| 49 | }; |
| 50 | if tag.len() != MAC_LEN { |
| 51 | return false; |
| 52 | } |
| 53 | mac_for(key, repo_id, expires).verify_slice(&tag).is_ok() |
| 54 | } |
| 55 | |
| 56 | #[cfg(test)] |
| 57 | mod tests { |
| 58 | use super::*; |
| 59 | |
| 60 | const KEY: [u8; 32] = [7; 32]; |
| 61 | const OTHER_KEY: [u8; 32] = [9; 32]; |
| 62 | const NOW: i64 = 1_700_000_000; |
| 63 | |
| 64 | #[test] |
| 65 | fn round_trip() { |
| 66 | let token = sign_raw(&KEY, "repo-1", NOW + 60); |
| 67 | assert!(verify_raw(&KEY, &token, "repo-1", NOW)); |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn token_has_expected_shape() { |
| 72 | let token = sign_raw(&KEY, "repo-1", NOW + 60); |
| 73 | let (expires, mac) = token.split_once('.').unwrap(); |
| 74 | assert_eq!(expires, (NOW + 60).to_string()); |
| 75 | assert_eq!(BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap().len(), MAC_LEN); |
| 76 | } |
| 77 | |
| 78 | #[test] |
| 79 | fn expired_is_rejected() { |
| 80 | let token = sign_raw(&KEY, "repo-1", NOW); |
| 81 | |
| 82 | assert!(!verify_raw(&KEY, &token, "repo-1", NOW)); |
| 83 | assert!(!verify_raw(&KEY, &token, "repo-1", NOW + 1)); |
| 84 | assert!(verify_raw(&KEY, &token, "repo-1", NOW - 1)); |
| 85 | } |
| 86 | |
| 87 | #[test] |
| 88 | fn wrong_repo_is_rejected() { |
| 89 | let token = sign_raw(&KEY, "repo-1", NOW + 60); |
| 90 | assert!(!verify_raw(&KEY, &token, "repo-2", NOW)); |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn tampered_mac_is_rejected() { |
| 95 | let token = sign_raw(&KEY, "repo-1", NOW + 60); |
| 96 | let (expires, mac) = token.split_once('.').unwrap(); |
| 97 | let mut tag = BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap(); |
| 98 | tag[0] ^= 1; |
| 99 | let tampered = format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag)); |
| 100 | assert!(!verify_raw(&KEY, &tampered, "repo-1", NOW)); |
| 101 | } |
| 102 | |
| 103 | #[test] |
| 104 | fn extended_expiry_is_rejected() { |
| 105 | let token = sign_raw(&KEY, "repo-1", NOW + 60); |
| 106 | let (_, mac) = token.split_once('.').unwrap(); |
| 107 | let forged = format!("{}.{mac}", NOW + 3600); |
| 108 | assert!(!verify_raw(&KEY, &forged, "repo-1", NOW)); |
| 109 | } |
| 110 | |
| 111 | #[test] |
| 112 | fn garbage_is_rejected() { |
| 113 | let good = sign_raw(&KEY, "repo-1", NOW + 60); |
| 114 | let (expires, mac) = good.split_once('.').unwrap(); |
| 115 | let inputs = [ |
| 116 | String::new(), |
| 117 | "garbage".to_owned(), |
| 118 | ".".to_owned(), |
| 119 | expires.to_string(), |
| 120 | format!("{expires}."), |
| 121 | format!(".{mac}"), |
| 122 | format!("{expires}.{mac}.extra"), |
| 123 | format!("+{expires}.{mac}"), |
| 124 | format!("{expires}.!!!"), |
| 125 | format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode([0u8; 16])), |
| 126 | format!("{expires}.{mac}=="), |
| 127 | format!("notanumber.{mac}"), |
| 128 | ]; |
| 129 | for input in &inputs { |
| 130 | assert!( |
| 131 | !verify_raw(&KEY, input, "repo-1", NOW), |
| 132 | "accepted {input:?}" |
| 133 | ); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | #[test] |
| 138 | fn different_key_is_rejected() { |
| 139 | let token = sign_raw(&KEY, "repo-1", NOW + 60); |
| 140 | assert!(!verify_raw(&OTHER_KEY, &token, "repo-1", NOW)); |
| 141 | } |
| 142 | } |