1use std::time::{SystemTime, UNIX_EPOCH};
35
36use hmac::{Hmac, KeyInit, Mac};
37use sha2::Sha256;
38use thiserror::Error;
39
40use crate::nonce_cache::NonceCache;
41
42type HmacSha256 = Hmac<Sha256>;
43
44pub const TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-v1";
47
48pub const UNPAIRED_TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-unpaired-v1";
51
52pub const DEFAULT_TTL_SECONDS: i64 = 30;
54
55pub const MAX_TTL_SECONDS: i64 = 120;
57
58pub const WS_TICKET_SUBPROTOCOL: &str = "ados-ws-ticket";
62
63pub const SCOPE_MAVLINK_WS: &str = "gs.mavlink_ws";
66
67pub fn crsf_inject_scope(client_id: &str) -> String {
81 format!("crsf.inject:{client_id}")
82}
83
84#[derive(Debug, Error, PartialEq, Eq)]
85pub enum WsTicketError {
86 #[error("malformed ws ticket")]
87 Malformed,
88 #[error("ws ticket timestamp is not an integer")]
89 BadTimestamp,
90 #[error("ws ticket signature is not valid hex")]
91 BadSignature,
92 #[error("ws ticket HMAC mismatch")]
93 HmacMismatch,
94 #[error("ws ticket scope mismatch")]
95 ScopeMismatch,
96 #[error("ws ticket expired")]
97 Expired,
98 #[error("ws ticket already used")]
99 Replayed,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct WsTicket {
106 pub token: String,
107 pub scope: String,
108 pub expires_at: i64,
109}
110
111static SPENT_TICKETS: NonceCache = NonceCache::new(4096, MAX_TTL_SECONDS);
113
114#[derive(Clone)]
118pub struct WsTicketIssuer {
119 key: Vec<u8>,
120}
121
122impl WsTicketIssuer {
123 pub fn from_api_key(api_key: &str) -> Self {
125 let mut mac =
126 HmacSha256::new_from_slice(api_key.as_bytes()).expect("HMAC accepts any key length");
127 mac.update(TICKET_KEY_LABEL);
128 let key = mac.finalize().into_bytes().to_vec();
129 Self { key }
130 }
131
132 pub fn issuer_unpaired(salt: &[u8]) -> Self {
139 let mut mac = HmacSha256::new_from_slice(salt).expect("HMAC accepts any key length");
140 mac.update(UNPAIRED_TICKET_KEY_LABEL);
141 let key = mac.finalize().into_bytes().to_vec();
142 Self { key }
143 }
144
145 pub fn mint(&self, scope: &str, ttl_seconds: i64) -> Option<WsTicket> {
149 let nonce = crate::nonce_cache::random_nonce_hex()?;
150 Some(self.mint_at(scope, ttl_seconds, now_unix(), &nonce))
151 }
152
153 pub fn mint_at(&self, scope: &str, ttl_seconds: i64, now: i64, nonce: &str) -> WsTicket {
156 let expires_at = now.saturating_add(ttl_seconds);
159 let payload = sign_payload(scope, now, expires_at, nonce);
160 let signature = self.sign(&payload);
161 WsTicket {
162 token: format!("{payload}|{signature}"),
163 scope: scope.to_owned(),
164 expires_at,
165 }
166 }
167
168 pub fn verify(&self, token: &str, expected_scope: &str, now: i64) -> Result<(), WsTicketError> {
172 self.verify_claims(token, expected_scope, now).map(|_| ())
173 }
174
175 pub fn verify_once(
178 &self,
179 token: &str,
180 expected_scope: &str,
181 now: i64,
182 ) -> Result<(), WsTicketError> {
183 let (nonce, expires_at) = self.verify_claims(token, expected_scope, now)?;
184 if SPENT_TICKETS.admit(nonce, now, expires_at) {
185 Ok(())
186 } else {
187 Err(WsTicketError::Replayed)
188 }
189 }
190
191 fn verify_claims<'t>(
193 &self,
194 token: &'t str,
195 expected_scope: &str,
196 now: i64,
197 ) -> Result<(&'t str, i64), WsTicketError> {
198 let parts: Vec<&str> = token.split('|').collect();
199 if parts.len() != 6 || parts[0] != "v2" {
200 return Err(WsTicketError::Malformed);
201 }
202 let scope = parts[1];
203 let _issued: i64 = parts[2].parse().map_err(|_| WsTicketError::BadTimestamp)?;
204 let expires_at: i64 = parts[3].parse().map_err(|_| WsTicketError::BadTimestamp)?;
205 let nonce = parts[4];
206 if nonce.len() != 32 || !nonce.bytes().all(|b| b.is_ascii_hexdigit()) {
207 return Err(WsTicketError::Malformed);
208 }
209 let sig = hex::decode(parts[5]).map_err(|_| WsTicketError::BadSignature)?;
210 let payload = parts[..5].join("|");
213 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
214 mac.update(payload.as_bytes());
215 mac.verify_slice(&sig)
217 .map_err(|_| WsTicketError::HmacMismatch)?;
218 if scope != expected_scope {
219 return Err(WsTicketError::ScopeMismatch);
220 }
221 if now >= expires_at {
222 return Err(WsTicketError::Expired);
223 }
224 Ok((nonce, expires_at))
225 }
226
227 fn sign(&self, payload: &str) -> String {
228 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
229 mac.update(payload.as_bytes());
230 hex::encode(mac.finalize().into_bytes())
231 }
232}
233
234pub fn mint_scoped_ticket(
242 pairing_path: &std::path::Path,
243 scope: &str,
244 ttl_seconds: i64,
245) -> Option<String> {
246 match crate::pairing_posture::load_pairing(pairing_path) {
247 crate::pairing_posture::Pairing::Unpaired | crate::pairing_posture::Pairing::Unreadable => {
248 None
249 }
250 crate::pairing_posture::Pairing::Paired(api_key) => WsTicketIssuer::from_api_key(&api_key)
251 .mint(scope, ttl_seconds)
252 .map(|t| t.token),
253 }
254}
255
256fn sign_payload(scope: &str, issued_at: i64, expires_at: i64, nonce: &str) -> String {
258 format!("v2|{scope}|{issued_at}|{expires_at}|{nonce}")
259}
260
261pub fn now_unix() -> i64 {
264 SystemTime::now()
265 .duration_since(UNIX_EPOCH)
266 .map(|d| d.as_secs() as i64)
267 .unwrap_or(0)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 const NONCE: &str = "00112233445566778899aabbccddeeff";
275
276 #[test]
277 fn mint_round_trips_and_verifies() {
278 let issuer = WsTicketIssuer::from_api_key("ados_secret");
279 let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
280 assert_eq!(t.expires_at, 1030);
281 assert!(t.token.starts_with("v2|gs.mavlink_ws|1000|1030|"));
282 assert!(issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1000).is_ok());
283 assert!(issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1029).is_ok());
284 }
285
286 #[test]
287 fn a_handshake_spends_the_ticket() {
288 let issuer = WsTicketIssuer::from_api_key("k");
290 let t = issuer.mint("gs.pic_events", 30).unwrap();
291 let now = now_unix();
292 assert_eq!(issuer.verify_once(&t.token, "gs.pic_events", now), Ok(()));
293 assert_eq!(
294 issuer.verify_once(&t.token, "gs.pic_events", now),
295 Err(WsTicketError::Replayed)
296 );
297 let forged = WsTicketIssuer::from_api_key("other")
299 .mint("gs.pic_events", 30)
300 .unwrap();
301 assert_eq!(
302 issuer.verify_once(&forged.token, "gs.pic_events", now),
303 Err(WsTicketError::HmacMismatch)
304 );
305 }
306
307 #[test]
308 fn rejects_expired_at_the_boundary() {
309 let issuer = WsTicketIssuer::from_api_key("k");
310 let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
311 assert_eq!(
313 issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1030),
314 Err(WsTicketError::Expired)
315 );
316 }
317
318 #[test]
319 fn rejects_wrong_scope() {
320 let issuer = WsTicketIssuer::from_api_key("k");
321 let t = issuer.mint_at("gs.pic_events", 30, 1000, NONCE);
322 assert_eq!(
323 issuer.verify(&t.token, SCOPE_MAVLINK_WS, 1000),
324 Err(WsTicketError::ScopeMismatch)
325 );
326 assert!(issuer.verify(&t.token, "gs.pic_events", 1000).is_ok());
328 }
329
330 #[test]
331 fn rejects_wrong_key() {
332 let a = WsTicketIssuer::from_api_key("key-a");
333 let b = WsTicketIssuer::from_api_key("key-b");
334 let t = a.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
335 assert_eq!(
336 b.verify(&t.token, SCOPE_MAVLINK_WS, 1000),
337 Err(WsTicketError::HmacMismatch)
338 );
339 }
340
341 #[test]
342 fn rejects_tampered_expiry() {
343 let issuer = WsTicketIssuer::from_api_key("k");
344 let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1000, NONCE);
345 let parts: Vec<&str> = t.token.split('|').collect();
347 let forged = format!(
348 "v2|{}|{}|99999|{}|{}",
349 parts[1], parts[2], parts[4], parts[5]
350 );
351 assert_eq!(
352 issuer.verify(&forged, SCOPE_MAVLINK_WS, 1000),
353 Err(WsTicketError::HmacMismatch)
354 );
355 }
356
357 #[test]
358 fn rejects_malformed() {
359 let issuer = WsTicketIssuer::from_api_key("k");
360 assert_eq!(
361 issuer.verify("", SCOPE_MAVLINK_WS, 0),
362 Err(WsTicketError::Malformed)
363 );
364 assert_eq!(
365 issuer.verify("v1|s|1|2|ff", SCOPE_MAVLINK_WS, 0),
366 Err(WsTicketError::Malformed)
367 );
368 assert_eq!(
369 issuer.verify(&format!("v2|s|notanint|2|{NONCE}|ff"), "s", 0),
370 Err(WsTicketError::BadTimestamp)
371 );
372 assert_eq!(
373 issuer.verify(&format!("v2|s|1|2|{NONCE}|nothex"), "s", 0),
374 Err(WsTicketError::BadSignature)
375 );
376 assert_eq!(
377 issuer.verify("v2|s|1|2|short|ff", "s", 0),
378 Err(WsTicketError::Malformed)
379 );
380 }
381
382 #[test]
386 fn known_answer_vector_for_python_interop() {
387 let issuer = WsTicketIssuer::from_api_key("ados_secret");
388 let t = issuer.mint_at(SCOPE_MAVLINK_WS, 30, 1_000_000, NONCE);
389 assert_eq!(
391 t.token,
392 "v2|gs.mavlink_ws|1000000|1000030|00112233445566778899aabbccddeeff|\
3934d99ddf7a9dc48119b137be1fe135a50f7618a77f44d3a104e5ccac9171193e7",
394 "if this fails, regenerate the vector and update ados.core.ws_ticket"
395 );
396 }
397
398 #[test]
401 fn unpaired_tickets_are_keyed_by_the_pin_salt() {
402 let salt = [7u8; 16];
403 let issuer = WsTicketIssuer::issuer_unpaired(&salt);
404 let t = issuer.mint_at("gs.mesh_events", 30, 1_000_000, NONCE);
405 assert_eq!(issuer.verify(&t.token, "gs.mesh_events", 1_000_001), Ok(()));
406 assert_eq!(
407 WsTicketIssuer::issuer_unpaired(&[8u8; 16]).verify(
408 &t.token,
409 "gs.mesh_events",
410 1_000_001
411 ),
412 Err(WsTicketError::HmacMismatch)
413 );
414 assert_eq!(
415 WsTicketIssuer::from_api_key("").verify(&t.token, "gs.mesh_events", 1_000_001),
416 Err(WsTicketError::HmacMismatch)
417 );
418 }
419}