Skip to main content

prospero_core/
auth.rs

1//! API token model for inbound authentication (#2, ADR-0010).
2//!
3//! Tokens are `pspo_` + 32 random bytes (base64url, no padding). prosperod
4//! stores only SHA-256 hashes, declared one per line in a tokens file:
5//! `<name> <scope> sha256:<hex>`. Kept here (not in `prospero-api`) so the CLI's
6//! offline `prospero token new` shares the exact format.
7
8use 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
17/// Prefix on every generated token, so leaked tokens are greppable.
18pub const TOKEN_PREFIX: &str = "pspo_";
19
20/// A fresh random token.
21pub 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
27/// SHA-256 over the full token string.
28pub fn hash_token(token: &str) -> [u8; 32] {
29    Sha256::digest(token.as_bytes()).into()
30}
31
32/// `sha256:<lowercase hex>`.
33pub fn format_hash(hash: &[u8; 32]) -> String {
34    format!("sha256:{}", hex::encode(hash))
35}
36
37/// `[a-z0-9][a-z0-9_-]{0,62}`.
38pub 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
55/// The tokens-file line for a freshly generated token.
56pub 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
64/// Constant-time byte-string equality: early exit only on a length mismatch
65/// (lengths aren't secret), then XOR-accumulate every byte. Shared by the
66/// session-plane preamble check and inbound API auth.
67pub 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/// One configured token.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct TokenEntry {
81    /// Unique name (recorded as the actor).
82    pub name: String,
83    /// Permission level.
84    pub scope: Scope,
85    /// SHA-256 of the token.
86    pub hash: [u8; 32],
87}
88
89/// A tokens-file problem. The message names the file/line, never a hash.
90#[derive(Debug, thiserror::Error)]
91#[error("{0}")]
92pub struct TokensFileError(pub String);
93
94/// The parsed tokens file.
95#[derive(Debug, Clone)]
96pub struct TokenSet {
97    entries: Vec<TokenEntry>,
98}
99
100impl TokenSet {
101    /// Parse tokens-file contents.
102    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    /// Read and parse a tokens file. Missing/unreadable is an error.
137    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    /// The entry whose hash matches `token`. Compares against **every** entry
145    /// (no early return on a match) so timing doesn't reveal which one matched.
146    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    /// Look up an entry by name (cookie verification).
158    pub fn by_name(&self, name: &str) -> Option<&TokenEntry> {
159        self.entries.iter().find(|e| e.name == name)
160    }
161
162    /// Number of configured tokens.
163    pub fn len(&self) -> usize {
164        self.entries.len()
165    }
166
167    /// Always false for a successfully parsed set (kept for clippy's `len_without_is_empty`).
168    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        // sha256("test") — the fixture hash H above.
203        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}