use std::collections::HashMap;
use std::sync::Mutex;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::Error;
use crate::key::{PublicKey, SigningKey};
const VERSION: &str = "okp1";
pub const MAX_LIFETIME_SECS: i64 = 300;
pub const SKEW_SECS: i64 = 60;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Claims {
pub team: String,
pub iss: String,
pub aud: String,
pub iat: i64,
pub exp: i64,
pub jti: String,
pub body: String,
pub ver: u64,
}
pub fn body_hash(body: &[u8]) -> String {
hex::encode(Sha256::digest(body))
}
impl Claims {
pub fn new(team: &str, iss: &str, aud: &str, ver: u64, body: &[u8], now: Timestamp) -> Self {
let iat = now.as_second();
Self {
team: team.to_string(),
iss: iss.to_string(),
aud: aud.to_string(),
iat,
exp: iat + MAX_LIFETIME_SECS,
jti: hex::encode(rand::random::<[u8; 16]>()),
body: body_hash(body),
ver,
}
}
}
pub fn sign(key: &SigningKey, claims: &Claims) -> String {
let payload = B64.encode(serde_json::to_vec(claims).expect("claims serialize"));
let signed = format!("{VERSION}.{payload}");
let signature = B64.encode(key.sign(signed.as_bytes()));
format!("{signed}.{signature}")
}
pub fn peek(token: &str) -> Result<Claims, Error> {
let bad = || Error::Token("malformed token".into());
let mut parts = token.trim().split('.');
let (Some(VERSION), Some(payload), Some(_), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(bad());
};
serde_json::from_slice(&B64.decode(payload).map_err(|_| bad())?).map_err(|_| bad())
}
pub fn verify(
token: &str,
signer: &PublicKey,
body: &[u8],
now: Timestamp,
) -> Result<Claims, Error> {
let claims = peek(token)?;
let token = token.trim();
let (signed, signature) = token
.rsplit_once('.')
.ok_or_else(|| Error::Token("malformed token".into()))?;
let signature = B64
.decode(signature)
.map_err(|_| Error::Token("malformed signature".into()))?;
if !signer.verify(signed.as_bytes(), &signature) {
return Err(Error::Token("bad signature".into()));
}
let now = now.as_second();
if claims.exp <= claims.iat || claims.exp - claims.iat > MAX_LIFETIME_SECS {
return Err(Error::Token("invalid lifetime".into()));
}
if claims.iat > now + SKEW_SECS || claims.exp + SKEW_SECS < now {
return Err(Error::Token(
"expired or not yet valid (check the clocks)".into(),
));
}
if claims.body != body_hash(body) {
return Err(Error::Token("the body does not match the token".into()));
}
Ok(claims)
}
#[derive(Debug, Default)]
pub struct Replays(Mutex<HashMap<String, i64>>);
impl Replays {
pub fn first_use(&self, claims: &Claims, now: Timestamp) -> bool {
let mut seen = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = now.as_second();
seen.retain(|_, exp| *exp + SKEW_SECS >= now);
let key = format!("{}/{}/{}", claims.team, claims.iss, claims.jti);
seen.insert(key, claims.exp).is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> Timestamp {
Timestamp::from_second(1_800_000_000).unwrap()
}
#[test]
fn signed_tokens_verify_and_bind_the_body() {
let (key, _) = SigningKey::generate().unwrap();
let claims = Claims::new("acme", "lead", "ops", 3, b"{}", now());
let token = sign(&key, &claims);
assert_eq!(peek(&token).unwrap(), claims);
assert_eq!(verify(&token, &key.public(), b"{}", now()).unwrap(), claims);
assert!(
verify(&token, &key.public(), b"{ }", now()).is_err(),
"body"
);
let (other, _) = SigningKey::generate().unwrap();
assert!(
verify(&token, &other.public(), b"{}", now()).is_err(),
"signer"
);
}
#[test]
fn times_are_checked_with_skew() {
let (key, _) = SigningKey::generate().unwrap();
let token = sign(&key, &Claims::new("t", "a", "b", 1, b"", now()));
let at = |secs: i64| {
now()
.checked_add(jiff::SignedDuration::from_secs(secs))
.unwrap()
};
assert!(verify(&token, &key.public(), b"", at(-SKEW_SECS)).is_ok());
assert!(verify(&token, &key.public(), b"", at(-SKEW_SECS - 1)).is_err());
assert!(
verify(
&token,
&key.public(),
b"",
at(MAX_LIFETIME_SECS + SKEW_SECS)
)
.is_ok()
);
assert!(
verify(
&token,
&key.public(),
b"",
at(MAX_LIFETIME_SECS + SKEW_SECS + 1)
)
.is_err()
);
let mut long = Claims::new("t", "a", "b", 1, b"", now());
long.exp = long.iat + MAX_LIFETIME_SECS + 1;
assert!(verify(&sign(&key, &long), &key.public(), b"", now()).is_err());
}
#[test]
fn altered_tokens_are_refused() {
let (key, _) = SigningKey::generate().unwrap();
let token = sign(&key, &Claims::new("t", "a", "b", 1, b"", now()));
let mut forged = Claims::new("t", "a", "admin", 1, b"", now());
forged.jti = peek(&token).unwrap().jti;
let payload = B64.encode(serde_json::to_vec(&forged).unwrap());
let signature = token.rsplit_once('.').unwrap().1;
let altered = format!("okp1.{payload}.{signature}");
assert!(verify(&altered, &key.public(), b"", now()).is_err());
assert!(peek("okp2.a.b").is_err());
assert!(peek("okp1.a").is_err());
}
#[test]
fn replays_are_refused() {
let claims = Claims::new("t", "a", "b", 1, b"", now());
let replays = Replays::default();
assert!(replays.first_use(&claims, now()));
assert!(!replays.first_use(&claims, now()));
let later = now()
.checked_add(jiff::SignedDuration::from_secs(
MAX_LIFETIME_SECS + SKEW_SECS + 1,
))
.unwrap();
assert!(replays.first_use(&Claims::new("t", "a", "b", 1, b"", later), later));
}
}