| 1 | // This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | |
| 5 | //! Opaque session tokens. |
| 6 | //! |
| 7 | //! A session token is 32 bytes of OS randomness, base64url-encoded and handed to |
| 8 | //! the browser in a cookie. The database stores **only** the SHA-256 of that |
| 9 | //! cookie value as the session-row id, so a leak of the database does not leak |
| 10 | //! usable session tokens: an attacker would still need the pre-image. Lookup |
| 11 | //! hashes the presented cookie the same way and matches on the digest. |
| 12 | |
| 13 | use base64::Engine; |
| 14 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 15 | use rand_core::{OsRng, RngCore}; |
| 16 | use sha2::{Digest, Sha256}; |
| 17 | |
| 18 | /// Number of random bytes behind a session token, before encoding. |
| 19 | pub const SESSION_TOKEN_BYTES: usize = 32; |
| 20 | |
| 21 | /// A freshly minted session token, split into the two halves that live in |
| 22 | /// different places. |
| 23 | #[derive(Debug, Clone)] |
| 24 | pub struct SessionToken { |
| 25 | /// The value placed in the user's cookie. Secret — treat like a password. |
| 26 | pub cookie: String, |
| 27 | /// The SHA-256 of [`SessionToken::cookie`], stored as the `sessions.id` |
| 28 | /// primary key. Safe to persist and log. |
| 29 | pub id: String, |
| 30 | } |
| 31 | |
| 32 | /// Mint a new session token: draw 32 bytes from the OS CSPRNG, encode them for |
| 33 | /// the cookie, and derive the id the database will key on. |
| 34 | #[must_use] |
| 35 | pub fn new_session_token() -> SessionToken { |
| 36 | let mut bytes = [0u8; SESSION_TOKEN_BYTES]; |
| 37 | OsRng.fill_bytes(&mut bytes); |
| 38 | let cookie = URL_SAFE_NO_PAD.encode(bytes); |
| 39 | let id = session_id(&cookie); |
| 40 | SessionToken { cookie, id } |
| 41 | } |
| 42 | |
| 43 | /// Derive the `sessions.id` for a presented cookie value. This is the one place |
| 44 | /// the cookie-to-id mapping is defined; login (minting) and every subsequent |
| 45 | /// request (lookup) both route through it so they cannot disagree. |
| 46 | #[must_use] |
| 47 | pub fn session_id(cookie: &str) -> String { |
| 48 | let digest = Sha256::digest(cookie.as_bytes()); |
| 49 | hex(&digest) |
| 50 | } |
| 51 | |
| 52 | /// Lowercase hex encoding of a byte slice. |
| 53 | fn hex(bytes: &[u8]) -> String { |
| 54 | use std::fmt::Write as _; |
| 55 | let mut out = String::with_capacity(bytes.len() * 2); |
| 56 | for b in bytes { |
| 57 | // Writing to a String is infallible. |
| 58 | let _ = write!(out, "{b:02x}"); |
| 59 | } |
| 60 | out |
| 61 | } |
| 62 | |
| 63 | #[cfg(test)] |
| 64 | mod tests { |
| 65 | use super::*; |
| 66 | |
| 67 | #[test] |
| 68 | fn tokens_are_unique_and_hex_ided() { |
| 69 | let a = new_session_token(); |
| 70 | let b = new_session_token(); |
| 71 | assert_ne!(a.cookie, b.cookie, "each token draws fresh randomness"); |
| 72 | assert_ne!(a.id, b.id); |
| 73 | // SHA-256 hex is 64 characters, all hex digits. |
| 74 | assert_eq!(a.id.len(), 64); |
| 75 | assert!(a.id.bytes().all(|c| c.is_ascii_hexdigit())); |
| 76 | } |
| 77 | |
| 78 | #[test] |
| 79 | fn id_is_a_pure_function_of_the_cookie() { |
| 80 | let token = new_session_token(); |
| 81 | assert_eq!( |
| 82 | session_id(&token.cookie), |
| 83 | token.id, |
| 84 | "lookup must reproduce the stored id" |
| 85 | ); |
| 86 | } |
| 87 | |
| 88 | #[test] |
| 89 | fn different_cookies_hash_differently() { |
| 90 | assert_ne!(session_id("a"), session_id("b")); |
| 91 | // Known-answer: SHA-256("") has a well-known digest prefix. |
| 92 | assert!(session_id("").starts_with("e3b0c44298fc1c14")); |
| 93 | } |
| 94 | } |