Skip to main content

ados_protocol/
plugin.rs

1//! Plugin RPC envelope and capability tokens.
2//!
3//! Wire format: length-prefixed msgpack frames over a Unix domain socket (see
4//! [`crate::frame`]). Each frame is a [`Envelope`] serialized as a msgpack map
5//! with short keys. At plugin start the supervisor mints a per-process
6//! HMAC-SHA256 capability token bound to `(plugin_id, granted_caps, session,
7//! exp)`; the token rides in every envelope and the supervisor verifies it
8//! before routing.
9//!
10//! This mirrors `ADOSDroneAgent/src/ados/plugins/rpc.py` exactly, so a Rust
11//! plugin or a Rust core speaks the identical wire as the Python supervisor.
12
13use std::collections::BTreeSet;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use hmac::{Hmac, KeyInit, Mac};
17use serde::{Deserialize, Deserializer, Serialize};
18use sha2::Sha256;
19use thiserror::Error;
20
21use crate::frame::{self, PLUGIN_MAX_FRAME};
22
23/// Protocol version carried in every envelope (`PROTOCOL_VERSION`).
24pub const PROTOCOL_VERSION: i64 = 1;
25
26/// Default capability-token lifetime in seconds (`TOKEN_TTL_SECONDS`).
27pub const TOKEN_TTL_SECONDS: i64 = 600;
28
29/// The event topic the host pushes the vehicle-state snapshot on, to a plugin
30/// that armed `telemetry.subscribe` (capability `telemetry.read`). The event's
31/// payload is the state object the MAVLink service publishes on `state.sock`.
32pub const TELEMETRY_STATE_TOPIC: &str = "telemetry.state";
33
34/// Most `telemetry.state` events the host pushes to one plugin per second. A
35/// snapshot that arrives sooner replaces the one waiting, so a plugin always
36/// receives the newest state.
37pub const TELEMETRY_STATE_MAX_HZ: u32 = 10;
38
39/// The event method the host pushes a rotated capability token on.
40///
41/// The plugin side listens for it and replaces the token it presents on
42/// subsequent requests, so a rotation never costs the plugin a failed call.
43/// Args are `{token, expires_at, granted_caps}`; the grant list rides along so
44/// a plugin can log (or degrade on) a capability it just lost instead of
45/// discovering it through a `capability_denied`.
46pub const TOKEN_REFRESH_METHOD: &str = "token.refresh";
47
48/// The env var the host sets on an `agent.http` plugin's process, carrying the
49/// Unix socket path the plugin must serve HTTP on.
50pub const ENV_PLUGIN_HTTP_SOCKET: &str = "ADOS_PLUGIN_HTTP_SOCKET";
51
52type HmacSha256 = Hmac<Sha256>;
53
54#[derive(Debug, Error)]
55pub enum PluginError {
56    #[error("framing error: {0}")]
57    Frame(#[from] frame::FrameError),
58    #[error("msgpack encode error: {0}")]
59    Encode(#[from] rmp_serde::encode::Error),
60    #[error("msgpack decode error: {0}")]
61    Decode(#[from] rmp_serde::decode::Error),
62}
63
64fn empty_args() -> rmpv::Value {
65    rmpv::Value::Map(Vec::new())
66}
67
68fn default_version() -> i64 {
69    PROTOCOL_VERSION
70}
71
72/// Deserialize the `args` field, coercing a `nil` value to an empty map so the
73/// behaviour matches the Python `from_dict`, which does `raw.get("a") or {}`.
74fn de_args<'de, D>(deserializer: D) -> Result<rmpv::Value, D::Error>
75where
76    D: Deserializer<'de>,
77{
78    let value = rmpv::Value::deserialize(deserializer)?;
79    Ok(match value {
80        rmpv::Value::Nil => empty_args(),
81        other => other,
82    })
83}
84
85/// One RPC message. Serializes to a msgpack map with the short keys the Python
86/// side uses: `v`, `t`, `m`, `c`, `a`, `id`, `tok`, `err`.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct Envelope {
89    #[serde(rename = "v", default = "default_version")]
90    pub version: i64,
91    /// "request" | "response" | "event".
92    #[serde(rename = "t")]
93    pub kind: String,
94    #[serde(rename = "m")]
95    pub method: String,
96    #[serde(rename = "c", default)]
97    pub capability: String,
98    #[serde(rename = "a", default = "empty_args", deserialize_with = "de_args")]
99    pub args: rmpv::Value,
100    #[serde(rename = "id")]
101    pub request_id: String,
102    #[serde(rename = "tok", default)]
103    pub token: String,
104    #[serde(rename = "err", default)]
105    pub error: Option<String>,
106}
107
108impl Envelope {
109    /// Serialize the envelope to a msgpack map (no length prefix).
110    pub fn to_msgpack(&self) -> Result<Vec<u8>, PluginError> {
111        // `to_vec_named` encodes the struct as a map keyed by the serde field
112        // names, matching Python's `msgpack.packb(dict, use_bin_type=True)`.
113        Ok(rmp_serde::to_vec_named(self)?)
114    }
115
116    /// Deserialize an envelope from a msgpack map body (no length prefix).
117    pub fn from_msgpack(body: &[u8]) -> Result<Self, PluginError> {
118        Ok(rmp_serde::from_slice(body)?)
119    }
120
121    /// Encode the envelope as a complete length-prefixed frame, ready to write
122    /// to the socket. Equivalent to Python `encode_frame`.
123    pub fn encode_frame(&self) -> Result<Vec<u8>, PluginError> {
124        let body = self.to_msgpack()?;
125        Ok(frame::encode_frame(&body, PLUGIN_MAX_FRAME)?)
126    }
127}
128
129#[derive(Debug, Error, PartialEq, Eq)]
130pub enum TokenError {
131    #[error("malformed capability token")]
132    Malformed,
133    #[error("capability blob is not valid hex")]
134    BadCapsBlob,
135    #[error("token timestamp is not an integer")]
136    BadTimestamp,
137    #[error("capability token HMAC mismatch")]
138    HmacMismatch,
139    #[error("capability token expired")]
140    Expired,
141}
142
143/// A minted, signed capability token. The string form is pipe-delimited:
144/// `v1|<plugin_id>|<session_id>|<issued_at>|<expires_at>|<caps_hex>|<sig_hex>`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct CapabilityToken {
147    pub plugin_id: String,
148    pub session_id: String,
149    /// Sorted set so the canonical `,`-joined form matches Python's
150    /// `",".join(sorted(caps))`.
151    pub granted_caps: BTreeSet<String>,
152    pub issued_at: i64,
153    pub expires_at: i64,
154    /// Hex-encoded HMAC-SHA256.
155    pub signature: String,
156}
157
158impl CapabilityToken {
159    fn caps_csv(&self) -> String {
160        // BTreeSet iterates in sorted order.
161        self.granted_caps
162            .iter()
163            .cloned()
164            .collect::<Vec<_>>()
165            .join(",")
166    }
167
168    /// Compact pipe-delimited string form.
169    pub fn to_token_string(&self) -> String {
170        let caps_hex = hex::encode(self.caps_csv().as_bytes());
171        [
172            "v1",
173            &self.plugin_id,
174            &self.session_id,
175            &self.issued_at.to_string(),
176            &self.expires_at.to_string(),
177            &caps_hex,
178            &self.signature,
179        ]
180        .join("|")
181    }
182
183    /// Parse the pipe-delimited string form.
184    pub fn from_token_string(encoded: &str) -> Result<Self, TokenError> {
185        let parts: Vec<&str> = encoded.split('|').collect();
186        if parts.len() != 7 || parts[0] != "v1" {
187            return Err(TokenError::Malformed);
188        }
189        let caps_bytes = hex::decode(parts[5]).map_err(|_| TokenError::BadCapsBlob)?;
190        let caps_csv = String::from_utf8(caps_bytes).map_err(|_| TokenError::BadCapsBlob)?;
191        let granted_caps: BTreeSet<String> = if caps_csv.is_empty() {
192            BTreeSet::new()
193        } else {
194            caps_csv
195                .split(',')
196                .filter(|c| !c.is_empty())
197                .map(str::to_owned)
198                .collect()
199        };
200        let issued_at: i64 = parts[3].parse().map_err(|_| TokenError::BadTimestamp)?;
201        let expires_at: i64 = parts[4].parse().map_err(|_| TokenError::BadTimestamp)?;
202        Ok(Self {
203            plugin_id: parts[1].to_owned(),
204            session_id: parts[2].to_owned(),
205            granted_caps,
206            issued_at,
207            expires_at,
208            signature: parts[6].to_owned(),
209        })
210    }
211
212    pub fn is_expired(&self, now: i64) -> bool {
213        now >= self.expires_at
214    }
215}
216
217/// Mints and verifies capability tokens. The supervisor holds one instance;
218/// the secret is generated once per process and never written to disk.
219pub struct TokenIssuer {
220    secret: Vec<u8>,
221}
222
223impl TokenIssuer {
224    pub fn new(secret: Vec<u8>) -> Self {
225        Self { secret }
226    }
227
228    /// New issuer with a fresh 32-byte random secret (`secrets.token_bytes(32)`).
229    pub fn new_random() -> Self {
230        let mut secret = vec![0u8; 32];
231        getrandom::fill(&mut secret).expect("OS RNG unavailable");
232        Self { secret }
233    }
234
235    /// Mint a token using the current wall clock and a fresh random session id.
236    pub fn mint(
237        &self,
238        plugin_id: &str,
239        granted_caps: &BTreeSet<String>,
240        ttl_seconds: i64,
241    ) -> CapabilityToken {
242        // Tolerate a clock set before the epoch (a freshly booted SBC before
243        // NTP sync) by clamping to 0 rather than aborting the process.
244        let now = SystemTime::now()
245            .duration_since(UNIX_EPOCH)
246            .map(|d| d.as_secs() as i64)
247            .unwrap_or(0);
248        let mut session_bytes = [0u8; 8];
249        getrandom::fill(&mut session_bytes).expect("OS RNG unavailable");
250        let session_id = hex::encode(session_bytes);
251        self.mint_at(plugin_id, granted_caps, ttl_seconds, now, &session_id)
252    }
253
254    /// Deterministic mint core (explicit clock + session) for tests and replay.
255    pub fn mint_at(
256        &self,
257        plugin_id: &str,
258        granted_caps: &BTreeSet<String>,
259        ttl_seconds: i64,
260        now: i64,
261        session_id: &str,
262    ) -> CapabilityToken {
263        // Saturating add so a far-future clock or a misconfigured ttl cannot
264        // overflow (which would panic under the workspace's panic=abort, or
265        // wrap to an always-expired token in release).
266        let expires_at = now.saturating_add(ttl_seconds);
267        let signature = self.sign(plugin_id, session_id, now, expires_at, granted_caps);
268        CapabilityToken {
269            plugin_id: plugin_id.to_owned(),
270            session_id: session_id.to_owned(),
271            granted_caps: granted_caps.clone(),
272            issued_at: now,
273            expires_at,
274            signature,
275        }
276    }
277
278    /// Verify the HMAC and expiry. `now` is unix seconds.
279    pub fn verify(&self, token: &CapabilityToken, now: i64) -> Result<(), TokenError> {
280        let actual = hex::decode(&token.signature).map_err(|_| TokenError::HmacMismatch)?;
281        let payload = self.sign_payload(
282            &token.plugin_id,
283            &token.session_id,
284            token.issued_at,
285            token.expires_at,
286            &token.granted_caps,
287        );
288        // verify_slice does a constant-time comparison internally.
289        let mut mac =
290            HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts any key length");
291        mac.update(payload.as_bytes());
292        mac.verify_slice(&actual)
293            .map_err(|_| TokenError::HmacMismatch)?;
294        if token.is_expired(now) {
295            return Err(TokenError::Expired);
296        }
297        Ok(())
298    }
299
300    fn sign_payload(
301        &self,
302        plugin_id: &str,
303        session_id: &str,
304        issued_at: i64,
305        expires_at: i64,
306        caps: &BTreeSet<String>,
307    ) -> String {
308        let caps_csv = caps.iter().cloned().collect::<Vec<_>>().join(",");
309        [
310            plugin_id,
311            session_id,
312            &issued_at.to_string(),
313            &expires_at.to_string(),
314            &caps_csv,
315        ]
316        .join("|")
317    }
318
319    fn sign(
320        &self,
321        plugin_id: &str,
322        session_id: &str,
323        issued_at: i64,
324        expires_at: i64,
325        caps: &BTreeSet<String>,
326    ) -> String {
327        let payload = self.sign_payload(plugin_id, session_id, issued_at, expires_at, caps);
328        let mut mac =
329            HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts any key length");
330        mac.update(payload.as_bytes());
331        hex::encode(mac.finalize().into_bytes())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn caps(items: &[&str]) -> BTreeSet<String> {
340        items.iter().map(|s| s.to_string()).collect()
341    }
342
343    /// The hand-written protocol version must match the contract registry entry
344    /// so a future drift is a build-time test failure, not a silent mis-parse.
345    #[test]
346    fn version_matches_registry() {
347        assert_eq!(
348            PROTOCOL_VERSION as u16,
349            crate::contracts::contract_version("plugin.rpc").unwrap()
350        );
351    }
352
353    #[test]
354    fn envelope_round_trips_through_msgpack() {
355        let env = Envelope {
356            version: PROTOCOL_VERSION,
357            kind: "request".into(),
358            method: "event.publish".into(),
359            capability: "event.publish".into(),
360            args: rmpv::Value::Map(vec![(
361                rmpv::Value::String("topic".into()),
362                rmpv::Value::String("demo".into()),
363            )]),
364            request_id: "abc123".into(),
365            token: "v1|p|s|0|600||sig".into(),
366            error: None,
367        };
368        let body = env.to_msgpack().unwrap();
369        let back = Envelope::from_msgpack(&body).unwrap();
370        assert_eq!(env, back);
371    }
372
373    #[test]
374    fn nil_args_decode_to_empty_map() {
375        // Python `from_dict` coerces `raw.get("a") or {}`, so an envelope
376        // carrying `a: nil` must decode to an empty map, not nil.
377        let env = Envelope {
378            version: 1,
379            kind: "event".into(),
380            method: "m".into(),
381            capability: String::new(),
382            args: rmpv::Value::Nil,
383            request_id: "id".into(),
384            token: String::new(),
385            error: None,
386        };
387        let body = env.to_msgpack().unwrap();
388        let back = Envelope::from_msgpack(&body).unwrap();
389        assert_eq!(back.args, rmpv::Value::Map(Vec::new()));
390    }
391
392    #[test]
393    fn envelope_uses_short_keys_on_the_wire() {
394        let env = Envelope {
395            version: 1,
396            kind: "event".into(),
397            method: "m".into(),
398            capability: "c".into(),
399            args: empty_args(),
400            request_id: "id".into(),
401            token: "tok".into(),
402            error: Some("boom".into()),
403        };
404        let body = env.to_msgpack().unwrap();
405        // Decode as a generic msgpack value and assert the map keys are the
406        // short forms the Python side reads.
407        let value: rmpv::Value = rmp_serde::from_slice(&body).unwrap();
408        let map = match value {
409            rmpv::Value::Map(m) => m,
410            other => panic!("expected map, got {other:?}"),
411        };
412        let keys: BTreeSet<String> = map
413            .iter()
414            .filter_map(|(k, _)| k.as_str().map(str::to_owned))
415            .collect();
416        assert_eq!(keys, caps(&["v", "t", "m", "c", "a", "id", "tok", "err"]));
417    }
418
419    #[test]
420    fn token_string_round_trips() {
421        let issuer = TokenIssuer::new(b"test-secret-key".to_vec());
422        let token = issuer.mint_at(
423            "com.example.plugin",
424            &caps(&["mavlink.read", "event.publish"]),
425            600,
426            1000,
427            "deadbeef",
428        );
429        let s = token.to_token_string();
430        // v1|plugin|session|issued|exp|caps_hex|sig
431        assert!(s.starts_with("v1|com.example.plugin|deadbeef|1000|1600|"));
432        let parsed = CapabilityToken::from_token_string(&s).unwrap();
433        assert_eq!(parsed, token);
434    }
435
436    #[test]
437    fn caps_are_canonically_sorted_in_the_signed_payload() {
438        let issuer = TokenIssuer::new(b"k".to_vec());
439        // Same caps, different insertion order, must yield the same signature.
440        let a = issuer.mint_at("p", &caps(&["b", "a", "c"]), 600, 0, "s");
441        let b = issuer.mint_at("p", &caps(&["c", "b", "a"]), 600, 0, "s");
442        assert_eq!(a.signature, b.signature);
443    }
444
445    #[test]
446    fn verify_accepts_a_good_token_and_rejects_tampering() {
447        let issuer = TokenIssuer::new(b"secret".to_vec());
448        let token = issuer.mint_at("p", &caps(&["mavlink.read"]), 600, 1000, "sess");
449        assert!(issuer.verify(&token, 1500).is_ok());
450
451        // Tamper with granted caps -> signature no longer matches.
452        let mut tampered = token.clone();
453        tampered.granted_caps.insert("mavlink.write".into());
454        assert_eq!(
455            issuer.verify(&tampered, 1500),
456            Err(TokenError::HmacMismatch)
457        );
458
459        // Wrong secret -> mismatch.
460        let other = TokenIssuer::new(b"different".to_vec());
461        assert_eq!(other.verify(&token, 1500), Err(TokenError::HmacMismatch));
462    }
463
464    #[test]
465    fn verify_rejects_expired_token() {
466        let issuer = TokenIssuer::new(b"secret".to_vec());
467        let token = issuer.mint_at("p", &caps(&["x"]), 600, 1000, "sess");
468        // now == expires_at -> expired (Python: ts >= expires_at).
469        assert_eq!(issuer.verify(&token, 1600), Err(TokenError::Expired));
470        assert!(issuer.verify(&token, 1599).is_ok());
471    }
472
473    #[test]
474    fn empty_caps_token_round_trips() {
475        let issuer = TokenIssuer::new(b"k".to_vec());
476        let token = issuer.mint_at("p", &BTreeSet::new(), 600, 0, "s");
477        let parsed = CapabilityToken::from_token_string(&token.to_token_string()).unwrap();
478        assert!(parsed.granted_caps.is_empty());
479        assert_eq!(parsed, token);
480    }
481}