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.

Limitation: Sealed sender protects against the server and network observers, but not against the recipient. The recipient learns the sender's identity (by design -- they need to know who they are talking to). A malicious recipient could reveal sender identities to a third party.

Further Reading

Last updated: 2026-04-12

RVNT Documentation — Post-quantum encrypted communications