1use std::path::Path;
9
10use base64::Engine as _;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use rand::RngCore as _;
13use sha2::{Digest, Sha256};
14
15pub use prospero_types::Scope;
16
17pub const TOKEN_PREFIX: &str = "pspo_";
19
20pub fn generate_token() -> String {
22 let mut bytes = [0u8; 32];
23 rand::thread_rng().fill_bytes(&mut bytes);
24 format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
25}
26
27pub fn hash_token(token: &str) -> [u8; 32] {
29 Sha256::digest(token.as_bytes()).into()
30}
31
32pub fn format_hash(hash: &[u8; 32]) -> String {
34 format!("sha256:{}", hex::encode(hash))
35}
36
37pub fn validate_token_name(name: &str) -> Result<(), String> {
39 let bytes = name.as_bytes();
40 let ok_first = bytes
41 .first()
42 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
43 let ok_rest = bytes
44 .iter()
45 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_' || *c == b'-');
46 if ok_first && ok_rest && bytes.len() <= 63 {
47 Ok(())
48 } else {
49 Err(format!(
50 "invalid token name {name:?} (expected [a-z0-9][a-z0-9_-]{{0,62}})"
51 ))
52 }
53}
54
55pub fn tokens_file_line(name: &str, scope: Scope, token: &str) -> String {
57 format!(
58 "{name} {} {}",
59 scope.as_str(),
60 format_hash(&hash_token(token))
61 )
62}
63
64pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
68 if a.len() != b.len() {
69 return false;
70 }
71 let mut diff: u8 = 0;
72 for (x, y) in a.iter().zip(b.iter()) {
73 diff |= x ^ y;
74 }
75 diff == 0
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct TokenEntry {
81 pub name: String,
83 pub scope: Scope,
85 pub hash: [u8; 32],
87}
88
89#[derive(Debug, thiserror::Error)]
91#[error("{0}")]
92pub struct TokensFileError(pub String);
93
94#[derive(Debug, Clone)]
96pub struct TokenSet {
97 entries: Vec<TokenEntry>,
98}
99
100impl TokenSet {
101 pub fn parse(src: &str) -> Result<TokenSet, TokensFileError> {
103 let mut entries: Vec<TokenEntry> = Vec::new();
104 for (idx, raw) in src.lines().enumerate() {
105 let line_no = idx + 1;
106 let line = raw.trim();
107 if line.is_empty() || line.starts_with('#') {
108 continue;
109 }
110 let err = |reason: String| TokensFileError(format!("line {line_no}: {reason}"));
111 let fields: Vec<&str> = line.split_whitespace().collect();
112 let [name, scope, hash] = fields[..] else {
113 return Err(err("expected `<name> <scope> sha256:<hex>`".into()));
114 };
115 validate_token_name(name).map_err(err)?;
116 let scope = Scope::parse(scope)
117 .ok_or_else(|| err(format!("unknown scope {scope:?} (read|operate|admin)")))?;
118 let hash = parse_hash(hash).ok_or_else(|| {
119 err("hash must be sha256: followed by 64 lowercase hex characters".into())
120 })?;
121 if entries.iter().any(|e| e.name == name) {
122 return Err(err(format!("duplicate token name {name:?}")));
123 }
124 entries.push(TokenEntry {
125 name: name.to_string(),
126 scope,
127 hash,
128 });
129 }
130 if entries.is_empty() {
131 return Err(TokensFileError("tokens file contains no tokens".into()));
132 }
133 Ok(TokenSet { entries })
134 }
135
136 pub fn load(path: &Path) -> Result<TokenSet, TokensFileError> {
138 let src = std::fs::read_to_string(path)
139 .map_err(|e| TokensFileError(format!("reading tokens file {}: {e}", path.display())))?;
140 TokenSet::parse(&src)
141 .map_err(|e| TokensFileError(format!("tokens file {}: {}", path.display(), e.0)))
142 }
143
144 pub fn authenticate(&self, token: &str) -> Option<&TokenEntry> {
147 let presented = hash_token(token);
148 let mut found = None;
149 for e in &self.entries {
150 if constant_time_eq(&e.hash, &presented) && found.is_none() {
151 found = Some(e);
152 }
153 }
154 found
155 }
156
157 pub fn by_name(&self, name: &str) -> Option<&TokenEntry> {
159 self.entries.iter().find(|e| e.name == name)
160 }
161
162 pub fn len(&self) -> usize {
164 self.entries.len()
165 }
166
167 pub fn is_empty(&self) -> bool {
169 self.entries.is_empty()
170 }
171}
172
173fn parse_hash(s: &str) -> Option<[u8; 32]> {
174 let hex_part = s.strip_prefix("sha256:")?;
175 if hex_part.len() != 64 || hex_part.bytes().any(|c| c.is_ascii_uppercase()) {
176 return None;
177 }
178 hex::decode(hex_part).ok()?.try_into().ok()
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 const H: &str = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
186
187 #[test]
188 fn generated_tokens_have_prefix_and_entropy() {
189 let a = generate_token();
190 let b = generate_token();
191 assert!(a.starts_with(TOKEN_PREFIX));
192 assert_eq!(
193 a.len(),
194 TOKEN_PREFIX.len() + 43,
195 "32 bytes base64url no pad"
196 );
197 assert_ne!(a, b);
198 }
199
200 #[test]
201 fn hash_matches_sha256_of_full_token() {
202 assert_eq!(format_hash(&hash_token("test")), H);
204 }
205
206 #[test]
207 fn tokens_file_line_parses_back() {
208 let tok = generate_token();
209 let set = TokenSet::parse(&tokens_file_line("ci-bot", Scope::Operate, &tok)).unwrap();
210 let e = set.authenticate(&tok).expect("round-trip authenticates");
211 assert_eq!((e.name.as_str(), e.scope), ("ci-bot", Scope::Operate));
212 }
213
214 #[test]
215 fn parse_skips_comments_and_blank_lines() {
216 let src = format!("# header\n\nalice admin {H}\n \n");
217 let set = TokenSet::parse(&src).unwrap();
218 assert_eq!(set.len(), 1);
219 assert_eq!(set.by_name("alice").unwrap().scope, Scope::Admin);
220 }
221
222 #[test]
223 fn parse_rejects_bad_input_naming_the_line() {
224 let cases = [
225 (String::from("alice admin"), "line 1"),
226 (format!("alice root {H}"), "unknown scope"),
227 (format!("Alice admin {H}"), "invalid token name"),
228 (String::from("alice admin sha256:abc"), "hash"),
229 (format!("alice admin md5:{}", &H[7..]), "hash"),
230 (format!("alice admin {H}\nalice read {H}"), "duplicate"),
231 (String::from("# only comments\n"), "no tokens"),
232 ];
233 for (src, needle) in cases {
234 let err = TokenSet::parse(&src)
235 .err()
236 .unwrap_or_else(|| panic!("accepted: {src}"));
237 assert!(err.0.contains(needle), "{src:?} → {err}");
238 assert!(
239 !err.0.contains(&H[7..]),
240 "error must not echo the hash: {err}"
241 );
242 }
243 }
244
245 #[test]
246 fn authenticate_rejects_wrong_and_near_miss_tokens() {
247 let tok = generate_token();
248 let set = TokenSet::parse(&tokens_file_line("a", Scope::Read, &tok)).unwrap();
249 assert!(set.authenticate("pspo_nope").is_none());
250 let mut near = tok.clone();
251 let last = near.pop().unwrap();
252 near.push(if last == 'A' { 'B' } else { 'A' });
253 assert_ne!(near, tok);
254 assert!(set.authenticate(&near).is_none());
255 assert!(set.authenticate("").is_none());
256 }
257
258 #[test]
259 fn token_names_are_validated() {
260 assert!(validate_token_name("ariel").is_ok());
261 assert!(validate_token_name("ci_bot-2").is_ok());
262 for bad in ["", "-x", "_x", "Bob", "a b", &"a".repeat(64)] {
263 assert!(validate_token_name(bad).is_err(), "{bad:?}");
264 }
265 }
266
267 #[test]
268 fn constant_time_eq_semantics() {
269 assert!(constant_time_eq(b"abc", b"abc"));
270 assert!(!constant_time_eq(b"abc", b"abd"));
271 assert!(!constant_time_eq(b"abc", b"abcd"));
272 }
273
274 #[test]
275 fn load_reports_missing_file() {
276 let dir = tempfile::tempdir().unwrap();
277 let err = TokenSet::load(&dir.path().join("nope")).err().unwrap();
278 assert!(err.0.contains("nope"), "{err}");
279 }
280}