fabrica

hanna/fabrica

3258 bytes
Raw
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
13use base64::Engine;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use rand_core::{OsRng, RngCore};
16use sha2::{Digest, Sha256};
17
18/// Number of random bytes behind a session token, before encoding.
19pub 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)]
24pub 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]
35pub 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]
47pub 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.
53fn 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)]
64mod 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}