Mutable retry state inside authenticated encrypted DTN mailboxes
Most mesh people talk about routing first. I spend more time worrying about what happens when a message never gets delivered at all.
GhostWire is a Rust mesh-protocol experiment: libp2p, XChaCha20-Poly1305, Kademlia-backed offline mailboxes, and DTN store-carry-forward routing. The mailbox layer has one awkward requirement:
- sender seals a message for the recipient’s public key
- recipient is offline
- one or more peers stores the sealed envelope
- retries happen without re-sealing the payload
That seemingly small requirement forces a design decision that I became uncomfortable with and want to put in front of people who understand AEAD better than I do.
The envelope
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailboxEnvelope {
pub recipient_id: String,
pub ephemeral_pub: String,
pub ciphertext: Vec<u8>,
pub nonce: String,
pub created_at: u64,
pub message_id: String,
pub attempts: u32, // deliberately excluded from AAD
}
impl Drop for MailboxEnvelope {
fn drop(&mut self) {
self.ciphertext.zeroize();
}
}
The encrypted mailbox AAD is bound to immutable metadata:
- recipient identity
- ephemeral key
- nonce
- message ID
- creation timestamp
attempts is intentionally not in the AAD.
Tradeoff A: include retry state in AAD
This is the clean cryptographic posture. The authenticator describes everything about the record, including mutable control state.
The practical problem: if retry state changes, the previous authenticator is invalidated. That means either:
- re-seal every time under the same recipient key, destroying a useful property
- predefine every possible retry counter, which is absurd
- let recipients reject or duplicate records every time a signer’s local counter changes
A strict AEAD model collides with retry policy changes.
Tradeoff B: exclude mutable retry state from AAD
The record authorizes what matters:
- who gets it
- who sealed it
- when it was created
- which message identifier it carries
Retries are a local delivery/cost concern layered above the sealed payload. AAT in this case is concerned with authenticity of intent, not anti-replay accounting for every delivery attempt.
I chose B because the alternative re-creates a seal for every attempt.
What I do not like: a future reader could miss that state at a glance. I need better documentation and possibly a separate signed receipt so retry behavior is non-cryptographically attested.
Retry state should become a proven promise
Instead of dropping attempts into AAD, the more honest pattern seems to be:
- keep ciphertext immutable and bound to the original AD
- keep retry-state metadata outside it
- expose an API boundary that returns a signed envelope descriptor only when attempts are changed
- audit every state mutation separately from payload readability
This treats the cryptographic envelope as an evidence object and the delivery system as a policy object. That is better than pretending one signature can mean both things.
The more serious concern: accidental ciphertext copies
MailboxEnvelope derives Clone, and this type can travel through serde serializations.
Drop zeroization only affects the dropped allocation. If:
- a cloned mailbox copy is held elsewhere
- a temporary serialization buffer is allocated
- transport middleware buffers bytes
then “the original object was zeroed” is not the same as “all copies disappeared.” Production Rust code has libraries specifically for this: secrecy, zeroize-on-drop patterns, and more explicit small-source handling.
I am not yet sure this level of copy-safety is necessary for ciphertext that is, by design, something one recipient is supposed to decrypt. It may still matter against heap snapshots, debug traces, or process forensics, but the right point is an operational requirement, not an automatic obvious gate.
If you work in Rust cryptography, I want one concrete answer
When ciphertext is being handled by protocols at multiple layers, what is the least effortful and still correct handling model?
- Manual
Dropplus short-lived scope? -
secrecy::ExposeandCryptographicBufferwrapper everywhere? - Accept temporary copies but only allow canonical object copies to remain live for bounded durations?
I am especially interested in patterns that are realistic at 100+ messages/second, not “never instantiate temporary slices.”
What stayed out of this post
GhostWire’s roadmap includes post-quantum key exchange, mix routing, and AI-assisted pathing, but none of that improves the trust model if mailbox confidentiality is sloppy. The mailbox layer is intentionally primitive because it is where subtle assumptions become failures.
If you have experience implementing this kind of encrypted storage in the Rust ecosystem, the comments here are the part I want most.
Top comments (0)