1. Introduction

Essenger Protocol provides end-to-end encryption for private and group chats on Web/PWA and iOS beta; the macOS client is not included in v1.0. It combines hybrid key agreement (ephemeral X25519 + ML-KEM-768, FIPS 203) via HKDF-SHA256, per-message sender-side ephemeral keys, AES-256-GCM authenticated encryption, and HMAC-SHA256 sender authentication. Regular chats do not use Double Ratchet or X3DH — see the limitations section for details.

Essenger Protocol achieves the following security goals:

  • Privacy — messages can only be read by the recipient; the server stores only the ciphertext.
  • Integrity - any change in the ciphertext is detected through the AES-GCM and HMAC-SHA256 authentication tags.
  • Per-message sender ephemeral key — each message derives its key from a fresh ephemeral X25519 pair on the sender's side, which is deleted after sending. Compromising the sender's long-term key therefore does not retroactively disclose messages already sent. This is not full forward secrecy because the recipient uses a static long-term key. Full forward secrecy and post-compromise security for regular 1:1 and group chats are not claimed — see the limitations section.
  • Post-quantum stability - Hybrid Mode v3 combines the X25519 with the ML-KEM-768 to ensure messages remain protected from the “collect now, decrypt later” attack when a quantum computer emerges that can break elliptic curve cryptography.

Encryption is required. There is no plaintext transmission mode.

2. Threat model

What are we protecting from?

  • Passive interceptor — an attacker monitoring all network traffic between clients and the server cannot read the contents of messages.
  • Compromised server — the server stores only ciphertexts, public keys and encrypted blobs of private keys. Complete server compromise does not reveal the plaintext of messages or private keys.
  • Future quantum computer - Hybrid messages v3/v4 combines the classic X25519 with the ML-KEM-768. An attacker who writes ciphertext today and later obtains a cryptographically valid quantum computer will not be able to decrypt hybrid-encrypted messages.
  • Impersonation when a key is compromised — sender authentication via HMAC-SHA256 using a static shared secret ECDH prevents identity spoofing even if the server is compromised.

What we don't protect against

  • Device compromise — if an attacker gains full control of the device (root access, malware), he can read decrypted messages from memory or local storage.
  • Metadata — the server sees the account of the sender and recipient on the current active path, delivery time, message size and network metadata. Server-blind Sealed Sender is in redesign and is not declared as a shipped function.
  • Bypass user authentication — the protocol does not protect against social engineering or account interception at the application level. TOTP 2FA and TOFU mitigate but do not eliminate this risk.

3. Cryptographic primitives

PrimitiveAlgorithmLinkApplication
Key agreement X25519 RFC 7748 Ephemeral sender ECDH, account-level encryption, sender authentication
Authenticated encryption AES-256-GCM NIST SP 800-38D Message encryption, key wrapping, storage encryption
Message Authentication HMAC-SHA256 RFC 2104 Sender authentication, storage MAC
Key output HKDF-SHA256 RFC 5869 Message key derivation, hybrid secret combination, recovery key
Post-quantum KEM ML-KEM-768 FIPS 203 Hybrid key encapsulation in v3/v4 messages
KDF for password (primary) Argon2id RFC 9106 Private key encryption (64 MB, t=3, p=1)
KDF for password (spare) PBKDF2-SHA256 RFC 8018 Legacy private key encryption (600K iterations)

Implementation libraries

  • Web client: @noble/curves (X25519), @noble/hashes (HKDF, HMAC, SHA-256), @noble/post-quantum (ML-KEM-768), Web Crypto API (AES-GCM), argon2-browser (Argon2id)
  • iOS/macOS client: Apple CryptoKit (Curve25519, AES.GCM, HKDF<SHA256>, HMAC<SHA256>)

4. Key Agreement (Ephemeral X25519)

Essenger does not use X3DH or prekey bundles. Each device, upon registration, generates one long-term X25519 key pair and (for the post-quantum path) an ML-KEM-768 key pair. Public keys are uploaded to the server; private keys never leave the device.

  • Long term key X25519 — a pair of keys generated on the device during registration. The public key is published on the server; Incoming messages are addressed to it.
  • Key ML-KEM-768 - post-quantum key pair for hybrid path v3 (web only by default). The public key is published on the server.
  • TOFU by UUID — the interlocutor’s public key is assigned (trust on first use) to the unchanged User UUID upon first contact.

Stream per message

Key reconciliation is performed again for everyone messages. The sender generates a fresh ephemeral X25519 pair, performs ECDH with the recipient's published static key, outputs the message key via HKDF-SHA256, and destroys the ephemeral private key:

Sender Server Recipient ------- fetch publicKey(B) -------> <------ recipientX25519Pub ---------- // TOFU: check the recipient's key by the stored value (by UUID) // Fresh ephemeral pair for this message ephPriv = random(32) ephPub = X25519.publicKey(ephPriv) shared = X25519(ephPriv, recipientX25519Pub) msgKey = HKDF-SHA256(shared, salt=ephPub, info="essenger-e2e-v2", len=32) // ephPriv is reset immediately after use --------- {ephPub, ct, sa} -------> shared = X25519(myPriv, ephPub) msgKey = HKDF-SHA256(shared, salt=ephPub, ...)

KDF Specification (DM v2)

function messageKeyV2(ephPriv, recipientPub):
    shared = X25519(ephPriv, recipientPub)
    salt   = ephPub
    info   = "essenger-e2e-v2"
    return HKDF-SHA256(shared, salt, info, outputLen=32)
No prekeys and no sessions

There are no signed pre-keys, one-time pre-keys, Ed25519 signature verification, or setting up a long session. Each message is self-contained: it carries the sender's own ephemeral public key, and the recipient deduces the message key directly from his static private key. The hybrid post-quantum variant (v3) adds the ML-KEM-768 to this - see section 6.

5. Per-message sender keys

In regular chats, Essenger does not implement Double Ratchet. There is no root key, chain key, ratchet session, or evolving state between messages. Every message is independent: it uses a fresh ephemeral X25519 pair on the sender's side (section 4) and is encrypted with its own one-time key. Full Double Ratchet — with forward secrecy and post-compromise security — is available in secret chats (section 13).

Message encryption

function encrypt(plaintext, recipientPub, senderStaticPriv):
    // Fresh ephemeral pair for this message
    ephPriv    = random(32)
    ephPub     = X25519.publicKey(ephPriv)
    shared     = X25519(ephPriv, recipientPub)
    msgKey     = HKDF-SHA256(shared, salt=ephPub, info="essenger-e2e-v2", len=32)

    blob       = 0x02 || ephPub(32) || nonce(12) || AES-256-GCM(msgKey, plaintext)
    senderAuth = HMAC-SHA256(blob, key=staticECDH(senderStaticPriv, recipientPub))

    // ephPriv is reset immediately after use
    return { ct: blob, sa: senderAuth }

Combined AES-GCM format

All AES-256-GCM ciphertext uses a combination format compatible with Apple CryptoKit:

nonce12 bytes
ciphertextvariable
tag16 bytes

What it gives - and what it doesn't give

Regular chats are not Double Ratchet: there is no full forward secrecy and post-compromise security

The following applies to regular chats. For full forward secrecy, open a secret chat — it runs on Double Ratchet (section 13). In a regular chat, only the sender key is ephemeral. The message is addressed to the recipient's static long-term X25519 key; there is no key rotation or ratcheting. This means:

  • Compromise long-term sender key does not retroactively disclose messages it has already sent (the sender's ephemeral keys have already been destroyed).
  • Compromise recipient's long-term private key reveals both past and future messages addressed to him.
  • Post-compromise security (recovery after hacking) is not provided. Signal-like Ratchet is included in the post-1.0 release.

6. Quantum Shield (ML-KEM-768, v3/v4)

Account-level encryption supports a hybrid post-quantum mode that combines X25519 ECDH with ML-KEM-768 (Modular Lattice Key Encapsulation Mechanism, FIPS 203). This provides protection against a “collect now, decrypt later” attack: even if all the ciphertext is written today, a future quantum computer will not be able to calculate the message keys.

Message format versions

VersionByte prefixDescription
v10x01 (implicit)Static X25519 ECDH. Outdated. Saved only for compatibility with older posts.
v20x02Ephemeral X25519 ECDH. Per-message ephemeral key of the sender. Current fallback when the recipient does not have a PQ key.
v30x03Hybrid ML-KEM-768 + ephemeral X25519. Active post-quantum path (web→web by default). Fixed PQ ciphertext size (1088 bytes).
v40x04Reserved extensible hybrid markup with 2-byte PQ ciphertext length prefix. Not activated (no call-site) and is not sent.

Wire format v2 (current default)

0x021 byte
ephPub32 bytes
IV12 bytes
ciphertext + tagvariable
// Output key v2
ephPriv    = random(32)
ephPub     = X25519.publicKey(ephPriv)
shared     = X25519(ephPriv, recipientPub)
messageKey = HKDF-SHA256(shared, salt=ephPub, info="essenger-e2e-v2", len=32)
// ephPriv is reset immediately after use

Wire format v3 (active hybrid path)

0x031 B
ephPub32 B
ML-KEM CT1088 B
IV12 B
ct + tagAC

Combining hybrid keys

// Sender (encryption)
x25519Shared              = X25519(ephPriv, recipientX25519Pub)
(pqCipherText, pqShared)  = ML-KEM-768.Encapsulate(recipientPQPub)
combinedSecret            = x25519Shared || pqShared
messageKey                = HKDF-SHA256(combinedSecret, salt=ephPub, info="essenger-hybrid-v1", len=32)

// Recipient (decryption)
x25519Shared  = X25519(myPriv, ephPub)
pqShared      = ML-KEM-768.Decapsulate(pqCipherText, myPQPriv)
combinedSecret = x25519Shared || pqShared
messageKey    = HKDF-SHA256(combinedSecret, salt=ephPub, info="essenger-hybrid-v1", len=32)
Security Guarantee

The hybrid combination is safe as long as at least one of the two algorithms has not been hacked. If X25519 is hacked by a quantum computer, but ML-KEM-768 remains secure, the combination key is still secure, and vice versa. Concatenation followed by HKDF is a safe combinator in the standard model.

Reserved markup v4

0x041 B
ephPub32 B
pqCTLen2 B
ML-KEM CT1088 B
IV12 B
ct + tagAC

v4 is identical to v3 but adds a 2-byte length prefix pqCTLen for future extensibility to larger KEM parameters. This markup is not yet activated (no call-site) and is not sent.

Backward Compatibility

When decrypting, the first byte of the ciphertext is checked to determine the version: 0x03 for hybrid v3, 0x02 for ephemeral X25519 v2. If none match, the message is processed as v1 (legacy static ECDH). New messages are sent as v3 (when the recipient has a PQ key - web→web by default) or as v2 (when there is no PQ key).

7. Sender Authentication

Each account-level message contains the sender's authentication HMAC, which proves that the sender owns the private key corresponding to the claimed public key, without server involvement.

Calculation

function computeSenderAuth(ciphertextBytes, senderPriv, recipientPub):
    staticShared = X25519(senderPriv, recipientPub)
    authKey      = HKDF-SHA256(staticShared, salt=empty, info="essenger-sender-auth", len=32)
    mac          = HMAC-SHA256(authKey, ciphertextBytes)
    // Reset staticShared and authKey immediately
    return mac  // 32 bytes

Check

function verifySenderAuth(ciphertextBytes, expectedMac, senderPub, myPriv):
    staticShared = X25519(myPriv, senderPub)
    authKey      = HKDF-SHA256(staticShared, salt=empty, info="essenger-sender-auth", len=32)
    computed     = HMAC-SHA256(authKey, ciphertextBytes)
    return constantTimeEqual(computed, expectedMac)

Envelope format

The authenticated ciphertext is sent as a JSON envelope:

{
  "ct": "",
  "sa": ""
}

Cross-platform compatibility

The JSON wrapper ensures that the web client (@noble/curves), and iOS client (CryptoKit) can parse the same message format. The base shared secret X25519 is deterministic in all implementations.

TOFU addiction

Sender authentication is only verified if the sender's public key is available in the local TOFU cache. For unknown senders, the check is skipped (soft degradation). Once TOFU remembers the key, all subsequent messages are authenticated.

8. Sealed Sender

The current shipping implementation hides the sender's identity inside the E2E-encrypted payload for the recipient client, but delivery on the active path occurs via an authenticated WebSocket. Therefore, the server still knows the sender's account for routing purposes. Completely server-blind Sealed Sender - a separate redesign in the roadmap, and not the current shipped claim.

Format

At the current client level, the plaintext is wrapped in a JSON envelope before encryption:

{
  "_ss":  true,              // sealed sender flag
  "sid":  "",
  "sun":  "",
  "msg":  ""
}

This JSON is encrypted with the recipient's public key using the standard v2/v3/v4 path. The recipient decrypts, discovers _ss: true and extracts the sender's displayed identity from the load. This does not make the current transport server-blind.

Unpacking logic

function unwrapSealedSender(plaintext):
    if plaintext starts with '{':
        parsed = JSON.parse(plaintext)
        if parsed._ss === true && typeof parsed.msg === 'string':
            return { text: parsed.msg, senderId: parsed.sid, senderUsername: parsed.sun }
    return plaintext  // not sealed sender, return as is
Metadata restrictions

On the current active path, the server sees the sender, recipient, time of sending, message size, and network metadata. Server-blind delivery requires a separate access-key/sealed-inbox design and is not declared as a shipped function.

9. Group E2E v2

Group encryption uses a fresh random message key and a sender-side ephemeral ECDH pair for every message, wrapping that message key separately for each participant. This provides confidentiality and message independence: compromising one message key does not expose the others. However, the message key is wrapped under each participant's long-term public key. As in regular private chats, there is no Double Ratchet: group messages do not provide forward secrecy. Compromising a participant's long-term private key exposes both past and future messages addressed to that participant. Group-key rotation and forward secrecy for groups remain on the roadmap. Groups currently use X25519 without ML-KEM, so there is no PQ layer.

Encryption stream

  1. Generating a random 32-byte messageKey.
  2. Generating an ephemeral key pair X25519 (ephPriv, ephPub).
  3. We encrypt the plaintext with messageKey via AES-256-GCM (random 12-byte IV).
  4. For each group member:
    • We calculate: shared = X25519(ephPriv, memberPub)
    • We display the wrapping key: wrappingKey = HKDF-SHA256(shared, salt=ephPub, info="essenger-group-e2e-v2", len=32)
    • Wrap: wrappedMK = AES-256-GCM(wrappingKey, messageKey, randomIV)
    • Save as: base64(mkIV(12) || wrappedMK+tag)
    • Reset the wrap key immediately.
  5. Reset to zero ephPriv (the ephemeral private key never leaves memory).

Wire format

{
  "v":   2,
  "eph": "",
  "iv":  "",
  "ct":  "",
  "mk":  {
    "<username1>": "<base64 mkIV(12) || wrappedMK+tag>",
    "<username2>": "<base64 mkIV(12) || wrappedMK+tag>",
    ...
  }
}

Decryption flow

  1. Parse the JSON payload and verify v === 2.
  2. Looking for your username in the dictionary mk. If missing, the message is not intended for this user.
  3. Derive the shared secret with shared = X25519(myPriv, ephPub), then run HKDF as above.
  4. Expand: divide mkIV(12) || wrappedMK+tag, decrypt with the wrapping key.
  5. Decrypt the ciphertext expanded messageKey.

Ephemeral sender key in groups

The ephemeral private key is generated anew for each group message and destroyed immediately after all wrapping keys are calculated. This protects the sender's long-term key: compromising it does not retroactively expose messages already sent. This is not forward secrecy for recipients: the message key is wrapped under each participant's static long-term key, so compromising a participant's key exposes every message addressed to that participant, past and future. Full forward secrecy for groups is not claimed and remains on the roadmap.

Adding or removing participants

  • Addition: new members are included in the mk dictionary for subsequent messages. They cannot decrypt messages sent before they were added because they have no access to the earlier ephemeral keys.
  • Removal: Removed members are excluded from the dictionary mk. Because each message uses a fresh ephemeral key, the remote participant cannot compute future wrapping keys.

Legacy v1 format

The v1 protocol for groups used static sender keys without wrapping them for each participant. Saved for backward compatibility only (decryptGroupMessageV1 delegates to accountE2E.decrypt). Auto-detection checks first groupCiphertextV2, rolling back to ciphertext for v1.

10. Key management

Trust On First Use (TOFU)

Long-term public keys are verified through TOFU by unchanged User UUID. During the first contact, the interlocutor’s public key is stored locally under the key tofu_{uuid}. During subsequent contacts, the stored key is compared with the received one. If they are different, the contact is rejected with an error code IDENTITY_KEY_CHANGED.

// TOFU check (based on unchanged UUID)
storedKey = load("tofu_{uuid}")
if storedKey exists and storedKey !== remotePub:
    throw "The public key has changed - a MITM attack is possible"
else if storedKey absent:
    save("tofu_{uuid}", remotePub)

Safety Numbers

Users can manually verify each other by comparing security numbers. The security number is calculated by lexicographically sorting both parties' public keys, concatenating them, SHA-256 hashing, and formatting them into 12 groups of 5 digits:

function generateSafetyNumber(myPubB64, theirPubB64):
    sorted   = [myPubB64, theirPubB64].sort()
    combined = sorted[0] + sorted[1]
    hash     = SHA-256(combined)
    digits   = ""
    for i in 0..29:
        digits += String(hash[i] % 100).padStart(2, '0')
    //Format as 12 groups of 5 digits
    return groups of 5, space-separated  // e.g. "04821 73920..."

Key transparency audit log

The server maintains an append-only table key_audit_log, which records all events associated with the keys. Each entry contains:

FieldTypeDescription
user_idUUIDUser whose key has changed
actionstringEvent type (register, rotate, revoke)
public_key_hashstringSHA-256 hash of the new public key
device_infostringName/type of device that initiated the change
ip_addressstringRequest source IP address
created_atdatetimeTimestamp

The audit log is write-protected by PostgreSQL policies to prevent retroactive modification.

Key rotation

  • Long-term account key can be regenerated (new pair X25519 and, if necessary, ML-KEM-768). The event is recorded in key_audit_log with action rotate/revoke.
  • TOFU-rebinding: when the peer's published key changes, the pinned value (by UUID) changes, and the client signals this to the user. There are no prekeys or periodic rotation of session keys - each message uses the sender's fresh ephemeral key.

Private key storage

Private keys are stored locally on the device (device-local mode). At rest, the private keys in localStorage are encrypted with a storage encryption key (SEK), which exists only in sessionStorage (cleared when closing the tab):

Format: "sek1:" + base64(IV(12) || AES-GCM(SEK, privateKey))
SEK = random 32 bytes, stored only in sessionStorage

For keys encrypted on the server, the private key is encrypted with the user's password:

  • Argon2id (main): 0xA2 || salt(16) || IV(12) || AES-GCM(derived, privKey). Parameters: memory 64 MB, time cost 3, parallelism 1, hash length 32 bytes.
  • PBKDF2 (spare): salt(16) || IV(12) || AES-GCM(derived, privKey). 600K SHA-256 iterations. Used when Argon2 WASM is not available. Legacy keys with 100K iterations are automatically migrated.

11. Recovery

Account recovery uses the BIP39 mnemonic phrase (12 words, 128 bits of entropy) as a human-readable backup of the cryptographic identity.

Generating mnemonics

phrase = BIP39.generateMnemonic(wordlist=english, entropy=128)
// Generates 12 English words, e.g. "abandon ability able about above absent..."

Output of the recovery key

seed        = BIP39.mnemonicToSeed(phrase)  // 64 bytes
recoveryKey = HKDF-SHA256(
    ikm  = seed,
    salt = "essenger-recovery-v1",
    info = "essenger-recovery-v1",
    len  = 32
)
// recoveryKey: Uint8Array(32)

Encrypted backup format (RP1)

The private key is encrypted with the recovery key and loaded as a backup:

"RP1"3 bytes
salt16 bytes
IV12 bytes
AES-GCM(privKey) + tag48 bytes
// Encryption
salt   = random(16)
aesKey = HKDF-SHA256(recoveryKey, salt, info="essenger-e2e-privkey-v1", len=32)
iv     = random(12)
blob   = "RP1" || salt || iv || AES-GCM(aesKey, privateKey, iv)
output = base64(blob)

Recovery flow

  1. The user enters a 12-word mnemonic phrase.
  2. The phrase is validated against the BIP39 dictionary (with normalization: lower case, numbering removed, spaces collapsed).
  3. The recovery key is output via HKDF.
  4. The RP1 blob is decrypted. The resulting private key is verified against the expected public key.
  5. If successful, the private key is loaded into memory and session storage.

Storing mnemonics

The mnemonic phrase is optionally cached in sessionStorage for the duration of the current session, encrypted per-session with the AES-256-GCM key, which exists only in memory. Mnemonics are never written to localStorage and is not sent to the server.

12. Security issues

Constant time comparison

All MAC checking uses constant-time comparison to prevent timing attacks:

function equal(a, b):
    if a.length !== b.length: return false
    diff = 0
    for i in 0..a.length:
        diff |= a[i] ^ b[i]
    return diff === 0

Resetting key material

Ephemeral private keys, shared secrets and deduced key material are reset to zero (.fill(0)) immediately after use, usually in a block finally. This minimizes the window during which sensitive material exists in memory.

Known limitations

  • There is no Double Ratchet in regular chats: there is no complete forward secrecy and post-compromise security. Only the sender's key is ephemeral; the recipient uses a static long-term key, so compromising his private key reveals past and future messages addressed to him. For complete forward secrecy there is secret chats on Double Ratchet (section 13); Ratchet group chats are not yet used.
  • No deniability: Sender Authentication HMAC uses a static ECDH key, creating cryptographic proof that a specific sender originated the message.
  • Group metadata: dictionary mk in group v2 messages, the composition of the group is revealed to any observer who can see the ciphertext (even though the contents of the message are encrypted).
  • JavaScript Memory Model: .fill(0) on Uint8Array - this is the best-effort in runtimes with a garbage collector. The JavaScript engine can keep copies of sensitive data in memory.
  • TOFU without PKI: The initial key exchange is trusted on first use (by an unchanged User UUID). An active MITM attack at the moment of first contact can replace the keys. Security numbers allow manual verification, but do not force it.
  • No external audit: The protocol is open source but not verified by an independent third party. Status - experimental.

13. Secret chats (Double Ratchet)

A secret chat is a separate, manually enabled conversation alongside the regular one. Unlike regular chats (section 5), it runs a full Double Ratchet on top of an X3DH handshake and provides forward secrecy and post-compromise security. The same transport also carries view-once messages. The iOS and web implementations mirror each other byte for byte and are covered by frozen test vectors (dr_handshake_v6). Secret chats are 1:1; no group ratchet is claimed.

Establishing a session - X3DH

Each device has a separate DR identity (stored in Keychain under its own service, regardless of the key of regular chats; only a numeric one shared with the account deviceID):

  • Identity key — X25519 (Curve25519) agreement key binding the transcript.
  • Signing key - Ed25519; signed pre-key (SPK) is signed with it.
  • Signed pre-key + pool one-time pre-keys (OTPK) - published to the server; OTPK is consumed upon the first incoming handshake.

The initiator performs X3DH against the published set of the recipient, checking Ed25519-SPK signature (incorrect signature → invalidSignedPreKeySignature, MITM sign). From the X3DH Diffie-Hellman set, the root key is derived with which the ratchet is seeded:

// Initiator → recipient, first contact
DH1 = X25519(IK_a, SPK_b)
DH2 = X25519(EK_a, IK_b)
DH3 = X25519(EK_a, SPK_b)
DH4 = X25519(EK_a, OTPK_b)   // if OTPK is available
rootKey = HKDF-SHA256(DH1 ‖ DH2 ‖ DH3 ‖ DH4)

Ratchet

State then advances through the classic Double Ratchet: a DH ratchet (a new X25519 pair when direction changes) over a symmetric-key ratchet for root and chain keys. Every message gets its own key derived from the current chain key, and sent message keys are immediately deleted. This provides both forward secrecy (compromising the current state does not expose past messages) and post-compromise security (after one clean exchange, the parties recover secrecy). Each session belongs to a username:deviceID pair and serialized so that parallel writes do not damage the state of the ratchet (Ns/CKs/DHs/PN).

Wire format (frame 0x06)

Live secret chat messages follow a self-describing frame 0x06:

version0x06
flags1 byte bit0 = INITIAL (X3DH)
bodyLenuint16-BE
bodyJSON: envelope §8 + senderDeviceID

senderDeviceID - routing only (which session): the payload is authenticated by the Ratchet itself (AAD), so replacing the id does not give anything. The INITIAL flag marks the first (prekey) frame carrying the ephemeral public key of the initiator.

Trust and protection

  • Per-device TOFU: the remote device identity key is fixed upon first contact; shift → identityKeyChanged (MITM sign), the session is not established silently.
  • Fail-closed: if a DR session cannot be established to the recipient's device (no published set), secret/one-time message not sent in clear text - it is not sent at all.
  • Anti-DoS: no more than 5 prekey messages per minute per sender.
What remains to be said honestly?
  • Secret chats are 1:1; no group Double Ratchet is claimed.
  • Initial trust - TOFU without PKI: active MITM exactly at the moment of the first contact is possible; Safety numbers allow manual verification.
  • The protocol is open and covered with test vectors, but an independent external audit has not yet been carried out. Status - experimental.

14. Links