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(×tamp.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}