Technical writing

Swarm SDK Sealed Sender: hiding the sender identity without breaking end-to-end encryption

· 8 min read· AI Analytics
SwarmCryptographyProtocol design

End-to-end encryption protects message content from relay infrastructure. It does not protect metadata. A relay server that sees { sender_id, recipient_id, ciphertext } cannot read the message, but it can map the social graph — who is communicating with whom, at what frequency, and over what time window. In adversarial environments where relay infrastructure can be seized or compromised, that graph is operationally sensitive. Sealed Sender removes the sender field from the relay-visible envelope entirely.

This article is the technical deep-dive on Sealed Sender specifically. The Swarm SDK v0.3 overview covers all three v0.3 features — Sender Keys, Sealed Sender, and Deniable HMAC — at a higher level. The Double Ratchet article covers the underlying session primitive that Sealed Sender wraps.

The problem: relay servers see the social graph

In a standard encrypted messaging protocol, every message in transit carries three pieces of information: the sender identity, the recipient identity, and the ciphertext. The ciphertext is opaque to anyone who does not hold the session keys. The sender and recipient identities are not — they are in plaintext so the relay can route the message.

For most applications this trade-off is acceptable. For drone mesh communications in contested environments, it is not. The relay infrastructure in a tactical mesh is not necessarily trusted. A ground relay station can be captured. A mesh node operated by a partner organization may have a compromised firmware stack. An adversary with network access can record every envelope that passes through the relay, building a persistent communication topology record even without ever breaking the encryption.

The goal of Sealed Sender is to eliminate the sender field from the relay-visible envelope. The relay sees only:

// What the relay sees with Sealed Sender enabled:
SealedSenderEnvelope {
    recipient_id:     DeviceId,      // necessary for routing — cannot be hidden
    ephemeral_public: X25519PublicKey,  // per-message ephemeral key (opaque without recipient sk)
    encrypted_inner:  Vec<u8>,       // ciphertext; relay cannot decrypt
    nonce:            [u8; 12],
}

// What the relay sees WITHOUT Sealed Sender:
PlaintextEnvelope {
    sender_id:   DeviceId,   // <-- reveals who sent
    recipient_id: DeviceId,
    ciphertext:  Vec<u8>,
}

The sender identity is encrypted inside the ciphertext and can only be recovered by the recipient. From the relay's perspective, the message appears to come from nobody. The sender is present — authenticated, with a verified certificate — but invisible to every node except the intended recipient.

Threat model

Sealed Sender is designed against a specific adversary. The adversary has full network access: they can capture, record, and replay every message that passes through the relay infrastructure. They control or can observe the relay server — they see all envelope fields that are not encrypted. They cannot break AES-256-GCM or X25519 ECDH within an operationally relevant timeframe. They cannot perform offline brute-force against a 48-hour certificate window.

What Sealed Sender does not protect against: an adversary who watches both the sender and recipient endpoints simultaneously can perform timing correlation. If they observe that Drone A transmitted at T=0 and the relay forwarded a sealed message to Drone B at T=0.003s, the timing correlation reveals the sender regardless of what the envelope contains. Sealed Sender provides no protection against traffic analysis of this kind.

A compromised recipient also defeats Sealed Sender. The recipient holds the private key necessary to decrypt the sender identity. If the recipient device is compromised, the adversary can recover sender identity from any captured message. Sealed Sender protects against relay-layer compromise, not endpoint compromise. These limitations are documented explicitly in the threat model section below.

SenderCertificate: the credential the recipient issues

The core mechanism requires a way for the recipient to authenticate the sender without the relay seeing who the sender is. The naive approach — include the sender's public key in the ciphertext and let the recipient look it up against a known-contacts list — works but leaks nothing useful on its own. Sealed Sender takes a more structured approach: the recipient issues a certificate for the sender's public key at session establishment time.

The SenderCertificate is issued by the recipient, not by any certificate authority. The recipient signs a binding between their own identity and the sender's certified public key. Because the recipient holds both the signing key and the verify key, they can authenticate any message that carries a valid certificate — and only they can do so. A third party with the relay log cannot verify the certificate because the verify key is the recipient's Ed25519 public key, and the certificate was issued specifically for this session.

pub struct SenderCertificate {
    pub issuer_device_id: DeviceId,
    pub recipient_public_key: X25519PublicKey,
    pub sender_certified_public_key: X25519PublicKey,
    pub expiry_unix: u64,
    pub signature: Ed25519Signature,
}

impl SenderCertificate {
    pub fn issue(
        sender_pubkey: &X25519PublicKey,
        recipient_keypair: &Ed25519KeyPair,
        recipient_static: &X25519PublicKey,
        ttl_secs: u64,
    ) -> Self {
        let expiry = now_unix() + ttl_secs;
        let payload = Self::sign_payload(sender_pubkey, recipient_static, expiry);
        let signature = recipient_keypair.sign(&payload);
        SenderCertificate {
            issuer_device_id: recipient_keypair.device_id(),
            recipient_public_key: *recipient_static,
            sender_certified_public_key: *sender_pubkey,
            expiry_unix: expiry,
            signature,
        }
    }
}

Notice the inversion: the recipient signs a certificate for the sender. This is deliberate. It means only the recipient can verify the certificate. An adversary who captures both the sealed envelope and the certificate cannot determine whether the certificate is valid — they do not have the recipient's Ed25519 verify key (which is the recipient's static identity key, never transmitted in plaintext to the relay).

The certificate binds four values: the issuing device (the recipient), the recipient's own static X25519 key (so the certificate is scoped to this recipient's key material), the sender's certified public key, and an expiry timestamp. The signature covers all four fields. A certificate issued for Drone A cannot be transferred to Drone B; a certificate issued for recipient R cannot be used with a different recipient even if the sender is the same.

Sealed Sender message structure

The on-wire format separates the relay-visible outer envelope from the recipient-only inner plaintext. The relay sees the outer envelope; the inner plaintext is opaque to everyone except the recipient.

// Outer envelope — visible to relay
pub struct SealedSenderEnvelope {
    pub recipient_id: DeviceId,          // routing field; relay uses this to deliver
    pub ephemeral_public: X25519PublicKey, // per-message ephemeral DH key
    pub encrypted_inner: Vec<u8>,        // AES-256-GCM ciphertext
    pub nonce: [u8; 12],                 // GCM nonce
}

// Inner plaintext — decryptable only by recipient
pub struct SealedSenderInner {
    pub cert: SenderCertificate,         // sender's credential, issued by recipient
    pub message: SwarmMessage,           // the actual application message
}

The relay routes on recipient_id and delivers the envelope. It sees the recipient, the ephemeral public key (which changes per message and carries no persistent identity), and opaque ciphertext. No field in the outer envelope identifies the sender.

The inner plaintext carries the SenderCertificate alongside the actual message. When the recipient decrypts the inner, they recover both the sender's certified public key and the session message in a single step. Authentication and delivery are inseparable — you cannot verify who sent without also reading the content, and you cannot claim to have read the content without performing the certificate verification.

Encryption construction: ephemeral X25519 per message

The sealing operation uses an ephemeral X25519 key pair — freshly generated for every individual message, not reused across messages or sessions. This is distinct from the session key established by the Double Ratchet. The ephemeral key exists only for the duration of the seal operation; it is immediately discarded after the envelope is constructed.

The shared secret is derived from the ephemeral private key and the recipient's static X25519 public key. Because the recipient's static key does not change between messages, the recipient can always derive the same shared secret from any sealed envelope by using their static private key with the envelope's ephemeral public key. The sender never needs to be identified to the relay for this derivation to work.

pub fn seal(
    inner: &SealedSenderInner,
    recipient_static_pub: &X25519PublicKey,
    rng: &mut (impl CryptoRng + RngCore),
) -> SealedSenderEnvelope {
    // Generate fresh ephemeral key pair — discarded after this function returns
    let ephemeral = X25519EphemeralSecret::random_from_rng(rng);
    let ephemeral_pub = X25519PublicKey::from(&ephemeral);

    // Derive shared secret: X25519(ephemeral_sk, recipient_static_pk)
    let dh_out = ephemeral.diffie_hellman(recipient_static_pub);

    // Derive message key: HKDF-SHA-256 with domain separation label
    let message_key = hkdf_expand(dh_out.as_bytes(), b"SealedSender_v1", 32);

    // Fresh nonce per message
    let nonce: [u8; 12] = rng.gen();

    // Serialize and encrypt the inner (cert + message)
    let plaintext = bincode::serialize(inner).unwrap();
    let ciphertext = aes_gcm_encrypt(&message_key, &nonce, &plaintext);

    SealedSenderEnvelope {
        recipient_id: inner.cert.issuer_device_id,
        ephemeral_public: ephemeral_pub,
        encrypted_inner: ciphertext,
        nonce,
    }
}

The key derivation uses HKDF-SHA-256 with the info string "SealedSender_v1" as a domain separator. This ensures that the 32-byte message key derived here is cryptographically distinct from any key derived by the Double Ratchet or session establishment layer, even if those layers happen to process the same raw DH output in a pathological edge case. Version-labeling the info string also provides an upgrade path if the derivation needs to change in a future protocol version.

The AES-256-GCM encryption of the inner provides both confidentiality and integrity. Any tampering with the encrypted_inner or nonce fields causes GCM authentication to fail before any plaintext is returned to the caller. The GCM authentication tag covers the ciphertext; the recipient cannot be given a decrypted inner that has been modified in transit.

Decryption and authentication

The recipient holds their static X25519 private key, which was never transmitted to the relay. Given the envelope's ephemeral public key, they can compute the same shared secret the sender used to seal the message. The derivation is symmetric in the ECDH sense: X25519(recipient_sk, ephemeral_pk) == X25519(ephemeral_sk, recipient_pk).

After decryption succeeds, the recipient has the SealedSenderInner in plaintext. They then perform four sequential verification steps before accepting the message as authentic:

  1. Certificate signature check. Verify the SenderCertificate.signature using the recipient's own Ed25519 verify key. The recipient issued this certificate at session establishment and knows their signing key; verification is local. If the signature is invalid, the certificate was not issued by this recipient and the message is rejected with CertificateSignatureInvalid.
  2. Expiry check. Compare SenderCertificate.expiry_unix against the current time. If expired, reject with CertificateExpired. An expired certificate indicates that the sender did not refresh their credential via the normal session rekeying path and the certificate is no longer valid.
  3. Sender key binding check. Extract the sender_certified_public_key from the certificate and verify that it matches the public key in the session's known-sender state (the key chain established by the Double Ratchet). If the certified key does not match the session key, reject with SenderKeyMismatch.
  4. Decryption error. If the AES-256-GCM tag fails, reject with DecryptionError before any of the above checks run. A bad tag means the envelope was tampered with or the keys are wrong; no partial plaintext is returned.

Only after all four checks pass is the SwarmMessage inside the inner delivered to the application layer. The four failure modes are distinct enum variants so the caller can distinguish a tampered ciphertext from an expired credential from a key mismatch — each implies a different operational response.

pub enum SealedSenderError {
    DecryptionError,           // GCM tag failed; envelope tampered or wrong keys
    CertificateExpired,        // cert.expiry_unix < now_unix()
    CertificateSignatureInvalid, // Ed25519 verify failed against recipient's own key
    SenderKeyMismatch,         // certified key != session's expected sender key
}

pub fn unseal(
    envelope: &SealedSenderEnvelope,
    recipient_static_sk: &X25519StaticSecret,
    recipient_verify_key: &Ed25519VerifyKey,
    session_sender_pk: &X25519PublicKey,
) -> Result<SwarmMessage, SealedSenderError> {
    // Step 1: derive shared secret using recipient's static private key
    let dh_out = recipient_static_sk.diffie_hellman(&envelope.ephemeral_public);
    let message_key = hkdf_expand(dh_out.as_bytes(), b"SealedSender_v1", 32);

    // Step 2: decrypt — returns Err(DecryptionError) if tag fails
    let plaintext = aes_gcm_decrypt(&message_key, &envelope.nonce, &envelope.encrypted_inner)
        .map_err(|_| SealedSenderError::DecryptionError)?;

    let inner: SealedSenderInner = bincode::deserialize(&plaintext)
        .map_err(|_| SealedSenderError::DecryptionError)?;

    // Step 3: verify certificate signature (recipient issued it, uses own verify key)
    let payload = SenderCertificate::sign_payload(
        &inner.cert.sender_certified_public_key,
        &inner.cert.recipient_public_key,
        inner.cert.expiry_unix,
    );
    recipient_verify_key.verify(&payload, &inner.cert.signature)
        .map_err(|_| SealedSenderError::CertificateSignatureInvalid)?;

    // Step 4: check expiry
    if now_unix() > inner.cert.expiry_unix {
        return Err(SealedSenderError::CertificateExpired);
    }

    // Step 5: verify sender key matches session state
    if &inner.cert.sender_certified_public_key != session_sender_pk {
        return Err(SealedSenderError::SenderKeyMismatch);
    }

    Ok(inner.message)
}

Certificate lifetime and rotation

Certificates expire after 48 hours. The 48-hour TTL is a deliberate balance point between two competing requirements.

The lower bound on TTL is set by the operational pattern of offline nodes. Drone swarms regularly include nodes that are powered down, out of radio range, or operating in RF-denied areas for hours at a time. A certificate that expires in 30 minutes would require frequent re-issue, which in turn requires the sender and recipient to be online simultaneously to execute a session rekeying handshake. In a mesh where nodes may be offline for 6-12 hours, a 30-minute TTL would cause constant certificate failures.

The upper bound on TTL is set by the risk window. A captured SenderCertificate bound to a specific sender and recipient key pair does not directly reveal the sender's identity — the certificate only identifies the sender's public key, not their device ID. But combined with a compromised recipient, the certificate lifetime determines how long a stolen credential remains valid. 48 hours is short enough to limit exposure while tolerating overnight-offline nodes.

The certificate cache is a HashMap<DeviceId, SenderCertificate> keyed by recipient device ID. Each entry represents the certificate this device has issued to the identified sender for communicating with this device. When a certificate in the cache is within 1 hour of expiry, the SDK emits a CERTIFICATE_REFRESH control message to the relevant sender, requesting that the sender initiate a new session handshake. The refresh is triggered proactively rather than reactively — waiting until the certificate actually expires would cause a brief window of authentication failures.

// Certificate cache management
struct CertificateCache {
    // Outer key: the sender's DeviceId (whose cert we issued)
    // Value: the certificate we issued for them
    entries: HashMap<DeviceId, SenderCertificate>,
}

impl CertificateCache {
    const REFRESH_THRESHOLD_SECS: u64 = 3_600;  // refresh when < 1 hour remains

    pub fn check_expiry(&self, now: u64) -> Vec<DeviceId> {
        self.entries
            .iter()
            .filter(|(_, cert)| {
                cert.expiry_unix.saturating_sub(now) < Self::REFRESH_THRESHOLD_SECS
            })
            .map(|(device_id, _)| *device_id)
            .collect()
    }

    pub fn insert(&mut self, sender_id: DeviceId, cert: SenderCertificate) {
        self.entries.insert(sender_id, cert);
    }

    pub fn get(&self, sender_id: &DeviceId) -> Option<&SenderCertificate> {
        self.entries.get(sender_id)
    }

    pub fn evict_expired(&mut self, now: u64) {
        self.entries.retain(|_, cert| cert.expiry_unix > now);
    }
}

The periodic expiry scan runs on a 15-minute timer in the SDK's background task. It calls check_expiry() and queues CERTIFICATE_REFRESH control messages for any senders whose certificates are nearing expiry. It also calls evict_expired() to remove any certificates that have already passed their TTL, preventing stale state from accumulating on memory-constrained hardware.

Integration with Sender Keys for group messages

Sender Keys, introduced in the same v0.3 release, provide O(1) group encryption: a single broadcast encrypted once that all group members can decrypt. Sealed Sender integrates with Sender Keys through a SealedSenderGroupMessage that wraps the Sender Key encrypted content with per-member sender certificates.

In the group context, each group member issues a SenderCertificate for every other member during the group session setup. When Drone A joins a 5-member group, it issues four certificates (one per other member) and receives four certificates from the other members (one from each). These certificates are distributed alongside the Sender Key Distribution Message (SKDM) via the pairwise Double Ratchet channels, so each pairwise exchange delivers both the group chain key and the mutual certificate.

// Sender Key Distribution Message extended for Sealed Sender groups
pub struct SealedSenderKeyDistributionMessage {
    pub skdm: SenderKeyDistributionMessage,  // chain key, iteration, signing key pub
    pub cert_for_sender: SenderCertificate,  // cert issued by recipient for this sender
}

// Group message sealed so relay cannot see sender identity
pub struct SealedSenderGroupMessage {
    // The Sender Key encrypted payload (single encrypt, all members can decrypt)
    pub sender_key_message: SenderKeyMessage,

    // Sealed sender envelope per recipient — contains the cert inside encrypted inner
    // Each envelope is a SealedSenderEnvelope addressed to one group member;
    // the inner carries the sender's cert for that specific recipient.
    //
    // In practice, only the recipient-specific envelope is sent to each member;
    // the broadcast ciphertext (sender_key_message) goes to all.
    pub per_recipient_cert_envelope: SealedSenderEnvelope,
}

pub fn seal_group_message(
    sender_key_state: &mut SenderKeyState,
    plaintext: &[u8],
    distribution_id: [u8; 16],
    recipient_static_pub: &X25519PublicKey,
    sender_cert: &SenderCertificate,
    rng: &mut (impl CryptoRng + RngCore),
) -> SealedSenderGroupMessage {
    // Step 1: encrypt content with Sender Key (O(1) cost regardless of group size)
    let skm = encrypt_group_message(sender_key_state, plaintext, distribution_id);

    // Step 2: seal the sender certificate in a per-recipient envelope
    let inner = SealedSenderInner {
        cert: sender_cert.clone(),
        message: SwarmMessage::GroupCertificateCarrier,  // cert-only inner; content is in skm
    };
    let envelope = seal(&inner, recipient_static_pub, rng);

    SealedSenderGroupMessage {
        sender_key_message: skm,
        per_recipient_cert_envelope: envelope,
    }
}

The separation of the group ciphertext from the per-recipient certificate envelope is important for efficiency. The Sender Key message is broadcast once and is the same for all recipients — O(1) encryption cost. The per-recipient certificate envelope is sealed individually for each recipient, because each recipient has a different static public key and thus requires a different ephemeral DH derivation. The total cost for a group of N members is: one Sender Key encrypt plus N Sealed Sender certificate seals.

For a 16-member swarm, this is 16 certificate seals (16 × ephemeral X25519 + HKDF + AES-GCM) plus one Sender Key encrypt. On the STM32H7 at 480 MHz: 16 × ~0.4ms + 0.7ms = approximately 7.1ms total, versus the 28.8ms that 16 individual Double Ratchet encrypts would require. The relay still learns nothing about who sent — every envelope it sees contains only a recipient ID and opaque ciphertext.

What Sealed Sender does not provide

Sealed Sender addresses relay-visible metadata. It does not address all metadata leakage vectors. The following limitations are inherent to the design and are not addressable within the Sealed Sender protocol boundary:

  • Traffic analysis. The relay observes message timing and size. An adversary who watches both endpoints simultaneously can correlate Drone A transmitting at time T with a sealed message arriving at Drone B's relay queue at T + propagation delay. Message size fingerprinting also applies: a sealed message of exactly 512 bytes from an observed transmitter to a known recipient is identifiable even without the sender field. Sealed Sender removes the explicit sender field; it does not prevent implicit identification through behavioral patterns.
  • Compromised recipients. The recipient holds the private key that decrypts the sealed inner and the Ed25519 verify key that validates the certificate. A compromised recipient can decrypt any sealed message addressed to them and recover the sender identity. Sealed Sender provides no protection once the recipient endpoint is under adversary control.
  • Group membership visibility. The relay sees all recipient device IDs for a group broadcast — the per-recipient certificate envelopes are addressed individually. The relay therefore knows which devices are members of any group that communicates through it. Sealed Sender hides who sent within the group; it does not hide group membership from the relay.
  • Timing correlation attacks. Even with Sealed Sender, a passive adversary monitoring network traffic at both the mesh ingress and egress points can correlate transmissions with delivery events. This is a traffic-analysis attack that requires a fundamentally different mitigation (mix networks, traffic shaping) and is out of scope for the Sealed Sender protocol.

The design is scoped deliberately. Sealed Sender provides a meaningful improvement against a relay-level adversary who has network access but not endpoint access, and who is not performing fine-grained traffic analysis. That adversary profile covers the most common relay compromise scenario — seized infrastructure, compromised relay firmware — while acknowledging the residual risks that require layered countermeasures beyond the cryptographic protocol.

Testing

The Sealed Sender test suite targets four key properties:

  • Relay opacity. Seal a message from Drone A to Drone B and route it through a test relay that captures the full envelope bytes. Assert that no field in the captured envelope equals or is derived from A's device ID or public key. The test extracts all byte sequences of length 16 and 32 from the captured envelope and checks them against known representations of A's identity.
  • Recipient authentication. Drone B decrypts the sealed envelope, verifies the certificate signature using its own Ed25519 verify key, and asserts that the recovered sender_certified_public_key matches A's known public key. The test confirms that B can recover and verify sender identity from the decrypted inner.
  • Tamper detection. Modify one byte of ephemeral_public in the envelope and attempt decryption. Assert that the result is Err(SealedSenderError::DecryptionError). Separately, modify one byte of encrypted_inner and assert the same. Both modifications should fail the GCM authentication tag before any plaintext is produced.
  • Certificate expiry. Issue a certificate with a TTL of 1 second, advance the test clock past expiry, and attempt to unseal a message carrying that certificate. Assert the result is Err(SealedSenderError::CertificateExpired) even if the GCM decryption succeeds. The expiry check must run after successful decryption to ensure the check is on the actual certificate content and not on unauthenticated data.

The relay opacity test is the most important and the most easily overlooked. It is not sufficient to verify that the sender field is absent from the envelope struct — a naive implementation could accidentally include a sender-derived value in some other field. The test scans all byte subsequences in the captured envelope to ensure no representation of the sender's identity is present, not just the named fields.


Related technical articles: