Sealed Sender
The server routes your message without knowing who sent it.
Why Metadata Matters
End-to-end encryption protects message content. But content is only half the story. Metadata -- who talked to whom, when, how often, for how long -- can be as revealing as the message itself.
Consider: a journalist contacts a government whistleblower. Even if the message content is encrypted, the metadata reveals that the journalist and the whistleblower communicated. The timing and frequency of messages can reveal urgency. The message sizes can hint at whether documents were exchanged. Metadata analysis has been used to identify sources, map organizational structures, and target individuals for surveillance.
"We kill people based on metadata." -- Former NSA Director Michael Hayden
In a standard messaging system, the server must know the sender's identity to route the message. The "from" field is in the clear. Even in end-to-end encrypted systems like Signal, the server knows that Alice sent a message to Bob at 3:47 PM. The server cannot read the message, but it can build a social graph.
RVNT eliminates this. The sender encrypts their identity inside the message envelope. The server sees only the recipient identifier and an opaque ciphertext blob. It cannot determine who sent the message.
How Sealed Sender Works
Step 1: Generate Ephemeral Key
The sender generates a fresh X25519 keypair for each envelope. This key is used solely for deriving the envelope encryption key and is deleted after use.
eph_private, eph_public = X25519.generate()
// eph_private is deleted after Step 3 Step 2: Derive Envelope Key
The sender performs a DH operation between the ephemeral private key and the recipient's identity public key (X25519 form). The result is stretched through HKDF to produce the envelope encryption key.
eph_secret = X25519.DH(eph_private, recipient_identity_dh)
envelope_key = HKDF-SHA256(
salt: eph_public, // 32 bytes
ikm: eph_secret, // 32 bytes
info: "rvnt-sealed-sender", // domain separator
len: 32 // AES-256 key
) Step 3: Build Sender Certificate
The sender constructs a certificate that proves their identity to the recipient. This certificate is placed inside the encrypted envelope -- the server never sees it.
sender_cert = SenderCertificate {
sender_identity_key: my_ed25519_public, // 32 bytes
username: my_username, // variable
timestamp: now_unix_ms(), // 8 bytes
signature: Ed25519.Sign( // 64 bytes
my_ed25519_private,
my_ed25519_public || my_username || now_unix_ms()
)
}
// Delete eph_private now -- it is no longer needed
secure_zero(eph_private) Step 4: Encrypt and Seal
The sender certificate and the ratchet-encrypted message are combined and encrypted with the envelope key:
plaintext_body = sender_cert || encrypted_header || ratchet_ciphertext
sealed_body = AES-256-GCM.Encrypt(
key: envelope_key,
nonce: random_96bit(),
aad: version || recipient_id || eph_public, // authenticated context
data: plaintext_body
)
envelope = SealedSenderEnvelope {
version: 0x01,
recipient_id: BLAKE3(recipient_identity_public)[0..20], // 20 bytes
eph_public: eph_public, // 32 bytes
sealed_body: sealed_body, // variable
mac: GCM_tag, // 16 bytes
} What the Server Sees
Server receives envelope:
┌─────────────────────────────────────────────────┐
│ version: 0x01 │ ← public
│ recipient_id: a7b3c9...d2e8 (20 bytes) │ ← public (truncated hash)
│ eph_public: 8f2a1b...c4d5 (32 bytes) │ ← public (ephemeral)
│ sealed_body: [opaque encrypted blob] │ ← ENCRYPTED
│ mac: [16 bytes] │ ← authentication tag
└─────────────────────────────────────────────────┘
What the server knows:
✓ Someone sent a message to recipient_id
✓ The envelope is ~N bytes (after padding, this is fixed-size)
What the server does NOT know:
✗ Who sent the message (sender identity is inside sealed_body)
✗ What the message says (content is double-encrypted)
✗ The sender's IP address (Tor routing)
✗ The actual message length (fixed-size padding) How the Recipient Learns the Sender
Step 1: Match Recipient ID
my_recipient_id = BLAKE3(my_identity_public)[0..20]
if envelope.recipient_id != my_recipient_id:
discard // Not for us Step 2: Derive Envelope Key
eph_secret = X25519.DH(my_identity_dh_private, envelope.eph_public)
envelope_key = HKDF-SHA256(
salt: envelope.eph_public,
ikm: eph_secret,
info: "rvnt-sealed-sender",
len: 32
)
// Same key as sender derived (DH commutativity) Step 3: Decrypt and Verify
plaintext_body = AES-256-GCM.Decrypt(
key: envelope_key,
nonce: extracted_nonce,
aad: version || recipient_id || eph_public,
data: envelope.sealed_body
)
// Extract sender certificate
sender_cert = parse_sender_certificate(plaintext_body)
// Verify sender's signature
valid = Ed25519.Verify(
sender_cert.sender_identity_key,
sender_cert.sender_identity_key || sender_cert.username || sender_cert.timestamp,
sender_cert.signature
)
if !valid:
discard // Forged sender certificate
// Verify timestamp is recent (within 5 minutes)
if abs(now() - sender_cert.timestamp) > 300_000:
discard // Stale or replayed envelope
// Look up sender in contacts
contact = contacts.find_by_identity_key(sender_cert.sender_identity_key)
if contact is None:
quarantine // Unknown sender, prompt user Envelope Wire Format
SealedSenderEnvelope (on the wire):
+--------+--------+-------------------------------+
| Offset | Length | Field |
+--------+--------+-------------------------------+
| 0 | 1 | version (0x01) |
| 1 | 20 | recipient_id (BLAKE3 truncated)|
| 21 | 32 | ephemeral_key (X25519 public) |
| 53 | 12 | nonce (random) |
| 65 | var | sealed_body (encrypted) |
| last | 16 | mac (GCM authentication tag) |
+--------+--------+-------------------------------+
sealed_body, when decrypted, contains:
+--------+--------+-------------------------------+
| 0 | 32 | sender_identity_key |
| 32 | 1 | username_length |
| 33 | var | username (UTF-8) |
| var | 8 | timestamp (BE64) |
| var | 64 | signature (Ed25519) |
| var | var | encrypted_header (ratchet) |
| var | var | ciphertext (ratchet) |
+--------+--------+-------------------------------+ Security Analysis
Sender Anonymity
The server cannot determine the sender because:
- The sender's identity key is encrypted inside
sealed_body - The ephemeral key is fresh per message and not linked to the sender's identity
- The envelope key is derived from a DH between the ephemeral key and the recipient's key -- the sender's key does not appear in any public field
- Tor routing prevents IP-based sender identification
Recipient Privacy
The recipient_id field is a 20-byte truncated BLAKE3 hash of the recipient's public key, not the public key itself. This provides:
- Sufficient uniqueness for routing (160-bit collision resistance)
- Cannot be reversed to recover the full public key
- Cannot be used to look up the recipient's username without the full key
Replay Protection
The timestamp in the sender certificate limits the replay window to 5 minutes. After that, the envelope is rejected even if re-transmitted. Within the 5-minute window, the Double Ratchet's message key deletion provides additional replay protection: a replayed message will fail to decrypt because the message key has already been consumed and deleted.
Forgery Resistance
An attacker who does not possess the sender's Ed25519 private key cannot forge a valid sender certificate. The signature covers the identity key, username, and timestamp. Modifying any field invalidates the signature.
Further Reading
- Tor Integration -- How Tor complements sealed sender for network-level anonymity
- Mixnet & Cover Traffic -- How timing analysis is defeated
- Threat Model -- Full analysis of what RVNT protects against