use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use thiserror::Error;
use crate::nonce_cache::NonceCache;
type HmacSha256 = Hmac<Sha256>;
pub const TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-v1";
pub const UNPAIRED_TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-unpaired-v1";
pub const DEFAULT_TTL_SECONDS: i64 = 30;
pub const MAX_TTL_SECONDS: i64 = 120;
pub const WS_TICKET_SUBPROTOCOL: &str = "ados-ws-ticket";
pub const SCOPE_MAVLINK_WS: &str = "gs.mavlink_ws";
pub fn crsf_inject_scope(client_id: &str) -> String {
format!("crsf.inject:{client_id}")
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum WsTicketError {
#[error("malformed ws ticket")]
Malformed,
#[error("ws ticket timestamp is not an integer")]
BadTimestamp,
#[error("ws ticket signature is not valid hex")]
BadSignature,
#[error("ws ticket HMAC mismatch")]
HmacMismatch,
#[error("ws ticket scope mismatch")]
ScopeMismatch,
#[error("ws ticket expired")]
Expired,
#[error("ws ticket already used")]
Replayed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WsTicket {
pub token: String,
pub scope: String,
pub expires_at: i64,
}
static SPENT_TICKETS: NonceCache = NonceCache::new(4096, MAX_TTL_SECONDS);
#[derive(Clone)]
pub struct WsTicketIssuer {
key: Vec<u8>,
}
impl WsTicketIssuer {
pub fn from_api_key(api_key: &str) -> Self {
let mut mac =
HmacSha256::new_from_slice(api_key.as_bytes()).expect("HMAC accepts any key length");
mac.update(TICKET_KEY_LABEL);
let key = mac.finalize().into_bytes().to_vec();
Self { key }
}
pub fn issuer_unpaired(salt: &[u8]) -> Self {
let mut mac = HmacSha256::new_from_slice(salt).expect("HMAC accepts any key length");
mac.update(UNPAIRED_TICKET_KEY_LABEL);
let key = mac.finalize().into_bytes().to_vec();
Self { key }
}
pub fn mint(&self, scope: &str, ttl_seconds: i64) -> Option<WsTicket> {
let nonce = crate::nonce_cache::random_nonce_hex()?;
Some(self.mint_at(scope, ttl_seconds, now_unix(), &nonce))
}
pub fn mint_at(&self, scope: &str, ttl_seconds: i64, now: i64, nonce: &str) -> WsTicket {
let expires_at = now.saturating_add(ttl_seconds);
let payload = sign_payload(scope, now, expires_at, nonce);
let signature = self.sign(&payload);
WsTicket {
token: format!("{payload}|{signature}"),
scope: scope.to_owned(),
expires_at,
}
}
pub fn verify(&self, token: &str, expected_scope: &str, now: i64) -> Result<(), WsTicketError> {
self.verify_claims(token, expected_scope, now).map(|_| ())
}
pub fn verify_once(
&self,
token: &str,
expected_scope: &str,
now: i64,
) -> Result<(), WsTicketError> {
let (nonce, expires_at) = self.verify_claims(token, expected_scope, now)?;
if SPENT_TICKETS.admit(nonce, now, expires_at) {
Ok(())
} else {
Err(WsTicketError::Replayed)
}
}
fn verify_claims<'t>(
&self,
token: &'t str,
expected_scope: &str,
now: i64,
) -> Result<(&'t str, i64), WsTicketError> {
let parts: Vec<&str> = token.split('|').collect();
if parts.len() != 6 || parts[0] != "v2" {
return Err(WsTicketError::Malformed);
}
let scope = parts[1];
let _issued: i64 = parts[2].parse().map_err(|_| WsTicketError::BadTimestamp)?;
let expires_at: i64 = parts[3].parse().map_err(|_| WsTicketError::BadTimestamp)?;
let nonce = parts[4];
if nonce.len() != 32 || !nonce.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(WsTicketError::Malformed);
}
let sig = hex::decode(parts[5]).map_err(|_| WsTicketError::BadSignature)?;
let payload = parts[..5].join("|");
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(payload.as_bytes());
mac.verify_slice(&sig)
.map_err(|_| WsTicketError::HmacMismatch)?;
if scope != expected_scope {
return Err(WsTicketError::ScopeMismatch);
}
if now >= expires_at {
return Err(WsTicketError::Expired);
}
Ok((nonce, expires_at))
}
fn sign(&self, payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(payload.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}
pub fn mint_scoped_ticket(
pairing_path: &std::path::Path,
scope: &str,
ttl_seconds: i64,
) -> Option<String> {
match crate::pairing_posture::load_pairing(pairing_path) {
crate::pairing_posture::Pairing::Unpaired | crate::pairing_posture::Pairing::Unreadable => {
None
}
crate::pairing_posture::Pairing::Paired(api_key) => WsTicketIssuer::from_api_key(&api_key)
.mint(scope, ttl_seconds)
.map(|t| t.token),
}
}
fn sign_payload(scope: &str, issued_at: i64, expires_at: i64, nonce: &str) -> String {
format!("v2|{scope}|{issued_at}|{expires_at}|{nonce}")
}
pub fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
const NONCE: &str = "00112233445566778899aabbccddeeff";
#[test]
fn mint_round_trips_and_verifies() {
let issuer = WsTicketIssuer::from_api_key("ados_secret");
let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
assert_eq!(t.expires_at, 1030);
assert!(t.token.starts_with("v2|gs.mavlink_ws|1000|1030|"));
assert!(issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1000).is_ok());
assert!(issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1029).is_ok());
}
#[test]
fn a_handshake_spends_the_ticket() {
let issuer = WsTicketIssuer::from_api_key("k");
let t = issuer.mint("gs.pic_events", 30).unwrap();
let now = now_unix();
assert_eq!(issuer.verify_once(&t.token, "gs.pic_events", now), Ok(()));
assert_eq!(
issuer.verify_once(&t.token, "gs.pic_events", now),
Err(WsTicketError::Replayed)
);
let forged = WsTicketIssuer::from_api_key("other")
.mint("gs.pic_events", 30)
.unwrap();
assert_eq!(
issuer.verify_once(&forged.token, "gs.pic_events", now),
Err(WsTicketError::HmacMismatch)
);
}
#[test]
fn rejects_expired_at_the_boundary() {
let issuer = WsTicketIssuer::from_api_key("k");
let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
assert_eq!(
issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1030),
Err(WsTicketError::Expired)
);
}
#[test]
fn rejects_wrong_scope() {
let issuer = WsTicketIssuer::from_api_key("k");
let t = issuer.mint_at("gs.pic_events", 30, 1000, NONCE);
assert_eq!(
issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1000),
Err(WsTicketError::ScopeMismatch)
);
assert!(issuer.verify(&t.token, "gs.pic_events", 1000).is_ok());
}
#[test]
fn rejects_wrong_key() {
let a = WsTicketIssuer::from_api_key("key-a");
let b = WsTicketIssuer::from_api_key("key-b");
let t = a.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
assert_eq!(
b.verify(&t.token, SCOPE_MAVLINK_WS, 1000),
Err(WsTicketError::HmacMismatch)
);
}
#[test]
fn rejects_tampered_expiry() {
let issuer = WsTicketIssuer::from_api_key("k");
let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
let parts: Vec<&str> = t.token.split('|').collect();
let forged = format!(
"v2|{}|{}|99999|{}|{}",
parts[1], parts[2], parts[4], parts[5]
);
assert_eq!(
issuer.verify(&forged, SCOPE_MAVLINK_WS, 1000),
Err(WsTicketError::HmacMismatch)
);
}
#[test]
fn rejects_malformed() {
let issuer = WsTicketIssuer::from_api_key("k");
assert_eq!(
issuer.verify("", SCOPE_MAVLINK_WS, 0),
Err(WsTicketError::Malformed)
);
assert_eq!(
issuer.verify("v1|s|1|2|ff", SCOPE_MAVLINK_WS, 0),
Err(WsTicketError::Malformed)
);
assert_eq!(
issuer.verify(&format!("v2|s|notanint|2|{NONCE}|ff"), "s", 0),
Err(WsTicketError::BadTimestamp)
);
assert_eq!(
issuer.verify(&format!("v2|s|1|2|{NONCE}|nothex"), "s", 0),
Err(WsTicketError::BadSignature)
);
assert_eq!(
issuer.verify("v2|s|1|2|short|ff", "s", 0),
Err(WsTicketError::Malformed)
);
}
#[test]
fn known_answer_vector_for_python_interop() {
let issuer = WsTicketIssuer::from_api_key("ados_secret");
let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1_000_000, NONCE);
assert_eq!(
t.token,
"v2|gs.mavlink_ws|1000000|1000030|00112233445566778899aabbccddeeff|\
4d99ddf7a9dc48119b137be1fe135a50f7618a77f44d3a104e5ccac9171193e7",
"if this fails, regenerate the vector and update ados.core.ws_ticket"
);
}
#[test]
fn unpaired_tickets_are_keyed_by_the_pin_salt() {
let salt = [7u8; 16];
let issuer = WsTicketIssuer::issuer_unpaired(&salt);
let t = issuer.mint_at("gs.mesh_events", 30, 1_000_000, NONCE);
assert_eq!(issuer.verify(&t.token, "gs.mesh_events", 1_000_001), Ok(()));
assert_eq!(
WsTicketIssuer::issuer_unpaired(&[8u8; 16]).verify(
&t.token,
"gs.mesh_events",
1_000_001
),
Err(WsTicketError::HmacMismatch)
);
assert_eq!(
WsTicketIssuer::from_api_key("").verify(&t.token, "gs.mesh_events", 1_000_001),
Err(WsTicketError::HmacMismatch)
);
}
}