Skip to main content

pacenotes_server/auth/
token_secret.rs

1/// Marks a Pacenotes access key, for users and for secret scanners.
2pub const TOKEN_PREFIX: &str = "pcn_";
3
4/// The random part of a minted key: 32 bytes as unpadded base64url.
5const TOKEN_BODY_LEN: usize = 43;
6
7/// `pcn_` + 32 random bytes as unpadded base64url; the caller shows it once
8/// and stores only `hash_token` of it.
9pub fn mint_token() -> String {
10    let mut bytes = [0u8; 32];
11    <rand::rngs::OsRng as rand::RngCore>::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);
12    format!("{TOKEN_PREFIX}{}", base64url(&bytes))
13}
14
15/// Lowercase hex sha256: the only form of a credential the server keeps.
16pub fn hash_token(raw: &str) -> String {
17    crate::store::sha256_bytes(raw.as_bytes())
18}
19
20/// Whether `mint_token` could have produced this, checked before any database
21/// lookup so a garbage header costs no query, which remote libSQL bills.
22pub fn well_formed(raw: &str) -> bool {
23    raw.strip_prefix(TOKEN_PREFIX).is_some_and(|body| {
24        body.len() == TOKEN_BODY_LEN
25            && body
26                .bytes()
27                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
28    })
29}
30
31fn base64url(bytes: &[u8]) -> String {
32    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
33    let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
34    for chunk in bytes.chunks(3) {
35        let padded = [
36            chunk[0],
37            chunk.get(1).copied().unwrap_or(0),
38            chunk.get(2).copied().unwrap_or(0),
39        ];
40        let group =
41            (u32::from(padded[0]) << 16) | (u32::from(padded[1]) << 8) | u32::from(padded[2]);
42        let sextets = [
43            group >> 18 & 63,
44            group >> 12 & 63,
45            group >> 6 & 63,
46            group & 63,
47        ];
48        let unpadded_length = chunk.len() + 1;
49        for sextet in sextets.iter().take(unpadded_length) {
50            encoded.push(ALPHABET[*sextet as usize] as char);
51        }
52    }
53    encoded
54}
55
56#[cfg(test)]
57mod tests {
58    #[test]
59    fn hashing_is_stable_and_hides_the_token() {
60        assert_eq!(
61            super::hash_token("abc"),
62            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
63        );
64        let token = super::mint_token();
65        assert!(!super::hash_token(&token).contains(&token));
66    }
67
68    #[test]
69    fn minted_tokens_are_prefixed_unique_and_url_safe() {
70        let first = super::mint_token();
71        let second = super::mint_token();
72        assert_ne!(first, second);
73        assert!(first.starts_with(super::TOKEN_PREFIX), "{first}");
74        let encoded_32_bytes = 43;
75        assert_eq!(
76            first.len(),
77            super::TOKEN_PREFIX.len() + encoded_32_bytes,
78            "{first}"
79        );
80        assert!(
81            first[super::TOKEN_PREFIX.len()..]
82                .bytes()
83                .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'),
84            "{first}"
85        );
86    }
87
88    #[test]
89    fn only_minted_shapes_are_well_formed() {
90        let body = "A".repeat(43);
91        for good in [
92            super::mint_token(),
93            format!("pcn_{body}"),
94            format!("pcn_{}abc", "-_9z".repeat(10)),
95        ] {
96            assert!(super::well_formed(&good), "{good}");
97        }
98        for bad in [
99            String::new(),
100            "not-a-token".to_string(),
101            body.clone(),
102            format!("pcn_{}", "A".repeat(42)),
103            format!("pcn_{}", "A".repeat(44)),
104            format!("pcn_{}=", "A".repeat(42)),
105            format!("pcn_{}+", "A".repeat(42)),
106            format!("PCN_{body}"),
107        ] {
108            assert!(!super::well_formed(&bad), "{bad}");
109        }
110    }
111
112    #[test]
113    fn base64url_matches_known_vectors() {
114        let vectors: [(&[u8], &str); 6] = [
115            (b"", ""),
116            (b"f", "Zg"),
117            (b"fo", "Zm8"),
118            (b"foo", "Zm9v"),
119            (b"foobar", "Zm9vYmFy"),
120            (&[0xfb, 0xff], "-_8"),
121        ];
122        for (input, expected) in vectors {
123            assert_eq!(super::base64url(input), expected, "{input:?}");
124        }
125    }
126}