forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
4.7 KiB
1//! Signed tokens for private raw-file URLs (see docs/m2-interfaces.md).
2//!
3//! Token format: `"<expires>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers
4//! `"raw-v1:" + repo_id + ":" + expires`.
5
6use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
7use hmac::{Hmac, KeyInit, Mac};
8use sha2::Sha256;
9
10type HmacSha256 = Hmac<Sha256>;
11
12const MAC_LEN: usize = 32;
13
14fn mac_for(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> HmacSha256 {
15 // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail.
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/// Signs access to `repo_id`'s raw files until `expires_unix` (seconds since the epoch).
26pub 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/// Verifies a token for `repo_id` at time `now_unix`. Rejects malformed input, expired
32/// tokens (`now >= expires`) and bad MACs. The MAC comparison is constant time.
33pub 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 // Strict canonical decimal: no sign, no leading zeros, no whitespace.
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)]
57mod 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 // now == expires is already expired.
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}