Skip to main content

pamoja_mavlink/
signing.rs

1//! MAVLink 2 message signing: the signature a sender appends and the check a receiver
2//! makes, so a ground station can trust that a command came from the vehicle it expects
3//! and was not replayed.
4//!
5//! The scheme follows the MAVLink reference exactly. A signed frame carries a 13-byte
6//! block after its checksum: a one-byte link id, a 48-bit timestamp, and a 48-bit
7//! signature. The signature is the first six bytes of
8//! `SHA-256(secret_key ++ header ++ payload ++ checksum ++ link_id ++ timestamp)`, where
9//! the header includes the start marker. The timestamp is in 10-microsecond units since
10//! 1 January 2015 GMT and must increase, which is what stops a captured frame from being
11//! replayed to re-arm a vehicle or re-trigger an actuator.
12//!
13//! [`Signer`] stamps and signs outgoing frames; [`Verifier`] checks incoming ones,
14//! tracking a timestamp per `(system, component, link)` stream so an old or repeated
15//! frame is rejected. SHA-256 is the one primitive borrowed (from `sha2`), as in
16//! [`pamoja-session`](https://docs.rs/pamoja-session); everything else is built here.
17
18use sha2::{Digest, Sha256};
19
20use crate::error::{MavlinkError, Result};
21use crate::frame::{Frame, Header, IFLAG_SIGNED};
22
23/// The length of a signing secret key, in bytes.
24pub const KEY_LEN: usize = 32;
25
26/// The default replay window: one minute, in 10-microsecond ticks.
27///
28/// A frame from a stream not seen before is rejected if its timestamp is more than this
29/// far behind the newest timestamp the verifier has accepted.
30pub const DEFAULT_TIMESTAMP_WINDOW: u64 = 6_000_000;
31
32/// The seconds between the Unix epoch and the MAVLink signing epoch (1 January 2015 GMT).
33pub const MAVLINK_EPOCH_OFFSET_SECS: u64 = 1_420_070_400;
34
35// The number of per-stream timestamps a verifier remembers at once.
36const MAX_STREAMS: usize = 16;
37
38// Computes the 48-bit signature over a frame: the first six bytes of the SHA-256 of the
39// key, the frame's header-payload-checksum region, and the link id and timestamp.
40fn signature_48(key: &[u8; KEY_LEN], signed_region: &[u8], link_and_timestamp: &[u8]) -> [u8; 6] {
41    let mut hasher = Sha256::new();
42    hasher.update(key);
43    hasher.update(signed_region);
44    hasher.update(link_and_timestamp);
45    let digest = hasher.finalize();
46    let mut out = [0u8; 6];
47    out.copy_from_slice(&digest[..6]);
48    out
49}
50
51/// Converts a wall-clock time to a MAVLink signing timestamp.
52///
53/// # Arguments
54///
55/// * `unix_micros` - microseconds since the Unix epoch, as a field clock (RTC or GPS)
56///   would report.
57///
58/// # Returns
59///
60/// The timestamp in 10-microsecond ticks since the MAVLink epoch, or `0` if the time is
61/// before that epoch.
62pub fn timestamp_from_unix_micros(unix_micros: u64) -> u64 {
63    let epoch_micros = MAVLINK_EPOCH_OFFSET_SECS * 1_000_000;
64    unix_micros.saturating_sub(epoch_micros) / 10
65}
66
67/// Signs outgoing v2 frames with a shared key.
68///
69/// A signer holds the key, the link id it stamps, and a monotonically increasing
70/// timestamp. Seed the timestamp from a field clock with
71/// [`timestamp_from_unix_micros`] where one is available; without a clock, any strictly
72/// increasing seed works, since the receiver only requires that timestamps rise.
73#[derive(Clone)]
74pub struct Signer {
75    key: [u8; KEY_LEN],
76    link_id: u8,
77    timestamp: u64,
78}
79
80impl Signer {
81    /// Creates a signer for a key, a link id, and a starting timestamp.
82    ///
83    /// # Arguments
84    ///
85    /// * `key` - the 32-byte shared secret.
86    /// * `link_id` - the id of the link this signer stamps on its frames.
87    /// * `timestamp` - the first timestamp to use, in 10-microsecond ticks since the
88    ///   MAVLink epoch.
89    ///
90    /// # Returns
91    ///
92    /// The signer.
93    pub fn new(key: [u8; KEY_LEN], link_id: u8, timestamp: u64) -> Self {
94        Signer {
95            key,
96            link_id,
97            timestamp,
98        }
99    }
100
101    /// Builds and signs a v2 frame for a message.
102    ///
103    /// The frame is assembled with the signed flag set so the flag is covered by the
104    /// checksum, then the signature block is filled and the signer's timestamp advanced.
105    ///
106    /// # Arguments
107    ///
108    /// * `header` - the addressing fields to stamp on the frame.
109    /// * `msgid` - the 24-bit message id.
110    /// * `payload` - the serialized message payload.
111    /// * `crc_extra` - the `CRC_EXTRA` seed for `msgid`.
112    ///
113    /// # Returns
114    ///
115    /// The signed frame, ready to send.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`MavlinkError::PayloadTooLong`] if the payload does not fit a frame.
120    pub fn sign(
121        &mut self,
122        header: Header,
123        msgid: u32,
124        payload: &[u8],
125        crc_extra: u8,
126    ) -> Result<Frame> {
127        let mut frame = Frame::assemble_v2(header, msgid, payload, crc_extra, IFLAG_SIGNED)?;
128        let timestamp = self.timestamp;
129        {
130            let block = frame.signature_mut();
131            block[0] = self.link_id;
132            block[1..7].copy_from_slice(&timestamp.to_le_bytes()[..6]);
133        }
134        let mac = signature_48(
135            &self.key,
136            frame.signed_region(),
137            &frame.signature().expect("just assembled as signed")[..7],
138        );
139        frame.signature_mut()[7..13].copy_from_slice(&mac);
140        self.timestamp = self.timestamp.wrapping_add(1);
141        Ok(frame)
142    }
143
144    /// Returns the link id this signer stamps.
145    ///
146    /// # Returns
147    ///
148    /// The link id.
149    pub fn link_id(&self) -> u8 {
150        self.link_id
151    }
152}
153
154// One remembered stream: the newest timestamp accepted for a (system, component, link).
155#[derive(Clone, Copy)]
156struct Stream {
157    system_id: u8,
158    component_id: u8,
159    link_id: u8,
160    timestamp: u64,
161    used: bool,
162}
163
164/// Verifies signed v2 frames against a shared key, rejecting forged and replayed frames.
165///
166/// A verifier recomputes each frame's signature and rejects it unless it matches. It also
167/// enforces freshness: a frame from a stream it has seen must carry a strictly newer
168/// timestamp than the last, and a frame from a new stream must not be more than the
169/// replay window behind the newest timestamp seen, so a recording of an old frame cannot
170/// be re-injected.
171#[derive(Clone)]
172pub struct Verifier {
173    key: [u8; KEY_LEN],
174    window: u64,
175    newest: u64,
176    streams: [Stream; MAX_STREAMS],
177}
178
179impl Verifier {
180    /// Creates a verifier for a key, using the default replay window.
181    ///
182    /// # Arguments
183    ///
184    /// * `key` - the 32-byte shared secret.
185    ///
186    /// # Returns
187    ///
188    /// The verifier.
189    pub fn new(key: [u8; KEY_LEN]) -> Self {
190        Verifier {
191            key,
192            window: DEFAULT_TIMESTAMP_WINDOW,
193            newest: 0,
194            streams: [Stream {
195                system_id: 0,
196                component_id: 0,
197                link_id: 0,
198                timestamp: 0,
199                used: false,
200            }; MAX_STREAMS],
201        }
202    }
203
204    /// Sets the replay window, in 10-microsecond ticks.
205    ///
206    /// # Arguments
207    ///
208    /// * `window` - how far behind the newest accepted timestamp a frame from a new
209    ///   stream may be.
210    ///
211    /// # Returns
212    ///
213    /// The verifier, for chaining.
214    pub fn with_window(mut self, window: u64) -> Self {
215        self.window = window;
216        self
217    }
218
219    /// Verifies a signed frame's signature and freshness.
220    ///
221    /// On success, the frame's stream timestamp is recorded so a later replay of the same
222    /// or an older frame is rejected.
223    ///
224    /// # Arguments
225    ///
226    /// * `frame` - the parsed frame to check.
227    ///
228    /// # Returns
229    ///
230    /// `Ok(())` if the frame is authentic and fresh.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`MavlinkError::Unsigned`] if the frame is not signed,
235    /// [`MavlinkError::BadSignature`] if the signature does not match the key, or
236    /// [`MavlinkError::ReplayedTimestamp`] if the timestamp is not fresh.
237    pub fn verify(&mut self, frame: &Frame) -> Result<()> {
238        let block = frame.signature().ok_or(MavlinkError::Unsigned)?;
239        let expected = signature_48(&self.key, frame.signed_region(), &block[..7]);
240        if expected != block[7..13] {
241            return Err(MavlinkError::BadSignature);
242        }
243
244        let link_id = block[0];
245        let mut timestamp_bytes = [0u8; 8];
246        timestamp_bytes[..6].copy_from_slice(&block[1..7]);
247        let timestamp = u64::from_le_bytes(timestamp_bytes);
248
249        let system_id = frame.system_id();
250        let component_id = frame.component_id();
251        match self.find_stream(system_id, component_id, link_id) {
252            Some(index) => {
253                if timestamp <= self.streams[index].timestamp {
254                    return Err(MavlinkError::ReplayedTimestamp);
255                }
256                self.streams[index].timestamp = timestamp;
257            }
258            None => {
259                if timestamp + self.window < self.newest {
260                    return Err(MavlinkError::ReplayedTimestamp);
261                }
262                self.remember(system_id, component_id, link_id, timestamp);
263            }
264        }
265        if timestamp > self.newest {
266            self.newest = timestamp;
267        }
268        Ok(())
269    }
270
271    fn find_stream(&self, system_id: u8, component_id: u8, link_id: u8) -> Option<usize> {
272        self.streams.iter().position(|stream| {
273            stream.used
274                && stream.system_id == system_id
275                && stream.component_id == component_id
276                && stream.link_id == link_id
277        })
278    }
279
280    // Records a new stream's timestamp, evicting the stream with the oldest timestamp
281    // when the table is full so a busy link cannot crowd out freshness tracking forever.
282    fn remember(&mut self, system_id: u8, component_id: u8, link_id: u8, timestamp: u64) {
283        let slot = self
284            .streams
285            .iter()
286            .position(|stream| !stream.used)
287            .unwrap_or_else(|| {
288                self.streams
289                    .iter()
290                    .enumerate()
291                    .min_by_key(|(_, stream)| stream.timestamp)
292                    .map(|(index, _)| index)
293                    .unwrap_or(0)
294            });
295        self.streams[slot] = Stream {
296            system_id,
297            component_id,
298            link_id,
299            timestamp,
300            used: true,
301        };
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    const KEY: [u8; KEY_LEN] = [0x42; KEY_LEN];
310
311    fn signed_heartbeat(signer: &mut Signer, timestamp_seq: u8) -> Frame {
312        let header = Header::new(1, 1, timestamp_seq);
313        // A HEARTBEAT payload; CRC_EXTRA 50.
314        signer
315            .sign(header, 0, &[0, 0, 0, 0, 6, 8, 0, 3, 3], 50)
316            .unwrap()
317    }
318
319    #[test]
320    fn sha256_primitive_matches_the_nist_vector() {
321        // SHA-256("abc"), the canonical FIPS-180 example, anchoring the primitive the
322        // signature is built on.
323        let mut hasher = Sha256::new();
324        hasher.update(b"abc");
325        let digest = hasher.finalize();
326        let expected = [
327            0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
328            0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
329            0xf2, 0x00, 0x15, 0xad,
330        ];
331        assert_eq!(digest[..], expected[..]);
332    }
333
334    #[test]
335    fn the_signature_block_is_laid_out_as_the_spec_requires() {
336        let mut signer = Signer::new(KEY, 0x07, 0x0000_1122_3344_5566);
337        let frame = signed_heartbeat(&mut signer, 0);
338        let block = frame.signature().expect("signed");
339        assert_eq!(block[0], 0x07); // link id
340                                    // 48-bit timestamp, little-endian: the low six bytes of the seed.
341        assert_eq!(&block[1..7], &[0x66, 0x55, 0x44, 0x33, 0x22, 0x11]);
342        assert!(frame.is_signed());
343    }
344
345    #[test]
346    fn a_signed_frame_verifies() {
347        let mut signer = Signer::new(KEY, 1, 1_000_000);
348        let mut verifier = Verifier::new(KEY);
349        let frame = signed_heartbeat(&mut signer, 0);
350        assert!(verifier.verify(&frame).is_ok());
351    }
352
353    #[test]
354    fn a_tampered_frame_fails_verification() {
355        let mut signer = Signer::new(KEY, 1, 1_000_000);
356        let mut verifier = Verifier::new(KEY);
357        let frame = signed_heartbeat(&mut signer, 0);
358        let mut bytes = frame.as_bytes().to_vec();
359        // Flip the last signature byte: the checksum does not cover it, so the frame still
360        // parses, but the recomputed signature no longer matches.
361        let last = bytes.len() - 1;
362        bytes[last] ^= 0xFF;
363        let tampered = Frame::parse(&bytes, 50).unwrap();
364        assert_eq!(verifier.verify(&tampered), Err(MavlinkError::BadSignature));
365    }
366
367    #[test]
368    fn the_wrong_key_fails_verification() {
369        let mut signer = Signer::new(KEY, 1, 1_000_000);
370        let mut verifier = Verifier::new([0x99; KEY_LEN]);
371        let frame = signed_heartbeat(&mut signer, 0);
372        assert_eq!(verifier.verify(&frame), Err(MavlinkError::BadSignature));
373    }
374
375    #[test]
376    fn an_unsigned_frame_is_rejected_by_a_verifier() {
377        let header = Header::new(1, 1, 0);
378        let frame = Frame::encode_v2(header, 0, &[0, 0, 0, 0, 6, 8, 0, 3, 3], 50).unwrap();
379        let mut verifier = Verifier::new(KEY);
380        assert_eq!(verifier.verify(&frame), Err(MavlinkError::Unsigned));
381    }
382
383    #[test]
384    fn a_replayed_frame_is_rejected() {
385        let mut signer = Signer::new(KEY, 1, 1_000_000);
386        let mut verifier = Verifier::new(KEY);
387        let frame = signed_heartbeat(&mut signer, 0);
388        assert!(verifier.verify(&frame).is_ok());
389        // The very same frame, replayed, carries a timestamp no newer than the last.
390        assert_eq!(
391            verifier.verify(&frame),
392            Err(MavlinkError::ReplayedTimestamp)
393        );
394    }
395
396    #[test]
397    fn timestamps_must_increase_on_a_stream() {
398        let mut verifier = Verifier::new(KEY);
399        let mut newer = Signer::new(KEY, 1, 100);
400        let mut older = Signer::new(KEY, 1, 50);
401        let new_frame = signed_heartbeat(&mut newer, 0);
402        let old_frame = signed_heartbeat(&mut older, 1);
403        assert!(verifier.verify(&new_frame).is_ok());
404        // A frame on the same stream with an older timestamp is a replay.
405        assert_eq!(
406            verifier.verify(&old_frame),
407            Err(MavlinkError::ReplayedTimestamp)
408        );
409    }
410
411    #[test]
412    fn timestamp_conversion_uses_the_mavlink_epoch() {
413        // Exactly at the MAVLink epoch, the timestamp is zero.
414        assert_eq!(
415            timestamp_from_unix_micros(MAVLINK_EPOCH_OFFSET_SECS * 1_000_000),
416            0
417        );
418        // One second later is 100,000 ten-microsecond ticks.
419        assert_eq!(
420            timestamp_from_unix_micros((MAVLINK_EPOCH_OFFSET_SECS + 1) * 1_000_000),
421            100_000
422        );
423    }
424}