ados-protocol 0.106.2

Wire contracts for the ADOS Drone Agent: length-prefixed IPC framing, plugin RPC envelope and capability tokens, the vision frame bus, MSP and MAVLink helpers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Self-contained WebSocket auth tickets.
//!
//! A browser cannot set an `X-ADOS-Key` header on a WebSocket handshake, so the
//! GCS first exchanges its pairing key (on a key-authenticated REST call) for a
//! short-lived ticket and hands it to `new WebSocket(url, ["ados-ws-ticket",
//! <ticket>])`. Unlike the older random-string-in-a-store design, this ticket is
//! **self-contained and HMAC-signed**: the minting surface and the validating
//! surface share no state, only the key, which both derive from the agent's
//! `pairing.json`. That lets the native `ados-control` front mint a ticket that
//! the native `ados-mavlink-router` (a separate process) validates with no IPC
//! and no shared ticket store.
//!
//! Wire form (pipe-delimited, like the plugin capability token):
//! `v2|<scope>|<issued_at>|<expires_at>|<nonce_hex>|<sig_hex>`. The signature
//! is `HMAC-SHA256(K, "v2|<scope>|<issued_at>|<expires_at>|<nonce_hex>")`
//! where the key `K = HMAC-SHA256(api_key, "ados-ws-ticket-v1")` is derived
//! from the pairing key under a fixed domain-separation label (so the ticket
//! key is never the pairing key itself, and is namespaced to this use). The
//! Python verifier in `ados.core.ws_ticket` mirrors this byte-for-byte.
//!
//! ## Single use
//!
//! A ticket travels in a subprotocol header that can surface in proxy logs and
//! HAR exports, so a WebSocket handshake spends it: [`WsTicketIssuer::verify_once`]
//! records the 16-byte nonce in a process-wide cache and refuses it a second
//! time. Each verifying process keeps its own cache, and a ticket is scoped to
//! one surface, so a spent ticket opens nothing anywhere.
//!
//! The RC injector attestation (`crsf_inject_scope`) is not a handshake: the
//! same attestation accompanies every declaration an injector makes for its
//! lifetime, so it is checked with [`WsTicketIssuer::verify`], which does not
//! spend.

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>;

/// Domain-separation label mixed into the pairing key to derive the ticket key.
/// MUST match `ados.core.ws_ticket._LABEL` exactly.
pub const TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-v1";

/// Domain-separation label for the ticket key of an UNPAIRED node, derived from
/// the dashboard-PIN salt rather than a pairing key (there is none yet).
pub const UNPAIRED_TICKET_KEY_LABEL: &[u8] = b"ados-ws-ticket-unpaired-v1";

/// Default ticket lifetime in seconds (matches the prior store's default).
pub const DEFAULT_TTL_SECONDS: i64 = 30;

/// Hard cap on a requested ticket lifetime.
pub const MAX_TTL_SECONDS: i64 = 120;

/// The WebSocket subprotocol marker a browser offers ahead of its ticket
/// (`["ados-ws-ticket", "<ticket>"]`); the server echoes it on the accepted
/// handshake, as RFC 6455 requires.
pub const WS_TICKET_SUBPROTOCOL: &str = "ados-ws-ticket";

/// The MAVLink WebSocket proxy scope: a ticket minted for this scope authorizes
/// the raw `:8765` MAVLink WS for an off-box paired caller.
pub const SCOPE_MAVLINK_WS: &str = "gs.mavlink_ws";

/// The RC channel-injection scope for one named client.
///
/// Unlike [`SCOPE_MAVLINK_WS`] this is not a fixed string, because the ticket
/// is attesting an IDENTITY rather than granting a capability: the RC lane's
/// authority rule asks which client is injecting and compares the answer with
/// the pilot-in-command holder. Building the client id into the signed scope is
/// what stops a ticket minted for one client from answering that question as
/// another.
///
/// It lives beside the issuer so the minting surface (the authenticated
/// ground-station route) and the verifying one (the RC lane daemon) cannot
/// drift on the spelling; a mismatch there would not fail loudly, it would just
/// silently stop attesting anyone.
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,
}

/// A minted ticket: its compact string form plus the parsed expiry, so a mint
/// surface can return `{ ticket, scope, expires_at }` without re-parsing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WsTicket {
    pub token: String,
    pub scope: String,
    pub expires_at: i64,
}

/// Every WebSocket ticket this process has accepted on a handshake.
static SPENT_TICKETS: NonceCache = NonceCache::new(4096, MAX_TTL_SECONDS);

/// Mints and verifies self-contained WS tickets, keyed by a pairing-derived
/// secret. Build one with [`WsTicketIssuer::from_api_key`] from the same
/// `pairing.json` `api_key` both daemons read.
#[derive(Clone)]
pub struct WsTicketIssuer {
    key: Vec<u8>,
}

impl WsTicketIssuer {
    /// Derive the ticket key from the pairing `api_key` under the fixed label.
    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 }
    }

    /// The issuer for an UNPAIRED node: the ticket key is derived from the
    /// dashboard-PIN `salt` under its own label. A ticket from it proves the
    /// holder unlocked the PIN on this node since the PIN was last set (a reset
    /// rotates the salt and revokes every such ticket), which is what an
    /// operator-LAN caller must show to open a stream while the node is
    /// unpaired. It can never verify as a paired ticket, nor the reverse.
    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 }
    }

    /// Mint a ticket for `scope` valid for `ttl_seconds`, using the wall clock
    /// and a fresh random nonce. `None` only when the system cannot supply
    /// randomness.
    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))
    }

    /// Deterministic mint core (explicit clock and nonce) for tests and
    /// cross-impl vectors.
    pub fn mint_at(&self, scope: &str, ttl_seconds: i64, now: i64, nonce: &str) -> WsTicket {
        // Saturating add so a far-future clock or a huge ttl cannot overflow
        // (which would panic under the workspace's panic=abort).
        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,
        }
    }

    /// Verify a ticket string: HMAC authenticity first, then scope, then expiry.
    /// `now` is unix seconds. Does not spend the ticket; a WebSocket handshake
    /// uses [`Self::verify_once`].
    pub fn verify(&self, token: &str, expected_scope: &str, now: i64) -> Result<(), WsTicketError> {
        self.verify_claims(token, expected_scope, now).map(|_| ())
    }

    /// Verify a ticket presented on a WebSocket handshake and spend it, so the
    /// same ticket cannot open a second socket.
    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)
        }
    }

    /// The checks both verifiers share; returns the nonce and expiry.
    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)?;
        // Recompute over the EXACT signed substring (the first five pipe
        // fields), so reformatting never drifts from what was signed.
        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());
        // verify_slice is a constant-time comparison.
        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())
    }
}

/// Mint a ticket for `scope` against the pairing key stored at `pairing_path`.
///
/// The convenience form for a surface that holds a path rather than a key.
/// `None` on an unpaired node, where there is no key to sign with — callers
/// treat that as "nothing to attest", not as a failure, because a verifier
/// keyed off the same file is equally unable to check one and is open in that
/// state for the same reason.
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),
    }
}

/// The signed substring: `v2|<scope>|<issued_at>|<expires_at>|<nonce>`.
fn sign_payload(scope: &str, issued_at: i64, expires_at: i64, nonce: &str) -> String {
    format!("v2|{scope}|{issued_at}|{expires_at}|{nonce}")
}

/// Unix seconds, clamped to 0 if the clock predates the epoch (a freshly booted
/// SBC before NTP sync) so minting never aborts the process.
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() {
        // A ticket lifted from a log must not open a second socket.
        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)
        );
        // A forged ticket does not reach the cache, so it cannot evict anything.
        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);
        // now == expires_at is expired (matches the plugin token's `>=`).
        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)
        );
        // ...and accepts the scope it was minted for.
        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);
        // Forge a longer expiry while keeping the original signature.
        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)
        );
    }

    // The exact vector the Python verifier (`ados.core.ws_ticket`) must agree
    // with. If this signature ever changes, the Python mirror must change in
    // lockstep or cross-language tickets silently stop verifying.
    #[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);
        // Pin the full token so the Python test can assert the identical string.
        assert_eq!(
            t.token,
            "v2|gs.mavlink_ws|1000000|1000030|00112233445566778899aabbccddeeff|\
4d99ddf7a9dc48119b137be1fe135a50f7618a77f44d3a104e5ccac9171193e7",
            "if this fails, regenerate the vector and update ados.core.ws_ticket"
        );
    }

    /// An unpaired ticket is keyed by the PIN salt: it verifies under the same
    /// salt only, never under another salt or as a paired (or empty-key) 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)
        );
    }
}