Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
7.0 kB · 216 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217//! The `headquarters_session` cookie: HMAC-signed `{did, exp}`.//!//! HttpOnly keeps page JavaScript from *reading* a cookie; it says nothing//! about who can *write* one, and the DID in this cookie is the entire//! authorization decision — unsigned, it is a text field where you type who//! you are (docs/identity-and-sessions.md).
use axum_extra::extract::cookie::{Cookie, SameSite};use base64::Engine;use base64::engine::general_purpose::URL_SAFE_NO_PAD;use hmac::{Hmac, Mac};use sha2::Sha256;
pub const COOKIE_NAME: &str = "headquarters_session";const SESSION_TTL_SECONDS: i64 = 30 * 24 * 60 * 60;
#[derive(serde::Serialize, serde::Deserialize)]struct Claims { did: String, exp: i64,}
pub struct SessionSigner { secret: Vec<u8>, secure: bool, domain: Option<String>,}
impl SessionSigner { pub fn new(secret: Vec<u8>, secure: bool, domain: Option<String>) -> SessionSigner { SessionSigner { secret, secure, domain, } }
fn mac(&self, payload: &[u8]) -> Hmac<Sha256> { let mut mac = Hmac::<Sha256>::new_from_slice(&self.secret).expect("hmac accepts any key length"); mac.update(payload); mac }
// SameSite=Lax, not Strict: the OAuth callback is a top-level navigation // from a different site, and Strict would drop the cookie on the redirect // immediately after it is set. With COOKIE_DOMAIN set the cookie goes to // every subdomain, so any service under lance.blue that holds the HMAC // secret can verify a session without this API being up — and anything // that proxies to a match container must strip Cookie, because the // browser sends it there too. Unset (development), the cookie is // host-only and goes to this API alone. fn attributes(&self, cookie: Cookie<'static>) -> Cookie<'static> { let mut cookie = cookie; cookie.set_http_only(true); cookie.set_same_site(SameSite::Lax); cookie.set_path("/"); cookie.set_secure(self.secure); if let Some(domain) = &self.domain { cookie.set_domain(domain.clone()); } cookie }
pub fn issue(&self, did: &str) -> Cookie<'static> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock before 1970") .as_secs() as i64; let payload = serde_json::to_vec(&Claims { did: did.to_owned(), exp: now + SESSION_TTL_SECONDS, }) .expect("claims serialize"); let tag = self.mac(&payload).finalize().into_bytes(); let value = format!( "{}.{}", URL_SAFE_NO_PAD.encode(&payload), URL_SAFE_NO_PAD.encode(tag) );
let mut cookie = self.attributes(Cookie::new(COOKIE_NAME, value)); cookie.set_max_age(cookie::time::Duration::seconds(SESSION_TTL_SECONDS)); cookie }
pub fn clear(&self) -> Cookie<'static> { let mut cookie = self.attributes(Cookie::new(COOKIE_NAME, "")); cookie.set_max_age(cookie::time::Duration::ZERO); cookie }
/// The DID, iff the tag verifies and the session has not expired. pub fn verify(&self, value: &str) -> Option<String> { let (payload_b64, tag_b64) = value.split_once('.')?; let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; let tag = URL_SAFE_NO_PAD.decode(tag_b64).ok()?;
// Constant-time comparison; a straight == would leak the tag byte by // byte. self.mac(&payload).verify_slice(&tag).ok()?;
let claims: Claims = serde_json::from_slice(&payload).ok()?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock before 1970") .as_secs() as i64; (claims.exp > now).then_some(claims.did) }}
#[cfg(test)]mod tests { use super::*;
fn signer() -> SessionSigner { SessionSigner::new(b"0123456789abcdef0123456789abcdef".to_vec(), false, None) }
#[test] fn round_trip() { let s = signer(); let cookie = s.issue("did:plc:abc"); assert_eq!(s.verify(cookie.value()), Some("did:plc:abc".to_owned())); }
#[test] fn tampering_fails() { let s = signer(); let value = s.issue("did:plc:abc").value().to_owned();
let (payload, tag) = value.split_once('.').unwrap(); let other = URL_SAFE_NO_PAD.encode( serde_json::to_vec(&Claims { did: "did:plc:evil".into(), exp: i64::MAX, }) .unwrap(), ); assert_eq!(s.verify(&format!("{other}.{tag}")), None, "forged payload");
let mut broken_tag = tag.to_owned(); broken_tag.replace_range(0..1, if &tag[0..1] == "A" { "B" } else { "A" }); assert_eq!( s.verify(&format!("{payload}.{broken_tag}")), None, "broken tag" );
assert_eq!(s.verify(payload), None, "no tag at all"); assert_eq!(s.verify(""), None, "empty"); }
#[test] fn expiry_is_enforced() { let s = signer(); let payload = serde_json::to_vec(&Claims { did: "did:plc:abc".into(), exp: 0, }) .unwrap(); let tag = s.mac(&payload).finalize().into_bytes(); let value = format!( "{}.{}", URL_SAFE_NO_PAD.encode(&payload), URL_SAFE_NO_PAD.encode(tag) ); assert_eq!(s.verify(&value), None); }
#[test] fn different_secrets_reject_each_other() { let a = signer(); let b = SessionSigner::new(b"another-secret-another-secret-32".to_vec(), false, None); let cookie = a.issue("did:plc:abc"); assert_eq!(b.verify(cookie.value()), None); }
#[test] fn secure_follows_mode() { let dev = signer().issue("did:plc:abc"); assert_ne!(dev.secure(), Some(true));
let public = SessionSigner::new(b"0123456789abcdef0123456789abcdef".to_vec(), true, None) .issue("did:plc:abc"); assert_eq!(public.secure(), Some(true)); }
#[test] fn domain_follows_config() { assert_eq!( signer().issue("did:plc:abc").domain(), None, "host-only by default" );
let shared = SessionSigner::new( b"0123456789abcdef0123456789abcdef".to_vec(), true, Some("lance.blue".to_owned()), ); assert_eq!(shared.issue("did:plc:abc").domain(), Some("lance.blue")); assert_eq!( shared.clear().domain(), Some("lance.blue"), "clearing needs the same domain" ); }
#[test] fn clear_expires_immediately() { let cookie = signer().clear(); assert_eq!(cookie.value(), ""); assert_eq!(cookie.max_age(), Some(cookie::time::Duration::ZERO)); }}