Skip to main content

ados_protocol/
ws_ticket.rs

1//! Self-contained WebSocket auth tickets.
2//!
3//! A browser cannot set an `X-ADOS-Key` header on a WebSocket handshake, so the
4//! GCS first exchanges its pairing key (on a key-authenticated REST call) for a
5//! short-lived ticket and hands it to `new WebSocket(url, ["ados-ws-ticket",
6//! <ticket>])`. Unlike the older random-string-in-a-store design, this ticket is
7//! **self-contained and HMAC-signed**: the minting surface and the validating
8//! surface share no state, only the key, which both derive from the agent's
9//! `pairing.json`. That lets the native `ados-control` front mint a ticket that
10//! the native `ados-mavlink-router` (a separate process) validates with no IPC
11//! and no shared ticket store.
12//!
13//! Wire form (pipe-delimited, like the plugin capability token):
14//! `v2|<scope>|<issued_at>|<expires_at>|<nonce_hex>|<sig_hex>`. The signature
15//! is `HMAC-SHA256(K, "v2|<scope>|<issued_at>|<expires_at>|<nonce_hex>")`
16//! where the key `K = HMAC-SHA256(api_key, "ados-ws-ticket-v1")` is derived
17//! from the pairing key under a fixed domain-separation label (so the ticket
18//! key is never the pairing key itself, and is namespaced to this use). The
19//! Python verifier in `ados.core.ws_ticket` mirrors this byte-for-byte.
20//!
21//! ## Single use
22//!
23//! A ticket travels in a subprotocol header that can surface in proxy logs and
24//! HAR exports, so a WebSocket handshake spends it: [`WsTicketIssuer::verify_once`]
25//! records the 16-byte nonce in a process-wide cache and refuses it a second
26//! time. Each verifying process keeps its own cache, and a ticket is scoped to
27//! one surface, so a spent ticket opens nothing anywhere.
28//!
29//! The RC injector attestation (`crsf_inject_scope`) is not a handshake: the
30//! same attestation accompanies every declaration an injector makes for its
31//! lifetime, so it is checked with [`WsTicketIssuer::verify`], which does not
32//! spend.
33
34use 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
44/// Domain-separation label mixed into the pairing key to derive the ticket key.
45/// MUST match `ados.core.ws_ticket._LABEL` exactly.
46pub const TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-v1";
47
48/// Domain-separation label for the ticket key of an UNPAIRED node, derived from
49/// the dashboard-PIN salt rather than a pairing key (there is none yet).
50pub const UNPAIRED_TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-unpaired-v1";
51
52/// Default ticket lifetime in seconds (matches the prior store's default).
53pub const DEFAULT_TTL_SECONDS: i64 = 30;
54
55/// Hard cap on a requested ticket lifetime.
56pub const MAX_TTL_SECONDS: i64 = 120;
57
58/// The WebSocket subprotocol marker a browser offers ahead of its ticket
59/// (`["ados-ws-ticket", "<ticket>"]`); the server echoes it on the accepted
60/// handshake, as RFC 6455 requires.
61pub const WS_TICKET_SUBPROTOCOL: &str = "ados-ws-ticket";
62
63/// The MAVLink WebSocket proxy scope: a ticket minted for this scope authorizes
64/// the raw `:8765` MAVLink WS for an off-box paired caller.
65pub const SCOPE_MAVLINK_WS: &str = "gs.mavlink_ws";
66
67/// The RC channel-injection scope for one named client.
68///
69/// Unlike [`SCOPE_MAVLINK_WS`] this is not a fixed string, because the ticket
70/// is attesting an IDENTITY rather than granting a capability: the RC lane's
71/// authority rule asks which client is injecting and compares the answer with
72/// the pilot-in-command holder. Building the client id into the signed scope is
73/// what stops a ticket minted for one client from answering that question as
74/// another.
75///
76/// It lives beside the issuer so the minting surface (the authenticated
77/// ground-station route) and the verifying one (the RC lane daemon) cannot
78/// drift on the spelling; a mismatch there would not fail loudly, it would just
79/// silently stop attesting anyone.
80pub 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/// A minted ticket: its compact string form plus the parsed expiry, so a mint
103/// surface can return `{ ticket, scope, expires_at }` without re-parsing.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct WsTicket {
106    pub token: String,
107    pub scope: String,
108    pub expires_at: i64,
109}
110
111/// Every WebSocket ticket this process has accepted on a handshake.
112static SPENT_TICKETS: NonceCache = NonceCache::new(4096, MAX_TTL_SECONDS);
113
114/// Mints and verifies self-contained WS tickets, keyed by a pairing-derived
115/// secret. Build one with [`WsTicketIssuer::from_api_key`] from the same
116/// `pairing.json` `api_key` both daemons read.
117#[derive(Clone)]
118pub struct WsTicketIssuer {
119    key: Vec<u8>,
120}
121
122impl WsTicketIssuer {
123    /// Derive the ticket key from the pairing `api_key` under the fixed label.
124    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    /// The issuer for an UNPAIRED node: the ticket key is derived from the
133    /// dashboard-PIN `salt` under its own label. A ticket from it proves the
134    /// holder unlocked the PIN on this node since the PIN was last set (a reset
135    /// rotates the salt and revokes every such ticket), which is what an
136    /// operator-LAN caller must show to open a stream while the node is
137    /// unpaired. It can never verify as a paired ticket, nor the reverse.
138    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    /// Mint a ticket for `scope` valid for `ttl_seconds`, using the wall clock
146    /// and a fresh random nonce. `None` only when the system cannot supply
147    /// randomness.
148    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    /// Deterministic mint core (explicit clock and nonce) for tests and
154    /// cross-impl vectors.
155    pub fn mint_at(&self, scope: &str, ttl_seconds: i64, now: i64, nonce: &str) -> WsTicket {
156        // Saturating add so a far-future clock or a huge ttl cannot overflow
157        // (which would panic under the workspace's panic=abort).
158        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    /// Verify a ticket string: HMAC authenticity first, then scope, then expiry.
169    /// `now` is unix seconds. Does not spend the ticket; a WebSocket handshake
170    /// uses [`Self::verify_once`].
171    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    /// Verify a ticket presented on a WebSocket handshake and spend it, so the
176    /// same ticket cannot open a second socket.
177    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    /// The checks both verifiers share; returns the nonce and expiry.
192    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        // Recompute over the EXACT signed substring (the first five pipe
211        // fields), so reformatting never drifts from what was signed.
212        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        // verify_slice is a constant-time comparison.
216        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
234/// Mint a ticket for `scope` against the pairing key stored at `pairing_path`.
235///
236/// The convenience form for a surface that holds a path rather than a key.
237/// `None` on an unpaired node, where there is no key to sign with — callers
238/// treat that as "nothing to attest", not as a failure, because a verifier
239/// keyed off the same file is equally unable to check one and is open in that
240/// state for the same reason.
241pub 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
256/// The signed substring: `v2|<scope>|<issued_at>|<expires_at>|<nonce>`.
257fn sign_payload(scope: &str, issued_at: i64, expires_at: i64, nonce: &str) -> String {
258    format!("v2|{scope}|{issued_at}|{expires_at}|{nonce}")
259}
260
261/// Unix seconds, clamped to 0 if the clock predates the epoch (a freshly booted
262/// SBC before NTP sync) so minting never aborts the process.
263pub 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        // A ticket lifted from a log must not open a second socket.
289        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        // A forged ticket does not reach the cache, so it cannot evict anything.
298        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        // now == expires_at is expired (matches the plugin token's `>=`).
312        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        // ...and accepts the scope it was minted for.
327        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        // Forge a longer expiry while keeping the original signature.
346        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    // The exact vector the Python verifier (`ados.core.ws_ticket`) must agree
383    // with. If this signature ever changes, the Python mirror must change in
384    // lockstep or cross-language tickets silently stop verifying.
385    #[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        // Pin the full token so the Python test can assert the identical string.
390        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    /// An unpaired ticket is keyed by the PIN salt: it verifies under the same
399    /// salt only, never under another salt or as a paired (or empty-key) ticket.
400    #[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}