Ratchet Internals (PAGE WIP...)
Implementation details of the Double Ratchet. For the conceptual overview, see Double Ratchet.
Session initialization
Initiator and responder initialize differently:
Responder (Bob): receives Alice's ephemeral DH public key from the Handshake event, computes the DH output, and derives the first receiving chain key. Bob's initial state has a receivingChainKey but no sendingChainKey -- that comes when Bob sends his first message and performs a DH ratchet step.
Initiator (Alice): receives Bob's ephemeral from the HandshakeResponse, computes the DH output, and derives the first sending chain key. Alice also pre-computes epoch 1 topics (the topics that will be active after Bob's first DH ratchet step), so she can listen on them immediately.
Both derive the root key from the hybrid secret: hybridInitialSecret(x25519Secret, kemSecret). See Protocol Flow for the full handshake sequence.
Key derivation functions
Root key derivation
When a DH ratchet step occurs, the new DH output and current root key produce a fresh root key and chain key:
function kdfRootKey(rootKey: Uint8Array, dhOutput: Uint8Array) {
// HKDF-SHA256: dhOutput as IKM, rootKey as salt
const output = hkdf(sha256, dhOutput, rootKey, 'VerbethRatchet', 64);
return {
rootKey: output.slice(0, 32), // New root key
chainKey: output.slice(32, 64), // New chain key
};
}
Chain key derivation
For each message in a chain, HMAC-SHA256 derives a unique message key and advances the chain:
function kdfChainKey(chainKey: Uint8Array) {
return {
messageKey: hmac(sha256, chainKey, [0x01]), // Encrypt/decrypt this message
chainKey: hmac(sha256, chainKey, [0x02]), // Next chain key
};
}
Hybrid initial secret
Combines X25519 and ML-KEM shared secrets into the initial root key:
function hybridInitialSecret(x25519Secret: Uint8Array, kemSecret: Uint8Array) {
const combined = concat([x25519Secret, kemSecret]);
return hkdf(sha256, combined, zeros(32), 'VerbethHybrid', 32);
}
Topic derivation
Each DH ratchet step derives new topics using the root key as PQ-secure salt:
function deriveTopic(
rootKey: Uint8Array,
dhOutput: Uint8Array,
direction: 'outbound' | 'inbound'
): `0x${string}` {
const info = `verbeth:topic-${direction}:v3`;
const okm = hkdf(sha256, dhOutput, rootKey, info, 32);
return keccak256(okm);
}
Encrypt flow
- If no
sendingChainKeyexists, perform a DH ratchet step: generate new DH keypair, compute DH output with their public key, derive new root key + sending chain key, derive new topics - Derive
messageKeyand advancesendingChainKeyviakdfChainKey - Encrypt plaintext with
nacl.secretboxusingmessageKey - Build header:
{ dh: myDHPublicKey, pn: previousChainLength, n: sendingMsgNumber } - Sign
header || ciphertextwith Ed25519 - Encode as binary payload (see Wire Format)
- Return updated session state -- caller must persist it
Decrypt flow
- Parse binary payload, extract version + signature + header + ciphertext
- Signature-first verification: verify Ed25519 signature over
header || ciphertextbefore any ratchet operations (DoS protection -- O(1) rejection of invalid messages) - Check skipped keys: if header's DH key + message number matches a stored skip key, decrypt with that key and remove it
- If header's DH key differs from
dhTheirPublicKey, perform a DH ratchet step: store skip keys for remaining messages in current receiving chain, compute new root key + receiving chain key from new DH output - Skip forward in current chain if
header.n > receivingMsgNumber - Derive
messageKeyviakdfChainKey, decrypt ciphertext - Return updated session state -- caller must persist it
DH ratchet step
When a new DH public key arrives in a message header:
- Store skip keys for any unreceived messages in the current receiving chain (up to
MAX_SKIP_PER_MESSAGE) - Compute
dhOutput = X25519(dhMySecretKey, newTheirPublicKey) kdfRootKey(rootKey, dhOutput)→ newrootKey+receivingChainKey- Generate fresh DH keypair for sending
- Compute
dhOutput = X25519(newMySecretKey, newTheirPublicKey) kdfRootKey(rootKey, dhOutput)→ newrootKey+sendingChainKey- Derive new topics from step 5's DH output (outbound/inbound labels swap relative to the peer)
- Set grace period on previous inbound topic (
TOPIC_TRANSITION_WINDOW_MS) - Reset
sendingMsgNumber = 0,receivingMsgNumber = 0, savepreviousChainLength
Skip key management
Stored as an array of SkippedKey entries:
interface SkippedKey {
dhPubKeyHex: string; // DH epoch identifier
msgNumber: number; // Message number in that epoch
messageKey: Uint8Array; // Derived message key (32 bytes)
createdAt: number; // Timestamp for TTL pruning
}
Pruning strategy:
- Before storing new skip keys, prune entries older than
MAX_SKIPPED_KEYS_AGE_MS(24h) - If storage exceeds
MAX_STORED_SKIPPED_KEYS(1,000), drop the oldest entries - A single message cannot request more than
MAX_SKIP_PER_MESSAGE(100,000) skips
DoS protection
Ratchet state is expensive to mutate (DH computations, chain key derivations). To prevent attackers from triggering these operations with invalid messages:
- Ed25519 signature is verified before any ratchet processing
- Verification is O(1) and cheap compared to ratchet operations
- Invalid messages are rejected without touching session state
See Wire Format for the binary layout that enables signature-first parsing.
Full session state
The complete RatchetSession interface from the SDK:
interface RatchetSession {
// Conversation Identity
conversationId: string; // keccak256(sort([topicOut, topicIn]))
topicOutbound: `0x${string}`; // Original handshake-derived outbound topic
topicInbound: `0x${string}`; // Original handshake-derived inbound topic
myAddress: string;
contactAddress: string;
// Root Ratchet
rootKey: Uint8Array; // 32 bytes, PQ-secure
// DH Ratchet Keys
dhMySecretKey: Uint8Array; // My current DH secret (32 bytes)
dhMyPublicKey: Uint8Array; // My current DH public (32 bytes)
dhTheirPublicKey: Uint8Array; // Their last DH public (32 bytes)
// Sending Chain
sendingChainKey: Uint8Array | null; // null until first DH ratchet as sender
sendingMsgNumber: number; // Ns
// Receiving Chain
receivingChainKey: Uint8Array | null; // null until first message received
receivingMsgNumber: number; // Nr
// Skip Handling
previousChainLength: number; // PN header field
skippedKeys: SkippedKey[];
// Topic Ratcheting
currentTopicOutbound: `0x${string}`; // May differ from original after ratcheting
currentTopicInbound: `0x${string}`;
nextTopicOutbound?: `0x${string}`; // Pre-computed for next DH step
nextTopicInbound?: `0x${string}`;
previousTopicInbound?: `0x${string}`; // Grace period for late messages
previousTopicExpiry?: number; // Date.now() + TOPIC_TRANSITION_WINDOW_MS
topicEpoch: number;
// Metadata
createdAt: number;
updatedAt: number;
epoch: number; // Increments on session reset
}