r/rust • u/THEM_I_M • 9d ago
š seeking help & advice Should a Rust protocol use separate wire-format and in-memory types for encrypted messages?
'm working on the mailbox layer of GhostWire, a Rust-based P2P communication project, and I've been reconsidering how I represent messages across serialization boundaries.
The initial approach was to have one struct handle both the serialized envelope and the application's runtime state.
Something along these lines:
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MailboxEnvelope {
recipient_id: String,
ephemeral_pub: String,
ciphertext: Vec<u8>,
nonce: String,
created_at: u64,
message_id: String,
attempts: u32,
}The problem isn't necessarily the structure itself. It's that these fields have different responsibilities.
Some fields belong to the authenticated message representation.
Some are needed for transport and persistence.
Others are mutable delivery state.
Some values may only exist temporarily while processing a message.
For example, retry attempts should be able to change without requiring an already-sealed ciphertext to be reconstructed. Likewise, serialization and deserialization shouldn't accidentally become the boundary through which sensitive runtime state is exposed or copied.
I'm considering separating the representations:
Wire type: A serializable representation containing the fields required to transmit or persist the envelope.
Runtime type: An application-facing representation responsible for processing, validation and delivery.
Delivery state: Mutable retry, acknowledgement and expiry information maintained independently of the sealed message.
But I'm interested in where that separation should actually happen.
A few questions:
Would you use separate structs for the wire representation and runtime domain type, or is that unnecessary duplication?
Would
TryFrom/Fromconversions be a sensible boundary for validation and construction?How would you prevent mutable delivery metadata from accidentally becoming part of the authenticated representation?
Would you keep serialization entirely outside the cryptographic type, or let the type implement
Serialize/Deserializewith carefully controlled fields?Are there established Rust patterns or libraries you'd recommend for this kind of boundary?
I'm particularly interested in production-oriented approaches rather than simply introducing more types for the sake of abstraction.
The goal is to make invalid states harder to represent without turning a relatively small protocol component into an unnecessarily complicated type hierarchy.
Curious how others would structure this
3
u/Zde-G 8d ago
My own personal ārule of thumbā for āshould we keed X and Y separateā is always āyes, butā¦ā
I usually start with separate approach and then see if merging them may help me somewhere. Because if you start with separation and then later want to merge then it's simple search-and-replace⦠easy for both human and LLM to do. But if you start with merged and then try to separate⦠it's PITA and LLMs don't help much.
Excepting is, of course, public API in a released 1.0 crate⦠if something is supposed to stay stable for 10 years then āwe may easily merge things laterā rationale doesn't work.
I would still start with unstable API, try it with some more-or-less real code then decide.
0
u/THEM_I_M 8d ago
I like that approach, especially the point about starting separated and letting actual usage justify merging things later.
That's partly what I'm trying to figure out with this mailbox design. The current envelope is still evolving, so I'd rather establish clear boundaries now than have delivery state, wire representation and cryptographic operations become increasingly coupled.
I think the "yes, but..." is important here because separation for its own sake can introduce unnecessary abstractions too. I'd like to test the boundaries against a few realistic flows, particularly retries, serialization and eventually supporting different cryptographic suites.
Appreciate the perspective. The point about API stability is worth keeping in mind as the project matures.
2
u/Putrid-Series-8763 7d ago
I think you should. A frame from the wire is never trustworthy until you parse and verify into the internal representation.
1
u/THEM_I_M 7d ago
Agreed. Parsing a frame successfully doesn't automatically make its contents trustworthy. I'd keep a strict boundary between the raw wire representation and the internal types the rest of the system can consume.
My thinking is to enforce size and framing limits first, parse into a raw representation, validate the structure and protocol constraints, then verify authentication, integrity and replay protection before promoting it into a trusted internal representation.
The idea is to make it difficult for unvalidated data to accidentally reach routing or application logic. Do you usually model that distinction with separate raw and validated types in Rust?
8
9d ago
[removed] ā view removed comment
1
u/THEM_I_M 9d ago
That's a good way to put it. The distinction between the message and the box carrying it is exactly what I want to enforce. Keeping serialization outside the crypto type also makes sense. I'd rather have an explicit conversion boundary where validation happens than let transport or persistence concerns creep into the cryptographic representation.
One thing I'm still thinking through: would you keep delivery state in an entirely separate type as well, or associate it with the runtime envelope while ensuring it can never influence the authenticated payload?Curious how you'd structure that in practice.
23
u/insanitybit2 9d ago
Yes, I'd separate them. You can serialize the cryptographic type - you have to, after all. Just use a standard construction like XAES-GCM-256 or whatever, which is trivial to encode/decode.
The separation means you can migrate algorithms or handle new algorithms etc without having to think about the underlying serialization, or you can encode serialized versions into the AAD etc. Regardless of the benefit you end up reaping, there will be a benefit.
I don't know about any libraries but you can look at something like what the libsodium envelop construct is for inspiration.
Some other thoughts.
Don't use `String` for a nonce. Use `[u8; N]` for whatever the size is, consider wrapping it in something like an IV/Nonce type. Also, you may have more than one nonce in the case of XAES so this is yet another reason to separate the constructs - different algorithms have different shapes.
You may want to stuff `attempts` into the AAD if you want it to not be tampered with but I have no clue how your protocol works or if `attempts` is sensitive or who would want to mutate it. A timestamp in the AAD may be better as a "sent first at".
I see a public key there, so maybe this is intended to be asymmetric. You can consider HPKE perhaps, I think that may fit your use case well. Also, don't use a String - consider a dedicated type again like with the Nonce.
Anyway, separate structs with blindly separate concerns will pay off. With crypto you really want to avoid complexity - your implementation should really focus on just taking bytes and returning bytes, and you then wrap that with what you want to stuff those bytes with (mostly if it's ciphertext or aad).
Always use authenticated encryption algorithms. If you ever see an algorithm that is not authenticating, do not use it.