Technical writing
Swarm SDK MAVLink v2 integration: encrypting mesh messages inside 253-byte drone protocol frames
Every message the Swarm SDK sends — a Double Ratchet ciphertext, a Sender Key distribution, a Sealed Sender envelope wrapping an ML-KEM-768 ciphertext — eventually has to leave the SDK and reach a radio. On most commercial and defense drone stacks, the radio interface speaks MAVLink v2. MAVLink was not designed for encrypted mesh communications: its TUNNEL message type caps the usable payload at 253 bytes, and the Swarm SDK's post-quantum key exchange produces ciphertexts that are over a kilobyte on their own. This post covers how the SDK solves that mismatch — fragment header design, the reassembly buffer, MAVLink dialect integration, and what happens at each layer of the stack on PX4, ArduPilot, and MAVSDK.
The 253-byte constraint
MAVLink v2 packets carry a maximum of 255 bytes in the payload field. The TUNNEL message (#385 in the common dialect) reserves 2 bytes for target_systemand target_component, leaving 253 bytes for the actual tunnel payload. That ceiling is fixed by the protocol wire format — the payload length byte in a MAVLink v2 header is 8 bits, and the checksumming and signing fields follow the payload, so there is no extension mechanism at the packet level.
To allow arbitrary-length encrypted messages, the Swarm SDK defines a custom dialect message — SWARM_MESH_FRAME (message ID 12520, allocated in the user-defined range 32767–65535) — that carries a 253-byte payload field along with routing metadata. Every SDK message is segmented into 253-byte chunks and sent as a sequence of SWARM_MESH_FRAME messages. The dialect definition lives atsdk/mavlink/swarm.xml and is compiled into C and Rust bindings at build time.
<!-- sdk/mavlink/swarm.xml (excerpt) -->
<message id="12520" name="SWARM_MESH_FRAME">
<description>Encrypted Swarm SDK mesh frame fragment.</description>
<field type="uint8_t[253]" name="payload">
Raw fragment payload: 18-byte SwarmFragHeader followed by
up to 235 bytes of ciphertext or key material.
</field>
<field type="uint8_t" name="target_node">
Destination gossip node ID (255 = broadcast).
</field>
<field type="uint8_t" name="source_node">
Originating gossip node ID.
</field>
</message>Fragment header design
The first 18 bytes of every SWARM_MESH_FRAME payload are aSwarmFragHeader, leaving 235 bytes of usable ciphertext per frame.
/// Prepended to every SWARM_MESH_FRAME payload.
/// 18 bytes total; 235 bytes remain for ciphertext.
#[repr(C, packed)]
pub struct SwarmFragHeader {
/// Matches SenderKeyDistributionMessage.distribution_id or
/// a per-message random ID for Double Ratchet messages.
pub distribution_id: [u8; 16],
/// 0-based fragment index.
pub fragment_index: u8,
/// Total number of fragments for this message (1–128).
pub fragment_count: u8,
}
impl SwarmFragHeader {
pub const SIZE: usize = 18;
pub const MAX_PAYLOAD: usize = 253 - Self::SIZE; // 235 bytes
pub fn new(distribution_id: [u8; 16], index: u8, count: u8) -> Self {
Self { distribution_id, fragment_index: index, fragment_count: count }
}
}The distribution_id field serves dual duty. For Sender Key messages, it is exactly the distribution_id from theSenderKeyDistributionMessage that established the group session, so the receiver can immediately route the fragment to the correctSenderKeyState without decrypting anything. For Double Ratchet point-to-point messages, it is a random 16-byte ID generated fresh per message and used only for fragment correlation. The receiver looks up the ID in the reassembly buffer, not in any cryptographic state.
The fragment_count field has an upper limit of 128. With 235 bytes per fragment, the maximum reassemblable message size is 128 × 235 = 30,080 bytes. No Swarm SDK message type approaches that limit in practice — the largest is a Sealed Sender key distribution message at approximately 1,350 bytes (6 fragments). The 128-fragment cap prevents a malformed or malicious frame from reserving an unbounded reassembly slot in the buffer.
Fragmentation and reassembly
pub struct MavlinkAdapter {
fragment_buf: FragmentBuffer,
node_id: u8,
}
impl MavlinkAdapter {
/// Segment an SDK message into SWARM_MESH_FRAME payloads.
pub fn fragment(&self, msg: &[u8], distribution_id: [u8; 16]) -> Vec<[u8; 253]> {
let count = msg.chunks(SwarmFragHeader::MAX_PAYLOAD).count();
assert!(count <= 128, "message exceeds 30,080-byte MAVLink limit");
msg.chunks(SwarmFragHeader::MAX_PAYLOAD)
.enumerate()
.map(|(i, chunk)| {
let header = SwarmFragHeader::new(distribution_id, i as u8, count as u8);
let mut payload = [0u8; 253];
payload[..SwarmFragHeader::SIZE]
.copy_from_slice(bytemuck::bytes_of(&header));
payload[SwarmFragHeader::SIZE..SwarmFragHeader::SIZE + chunk.len()]
.copy_from_slice(chunk);
payload
})
.collect()
}
/// Ingest a received SWARM_MESH_FRAME payload.
/// Returns Some(message) when all fragments of a message have arrived.
pub fn reassemble(&mut self, payload: &[u8; 253]) -> Option<Vec<u8>> {
let header: SwarmFragHeader =
*bytemuck::from_bytes(&payload[..SwarmFragHeader::SIZE]);
let data = &payload[SwarmFragHeader::SIZE
..SwarmFragHeader::SIZE + SwarmFragHeader::MAX_PAYLOAD];
self.fragment_buf.ingest(header, data)
}
}pub struct FragmentBuffer {
pending: HashMap<[u8; 16], PendingMessage>,
ttl_secs: u64, // fragments discarded if incomplete after this many seconds
}
struct PendingMessage {
fragments: Vec<Option<Vec<u8>>>, // indexed by fragment_index
received: usize,
fragment_count: usize,
first_seen: Instant,
}
impl FragmentBuffer {
pub fn new(ttl_secs: u64) -> Self {
Self { pending: HashMap::new(), ttl_secs }
}
pub fn ingest(&mut self, header: SwarmFragHeader, data: &[u8]) -> Option<Vec<u8>> {
self.evict_expired();
let entry = self.pending
.entry(header.distribution_id)
.or_insert_with(|| PendingMessage {
fragments: vec![None; header.fragment_count as usize],
received: 0,
fragment_count: header.fragment_count as usize,
first_seen: Instant::now(),
});
let idx = header.fragment_index as usize;
if idx >= entry.fragments.len() { return None; } // malformed, ignore
if entry.fragments[idx].is_none() {
entry.fragments[idx] = Some(data[..data.len()].to_vec());
entry.received += 1;
}
if entry.received == entry.fragment_count {
let msg = entry.fragments.iter()
.filter_map(|f| f.as_deref())
.flat_map(|b| b.iter().copied())
.collect();
self.pending.remove(&header.distribution_id);
return Some(msg);
}
None
}
fn evict_expired(&mut self) {
let ttl = self.ttl_secs;
self.pending.retain(|_, v| v.first_seen.elapsed().as_secs() < ttl);
}
}The reassembly buffer TTL is set to 5 seconds by default. In a 100ms radio link with 6-fragment Sealed Sender envelopes, all 6 frames arrive within roughly 600ms at worst — the 5-second TTL is generous enough to absorb radio retransmits while aggressively discarding state from dead nodes. Incomplete fragments from a crashed sender do not accumulate indefinitely.
Fragment ordering is not assumed. The gossip mesh may deliver frames out of order if they take different routing paths. The reassembly buffer accepts fragments in any order; the slot in Vec<Option<Vec<u8>>> is indexed by fragment_index. Duplication is handled by the outer mesh deduplication layer (UUIDv4 sliding-window, described separately) before frames reach theMavlinkAdapter, so the fragment buffer does not need to guard against receiving the same fragment index twice.
Message size accounting
Each Swarm SDK message type has a known overhead that determines its fragment count.
| Message type | Wire size (bytes) | Fragments | Dominant cost |
|---|---|---|---|
| SenderKeyMessage (24-byte payload) | 136 | 1 | 64-byte Ed25519 signature |
| SenderKeyMessage (200-byte payload) | 312 | 2 | plaintext growth |
| SenderKeyDistributionMessage | 84 | 1 | chain_key + signing_key_pub |
| Double Ratchet message (24-byte payload) | 124 | 1 | encrypted header (72 bytes) |
| SealedSenderEnvelope (short payload) | ~1,256 | 6 | ML-KEM-768 ciphertext (1,088 bytes) |
| X3DH initial message (PQ) | ~1,248 | 6 | ML-KEM-768 encapsulation ciphertext |
| Gossip AntiEntropyDigest (200 IDs) | 3,200 | 14 | 200 × 16-byte message IDs |
The 1,088-byte ML-KEM-768 ciphertext is the dominant driver of fragmentation for any message type that involves key establishment. A SealedSenderEnvelope always requires at least 5 fragments — the ML-KEM-768 ciphertext alone occupies ceil(1088/235) = 5 frames, before the AES-GCM encrypted sender field and the inner SenderKeyMessage are added. This is a deliberate design trade-off: the identity-hiding property of Sealed Sender is worth 5 frames of radio bandwidth for any message where sender anonymity matters.
For routine telemetry — position, sensor readings, status — the SDK uses SenderKeyMessage, which fits in a single fragment at typical payload sizes. The asymmetry is intentional: frequent, small telemetry packets pay 18 bytes of fragment header overhead (7.1% on a 235-byte payload), while rare key establishment events pay the full ML-KEM-768 cost across 5–6 fragments.
PX4 integration
PX4 exposes a uORB messaging bus that allows companion computer modules to publish and subscribe to topics. The Swarm SDK's PX4 adapter registers as an onboard module that subscribes to themavlink_tunnel uORB topic (populated when PX4 receives any TUNNEL or SWARM_MESH_FRAME packet over the active MAVLink stream) and publishes back via the mavlink_shell or via a directMavlinkStreamRaw write.
// px4_adapter.cpp (simplified)
#include <uORB/topics/mavlink_tunnel.h>
class SwarmPx4Module : public ModuleBase<SwarmPx4Module> {
int _tunnel_sub = orb_subscribe(ORB_ID(mavlink_tunnel));
void Run() override {
mavlink_tunnel_s tunnel;
if (orb_check(_tunnel_sub) && orb_copy(ORB_ID(mavlink_tunnel), _tunnel_sub, &tunnel)) {
if (tunnel.payload_type == SWARM_MESH_FRAME_MSG_ID) {
// Hand 253-byte payload to the Rust FFI reassembly layer
swarm_ingest_fragment(tunnel.payload, 253);
}
}
}
};PX4's MAVLink module is configured to forward SWARM_MESH_FRAMEpackets to the uORB bus by adding the custom message ID to themavlink_msg_filter list in mavlink_main.cpp. Without this filter entry, PX4 drops unknown message IDs silently — a common source of confusion when first integrating the dialect.
ArduPilot integration
ArduPilot uses a different architecture: companion computer integration goes through the AP_ExternalAHRS or AP_Scripting layers, or via a GCS-proxy port that forwards raw MAVLink to an external process. For the Swarm SDK, the simplest path is the SERIAL port proxy: ArduPilot exposes a MAVLink-passthrough serial port that can be wired to a UART on the companion computer, and the SDK reads and writes raw MAVLink bytes on that UART.
// ArduPilot parameters (set via MAVLink param protocol) SERIAL3_PROTOCOL = 2 // MAVLink2 SERIAL3_BAUD = 921 // 921600 baud BRD_SER3_RTSCTS = 0 // disable hardware flow control if not wired
On the companion computer side, the Rust adapter opens the UART device directly and drives it with the mavlink crate'sMavConnection interface:
use mavlink::{MavConnection, MavHeader, connect};
let conn = connect::<mavlink::ardupilotmega::MavMessage>("serial:/dev/ttyTHS1:921600")?;
loop {
let (header, msg) = conn.recv()?;
if let mavlink::ardupilotmega::MavMessage::SWARM_MESH_FRAME(frame) = msg {
adapter.reassemble(&frame.payload);
}
}MAVSDK integration
MAVSDK provides a cleaner abstraction for fleet-management systems running on ground control stations or cloud-connected edge nodes. The SDK's MAVSDK adapter uses the ServerPlugin API to intercept raw MAVLink messages before they are dispatched to higher-level plugin handlers.
// C++ ground-station integration
#include <mavsdk/mavsdk.h>
#include <mavsdk/plugins/mavlink_passthrough/mavlink_passthrough.h>
mavsdk::Mavsdk sdk;
auto system = sdk.systems().at(0);
auto passthrough = mavsdk::MavlinkPassthrough{system};
passthrough.subscribe_message(
SWARM_MESH_FRAME_MSG_ID,
[&adapter](const mavlink_message_t& msg) {
mavlink_swarm_mesh_frame_t frame;
mavlink_msg_swarm_mesh_frame_decode(&msg, &frame);
adapter.reassemble(frame.payload);
});MAVSDK's MavlinkPassthrough plugin works on the decodedmavlink_message_t struct from the C MAVLink library, so the custom dialect's generated header (mavlink_swarm_mesh_frame_t andmavlink_msg_swarm_mesh_frame_decode()) must be compiled into the project. This requires adding swarm.xml to the MAVSDK dialect build or building a separate C wrapper that exposes the decode function.
Heartbeat and peer discovery
MAVLink systems announce their presence via the HEARTBEAT message (ID 0), broadcast every second. The Swarm SDK sends a HEARTBEAT from each gossip node withtype = MAV_TYPE_ONBOARD_CONTROLLER (18) and a customautopilot = MAV_AUTOPILOT_INVALID (8) to signal that this component is not a flight controller. The system_id encodes the gossip node's swarm-internal ID (1–240), and component_id is set toMAV_COMP_ID_USER1 (25) by default.
Peer discovery for the Swarm SDK mesh works differently from standard MAVLink component discovery: the gossip mesh itself maintains peer awareness via the anti-entropy protocol (AntiEntropyDigest exchange every 5 seconds), so the MAVLink HEARTBEAT is primarily for integration with GCS software like QGroundControl and Mission Planner that display component lists. The gossip mesh does not rely on HEARTBEAT for peer liveness — it uses the SDK's own heartbeat mechanism (120-second keepalive inside the Double Ratchet session) for that.
Performance on embedded targets
Fragment processing is fast enough that it does not appear on the STM32H7 profiling traces for typical message volumes. The per-fragment cost at 480 MHz CPU with no DMA:
| Operation | STM32H7 p50 | STM32H7 p99 | Jetson Nano p50 |
|---|---|---|---|
| Fragment header write (single frame) | 0.04 ms | 0.06 ms | <0.01 ms |
| Reassembly buffer ingest (per frame) | 0.09 ms | 0.14 ms | 0.02 ms |
| Reassembly completion (6-fragment message) | 0.61 ms | 0.89 ms | 0.13 ms |
| Expired buffer eviction (per entry) | 0.03 ms | 0.05 ms | <0.01 ms |
| Full fragmentation + crypto (1-frame msg) | 0.78 ms | 1.02 ms | 0.11 ms |
The reassembly cost scales linearly with fragment count, but fragment count for routine messages is 1–2, so the overhead is negligible. SealedSenderEnvelope reassembly at 6 frames adds 0.54ms on the STM32H7 — about a third of the full encrypt cost — acceptable given that key-establishment messages are infrequent.
Memory for the reassembly buffer is statically allocated at compile time on embedded targets. The default configuration supports 16 concurrent pending messages (max 128 fragments × 235 bytes each = 29.8 KB per slot, but in practice only a small portion of each slot is used). Total worst-case buffer size: 16 × 128 × 235 bytes = 480 KB. On the STM32H7's 1 MB RAM this is acceptable; on tighter targets like the STM32F4 (192 KB RAM), the buffer configuration is reduced to 4 pending messages with a 10-fragment cap.
Out-of-order delivery and radio reliability
The gossip mesh uses best-effort delivery: messages are not guaranteed to arrive, and frames from a single message may take different routing paths with different latencies. For single-fragment messages — the majority of telemetry traffic — this is fine: if the frame is lost, the sender will retransmit the gossip message at the next gossip tick (250ms ± 50ms). For multi-fragment messages, frame loss means the entire reassembled message is discarded after the TTL expires.
We considered adding per-fragment acknowledgment and selective retransmit, but rejected it because it requires per-fragment sequence tracking state that compounds the existing gossip mesh deduplication state, and the additional round-trips would defeat the low-latency requirement for tactical mesh communications. Instead, the SDK relies on the gossip epidemic broadcast to deliver redundant copies of each fragment via different paths. With k=3 fanout and TTL=7, a 64-drone swarm typically delivers 3–5 independent copies of each message to every node within 3 gossip ticks (750ms). Fragment loss under this redundancy model is rare except in heavily degraded RF environments, where store-and-forward mode takes over.
For the Double Ratchet KDF chains that produce the ciphertexts this adapter fragments: The Swarm SDK double ratchet: forward secrecy and post-compromise security in drone mesh networks →
For how Sealed Sender and the ML-KEM-768 envelope are constructed: Swarm SDK v0.3: Sender Keys, Sealed Sender, and Deniable Authentication →
For the gossip mesh routing that carries SWARM_MESH_FRAME packets between nodes: Swarm SDK gossip mesh: bounded fanout routing, message deduplication, and network partition handling →
For how the binary wire format and fragmentation layer works beneath this MAVLink adapter — SwarmFrame 16-byte header, 237-byte payload, reassembly state machine, and CONTROL frame authentication: Swarm SDK message framing: binary wire format, fragmentation, and MAVLink packing →
For the overall Swarm SDK architecture — ML-KEM-768 hybrid KEM, Double Ratchet, and CNSA 2.0 compliance: Post-quantum mesh cryptography for drone swarms: the Swarm SDK design →
For how the SDK conceals traffic patterns from passive observers — fixed-bin message padding, TRNG-driven transmission jitter, store-and-forward buffering, and degraded-channel operational modes: Swarm SDK operational security: traffic analysis resistance, message size normalization, and timing jitter →