Technical writing
Swarm SDK v0.3: Sender Keys, Sealed Sender, and Deniable Authentication for Drone Mesh Networks
Swarm SDK v0.3 shipped three features that address group communication efficiency, operator identity privacy, and metadata deniability in contested environments. This post covers the design rationale and implementation details for each.
The prior gossip mesh and X3DH session establishment describe the foundations v0.3 builds on. The Double Ratchet remains the pairwise channel primitive; v0.3 adds Sender Keys as a group layer above it.
Sender Keys: O(1) group encryption
Before v0.3, broadcasting a message to a swarm of N drones required N separate Double Ratchet encryptions — one per recipient. A 32-drone swarm broadcasting a position update every 500ms generates 64 encrypt operations per second on the sender alone. On a Cortex-M7 doing ~8K HKDF-SHA-256 operations per second, this becomes the bottleneck at swarm sizes above 12.
Sender Keys replace the N-encrypt pattern with a group cipher:
- Each drone generates a Sender Key: a 32-byte chain key and a 32-byte signing key. The chain key evolves with each broadcast (KDF ratchet), so the Nth message uses a fresh message key derived from the chain.
- The Sender Key is distributed to each current member via the pairwise Double Ratchet channel (one-time, at join). After distribution, any broadcast uses a single encryption and all members can decrypt.
- New members receive the current Sender Key via a fresh pairwise X3DH session. Members who leave do not receive future chain iterations (forward secrecy at group level).
// sender_key.rs
use hkdf::Hkdf;
use sha2::Sha256;
use rand::RngCore;
pub struct SenderKeyState {
chain_key: [u8; 32],
iteration: u32,
signing_key: [u8; 32], // Ed25519 private key for message signing
}
impl SenderKeyState {
/// Derive the message key for iteration N and advance the chain.
/// Both message key and next chain key are derived from the current chain key.
pub fn advance(&mut self) -> MessageKey {
let hk = Hkdf::<Sha256>::new(None, &self.chain_key);
let mut message_key = [0u8; 32];
let mut next_chain_key = [0u8; 32];
hk.expand(b"msg", &mut message_key).unwrap();
hk.expand(b"chain", &mut next_chain_key).unwrap();
self.chain_key = next_chain_key;
self.iteration += 1;
MessageKey {
key: message_key,
iteration: self.iteration - 1,
}
}
}
pub struct SenderKeyMessage {
pub distribution_id: [u8; 16], // UUID identifying this Sender Key session
pub iteration: u32,
pub ciphertext: Vec<u8>, // AES-256-GCM
pub signature: [u8; 64], // Ed25519 over (distribution_id, iteration, ciphertext)
}
pub fn encrypt_group_message(
state: &mut SenderKeyState,
plaintext: &[u8],
distribution_id: [u8; 16],
) -> SenderKeyMessage {
let mk = state.advance();
let (nonce, ciphertext) = aes_256_gcm_encrypt(&mk.key, plaintext);
// ... sign and return
SenderKeyMessage {
distribution_id,
iteration: mk.iteration,
ciphertext: [&nonce, ciphertext.as_slice()].concat(),
signature: ed25519_sign(&state.signing_key,
&[&distribution_id, &mk.iteration.to_le_bytes(),
ciphertext.as_slice()].concat()),
}
}Benchmarks on STM32H7 at 480 MHz: Sender Key encrypt for 256-byte payload takes 0.7ms, versus 1.8ms for a full Double Ratchet encrypt. For a 32-drone swarm, this is 2.5ms vs. 57.6ms per broadcast cycle — a 23× reduction. The Cortex-M4 (used in some payload controllers) benefits even more due to the lower single-core frequency.
Key distribution: Sender Key delivery via the Double Ratchet
Sender Key distribution is the one expensive operation — it runs once per member per session. A new drone joining a 31-member swarm sends 31 pairwise Double Ratchet messages, each containing a SenderKeyDistributionMessage:
pub struct SenderKeyDistributionMessage {
pub distribution_id: [u8; 16], // stable per session, not per message
pub iteration: u32, // current chain position
pub chain_key: [u8; 32], // serialized under pairwise DR encryption
pub signing_key_pub: [u8; 32], // Ed25519 public key for signature verification
}Distribution messages are wrapped in a standard Double Ratchet envelope. The receiving drone stores the SenderKeyState keyed on(sender_device_id, distribution_id). If the chain state falls behind (the receiving drone missed some broadcasts), it can derive the current message key by fast-forwarding the chain from the stored iteration.
Sealed Sender: hiding drone identity from mesh participants
In the default Double Ratchet protocol, the sender's identity key is transmitted in plaintext in the message header so the recipient can look up the session. This means any mesh participant that intercepts a message knows which drone sent it, even if they cannot decrypt the payload.
In adversarial RF environments, this matters. An electronic warfare system that can capture and analyze mesh traffic without decrypting it can still map drone communication topology from the plaintext sender fields. Sealed Sender removes this by encrypting the sender's identity key under the recipient's public identity key before including it in the message.
// sealed_sender.rs
//
// The sender's device ID and ephemeral identity key are encrypted
// under the recipient's identity key using ML-KEM-768 + AES-256-GCM.
// The recipient can decrypt to learn who sent the message;
// a passive observer sees only ciphertext in the sender field.
pub struct SealedSenderEnvelope {
// Ephemeral ML-KEM-768 encapsulation for sender identity
pub mlkem_ciphertext: Vec<u8>, // 1088 bytes for ML-KEM-768
// Encrypted sender info: (sender_device_id, sender_identity_key_pub)
pub encrypted_sender: Vec<u8>, // AES-256-GCM under KEM-derived key
// The actual message, encrypted normally
pub message: Vec<u8>,
}
pub fn seal_sender(
recipient_identity_pub: &MlKem768PublicKey,
sender_device_id: &[u8; 16],
sender_identity_key_pub: &[u8; 32],
encrypted_message: &[u8],
) -> SealedSenderEnvelope {
// Encapsulate against recipient's ML-KEM-768 key to get a shared secret
let (ciphertext, shared_secret) = mlkem768_encapsulate(recipient_identity_pub);
let (nonce, encrypted_sender) = aes_256_gcm_encrypt(
&shared_secret[..32],
&[sender_device_id.as_slice(), sender_identity_key_pub].concat(),
);
SealedSenderEnvelope {
mlkem_ciphertext: ciphertext,
encrypted_sender: [&nonce, encrypted_sender.as_slice()].concat(),
message: encrypted_message.to_vec(),
}
}
pub fn unseal_sender(
recipient_identity_priv: &MlKem768PrivateKey,
envelope: &SealedSenderEnvelope,
) -> Option<(DeviceId, IdentityKeyPublic)> {
let shared_secret = mlkem768_decapsulate(
recipient_identity_priv,
&envelope.mlkem_ciphertext
)?;
let sender_bytes = aes_256_gcm_decrypt(
&shared_secret[..32],
&envelope.encrypted_sender
)?;
// Parse: first 16 bytes = device_id, next 32 bytes = identity_key_pub
let device_id: DeviceId = sender_bytes[..16].try_into().ok()?;
let id_key: [u8; 32] = sender_bytes[16..48].try_into().ok()?;
Some((device_id, id_key))
}Sealed Sender adds approximately 1,108 bytes (1,088 ML-KEM-768 ciphertext + 20 encrypted-sender AES-GCM overhead) to each message. For low-frequency command messages this is acceptable; for high-frequency position broadcasts we use Sender Keys (which have a stable, known sender) and disable Sealed Sender. The choice is configurable per message type.
Deniable HMAC authentication
Standard digital signatures (Ed25519) are non-repudiable: a message signed with a private key proves the sender to any third party who has the public key. In drone operations, this is sometimes undesirable — if an adversary captures a message, they can prove origin attribution. Deniable authentication provides sender authentication to the intended recipient without creating third-party verifiable proof.
We implement deniable authentication using HMAC over a shared secret. Both the sender and recipient can compute the MAC, so neither can prove to a third party that the other generated it (since either of them could have generated the same HMAC). The shared secret is derived from the current Double Ratchet chain key, so it is forward-secret.
pub enum AuthMode {
/// Ed25519 signature — non-repudiable, verifiable by any party with the public key.
/// Default for command and control messages requiring attribution.
Signing,
/// HMAC-SHA-256 over a DR-derived shared key — deniable.
/// Both parties can compute the MAC; neither can prove the other generated it.
/// Used for situational awareness broadcasts in high-risk operational contexts.
Deniable,
}
pub fn authenticate_message(
payload: &[u8],
auth_mode: AuthMode,
dr_chain_key: &[u8; 32],
ed25519_key: Option<&Ed25519SigningKey>,
) -> MessageAuth {
match auth_mode {
AuthMode::Signing => {
let sig = ed25519_key.expect("signing key required").sign(payload);
MessageAuth::Signature(sig.to_bytes())
}
AuthMode::Deniable => {
// Derive a message-specific HMAC key from the DR chain key
let hk = Hkdf::<Sha256>::new(None, dr_chain_key);
let mut hmac_key = [0u8; 32];
hk.expand(b"deniable_mac", &mut hmac_key).unwrap();
use hmac::{Hmac, Mac};
let mut mac = Hmac::<Sha256>::new_from_slice(&hmac_key).unwrap();
mac.update(payload);
MessageAuth::Hmac(mac.finalize().into_bytes().into())
}
}
}PKCS7 padding normalization
v0.3 also normalizes padding across all AES-GCM operations to PKCS7. The previous implementation used zero-padding for some message types and PKCS7 for others, which created a padding oracle condition when an attacker could observe timing differences in decryption. With AES-GCM the authentication tag catches tampering before any padding check, but the inconsistency was a latent risk. Unified PKCS7 across all message types removes the ambiguity.
Test coverage
v0.3 ships with 127 new tests (302 total). Key test categories:
- Sender Key round-trip: single sender, 64 recipients, 1,000 messages with out-of-order delivery (iterations can arrive late by up to 50 messages)
- Sealed Sender: verify that the message ciphertext is identical whether or not Sealed Sender is enabled (only the envelope differs)
- Deniable HMAC: verify that a third-party observer with only the public key cannot verify the MAC (i.e., the public key provides no verification capability)
- Sender Key forward secrecy: after a member leaves and the session is re-keyed, the former member's stored chain key cannot decrypt future messages
- PKCS7 padding: ensure all paths pad and unpad identically; no timing difference between correct and incorrect padding (before the GCM tag check)
For the Double Ratchet that Sender Keys layer above: The Swarm SDK double ratchet: forward secrecy and post-compromise security in drone mesh networks →
For the X3DH session establishment that bootstraps the pairwise Double Ratchet channels used for Sender Key distribution: Swarm SDK session establishment: X3DH prekey bundles and the initial drone-to-drone handshake →
For what shipped in v0.4 — Situational Awareness, EW Coordination, and Adversarial Resilience: Swarm SDK v0.4: situational awareness, electronic warfare coordination, and adversarial resilience →
For the Sealed Sender implementation deep-dive — SenderCertificate construction, ephemeral X25519 per-message encryption, and relay-opacity guarantees: Swarm SDK Sealed Sender: hiding the sender identity without breaking end-to-end encryption →