MAVLink 安全通信系列(5):X.509 与 MAVLink 握手集成
返回系列总目录。
本篇不再推导证书、ECDH、ECDSA 或 HKDF 的基础原理,而是讨论如何把这些构件 组合成 MAVLink 握手协议:握手消息怎么复用 TUNNEL #385 承载、三个 flight 加一个加密 DONE 如何推进、transcript 摘要如何参与认证、HKDF 怎么把协商结果 派生成会话密钥并"热替换"进 AES 加密状态、丢包怎么重传、验证失败怎么告警。
对应项目文件:
src/c_library_v2/mavlink_handshake.h(状态机)、mavlink_x509.h(证书校验)、mavlink_ecc.h(P-256/ECDH/ECDSA)。编译宏:MAVLINK_USE_X509_HANDSHAKE。
1. 设计取舍:刻意的简化
完整 TLS/DTLS 对 STM32 太重,本项目做了四刀裁剪,换来嵌入式可用性:
| 裁剪项 | 保留 | 说明 |
|---|---|---|
| 证书体系 | 只钉扎一个 CA、只验一级 leaf | 不做证书链 / 多 CA |
| 密码套件 | 只一套:ECDHE-P256 + ECDSA/SHA-256 + AES-128-GCM | 不协商,suite 不符即告警 |
| 吊销机制 | 无 CRL/OCSP 或单设备撤销查询 | 换取 Flash/RAM;CA 轮换需同步更新两端信任锚 |
| 承载通道 | 复用标准 TUNNEL #385(私有 payload_type 32769) | 不改 MAVLink 协议 |
四条铁律:
- 真 X.509 DER 证书,但只信任用户钉扎的 CA 公钥点;
- 证书与私钥一律运行时注入,库与示例不硬编码任何凭证;
- 只有 GCS(client)主动发起握手,UAV(server)纯应答;
- 未定义
MAVLINK_USE_X509_HANDSHAKE宏时,编译产物与原生 MAVLink 逐字节一致。
2. 承载通道:TUNNEL #385
MAVLink 自带 TUNNEL 消息(msgid 385),用于承载不透明数据,线格式:
payload_type(u16) + payload_length(u8) + payload[payload_length]payload_type 大于 32767 是私有实验区间。本项目固定用:
#define MAVLINK_HANDSHAKE_TUNNEL_PAYLOAD_TYPE 32769 /* 0x8001 */握手期间 TUNNEL 帧一律明文发送(旁路标志 HANDSHAKE_PASSTHROUGH),唯一 例外是末尾的 DONE 帧——它是链路第一帧加密报文,同时扮演"加密通道已就绪"信号。 这样握手数据能先于加密通道流动,而加密通道的建立又被那帧加密 DONE 所确认。
2.1 Record 头(5 字节)
TUNNEL payload 内部是自定义定长记录头:
偏移 长度 字段 说明
0 1 ver = 0x01 版本
1 1 rtype 记录类型(见下表)
2 1 mid 逻辑消息序号,同一条逻辑消息的所有分片相同
3 1 frag_idx 分片下标,从 0 起
4 1 frag_total 分片总数(1..6,越界 → ALERT)
5..127 chunk 本分片数据,≤123 字节记录类型:
| rtype | 名称 | 方向 | body |
|---|---|---|---|
| 1 | CLIENT_HELLO | GCS→UAV | suite(1) ‖ random_c(32) ‖ eph_c(65),共 98B |
| 2 | SERVER_HELLO | UAV→GCS | random_s(32) ‖ eph_s(65),共 97B |
| 3 | CERTIFICATE | 双向 | 本端 leaf 证书 DER(约 440B,4 分片) |
| 4 | VERIFY | 双向 | 对 transcript 的 ECDSA 签名 r‖s(64B) |
| 5 | FINISHED | 双向 | HMAC-SHA256 finished 标签(32B) |
| 6 | DONE | UAV→GCS | 空 body,用会话密钥加密发送 |
| 7 | ALERT | 双向 | 1 字节错误码 |
重组上限:单条逻辑消息 123×6 = 738 字节,实际封顶 620 字节 (MAVLINK_HANDSHAKE_MAX_REASM);同一通道同一时刻只允许一个未完成的多分片消息。
3. 消息流:三个握手 flight + 一个加密 DONE
一张图看懂整个握手时序:
sequenceDiagram
autonumber
participant G as GCS(client)
participant U as UAV(server)
Note over G: 生成临时私钥,公开 eph_c
G->>U: ① CLIENT_HELLO<br/>(suite + random_c + eph_c) ——flight 1,明文
rect rgb(240,248,255)
Note over U: 生成临时私钥,公开 eph_s;算出共享秘密 Z
U->>G: ② SERVER_HELLO (random_s + eph_s) ——flight 2,明文
U->>G: ③ CERTIFICATE (UAV 证书,4 分片)
U->>G: ④ VERIFY (UAV 的 ECDSA 签名)
U->>G: ⑤ FINISHED (UAV 的 HMAC)
end
Note over G: 验 UAV 证书→提取公钥→验签→验 FINISHED
rect rgb(255,250,240)
G->>U: ⑥ CERTIFICATE (GCS 证书,4 分片) ——flight 3,明文
G->>U: ⑦ VERIFY (GCS 的 ECDSA 签名)
G->>U: ⑧ FINISHED (GCS 的 HMAC)
end
Note over U: 验 GCS 证书→提取公钥→验签→验 FINISHED→装密钥
U-->>G: ⑨ DONE(用会话密钥加密的 TUNNEL) ——加密确认消息
Note over G,U: 双向认证完成,会话密钥就绪,后续业务帧 AES-GCM 加解密四个关键步骤:
- 出示证书:双方各发 leaf 证书(含公钥),对端用钉扎的 CA 公钥点验签, 确认"证书是该 CA 签发且内容未改变",从而取得按本地信任策略接受的公钥;
- 签名自证:双方用身份私钥对 transcript 做 ECDSA 签名(VERIFY),对端用 上一步的公钥验签,证明"我确实持有证书对应的私钥";
- 密钥协商:CLIENT_HELLO / SERVER_HELLO 里各带临时公钥
eph,双方经 ECDHE 算出同一个共享秘密 Z;被动窃听者无法据此计算 Z。防主动中间人还依赖 证书、签名覆盖临时公钥和完整 transcript; - 确认:FINISHED 用派生出的 finished 密钥做 HMAC,双方校验一致即确认 "密钥一致、握手未被篡改";UAV 发出加密的 DONE,双向认证完成。
DONE 是加密帧:若 GCM 认证失败会被现有解析路径直接丢弃,状态不前进,靠重传 补齐——加密通道不可能在假密钥下建立。
4. Transcript:认证握手上下文的有序摘要
握手的安全核心之一是对有序、类型化握手记录计算的 SHA-256 transcript 摘要。 实现对重组完成的每条逻辑消息依次更新 SHA-256 状态,记录编码为:
record = rtype(1) ‖ len(16位大端) ‖ body
T = SHA256(record_1 ‖ record_2 ‖ ... ‖ record_n)图中的 H_i 表示截至第 i 条记录的前缀摘要 SHA256(record_1‖...‖record_i);它是对流式 SHA-256 状态的概念化表示,不是把 上一个摘要再次哈希的 SHA256(H_(i-1)‖record_i) 构造。
flowchart LR
H0["空 transcript"] --> H1["H1 = SHA256(record_CLIENT_HELLO)"]
H1 --> H2["H2 = SHA256(record_CLIENT_HELLO ‖ record_SERVER_HELLO)"]
H2 --> H3["H3 = SHA256(… ‖ record_CERT_s)"]
H3 --> H4["H4 = SHA256(… ‖ record_VERIFY_s)"]
H4 --> H5["H5 = SHA256(… ‖ record_CERT_g)"]
H5 --> H6["H6 = SHA256(… ‖ record_VERIFY_g)"]签名与 FINISHED 各自锚定在某一个 H 上:
VERIFY_s 签 H3 = H(CLIENT_HELLO ‖ SERVER_HELLO ‖ CERT_s)
VERIFY_g 签 H5 = H(… ‖ VERIFY_s ‖ CERT_g)
FINISHED_s = HMAC(fk_s, H4=H(… ‖ VERIFY_s))
FINISHED_g = HMAC(fk_g, H(… ‖ CERT_g ‖ VERIFY_g))设计要点:任何被 transcript 纳入的逻辑记录若发生变化,后续摘要都会变化,导致 VERIFY 验签或 FINISHED 校验失败。suite 字节也进入 transcript,绑定算法选择。 双方签名覆盖的前缀不同,并在各自计算 Finished 前纳入对端签名,减少反射或消息 互换风险。外层 MAVLink header 并未因此自动进入 transcript;其身份绑定缺口见 第 8、11 节。
5. 密钥派生:HKDF
协商得到的共享密钥 Z 不能直接用,还要经 HKDF 派生成不同用途的密钥:
flowchart TB
Z["Z = ECDH-P256(本端eph私钥, 对端eph公钥) 的 x 坐标 (32B)"] --> IKM["IKM = Z ‖ random_c ‖ random_s (96B)"]
IKM --> PRK["PRK = HMAC-SHA256(key='MAVSECv1', IKM) ← HKDF-Extract"]
PRK --> K1["key = HKDF-Expand(PRK, 'mavlink-aes128-gcm')[0:16]<br/>→ 热加载到 aes_state.key"]
PRK --> K2["fk_s = HKDF-Expand(PRK, 'finished-server')"]
PRK --> K3["fk_g = HKDF-Expand(PRK, 'finished-client')"]- 本实现把一把会话 AES 密钥双向复用,且没有独立的方向密钥;这是实现简化, 不应被误写为最佳实践。双向密钥会让任一端都能构造任一方向的有效 GCM 标签, 也无法仅凭标签归因发送方。更严格的协议应使用方向分离的 traffic keys,并将 方向/协议上下文纳入派生标签;
- GCM nonce 沿用底层 12 字节随机机制;这不提供 anti-replay,且同一密钥双向共用 时 nonce 唯一性必须跨两个发送方向共同成立;
- 会话密钥 memcpy 进
aes->key即时生效,每帧重新做密钥扩展,无需重建设备 或通道——这就是"热替换"; - ECDH 临时私钥、Z、PRK、finished 密钥在握手成功/失败后 volatile memset 清零。 这有助于降低内存残留风险,但不能保证编译器、缓存、寄存器或崩溃转储中的副本 均已清除。
6. 状态机
双方用一个简单的状态机推进:
stateDiagram-v2
[*] --> GCS_INIT
GCS_INIT --> GCS_WAIT_SH : start()(生成临时密钥 + 发 CLIENT_HELLO)
GCS_WAIT_SH --> GCS_WAIT_DONE : 收齐并验完飞行2
GCS_WAIT_DONE --> ESTABLISHED : 收加密 DONE
[*] --> UAV_WAIT_CF
UAV_WAIT_CF --> UAV_WAIT_CF : 收 CLIENT_HELLO,发飞行2
UAV_WAIT_CF --> ESTABLISHED : 收齐验完飞行3,装密钥,发加密 DONE
GCS_WAIT_SH --> FAILED : 任一验证失败 / 超时 / ALERT
GCS_WAIT_DONE --> FAILED
UAV_WAIT_CF --> FAILED
FAILED --> [*] : 终态,重新 initGCS : INIT ─start()→ WAIT_SH ─收齐验完 flight2→ WAIT_DONE ─收加密 DONE→ ESTABLISHED
UAV : WAIT_CF ─收 CLIENT_HELLO 并发 flight2→ WAIT_CF ─收齐验完 flight3,装密钥、发加密 DONE→ ESTABLISHEDFAILED 是终态、不可恢复——任何失步只能重新 init 从头握手,状态机刻意不提供 "半截恢复",换来实现的简单与可证性。
7. 重传与超时(航班风格)
数传链路会丢包,握手用整班重传保证到达:
- 发送方缓存当前航班已序列化的记录(≤8 条 TUNNEL 载荷,约 1.1KB);
mavlink_handshake_tick(now_ms)周期性驱动:航班全部发出后 1000ms 无进展, 从头发整个航班;最多 4 次重传,仍无进展即FAILED(TIMEOUT)。
对端对重传航班幂等处理:
flowchart TD
RX["收到重传航班"] --> DUP{"已处理过这条逻辑消息?"}
DUP -->|是| DROP["按 rtype+mid 去重,直接丢弃"]
DUP -->|否| PROC["正常处理"]
RX --> CH{"前一条多分片消息有缺口?"}
CH -->|是| WAIT["丢弃,等整个航班重传补缺口"]
CH -->|否| PROC- UAV 在 WAIT_CF 收到重复 CLIENT_HELLO → 原样重发飞行 2;
- UAV 已 ESTABLISHED 又收到飞行 3 重放(说明 DONE 丢了)→ 重发 DONE。
DoS 上限:单通道单 context、重组缓冲 620B、frag_total≤6、航班 ≤8 条、1s/4 次 重传;非法记录直接终态 FAILED,不做重试式资源消耗。
8. X.509 最小验证策略
信任锚是集成方注入的 CA 公钥点(65B 非压缩点,0x04‖X‖Y),比钉整张 DER 证书省 Flash。验证流程:
flowchart TB
A["严格边界的 DER TLV 游标"] --> B["校验签名算法 OID = ecdsa-with-SHA256"]
B --> C["定位 subjectPublicKeyInfo:<br/>id-ecPublicKey + prime256v1,65B 非压缩点"]
C --> D["曲线点有效性校验(x,y∈[1,p-1]、在曲线上、非无穷远)"]
D --> E["用钉扎 CA 公钥验 SHA256(tbsCertificate) 的签名"]
E --> F["校验有效期(无 now_unix 回调则跳过)"]- 拒绝不定长编码、2 字节以上 length、声明长度越界、尾部多余字节;解析全程边界 检查,恶意证书不可越界读;
- 有效期由回调
uint64_t (*now_unix)(void)提供,回调为 NULL 时跳过有效期 (实现行为,不应视为生产安全默认值;无可信时间时应采用经评审的替代策略或拒绝握手); - 只验一级,不做 CN/SAN 与 sysid 的绑定——同一 CA 签发即视为机群可信成员。
9. 集成 API 与胶水代码
握手对外暴露 6 个函数(mavlink_handshake.h):
void mavlink_handshake_init(ctx, role, aes, ca_pubkey65, cert_der, cert_len,
privkey32, rng_cb, now_unix_cb);
int mavlink_handshake_start(ctx); /* 仅 GCS 主动发起 */
uint8_t mavlink_handshake_poll_tx(ctx, out128); /* 取待发握手记录 */
void mavlink_handshake_handle_tunnel(ctx, payload, len, sysid, compid);
void mavlink_handshake_tick(ctx, now_ms); /* 周期驱动重传 */
mavlink_handshake_state_t mavlink_handshake_state(ctx);
uint8_t mavlink_handshake_alert(ctx);集成方胶水(两端同构,完整可运行示例见 example/):
static mavlink_aes_gcm_state_t aes = {0};
static mavlink_handshake_ctx_t hs;
/* ca_pubkey / cert / priv 来自安全存储,绝不来自编译期常量 */
mavlink_handshake_init(&hs, MAVLINK_HANDSHAKE_ROLE_SERVER, &aes,
ca_pubkey, cert_der, cert_len, privkey, my_hw_random, my_unix_clock);
mavlink_get_channel_status(chan)->aes_gcm = &aes;
/* GCS 侧再调:mavlink_handshake_start(&hs); */收到 TUNNEL 时分发给握手状态机:
if (msg.msgid == MAVLINK_MSG_ID_TUNNEL) {
mavlink_tunnel_t t;
mavlink_msg_tunnel_decode(&msg, &t);
if (t.payload_type == MAVLINK_HANDSHAKE_TUNNEL_PAYLOAD_TYPE)
mavlink_handshake_handle_tunnel(&hs, t.payload, t.payload_length,
msg.sysid, msg.compid);
}链路可写时泵出待发记录 + 周期计时:
uint8_t rec[128], n;
while ((n = mavlink_handshake_poll_tx(&hs, rec)) != 0) {
/* 打包成 TUNNEL(385,32769) 帧发出;握手期间旁路保证不被加密 */
}
mavlink_handshake_tick(&hs, millis()); /* 建议每 100ms 调一次 */ESTABLISHED 之后什么都不用做:aes.key 已是协商密钥、旁路标志已清除、外发 加密标志已置位,所有业务消息按底层加密方式透明加解密。
注意:握手未完成时,除 TUNNEL(32769) 之外的业务消息仍会按加密标志照常加密 (此时密钥无意义)。上层必须保证 ESTABLISHED 之前不发业务数据。
10. 资源占用(嵌入式)
纯软件、header-only、无堆分配,实测(-Os):
| 项 | 数值 |
|---|---|
| Flash(代码) | P-256 ECC ~7.2 KB + 握手状态机 ~4.3 KB + X.509 ~2.2 KB + SHA-256 ~1.2 KB ≈ 15 KB |
| 全栈 Flash | 加上 AES-GCM 约 2 ~ 4 KB,共 17 ~ 19 KB |
| RAM | mavlink_handshake_ctx_t 2272 字节/链路 + mavlink_aes_gcm_state_t 40 字节 |
| 栈峰值 | 约 3~4 KB(峰值在"收证书→X.509 验签→ECDSA 验签"链路上) |
- 2272 B 上下文主要由航班重传缓存(~1 KB)+ 分片重组缓冲(620 B)+ 临时密钥 (~400 B)构成;
- 最需留意的是栈峰值:握手应放独立线程并分配 4 ~ 6 KB 栈,勿塞进默认 1 ~ 2 KB。
11. 局限与后续
- 无 sysid 身份绑定:任何同 CA 签发的证书都能在任何 sysid 上通过认证 (机群成员模型)。需要单机强绑定可把设备 ID 写入证书 CN/SAN 再与 sysid 比对;
- 有效期默认跳过:无时钟无法判断证书是否在有效期内;生产集成应在可信时间 可用时提供
now_unix回调,或明确定义 fail-closed 的替代策略; - 无吊销机制(CRL/OCSP):私钥泄露的设备在 CA 轮换前仍被信任;
- 单套件、单 CA、一级证书:suite≠1 直接告警;
- FAILED 不可恢复:任何失步只能重新 init 从头握手;
- 随机 nonce 与 anti-replay:随机 96 位 nonce 不是重放保护;在
q次独立 均匀抽取下,碰撞概率近似q(q-1)/2^97。应按风险和标准约束配置每密钥数据量 上限并及时 rekey;当前业务帧接收路径没有 nonce 重放窗口。
12. 小结与下一篇
- 握手复用 TUNNEL #385 承载:三个握手 flight 后由 server 发送加密 DONE,约两个 RTT(分片和重传会增加实际时延);
- 有序 transcript 摘要、ECDSA 签名和 HMAC FINISHED 共同认证握手记录;
- HKDF 把 ECDH 共享秘密派生成会话密钥并热替换进 AES 状态;PFS 取决于临时 密钥的生成、使用和清除;
- 纯软件、无堆、无外部依赖,Flash 约 15 KB,适配 STM32。
下一篇《PX4/QGC 集成与实操》将从证书 生命周期、编译配置和库的接线点出发,给出 PC 双端示例及 PX4/QGC 集成路径。
延伸阅读与规范
- IETF, RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF);
- NIST, SP 800-56A Rev. 3: Pair-Wise Key-Establishment Schemes;
- 本项目协议设计文档:
MAVLINK_X509_HANDSHAKE.md。
本文协议借鉴了成熟握手协议中的概念,但不是 TLS 1.3 实现,也没有经形式化验证; 不能因为使用 X.509、ECDHE、HKDF 等标准构件,就推断自定义消息序列具有同等安全性。
