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 |