Skip to main content

ocre/
token.rs

1//! Random tokens for emailed links and API keys, stored as digests.
2//!
3//! Used by `ocre g auth` for password-reset and magic-link emails and for API
4//! keys. A token is 32 random bytes (256 bits) from the platform's secure
5//! random generator (WebCrypto on Workers), encoded as URL-safe base64
6//! without padding: 43 characters, safe in URLs, headers and emails.
7//!
8//! Store only [`digest`]s: a leaked database then holds no usable token. A
9//! fast hash (SHA-256) is enough here because tokens are random and long;
10//! passwords, which people choose, need [`crate::password`] instead. Compare
11//! secrets that cannot be looked up by digest with [`constant_time_eq`].
12//!
13//! Cost: one SHA-256 per [`digest`]; this module uses no binding (the app
14//! stores the digest in D1).
15//!
16//! ```
17//! // Issue: email or show `token` once, store `stored` (e.g. in a `token_digest` column).
18//! let token = ocre::token::generate();
19//! let stored = ocre::token::digest(&token);
20//! // Later, from a request: look the row up by digest (`WHERE token_digest = ?1`).
21//! assert_eq!(ocre::token::digest(&token), stored);
22//! ```
23
24use std::fmt::Write as _;
25
26use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
27use sha2::{Digest as _, Sha256};
28
29/// Random bytes in a token generated by [`generate`]: 32 (256 bits).
30///
31/// # Examples
32///
33/// ```
34/// assert_eq!(ocre::token::TOKEN_BYTES, 32);
35/// ```
36pub const TOKEN_BYTES: usize = 32;
37
38/// A new random token: [`TOKEN_BYTES`] secure random bytes as URL-safe base64 without padding (43 characters).
39///
40/// Show or email it once and store only its [`digest`].
41///
42/// # Panics
43///
44/// If the platform's secure random generator is unavailable (WebCrypto on
45/// Workers, the OS generator natively), which does not happen on supported
46/// targets.
47///
48/// # Examples
49///
50/// ```
51/// let token = ocre::token::generate();
52/// assert_eq!(token.len(), 43);
53/// assert!(token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
54/// assert_ne!(token, ocre::token::generate());
55/// ```
56pub fn generate() -> String {
57    URL_SAFE_NO_PAD.encode(random_bytes::<TOKEN_BYTES>())
58}
59
60/// A random id for URLs: 22 URL-safe characters (128 bits), the value of a
61/// `public_id:token` column. Unguessable, so a page at `/videos/<public_id>`
62/// cannot be found by counting, and it says nothing about how many rows exist.
63///
64/// # Panics
65///
66/// As [`generate`].
67///
68/// # Examples
69///
70/// ```
71/// let id = ocre::token::public_id();
72/// assert_eq!(id.len(), 22);
73/// assert!(id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
74/// ```
75pub fn public_id() -> String {
76    URL_SAFE_NO_PAD.encode(random_bytes::<16>())
77}
78
79/// SHA-256 of `token` as 64 lowercase hex characters: the value to store and look up.
80///
81/// Query by it (`WHERE digest = ?1`), so the database never holds the token
82/// itself. Deterministic: the same token always gives the same digest.
83///
84/// # Examples
85///
86/// ```
87/// assert_eq!(ocre::token::digest("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
88/// ```
89pub fn digest(token: &str) -> String {
90    let hash = Sha256::digest(token.as_bytes());
91    let mut hex = String::with_capacity(hash.len() * 2);
92    for byte in hash {
93        write!(hex, "{byte:02x}").expect("writing to a String");
94    }
95    hex
96}
97
98/// Whether `a` equals `b`, compared in constant time for equal lengths.
99///
100/// Response timing then does not reveal how many leading bytes matched.
101/// Different lengths return `false` at once: lengths are not secret.
102///
103/// # Examples
104///
105/// ```
106/// assert!(ocre::token::constant_time_eq(b"secret", b"secret"));
107/// assert!(!ocre::token::constant_time_eq(b"secret", b"secreT"));
108/// assert!(!ocre::token::constant_time_eq(b"secret", b"secrets"));
109/// ```
110pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
111    if a.len() != b.len() {
112        return false;
113    }
114    let difference = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y));
115    // Keeps the optimizer from turning the loop into an early exit.
116    std::hint::black_box(difference) == 0
117}
118
119/// `N` bytes from the secure random generator.
120pub(crate) fn random_bytes<const N: usize>() -> [u8; N] {
121    let mut bytes = [0u8; N];
122    getrandom::getrandom(&mut bytes).expect("the secure random generator is available");
123    bytes
124}
125
126#[cfg(test)]
127#[path = "../tests/token.rs"]
128mod tests;