use super::*;
mod revocations;
pub(super) use revocations::ViewerRevocations;
pub(super) fn code_locked(state: &ServerState) -> bool {
state
.code_guard
.lock()
.expect("viewer code guard poisoned")
.locked_at(Instant::now())
}
pub(super) fn record_code_failure(state: &ServerState) {
state
.code_guard
.lock()
.expect("viewer code guard poisoned")
.record_failure_at(Instant::now());
}
pub(super) fn reset_code_failures(state: &ServerState) {
*state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
}
pub(super) fn generate_viewer_code() -> AnyResult<String> {
const RANGE: u32 = 1_000_000;
const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
loop {
let mut bytes = [0_u8; 4];
getrandom::fill(&mut bytes)
.map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
let value = u32::from_le_bytes(bytes);
if value < LIMIT {
return Ok(format!("{:06}", value % RANGE));
}
}
}
pub(super) fn derive_login_token(key: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
mac.update(b"mjolnir:viewer-login-token:v1");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub(super) fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
let mut key = [0_u8; COOKIE_KEY_BYTES];
getrandom::fill(&mut key)
.map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
Ok(key)
}
pub(super) fn generate_viewer_id() -> AnyResult<String> {
let mut id = [0_u8; 16];
getrandom::fill(&mut id)
.map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
}
pub(super) fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
let canonical = format!("{viewer}|{expiry}");
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
mac.update(canonical.as_bytes());
let signature =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{viewer}.{expiry}.{signature}")
}
#[cfg(test)]
pub(super) fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
cookie_viewer(key, value, now).is_some()
}
pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
let viewer = generate_viewer_id()?;
Ok(signed_cookie_value(
key,
&viewer,
now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
))
}
pub(super) fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<String> {
validate_cookie(key, Some(value), now)
.ok()
.map(|cookie| cookie.viewer)
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct ViewerCookie {
viewer: String,
expiry: u64,
}
fn validate_cookie(
key: &[u8],
value: Option<&str>,
now: u64,
) -> Result<ViewerCookie, &'static str> {
let value = value.ok_or("absent")?;
let [viewer, expiry, _] = value.split('.').collect::<Vec<_>>()[..] else {
return Err("malformed");
};
let expiry = expiry.parse::<u64>().map_err(|_| "malformed")?;
let expected = signed_cookie_value(key, viewer, expiry);
if !constant_time_eq(expected.as_bytes(), value.as_bytes()) {
return Err("bad_signature");
}
if now >= expiry {
return Err("expired");
}
Ok(ViewerCookie {
viewer: viewer.to_owned(),
expiry,
})
}
fn request_cookie(headers: &HeaderMap) -> Option<&str> {
headers
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|header| cookie_value(header, COOKIE_NAME))
}
pub(super) fn authenticated_viewer(
state: &ServerState,
headers: &HeaderMap,
) -> Result<ViewerCookie, ApiError> {
let now = now_unix();
let cookie = validate_cookie(&state.cookie_key, request_cookie(headers), now)
.and_then(|cookie| {
if state.viewer_revocations.contains(&cookie.viewer, now) {
Err("revoked")
} else {
Ok(cookie)
}
})
.map_err(|reason| {
tracing::debug!(reason, "viewer session cookie rejected");
ApiError::unauthorized()
})?;
Ok(cookie)
}
fn renewal_policy(state: &ServerState, viewer: &str) -> Option<(Duration, bool)> {
if viewer.starts_with("session:") {
Some((EPHEMERAL_SESSION_TTL, false))
} else if viewer.starts_with("phone:") {
Some(if state.session_ttl.is_zero() {
(EPHEMERAL_SESSION_TTL, false)
} else {
(state.session_ttl, true)
})
} else {
None
}
}
pub(super) fn renew_viewer_response(
state: &ServerState,
cookie: &ViewerCookie,
response: &mut Response<Body>,
) -> Result<(), ApiError> {
let now = now_unix();
if state.viewer_revocations.contains(&cookie.viewer, now)
|| cookie.expiry <= now
|| renewal_policy(state, &cookie.viewer).is_none()
{
return Ok(());
}
let renewed = viewer_session_cookie(state, &cookie.viewer, now)?;
response.headers_mut().entry(SET_COOKIE).or_insert(renewed);
Ok(())
}
pub(super) fn viewer_session_cookie(
state: &ServerState,
viewer: &str,
now: u64,
) -> Result<HeaderValue, ApiError> {
let (validity, persistent) =
renewal_policy(state, viewer).expect("new phone cookies carry a renewal policy");
let value = signed_cookie_value(
&state.cookie_key,
viewer,
now.saturating_add(validity.as_secs()),
);
session_cookie_header(
&value,
persistent.then_some(validity.as_secs()),
state.secure_cookie,
)
}
pub(super) async fn revoke_viewer(
state: &ServerState,
headers: &HeaderMap,
) -> Result<(), ApiError> {
let now = now_unix();
let Ok(cookie) = validate_cookie(&state.cookie_key, request_cookie(headers), now) else {
return Ok(());
};
let renewal_ttl = renewal_policy(state, &cookie.viewer).map_or(Duration::ZERO, |(ttl, _)| ttl);
let revocations = state.viewer_revocations.clone();
tokio::task::spawn_blocking(move || {
let result = revocations.revoke(cookie.viewer, cookie.expiry, renewal_ttl);
if let Err(error) = &result {
tracing::error!(%error, "could not persist viewer logout");
}
result
})
.await
.map_err(|error| {
tracing::error!(%error, "viewer logout task failed");
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not persist logout; retry",
)
})?
.map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not persist logout; retry",
)
})
}
pub(super) fn session_cookie_header(
value: &str,
max_age: Option<u64>,
secure: bool,
) -> Result<HeaderValue, ApiError> {
let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
if secure {
header.push_str("; Secure");
}
if let Some(max_age) = max_age {
header.push_str(&format!("; Max-Age={max_age}"));
}
HeaderValue::from_str(&header)
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
}
pub(super) fn clear_cookie_header(secure: bool) -> HeaderValue {
let secure = if secure { "; Secure" } else { "" };
HeaderValue::from_str(&format!(
"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
))
.expect("static cookie header is valid")
}
pub(super) fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
let cookie = headers
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|header| cookie_value(header, COOKIE_NAME))?;
cookie_viewer(&state.cookie_key, cookie, now_unix()).map(|viewer| format!("phone:{viewer}"))
}
pub(super) fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
header
.split(';')
.filter_map(|part| part.trim().split_once('='))
.find(|(cookie_name, _)| *cookie_name == name)
.map(|(_, value)| value)
}
pub(super) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
pub(super) fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cookie_rejection_reasons_distinguish_eviction_expiry_and_tampering() {
let key = b"a test key";
assert_eq!(validate_cookie(key, None, 100), Err("absent"));
assert_eq!(validate_cookie(key, Some("bad"), 100), Err("malformed"));
assert_eq!(
validate_cookie(key, Some("viewer.bad.signature"), 100),
Err("malformed")
);
let expired = signed_cookie_value(key, "viewer", 100);
assert_eq!(validate_cookie(key, Some(&expired), 100), Err("expired"));
let wrong = signed_cookie_value(b"other key", "viewer", 200);
assert_eq!(
validate_cookie(key, Some(&wrong), 100),
Err("bad_signature")
);
let valid = signed_cookie_value(key, "viewer", 200);
assert_eq!(
validate_cookie(key, Some(&valid), 100),
Ok(ViewerCookie {
viewer: "viewer".into(),
expiry: 200
})
);
}
}