Envelope format
The exact wire format of a sealed message, field by field, and the validation an incoming envelope must pass before anything is displayed.
An envelope is UTF-8 JSON, hex-encoded into the envelope
parameter of send. It is opaque to the contract, which only checks
its length.
Structure
Fields
| Field | Type | Meaning |
|---|---|---|
v | number | Envelope version. Currently 2. A client refuses anything it does not know. |
kv | number | Which recipient key version this was sealed to, so an old key can be re-derived. |
sv | number | Which sender key version the self-copy uses. |
ep | base64 | Ephemeral public key for this message only. |
r.n, r.c | base64 | Nonce and ciphertext for the recipient. |
s.n, s.c | base64 | Nonce and ciphertext for the sender's own copy. |
Sealed payload
{
subject: "...", // at most 512 characters
body: "...", // at most 64 KB
sentAt: 1730000000, // client clock, untrusted
from: "0x...", // must match the event's from topic
to: "0x...", // must match the event's to topic
chainId: 56, // binds to one network
mailbox: "0x...", // binds to one MailBox deployment
skv: 1 // sender key version
}
The last four fields are what make a copied envelope useless. A client compares them against the event it actually observed and rejects a mismatch, so an envelope lifted from the log cannot be re-emitted as somebody else's message or replayed onto a different deployment.
Padding
Before sealing, the JSON is length-prefixed with four bytes and padded with zeroes up to a multiple of 256.
target = ceil((len + 4) / 256) * 256
On opening, the prefix gives the true length and the padding is discarded. This collapses messages into size buckets so an observer learns less from the ciphertext length. It reduces the leak rather than removing it: a 40 KB message is still visibly larger than a one-liner.
Opening: the validation order
Order matters. Each step assumes the previous one passed.
- Version. Unknown
vis refused outright. - Screen the ephemeral key. Small-order points, non-canonical encodings, and a zero shared secret are all rejected. Skipping this is what made an earlier revision vulnerable from the receiving side.
- Pick the role. Open
rif you are the recipient,sif you are the sender. Roles are never tried interchangeably. - Open and authenticate. A failed Poly1305 tag ends processing.
- Unpad and parse. Malformed JSON is discarded.
- Validate the payload. Types and length bounds on every field.
- Compare routing.
from,to,chainIdandmailboxmust match the observed event.
A valid tag proves the ciphertext was not altered in transit. It proves nothing about who composed it. Every field inside the payload is untrusted input and is escaped before display.
Return value
open() returns the payload alongside the version metadata, so a
client can surface a mismatch rather than hiding it:
{
payload, // the validated contents
keyVersion, // the key that actually opened it
claimedVersion, // the version the envelope claimed
cipher // which box was opened, r or s
}
Version history
| Version | Status | Change |
|---|---|---|
| 1 | Superseded | No routing commitment, so envelopes could be replayed and re-attributed. |
| 2 | Current | Routing commitment, padding, dual boxes, key versioning, peer-key screening. |
| 3 | Planned | XChaCha20-Poly1305. Clients keep opening version 2 indefinitely. |