use alloc::string::String;
use core::cell::RefCell;
use core::fmt::Write as _;
use core::sync::atomic::{AtomicU32, Ordering};
use embassy_sync::blocking_mutex::Mutex;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_time::{Duration, Instant};
use picoserve::ResponseSent;
use picoserve::io::Read;
use picoserve::request::{Path, RequestParts};
use picoserve::response::{IntoResponse, ResponseWriter, StatusCode};
use picoserve::routing::{Layer, Next};
use subtle::ConstantTimeEq;
use crate::web::TextBody;
pub const COOKIE: &str = "session";
pub const LIFETIME_SECONDS: u64 = 12 * 60 * 60;
pub const LIFETIME: Duration = Duration::from_secs(LIFETIME_SECONDS);
pub const PASSWORD_MIN: usize = 8;
pub const ITERATIONS: u32 = 100_000;
const TOKEN_BYTES: usize = 32;
const SALT_BYTES: usize = 16;
const HASH_BYTES: usize = 32;
const SCHEME: &str = "pbkdf2-sha256";
pub fn store(password: &str) -> String {
let mut salt = [0u8; SALT_BYTES];
fill(&mut salt);
store_with(password, &salt, ITERATIONS)
}
fn store_with(password: &str, salt: &[u8; SALT_BYTES], iterations: u32) -> String {
let hash = derive(password, salt, iterations);
let mut text = String::new();
let _ = write!(text, "{SCHEME}${iterations}$");
push_hex(&mut text, salt);
text.push('$');
push_hex(&mut text, &hash);
text
}
pub fn verify(password: &str, stored: &str) -> bool {
let Some((salt, expected, iterations)) = parse_stored(stored) else {
return false;
};
let computed = derive(password, &salt, iterations);
bool::from(computed.ct_eq(&expected))
}
fn parse_stored(stored: &str) -> Option<([u8; SALT_BYTES], [u8; HASH_BYTES], u32)> {
let mut fields = stored.split('$');
if fields.next()? != SCHEME {
return None;
}
let iterations: u32 = fields.next()?.parse().ok()?;
if iterations == 0 {
return None;
}
let salt = decode_hex::<SALT_BYTES>(fields.next()?)?;
let hash = decode_hex::<HASH_BYTES>(fields.next()?)?;
if fields.next().is_some() {
return None;
}
Some((salt, hash, iterations))
}
fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; HASH_BYTES] {
let mut out = [0u8; HASH_BYTES];
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, iterations, &mut out);
out
}
pub struct Sessions<const N: usize> {
table: Mutex<CriticalSectionRawMutex, RefCell<[Option<Session>; N]>>,
next_serial: AtomicU32,
lifetime: Duration,
}
#[derive(Clone, Copy)]
struct Session {
token: [u8; TOKEN_BYTES],
expires: Instant,
serial: u32,
}
impl<const N: usize> Sessions<N> {
pub const fn new(lifetime: Duration) -> Self {
Sessions {
table: Mutex::new(RefCell::new([None; N])),
next_serial: AtomicU32::new(0),
lifetime,
}
}
pub fn open(&self) -> String {
let mut token = [0u8; TOKEN_BYTES];
fill(&mut token);
let now = Instant::now();
let session = Session {
token,
expires: now + self.lifetime,
serial: self.next_serial.fetch_add(1, Ordering::Relaxed),
};
self.table.lock(|table| {
let mut table = table.borrow_mut();
if let Some(slot) = table
.iter_mut()
.find(|slot| slot.is_none_or(|held| held.expires <= now))
{
*slot = Some(session);
return;
}
let oldest = table
.iter()
.enumerate()
.filter_map(|(index, slot)| slot.map(|held| (index, held.serial)))
.min_by_key(|&(_, serial)| serial)
.map_or(0, |(index, _)| index);
table[oldest] = Some(session);
});
let mut hex = String::new();
push_hex(&mut hex, &token);
hex
}
pub fn accepts(&self, token: &str) -> bool {
let Some(raw) = decode_hex::<TOKEN_BYTES>(token) else {
return false;
};
let now = Instant::now();
self.table.lock(|table| {
let mut table = table.borrow_mut();
for slot in table.iter_mut() {
let Some(session) = slot else { continue };
if !bool::from(session.token.ct_eq(&raw)) {
continue;
}
if session.expires <= now {
*slot = None;
return false;
}
return true;
}
false
})
}
pub fn close(&self, token: &str) {
let Some(raw) = decode_hex::<TOKEN_BYTES>(token) else {
return;
};
self.table.lock(|table| {
for slot in table.borrow_mut().iter_mut() {
if slot.is_some_and(|held| bool::from(held.token.ct_eq(&raw))) {
*slot = None;
}
}
});
}
pub fn close_all(&self) {
self.table.lock(|table| *table.borrow_mut() = [None; N]);
}
}
pub fn session_cookie(header: &str) -> Option<&str> {
header.split(';').find_map(|pair| {
let (name, value) = pair.split_once('=')?;
(name.trim() == COOKIE).then(|| value.trim())
})
}
pub fn set_cookie(token: &str) -> String {
alloc::format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={LIFETIME_SECONDS}"
)
}
pub fn clear_cookie() -> String {
alloc::format!("{COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")
}
pub const PATH_MAX: usize = 192;
pub fn decoded_path(path: Path<'_>) -> Option<heapless::String<PATH_MAX>> {
let mut decoded = heapless::String::new();
for segment in path.segments() {
decoded.push('/').ok()?;
for character in segment.chars() {
decoded.push(character.ok()?.into_char()).ok()?;
}
}
Some(decoded)
}
pub struct RequireLogin<'a, const N: usize> {
sessions: &'a Sessions<N>,
required: fn() -> bool,
public: fn(&str, &str) -> bool,
}
impl<'a, const N: usize> RequireLogin<'a, N> {
pub fn new(
sessions: &'a Sessions<N>,
required: fn() -> bool,
public: fn(&str, &str) -> bool,
) -> Self {
RequireLogin {
sessions,
required,
public,
}
}
fn allows(&self, path: Option<&str>, method: &str, cookie: Option<&str>) -> bool {
if !(self.required)() {
return true;
}
if path.is_some_and(|path| (self.public)(path, method)) {
return true;
}
cookie
.and_then(session_cookie)
.is_some_and(|token| self.sessions.accepts(token))
}
}
impl<const N: usize, State, PathParameters> Layer<State, PathParameters> for RequireLogin<'_, N> {
type NextState = State;
type NextPathParameters = PathParameters;
async fn call_layer<
'a,
R: Read + 'a,
NextLayer: Next<'a, R, Self::NextState, Self::NextPathParameters>,
W: ResponseWriter<Error = R::Error>,
>(
&self,
next: NextLayer,
state: &State,
path_parameters: PathParameters,
request_parts: RequestParts<'_>,
response_writer: W,
) -> Result<ResponseSent, W::Error> {
let cookie = request_parts
.headers()
.get("cookie")
.and_then(|value| value.as_str().ok());
let path = decoded_path(request_parts.path());
if self.allows(path.as_deref(), request_parts.method(), cookie) {
return next.run(state, path_parameters, response_writer).await;
}
(
StatusCode::new(401),
TextBody {
body: String::from(r#"{"ok":false,"error":"a session is required"}"#),
content_type: "application/json",
},
)
.write_to(next.into_connection().await?, response_writer)
.await
}
}
fn fill(out: &mut [u8]) {
getrandom::getrandom(out).expect("no randomness for a salt or a session token");
}
fn push_hex(out: &mut String, bytes: &[u8]) {
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
}
fn decode_hex<const N: usize>(text: &str) -> Option<[u8; N]> {
if text.len() != N * 2 {
return None;
}
let digit = |byte: u8| match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
};
let mut raw = [0u8; N];
let (pairs, _) = text.as_bytes().as_chunks::<2>();
for (byte, pair) in raw.iter_mut().zip(pairs) {
*byte = (digit(pair[0])? << 4) | digit(pair[1])?;
}
Some(raw)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_ROUNDS: u32 = 1000;
#[test]
fn a_stored_password_verifies_and_another_does_not() {
let stored = store_with("correct horse", &[7; SALT_BYTES], TEST_ROUNDS);
assert!(verify("correct horse", &stored));
assert!(!verify("correct hors", &stored));
assert!(!verify("", &stored));
}
#[test]
fn the_stored_form_is_scheme_count_salt_hash() {
let stored = store_with("pw", &[0xab; SALT_BYTES], 42);
let fields: alloc::vec::Vec<&str> = stored.split('$').collect();
assert_eq!(fields[0], "pbkdf2-sha256");
assert_eq!(fields[1], "42");
assert_eq!(fields[2], "ab".repeat(SALT_BYTES));
assert_eq!(fields[3].len(), HASH_BYTES * 2);
assert_eq!(fields.len(), 4);
}
#[test]
fn store_salts_freshly_each_time() {
let first = store("same password");
let second = store("same password");
assert_ne!(first, second);
assert!(verify("same password", &first));
}
#[test]
fn a_malformed_verifier_is_a_no() {
let good = store_with("pw", &[1; SALT_BYTES], TEST_ROUNDS);
let (head, hash) = good.rsplit_once('$').unwrap();
for stored in [
"",
"pw",
&good.replace("pbkdf2-sha256", "pbkdf2-sha1"),
&good.replacen(&alloc::format!("${TEST_ROUNDS}$"), "$0$", 1),
&alloc::format!("{good}$extra"),
&alloc::format!("{head}${}", &hash[1..]),
&alloc::format!("{head}${}g", &hash[1..]),
] {
assert!(!verify("pw", stored), "accepted {stored:?}");
}
}
#[test]
fn an_upper_case_verifier_still_reads() {
let stored = store_with("pw", &[0xcd; SALT_BYTES], TEST_ROUNDS);
assert!(verify(
"pw",
&stored.to_uppercase().replace("PBKDF2-SHA256", SCHEME)
));
}
#[test]
fn the_session_cookie_is_found_among_others() {
assert_eq!(session_cookie("session=abc"), Some("abc"));
assert_eq!(session_cookie("theme=dark; session=abc; x=1"), Some("abc"));
assert_eq!(session_cookie(" session = abc "), Some("abc"));
assert_eq!(session_cookie("sessionx=abc; xsession=def"), None);
assert_eq!(session_cookie(""), None);
}
#[test]
fn the_cookies_say_what_they_should() {
assert_eq!(
set_cookie("ab12"),
"session=ab12; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200"
);
assert!(clear_cookie().ends_with("Max-Age=0"));
assert!(!set_cookie("t").contains("Secure"));
}
#[test]
fn a_session_is_accepted_until_it_expires_or_closes() {
let sessions: Sessions<2> = Sessions::new(LIFETIME);
let token = sessions.open();
assert_eq!(token.len(), TOKEN_BYTES * 2);
assert!(sessions.accepts(&token));
assert!(sessions.accepts(&token.to_uppercase()));
assert!(!sessions.accepts("not hex"));
assert!(!sessions.accepts(&"0".repeat(TOKEN_BYTES * 2)));
sessions.close(&token);
assert!(!sessions.accepts(&token));
let expired: Sessions<2> = Sessions::new(Duration::from_ticks(0));
let token = expired.open();
assert!(!expired.accepts(&token));
let first = expired.open();
let _second = expired.open();
assert!(!expired.accepts(&first));
}
#[test]
fn a_full_table_evicts_the_oldest_login() {
let sessions: Sessions<2> = Sessions::new(LIFETIME);
let first = sessions.open();
let second = sessions.open();
let third = sessions.open();
assert!(!sessions.accepts(&first));
assert!(sessions.accepts(&second));
assert!(sessions.accepts(&third));
sessions.close_all();
assert!(!sessions.accepts(&second));
assert!(!sessions.accepts(&third));
}
#[test]
fn the_gate_lets_through_what_it_should() {
static SESSIONS: Sessions<2> = Sessions::new(LIFETIME);
fn required() -> bool {
true
}
fn open() -> bool {
false
}
fn public(path: &str, method: &str) -> bool {
matches!((method, path), ("POST", "/api/v1/login"))
}
let gate = RequireLogin::new(&SESSIONS, required, public);
assert!(gate.allows(Some("/api/v1/login"), "POST", None));
assert!(!gate.allows(Some("/api/v1/login"), "GET", None));
assert!(!gate.allows(Some("/api/v1/config"), "GET", None));
assert!(!gate.allows(None, "POST", None));
let token = SESSIONS.open();
let cookie = alloc::format!("theme=dark; session={token}");
assert!(gate.allows(Some("/api/v1/config"), "GET", Some(&cookie)));
assert!(!gate.allows(Some("/api/v1/config"), "GET", Some("session=00")));
let open_board = RequireLogin::new(&SESSIONS, open, public);
assert!(open_board.allows(None, "POST", None));
}
}