// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Opaque session tokens. //! //! A session token is 32 bytes of OS randomness, base64url-encoded and handed to //! the browser in a cookie. The database stores **only** the SHA-256 of that //! cookie value as the session-row id, so a leak of the database does not leak //! usable session tokens: an attacker would still need the pre-image. Lookup //! hashes the presented cookie the same way and matches on the digest. use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use rand_core::{OsRng, RngCore}; use sha2::{Digest, Sha256}; /// Number of random bytes behind a session token, before encoding. pub const SESSION_TOKEN_BYTES: usize = 32; /// A freshly minted session token, split into the two halves that live in /// different places. #[derive(Debug, Clone)] pub struct SessionToken { /// The value placed in the user's cookie. Secret — treat like a password. pub cookie: String, /// The SHA-256 of [`SessionToken::cookie`], stored as the `sessions.id` /// primary key. Safe to persist and log. pub id: String, } /// Mint a new session token: draw 32 bytes from the OS CSPRNG, encode them for /// the cookie, and derive the id the database will key on. #[must_use] pub fn new_session_token() -> SessionToken { let mut bytes = [0u8; SESSION_TOKEN_BYTES]; OsRng.fill_bytes(&mut bytes); let cookie = URL_SAFE_NO_PAD.encode(bytes); let id = session_id(&cookie); SessionToken { cookie, id } } /// Derive the `sessions.id` for a presented cookie value. This is the one place /// the cookie-to-id mapping is defined; login (minting) and every subsequent /// request (lookup) both route through it so they cannot disagree. #[must_use] pub fn session_id(cookie: &str) -> String { let digest = Sha256::digest(cookie.as_bytes()); hex(&digest) } /// Lowercase hex encoding of a byte slice. fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { // Writing to a String is infallible. let _ = write!(out, "{b:02x}"); } out } #[cfg(test)] mod tests { use super::*; #[test] fn tokens_are_unique_and_hex_ided() { let a = new_session_token(); let b = new_session_token(); assert_ne!(a.cookie, b.cookie, "each token draws fresh randomness"); assert_ne!(a.id, b.id); // SHA-256 hex is 64 characters, all hex digits. assert_eq!(a.id.len(), 64); assert!(a.id.bytes().all(|c| c.is_ascii_hexdigit())); } #[test] fn id_is_a_pure_function_of_the_cookie() { let token = new_session_token(); assert_eq!( session_id(&token.cookie), token.id, "lookup must reproduce the stored id" ); } #[test] fn different_cookies_hash_differently() { assert_ne!(session_id("a"), session_id("b")); // Known-answer: SHA-256("") has a well-known digest prefix. assert!(session_id("").starts_with("e3b0c44298fc1c14")); } }