//! 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, secure: bool, domain: Option, } impl SessionSigner { pub fn new(secret: Vec, secure: bool, domain: Option) -> SessionSigner { SessionSigner { secret, secure, domain, } } fn mac(&self, payload: &[u8]) -> Hmac { let mut mac = Hmac::::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 { 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)); } }