How It Works

From keystroke to delivery. Every step, explained.

The Full Pipeline

When you type a message in RVNT and press send, the following pipeline executes. Every step is designed to eliminate a specific class of information leakage.


 SENDER DEVICE                                         RECIPIENT DEVICE
 +------------------+                                  +------------------+
 |  1. Compose      |                                  | 14. Display      |
 |  2. Compress     |                                  | 13. Decompress   |
 |  3. Ratchet      |                                  | 12. Ratchet      |
 |     Encrypt      |                                  |     Decrypt      |
 |  4. Sealed       |                                  | 11. Verify       |
 |     Sender Wrap  |                                  |     Sender Cert  |
 |  5. Fixed-Size   |                                  | 10. Unseal       |
 |     Pad          |                                  |     Envelope     |
 |  6. Mixnet       |                                  |  9. Receive      |
 |     Batch        |                                  |     from Tor     |
 |  7. Tor Circuit  |                                  |  8. Tor Circuit  |
 +--------+---------+                                  +--------+---------+
          |                                                     ^
          |            NETWORK (P2P via libp2p)                 |
          +-----------------------------------------------------+
                    (Tor hidden service / QUIC transport)
  

Step 1: Compose

The user types a message in the UI. At this point, the message exists only in application memory on the sender's device. It is a UTF-8 string. Nothing has been written to disk, nothing has been transmitted.

RVNT constructs an internal message object:

Message {
    id:          UUID v4 (128-bit random)
    timestamp:   Unix epoch milliseconds (u64)
    content:     UTF-8 string (variable length)
    attachments: Vec<AttachmentRef> (file hashes, if any)
    reply_to:    Option<UUID> (if replying to a message)
    flags:       u8 (ephemeral, priority, etc.)
}

This object is serialized using Protocol Buffers (protobuf) for compact binary encoding. The serialized form is typically 30-60% smaller than JSON.

Step 2: Compress

The serialized message is compressed using Zstandard (zstd) at compression level 3. This reduces bandwidth usage and, more importantly, helps normalize message sizes before padding.

compressed = zstd::compress(serialized_message, level=3)

Typical compression ratios:
  Short text (< 100 bytes):  ~1.0x (negligible)
  Medium text (100-1000):    ~1.3-1.8x
  Long text (1000+):         ~2.0-4.0x
  Repeated patterns:         ~5.0-10.0x

Step 3: Double Ratchet Encrypt

The compressed plaintext is encrypted using the Double Ratchet algorithm. This is the core of RVNT's forward secrecy guarantees.

Symmetric Ratchet Step

// Derive message key from sending chain
chain_key_new, message_key = HKDF-SHA256(
    ikm:  chain_key_current,
    salt: 0x01,  // message key derivation
    info: "rvnt-msg-key"
)

// Encrypt with derived message key
nonce = counter_to_nonce(message_number)  // 96-bit
ciphertext = AES-256-GCM.Encrypt(
    key:   message_key,
    nonce: nonce,
    aad:   header_bytes,
    data:  compressed_plaintext
)

// Delete message key immediately after use
secure_zero(message_key)

Message Header

Header {
    dh_public:      [32 bytes]  // Current DH ratchet public key
    prev_chain_len: u32         // Messages in previous sending chain
    message_number: u32         // Message number in current chain
}

// Header is encrypted separately with header key
encrypted_header = AES-256-GCM.Encrypt(
    key:   header_key,
    nonce: header_nonce,
    data:  header_bytes
)

The message key is derived, used exactly once, and immediately zeroed from memory. Even if an attacker captures the device's RAM after encryption, the message key for this specific message no longer exists.

Step 4: Sealed Sender Wrap

The encrypted message is wrapped in a sealed sender envelope. This hides the sender's identity from the network and any intermediary.

// Generate ephemeral X25519 keypair for this envelope
eph_private, eph_public = X25519.generate()

// Derive envelope encryption key
envelope_secret = X25519.DH(eph_private, recipient_identity_public)
envelope_key = HKDF-SHA256(
    ikm:  envelope_secret,
    salt: eph_public,
    info: "rvnt-sealed-sender"
)

// Build sender certificate
sender_cert = SenderCertificate {
    sender_identity_key: my_identity_public,   // [32 bytes]
    sender_username:     my_username,           // variable
    timestamp:           now(),                 // u64
    signature:           Ed25519.Sign(          // [64 bytes]
        my_identity_private,
        sender_identity_key || timestamp
    )
}

// Encrypt the ratchet ciphertext + sender cert
sealed_body = AES-256-GCM.Encrypt(
    key:   envelope_key,
    nonce: random_96bit(),
    data:  sender_cert || encrypted_header || ciphertext
)

// Final envelope
SealedEnvelope {
    version:       0x01,
    recipient_id:  BLAKE3(recipient_identity_public)[0..20],  // [20 bytes]
    ephemeral_key: eph_public,                                // [32 bytes]
    sealed_body:   sealed_body,                               // [variable]
    mac:           GCM authentication tag                     // [16 bytes]
}

// Delete ephemeral private key
secure_zero(eph_private)

The envelope's recipient_id is a truncated hash of the recipient's public key, not the key itself. This is sufficient for routing but does not reveal the full recipient identity to passive observers.

Step 5: Fixed-Size Padding

The sealed envelope is padded to a fixed size to prevent message length analysis. An observer who can see encrypted traffic should not be able to distinguish a "yes" from a 10-paragraph message based on packet size.

Padding scheme:
  Messages < 256 bytes:    padded to 256 bytes
  Messages < 1024 bytes:   padded to 1024 bytes
  Messages < 4096 bytes:   padded to 4096 bytes
  Messages < 16384 bytes:  padded to 16384 bytes
  Messages >= 16384 bytes: padded to next multiple of 4096

Padding format:
  [sealed_envelope] [0x80] [0x00 ... 0x00]
  (ISO 7816-4 padding: 0x80 byte followed by zero bytes)

The recipient strips padding by scanning backward from the
end for the 0x80 delimiter.

Step 6: Mixnet Batching

In standard privacy mode, the padded envelope is sent immediately. In maximum privacy mode, RVNT uses a mixnet-style batching system to defeat timing analysis.

Standard mode:
  - Send immediately
  - Cover traffic: 1 dummy message per 30-60 seconds (random)

Maximum privacy mode:
  - Batch window: 500ms - 2000ms (random per batch)
  - Batch size: accumulate messages during window
  - Add 1-3 cover traffic messages per batch
  - Shuffle order within batch
  - Random inter-batch delay: 100ms - 500ms
  - All messages (real + cover) are indistinguishable

Cover traffic consists of encrypted dummy envelopes that are structurally identical to real messages. They are addressed to the sender's own identity and silently discarded upon receipt. An observer cannot distinguish cover traffic from real messages.

Step 7: Tor Circuit

The padded envelope (or batch of envelopes) is transmitted through a Tor circuit. RVNT embeds the arti-client Rust Tor implementation directly -- no external Tor binary is required.

Standard mode (3-hop):
  Sender → Guard → Middle → Exit → Recipient

Maximum privacy mode (5-hop):
  Sender → Guard → Middle1 → Middle2 → Middle3 → Exit → Recipient

Each hop:
  1. TLS 1.3 connection to relay
  2. Tor cell encryption (AES-128-CTR + SHA-1 for Tor compat)
  3. Onion-peel one layer of encryption at each relay
  4. Final relay delivers to recipient's .onion address or IP

When both peers are running Tor, communication occurs between two .onion hidden services. In this mode, no relay in the circuit knows either the sender's or recipient's IP address. The guard node knows the sender's IP but not the destination. The exit node knows the destination but not the sender. No single relay knows both.

Step 8-9: Network Transport and Receipt

The envelope travels over libp2p using QUIC transport with Noise XX authentication. On the recipient's side, the Tor circuit terminates at their embedded Tor instance, and the libp2p layer delivers the envelope to the RVNT application.

Transport stack:
  Application:  RVNT sealed envelope
  Encryption:   Tor onion encryption (3-5 layers)
  Transport:    QUIC (UDP, multiplexed streams)
  Auth:         Noise XX (X25519 + ChaChaPoly)
  Network:      Tor circuit (TCP between relays)

The recipient's device receives the raw envelope bytes
from the libp2p stream handler.

Step 10: Unseal Envelope

The recipient's device processes the sealed envelope:

1. Parse envelope: extract recipient_id, ephemeral_key, sealed_body
2. Check recipient_id matches BLAKE3(my_identity_public)[0..20]
3. Derive envelope decryption key:
   envelope_secret = X25519.DH(my_identity_private, ephemeral_key)
   envelope_key = HKDF-SHA256(envelope_secret, ephemeral_key, "rvnt-sealed-sender")
4. Decrypt sealed_body with envelope_key
5. Strip ISO 7816-4 padding
6. Extract sender_cert, encrypted_header, ciphertext

Step 11: Verify Sender Certificate

1. Extract sender_identity_key from sender_cert
2. Verify Ed25519 signature over (sender_identity_key || timestamp)
3. Check timestamp is within acceptable window (5 minutes)
4. Look up sender in local contact database
5. Verify sender_identity_key matches stored key for this contact
6. If unknown sender: quarantine message, prompt user

Step 12: Double Ratchet Decrypt

1. Decrypt header with header key to get: dh_public, prev_chain_len, message_number
2. If dh_public differs from stored remote DH key:
   a. Perform DH ratchet step (new shared secret)
   b. Derive new receiving chain key
3. Advance receiving chain to message_number
   a. For each skipped message, store the skipped key (up to 2000 max)
4. Derive message key from receiving chain
5. Decrypt ciphertext with AES-256-GCM using message key
6. Delete message key immediately
7. Return compressed plaintext

Step 13: Decompress

plaintext = zstd::decompress(compressed_plaintext)
message = protobuf::deserialize(plaintext)

Step 14: Display

The decrypted message is stored in the local SQLCipher database (encrypted at rest with the user's PIN-derived key) and displayed in the UI. The message status is updated to "Delivered" and an acknowledgment is sent back through the same pipeline (encrypted, sealed, padded, routed through Tor).

Security Properties Achieved

Property Mechanism What It Defeats
Confidentiality AES-256-GCM (Double Ratchet) Content interception
Integrity GCM authentication tag Message tampering
Authentication Ed25519 sender certificate Impersonation
Forward Secrecy Double Ratchet key deletion Retrospective decryption
Sender Anonymity Sealed sender envelope Metadata collection
Network Anonymity Tor routing IP correlation
Length Hiding Fixed-size padding Traffic analysis
Timing Hiding Mixnet batching + cover traffic Timing correlation
Post-Quantum ML-KEM-768 hybrid KEM Harvest-now-decrypt-later

Last updated: 2026-04-12

RVNT Documentation — Post-quantum encrypted communications