forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.2 KiB
1//! Short-lived signed tokens for Git LFS transfer actions (see docs/wave-b-interfaces.md).
2//!
3//! Token format: `"<expires>.<op>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers
4//! `"lfs\0" + repo_id + "\0" + op + "\0" + expires`. `op` is `download`, `upload` or `any`.
5
6use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
7use hmac::{Hmac, KeyInit, Mac};
8use sha2::Sha256;
9use subtle::ConstantTimeEq;
10
11type HmacSha256 = Hmac<Sha256>;
12
13const MAC_LEN: usize = 32;
14
15/// The operation a token grants. `Any` grants both download and upload.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum LfsOp {
18 Download,
19 Upload,
20 Any,
21}
22
23impl LfsOp {
24 pub fn as_str(self) -> &'static str {
25 match self {
26 LfsOp::Download => "download",
27 LfsOp::Upload => "upload",
28 LfsOp::Any => "any",
29 }
30 }
31
32 fn parse(s: &str) -> Option<LfsOp> {
33 match s {
34 "download" => Some(LfsOp::Download),
35 "upload" => Some(LfsOp::Upload),
36 "any" => Some(LfsOp::Any),
37 _ => None,
38 }
39 }
40}
41
42fn mac_for(key: &[u8; 32], repo_id: &str, op: &str, expires_unix: i64) -> HmacSha256 {
43 // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail.
44 let mut mac =
45 <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
46 mac.update(b"lfs\0");
47 mac.update(repo_id.as_bytes());
48 mac.update(b"\0");
49 mac.update(op.as_bytes());
50 mac.update(b"\0");
51 mac.update(expires_unix.to_string().as_bytes());
52 mac
53}
54
55/// Issues a token for `repo_id`, valid for `op` until `expires_unix` (seconds since the epoch).
56pub fn issue(key: &[u8; 32], repo_id: &str, op: LfsOp, expires_unix: i64) -> String {
57 let tag = mac_for(key, repo_id, op.as_str(), expires_unix)
58 .finalize()
59 .into_bytes();
60 format!(
61 "{expires_unix}.{}.{}",
62 op.as_str(),
63 BASE64_URL_SAFE_NO_PAD.encode(tag)
64 )
65}
66
67/// Verifies `token` for `repo_id` and the wanted operation at time `now_unix`.
68///
69/// Rejects malformed input, expired tokens (`now >= expires`) and bad MACs. The MAC
70/// comparison is constant time. An `Any` token satisfies `Download` and `Upload`; a
71/// `Download` token never satisfies `Upload`, and only an `Any` token satisfies `Any`.
72pub fn verify(key: &[u8; 32], token: &str, repo_id: &str, wanted: LfsOp, now_unix: i64) -> bool {
73 let mut parts = token.splitn(3, '.');
74 let (Some(expires_str), Some(op_str), Some(mac_str)) =
75 (parts.next(), parts.next(), parts.next())
76 else {
77 return false;
78 };
79 // Strict canonical decimal: no sign, no leading zeros, no whitespace.
80 let Ok(expires) = expires_str.parse::<i64>() else {
81 return false;
82 };
83 if expires.to_string() != expires_str {
84 return false;
85 }
86 if now_unix >= expires {
87 return false;
88 }
89 let Some(token_op) = LfsOp::parse(op_str) else {
90 return false;
91 };
92 let satisfies = token_op == LfsOp::Any || token_op == wanted;
93 if !satisfies {
94 return false;
95 }
96 let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else {
97 return false;
98 };
99 if tag.len() != MAC_LEN {
100 return false;
101 }
102 let expected = mac_for(key, repo_id, op_str, expires)
103 .finalize()
104 .into_bytes();
105 bool::from(expected.as_slice().ct_eq(&tag))
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 const KEY: [u8; 32] = [7; 32];
113 const OTHER_KEY: [u8; 32] = [9; 32];
114 const NOW: i64 = 1_700_000_000;
115
116 #[test]
117 fn round_trip_each_op() {
118 let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60);
119 assert!(verify(&KEY, &dl, "repo-1", LfsOp::Download, NOW));
120 let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60);
121 assert!(verify(&KEY, &ul, "repo-1", LfsOp::Upload, NOW));
122 let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
123 assert!(verify(&KEY, &any, "repo-1", LfsOp::Any, NOW));
124 }
125
126 #[test]
127 fn token_has_expected_shape() {
128 let token = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60);
129 let mut parts = token.splitn(3, '.');
130 assert_eq!(parts.next().unwrap(), (NOW + 60).to_string());
131 assert_eq!(parts.next().unwrap(), "upload");
132 assert_eq!(
133 BASE64_URL_SAFE_NO_PAD
134 .decode(parts.next().unwrap())
135 .unwrap()
136 .len(),
137 MAC_LEN
138 );
139 }
140
141 #[test]
142 fn expired_is_rejected() {
143 let token = issue(&KEY, "repo-1", LfsOp::Any, NOW);
144 assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW));
145 let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 1);
146 assert!(verify(&KEY, &token, "repo-1", LfsOp::Download, NOW));
147 assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW + 1));
148 }
149
150 #[test]
151 fn wrong_repo_is_rejected() {
152 let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
153 assert!(!verify(&KEY, &token, "repo-2", LfsOp::Download, NOW));
154 }
155
156 #[test]
157 fn wrong_key_is_rejected() {
158 let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
159 assert!(!verify(&OTHER_KEY, &token, "repo-1", LfsOp::Download, NOW));
160 }
161
162 #[test]
163 fn wrong_op_is_rejected() {
164 let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60);
165 assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Upload, NOW));
166 assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Any, NOW));
167 let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60);
168 assert!(!verify(&KEY, &ul, "repo-1", LfsOp::Download, NOW));
169 }
170
171 #[test]
172 fn any_satisfies_download_and_upload() {
173 let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60);
174 assert!(verify(&KEY, &any, "repo-1", LfsOp::Download, NOW));
175 assert!(verify(&KEY, &any, "repo-1", LfsOp::Upload, NOW));
176 }
177
178 #[test]
179 fn tampering_is_rejected() {
180 let token = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60);
181 // Upgrading the op in the clear must fail the MAC check.
182 let upgraded = token.replacen(".download.", ".any.", 1);
183 assert!(!verify(&KEY, &upgraded, "repo-1", LfsOp::Upload, NOW));
184 // Extending expiry must fail too.
185 let (exp, rest) = token.split_once('.').unwrap();
186 let extended = format!("{}.{rest}", exp.parse::<i64>().unwrap() + 1000);
187 assert!(!verify(&KEY, &extended, "repo-1", LfsOp::Download, NOW));
188 // Flipping a MAC character must fail.
189 let mut bytes = token.into_bytes();
190 let last = bytes.len() - 1;
191 bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
192 let flipped = String::from_utf8(bytes).unwrap();
193 assert!(!verify(&KEY, &flipped, "repo-1", LfsOp::Download, NOW));
194 }
195
196 #[test]
197 fn malformed_is_rejected() {
198 assert!(!verify(&KEY, "", "repo-1", LfsOp::Any, NOW));
199 assert!(!verify(&KEY, "garbage", "repo-1", LfsOp::Any, NOW));
200 assert!(!verify(&KEY, "1.any", "repo-1", LfsOp::Any, NOW));
201 assert!(!verify(
202 &KEY,
203 "+1700000060.any.AAAA",
204 "repo-1",
205 LfsOp::Any,
206 NOW
207 ));
208 assert!(!verify(
209 &KEY,
210 "1700000060.bogus.AAAA",
211 "repo-1",
212 LfsOp::Any,
213 NOW
214 ));
215 }
216}