use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Readonly,
Admin,
Owner,
}
impl Role {
fn rank(self) -> u8 {
match self {
Role::Readonly => 0,
Role::Admin => 1,
Role::Owner => 2,
}
}
pub fn parse(s: &str) -> Option<Role> {
match s {
"readonly" => Some(Role::Readonly),
"admin" => Some(Role::Admin),
"owner" => Some(Role::Owner),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Role::Readonly => "readonly",
Role::Admin => "admin",
Role::Owner => "owner",
}
}
pub fn satisfies(self, min: Role) -> bool {
self.rank() >= min.rank()
}
}
#[derive(Debug, Clone)]
pub struct AdminActor {
pub name: String,
pub role: Role,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionClaims {
pub sub: String,
pub role: String,
pub ver: i64,
pub kid: String,
pub iat: i64,
pub exp: i64,
}
pub fn sign_session(key: &[u8], claims: &SessionClaims) -> Result<String, String> {
let payload = serde_json::to_vec(claims).map_err(|e| format!("encode session claims: {e}"))?;
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
let mut mac =
HmacSha256::new_from_slice(key).map_err(|_| "bad session key length".to_string())?;
mac.update(payload_b64.as_bytes());
let sig = mac.finalize().into_bytes();
Ok(format!("{payload_b64}.{}", URL_SAFE_NO_PAD.encode(sig)))
}
#[derive(Debug, PartialEq, Eq)]
pub enum SessionError {
Malformed,
BadSignature,
Expired,
BadPayload,
}
pub fn verify_session(
key: &[u8],
token: &str,
now: i64,
skew_secs: i64,
) -> Result<SessionClaims, SessionError> {
let (payload_b64, sig_b64) = token.split_once('.').ok_or(SessionError::Malformed)?;
if payload_b64.is_empty() || sig_b64.is_empty() {
return Err(SessionError::Malformed);
}
let sig = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| SessionError::Malformed)?;
let mut mac = HmacSha256::new_from_slice(key).map_err(|_| SessionError::BadSignature)?;
mac.update(payload_b64.as_bytes());
mac.verify_slice(&sig)
.map_err(|_| SessionError::BadSignature)?;
let payload = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| SessionError::BadPayload)?;
let claims: SessionClaims =
serde_json::from_slice(&payload).map_err(|_| SessionError::BadPayload)?;
if now > claims.exp + skew_secs {
return Err(SessionError::Expired);
}
Ok(claims)
}
pub fn hash_password(password: &str) -> Result<String, String> {
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
use argon2::Argon2;
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| format!("argon2 hash: {e}"))
}
pub fn verify_password(password: &str, phc: &str) -> bool {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use argon2::Argon2;
match PasswordHash::new(phc) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
pub fn decoy_hash() -> &'static str {
static DECOY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
DECOY.get_or_init(|| {
hash_password("decoy-not-a-real-account-timing-equalizer")
.expect("argon2 default params always hash")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_ordering_and_parse() {
assert!(Role::Owner.satisfies(Role::Admin));
assert!(Role::Admin.satisfies(Role::Readonly));
assert!(!Role::Readonly.satisfies(Role::Admin));
assert_eq!(Role::parse("owner"), Some(Role::Owner));
assert_eq!(Role::parse("root"), None); assert_eq!(Role::parse(""), None);
}
#[test]
fn decoy_hash_is_valid_argon2() {
use argon2::password_hash::PasswordHash;
assert!(PasswordHash::new(decoy_hash()).is_ok());
assert!(!verify_password("anything", decoy_hash()));
}
#[test]
fn password_roundtrip() {
let h = hash_password("s3cret-pw").unwrap();
assert!(verify_password("s3cret-pw", &h));
assert!(!verify_password("wrong", &h));
assert!(!verify_password("s3cret-pw", "not-a-hash"));
}
#[test]
fn session_sign_verify_and_tamper() {
let key = b"0123456789abcdef0123456789abcdef";
let claims = SessionClaims {
sub: "alice".into(),
role: "owner".into(),
ver: 3,
kid: "k1".into(),
iat: 1000,
exp: 5000,
};
let tok = sign_session(key, &claims).unwrap();
let got = verify_session(key, &tok, 2000, 30).unwrap();
assert_eq!(got.sub, "alice");
assert_eq!(got.ver, 3);
assert_eq!(
verify_session(key, &tok, 6000, 30),
Err(SessionError::Expired)
);
assert_eq!(
verify_session(b"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", &tok, 2000, 30),
Err(SessionError::BadSignature)
);
let mut parts: Vec<&str> = tok.split('.').collect();
let mangled_payload = format!("{}A", parts[0]);
parts[0] = &mangled_payload;
let tampered = format!("{}.{}", parts[0], parts[1]);
assert!(verify_session(key, &tampered, 2000, 30).is_err());
assert_eq!(
verify_session(key, "no-dot", 2000, 30),
Err(SessionError::Malformed)
);
}
}