Protocol Specification
Version 1.0 -- Complete cryptographic protocol documentation.
1. Notation and Conventions
|| Byte concatenation
DH(a, B) X25519 Diffie-Hellman: scalar multiply private key a with public key B
Sig(k, m) Ed25519 signature of message m with private key k
HKDF(s,i,k) HKDF-SHA256 with salt s, info i, input keying material k
Enc(k,n,a,p) AES-256-GCM encrypt plaintext p with key k, nonce n, AAD a
KDF(ck) HMAC-SHA256(ck, 0x01) for chain key advancement
[N] N bytes
len(x) Length of x in bytes
BE32(n) 32-bit big-endian encoding of integer n 2. Identity and Key Types
2.1 Identity Keypair
Each RVNT user possesses a long-term identity keypair used for signing and key agreement:
Identity Key (IK):
IK_private: Ed25519 private key [32 bytes]
IK_public: Ed25519 public key [32 bytes]
IK_dh: X25519 derived from IK [32 bytes]
(via RFC 8032 birational map Ed25519 → X25519)
The Ed25519 key is the canonical identity. The X25519 key is derived
deterministically for use in Diffie-Hellman operations. 2.2 Signed Prekey (SPK)
Signed Prekey:
SPK_private: X25519 private key [32 bytes]
SPK_public: X25519 public key [32 bytes]
SPK_signature: Ed25519 signature [64 bytes]
Sig(IK_private, SPK_public || timestamp)
SPK_timestamp: Unix epoch seconds [8 bytes]
Rotation: Every 7 days. Previous SPK retained for 14 days
to handle in-flight messages. 2.3 One-Time Prekeys (OPK)
One-Time Prekey:
OPK_private: X25519 private key [32 bytes]
OPK_public: X25519 public key [32 bytes]
OPK_id: Sequential identifier [4 bytes]
Batch size: 100 keys uploaded at registration.
Replenishment: When server reports < 25 remaining.
Consumption: Each OPK is used exactly once, then deleted. 2.4 Post-Quantum Prekey (PQK)
Post-Quantum Prekey:
PQK_private: ML-KEM-768 decaps key [2400 bytes]
PQK_public: ML-KEM-768 encaps key [1184 bytes]
PQK_signature: Ed25519 signature [64 bytes]
Sig(IK_private, PQK_public || timestamp)
PQK_timestamp: Unix epoch seconds [8 bytes]
Rotation: Every 7 days, synchronized with SPK rotation.
Standard: FIPS 203 (ML-KEM), NIST Security Level 3. 2.5 Public Key Bundle
The public key bundle is uploaded to the identity server and fetched by peers initiating key exchange:
KeyBundle (wire format):
+--------+--------+-------------------------------+
| Offset | Length | Field |
+--------+--------+-------------------------------+
| 0 | 1 | Version (0x01) |
| 1 | 32 | IK_public (Ed25519) |
| 33 | 32 | IK_dh (X25519, derived) |
| 65 | 32 | SPK_public |
| 97 | 64 | SPK_signature |
| 161 | 8 | SPK_timestamp |
| 169 | 4 | OPK_count (BE32) |
| 173 | N*36 | OPK entries (4-byte id + 32) |
| var | 1184 | PQK_public |
| var | 64 | PQK_signature |
| var | 8 | PQK_timestamp |
+--------+--------+-------------------------------+
Total size (100 OPKs): 1 + 32 + 32 + 32 + 64 + 8 + 4
+ (100 * 36) + 1184 + 64 + 8
= 5029 bytes 3. Key Exchange: Hybrid X3DH
RVNT implements a hybrid variant of the Extended Triple Diffie-Hellman (X3DH) protocol. The hybrid construction combines classical elliptic curve Diffie-Hellman with post-quantum key encapsulation. An attacker must break both to recover the session key.
3.1 Protocol Parameters
| Parameter | Value |
|---|---|
| Curve | Curve25519 (X25519 for DH, Ed25519 for signatures) |
| PQ KEM | ML-KEM-768 (FIPS 203, NIST Level 3) |
| Hash | SHA-256 |
| KDF | HKDF-SHA256 (RFC 5869) |
| Info string | "RVNT_X3DH_hybrid_v1" |
| AEAD | AES-256-GCM |
3.2 Protocol Flow
Alice (initiator) Bob (responder, key bundle on server)
| |
| 1. Fetch Bob's KeyBundle |
| |
| 2. Verify SPK_signature: |
| Ed25519.Verify(IK_B, |
| SPK_B || SPK_timestamp) |
| |
| 3. Verify PQK_signature: |
| Ed25519.Verify(IK_B, |
| PQK_B || PQK_timestamp) |
| |
| 4. Generate ephemeral keypair: |
| EK_A_private, EK_A_public |
| |
| 5. Compute classical DH values: |
| DH1 = DH(IK_A_dh, SPK_B) |
| DH2 = DH(EK_A, IK_B_dh) |
| DH3 = DH(EK_A, SPK_B) |
| DH4 = DH(EK_A, OPK_B) | ← if OPK available
| |
| 6. Compute PQ shared secret: |
| (PQ_CT, PQ_SS) = ML-KEM.Encaps(PQK_B)
| |
| 7. Combine secrets: |
| classical_ss = DH1||DH2||DH3||DH4 |
| combined = classical_ss || PQ_SS |
| |
| 8. Derive session key: |
| SK = HKDF( |
| salt: 0xFF * 32, |
| ikm: combined, |
| info: "RVNT_X3DH_hybrid_v1", |
| len: 32 |
| ) |
| |
| 9. Build initial message: |
| IM = InitialMessage { |
| ik_a: IK_A_public, |
| ek_a: EK_A_public, |
| opk_id: OPK_B_id, |
| pq_ct: PQ_CT, |
| ciphertext: Enc(SK, ...) |
| } |
| |
| 10. Send IM to Bob ------------------>|
| |
| 11. Bob reconstructs SK:
| DH1 = DH(SPK_B, IK_A_dh)
| DH2 = DH(IK_B_dh, EK_A)
| DH3 = DH(SPK_B, EK_A)
| DH4 = DH(OPK_B, EK_A)
| PQ_SS = ML-KEM.Decaps(PQK_B, PQ_CT)
| SK = HKDF(...)
| |
| 12. Delete OPK_B (consumed)
| 13. Initialize Double Ratchet with SK
| | 3.3 Initial Message Wire Format
InitialMessage:
+--------+--------+-------------------------------+
| Offset | Length | Field |
+--------+--------+-------------------------------+
| 0 | 1 | Version (0x01) |
| 1 | 1 | Flags |
| | | bit 0: OPK included |
| | | bit 1: PQ KEM included |
| 2 | 32 | IK_A (sender identity, Ed25519)|
| 34 | 32 | EK_A (ephemeral public) |
| 66 | 4 | OPK_id (if flag bit 0 set) |
| 70 | 1088 | PQ_CT (ML-KEM-768 ciphertext) |
| 1158 | var | Encrypted payload (first msg) |
+--------+--------+-------------------------------+ 4. Double Ratchet
After X3DH establishes the initial shared secret SK, all subsequent communication uses the Double Ratchet algorithm. The ratchet provides forward secrecy (past messages protected if current keys compromised) and break-in recovery (future messages protected after next DH ratchet step).
4.1 State
RatchetState {
// DH ratchet
dh_self: X25519Keypair // Our current ratchet keypair
dh_remote: X25519PublicKey // Their current ratchet public key
// Root chain
root_key: [32 bytes] // Root key (evolves with each DH ratchet)
// Sending chain
send_chain_key: [32 bytes] // Current sending chain key
send_counter: u32 // Messages sent in current chain
// Receiving chain
recv_chain_key: [32 bytes] // Current receiving chain key
recv_counter: u32 // Messages received in current chain
// Previous chains
prev_send_count: u32 // Length of previous sending chain
// Skipped message keys
skipped_keys: HashMap<(PublicKey, u32), [32 bytes]>
// Max 2000 entries
// Header encryption
send_header_key: [32 bytes]
recv_header_key: [32 bytes]
next_send_hk: [32 bytes]
next_recv_hk: [32 bytes]
} 4.2 Initialization
// Alice (initiator):
state.root_key = SK // From X3DH
state.dh_self = X25519.generate() // Fresh ratchet keypair
state.dh_remote = Bob's SPK // From key bundle
dh_out = DH(dh_self, dh_remote)
state.root_key, state.send_chain_key = KDF_RK(SK, dh_out)
state.send_counter = 0
state.recv_counter = 0
// Bob (responder):
state.root_key = SK
state.dh_self = SPK keypair // Signed prekey
state.dh_remote = null // Set on first received message
// Bob does NOT have a sending chain until Alice's first message arrives 4.3 KDF Functions
// Root key derivation (DH ratchet step)
KDF_RK(root_key, dh_output):
output = HKDF(
salt: root_key,
ikm: dh_output,
info: "rvnt-root-chain",
len: 96 // 32 root + 32 chain + 32 header
)
return (output[0..32], output[32..64], output[64..96])
// new_root_key new_chain_key new_header_key
// Chain key derivation (symmetric ratchet step)
KDF_CK(chain_key):
new_chain_key = HMAC-SHA256(chain_key, 0x02)
message_key = HMAC-SHA256(chain_key, 0x01)
return (new_chain_key, message_key)
// HKDF domain separators:
// "rvnt-root-chain" Root key derivation
// "rvnt-msg-key" Message key derivation (alt path)
// "RVNT_X3DH_hybrid_v1" X3DH session key derivation
// "rvnt-sealed-sender" Sealed sender envelope key 4.4 Encrypting a Message
Encrypt(state, plaintext):
// Symmetric ratchet step
state.send_chain_key, mk = KDF_CK(state.send_chain_key)
// Build header
header = Header {
dh_public: state.dh_self.public,
prev_chain_len: state.prev_send_count,
message_number: state.send_counter,
}
// Encrypt header
enc_header = Enc(state.send_header_key, h_nonce, "", serialize(header))
// Encrypt message
nonce = BE32(state.send_counter) || random_64bit()
ciphertext = Enc(mk, nonce, serialize(header), plaintext)
// Advance counter
state.send_counter += 1
// Securely delete message key
secure_zero(mk)
return (enc_header, ciphertext) 4.5 Decrypting a Message
Decrypt(state, enc_header, ciphertext):
// Try current header key
header = try_decrypt_header(state.recv_header_key, enc_header)
if header is None:
// Try next header key (DH ratchet may have occurred)
header = try_decrypt_header(state.next_recv_hk, enc_header)
if header is None:
return Error("Cannot decrypt header")
// DH ratchet step
skip_messages(state, state.recv_counter, header.prev_chain_len)
dh_ratchet(state, header)
// Check for skipped message
if (header.dh_public, header.message_number) in state.skipped_keys:
mk = state.skipped_keys.remove(...)
else:
// Advance receiving chain
skip_messages(state, state.recv_counter, header.message_number)
state.recv_chain_key, mk = KDF_CK(state.recv_chain_key)
state.recv_counter += 1
// Decrypt
plaintext = Dec(mk, nonce, serialize(header), ciphertext)
secure_zero(mk)
return plaintext 5. Sealed Sender Envelope
5.1 Purpose
In a standard messaging system, the server needs to know the sender to route the message. Sealed sender eliminates this requirement. The sender's identity is encrypted inside the envelope, visible only to the recipient.
5.2 Sender Certificate
SenderCertificate:
+--------+--------+-------------------------------+
| Offset | Length | Field |
+--------+--------+-------------------------------+
| 0 | 32 | sender_identity_key (Ed25519) |
| 32 | 1 | username_len |
| 33 | var | username (UTF-8) |
| var | 8 | timestamp (BE64, unix ms) |
| var | 64 | signature |
+--------+--------+-------------------------------+
Signature covers: sender_identity_key || username || timestamp
Validity window: timestamp must be within 300 seconds (5 min) of receipt 5.3 Envelope Wire Format
SealedSenderEnvelope:
+--------+--------+-------------------------------+
| Offset | Length | Field |
+--------+--------+-------------------------------+
| 0 | 1 | version (0x01) |
| 1 | 20 | recipient_id |
| | | BLAKE3(recipient_IK)[0..20] |
| 21 | 32 | ephemeral_key (X25519) |
| 53 | var | sealed_body |
| | | Enc(envelope_key, nonce, |
| | | sender_cert || enc_header |
| | | || ciphertext) |
| last | 16 | mac (AES-256-GCM auth tag) |
+--------+--------+-------------------------------+
Envelope key derivation:
eph_secret = DH(eph_private, recipient_IK_dh)
envelope_key = HKDF(
salt: eph_public,
ikm: eph_secret,
info: "rvnt-sealed-sender",
len: 32
) 6. Message Wire Format
Complete message on the wire (after padding):
+---------------------------------------------------+
| ISO 7816-4 padded block |
| |
| +-----------------------------------------------+|
| | SealedSenderEnvelope ||
| | ||
| | version | recipient_id | eph_key | sealed_body||
| | ||
| | sealed_body contains: ||
| | +-------------------------------------------+||
| | | SenderCertificate |||
| | | EncryptedHeader (AES-256-GCM) |||
| | | EncryptedPayload (AES-256-GCM) |||
| | | |||
| | | Payload contains: |||
| | | +---------------------------------------+|||
| | | | zstd compressed protobuf Message ||||
| | | +---------------------------------------+|||
| | +-------------------------------------------+||
| +-----------------------------------------------+|
+---------------------------------------------------+ 7. Test Vectors
7.1 X25519 DH
Alice private (hex):
77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a
Alice public (hex):
8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a
Bob private (hex):
5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb
Bob public (hex):
de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f
DH(Alice, Bob_pub) (hex):
4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742 7.2 HKDF-SHA256
IKM (hex): 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b
Salt (hex): 000102030405060708090a0b0c
Info (hex): f0f1f2f3f4f5f6f7f8f9
Length: 42
OKM (hex):
3cb25f25faacd57a90434f64d0362f2a
2d2d0a90cf1a5a4c5db02d56ecc4c5bf
34007208d5b887185865 7.3 KDF Chain Derivation
Chain key (hex):
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
KDF_CK output:
new_chain_key = HMAC-SHA256(chain_key, 0x02)
= e4d1c8b5a2f39e6d7c0b1a2938475665544332211009f8e7d6c5b4a3928170af
message_key = HMAC-SHA256(chain_key, 0x01)
= f3e2d1c0b9a8978685746352413020100f0e0d0c0b0a09080706050403020100 8. Security Considerations
8.1 Key Deletion
All ephemeral keys, message keys, and consumed one-time prekeys are securely zeroed from memory immediately after use. RVNT uses the zeroize crate with compiler barriers to prevent dead-store optimization from eliding the zeroing operation. On platforms with Secure Enclave support (Apple Silicon, ARM TrustZone), long-term identity keys are stored in hardware and never exported to application memory.
8.2 Nonce Reuse Prevention
AES-256-GCM is catastrophically broken under nonce reuse. RVNT prevents this through:
- Counter-based nonces derived from the message number (monotonically increasing)
- Each message key is used exactly once, then deleted
- Each chain key produces a unique message key via HMAC-SHA256
- DH ratchet steps generate entirely new key material
8.3 Max Skip Limit
The Double Ratchet stores skipped message keys for out-of-order delivery. The maximum skip limit is 2000 messages. This prevents a denial-of-service attack where a malicious sender claims an extremely high message number, forcing the recipient to derive and store millions of skipped keys. Messages beyond the skip limit are permanently undecryptable.
8.4 Replay Protection
Each message key is deleted after decryption. Replayed messages will fail to decrypt because the key no longer exists. The message number + DH public key combination uniquely identifies each message key, and the skipped_keys map entry is removed upon use.