Skip to main content
Proteus is Wire’s implementation of the Signal double ratchet protocol. It provides end-to-end encryption for pairwise (1:1) sessions between two devices. Unlike MLS, Proteus does not define a group abstraction — a “group” in Proteus is simply a fan-out of independently encrypted pairwise envelopes.
Proteus support is behind the proteus Cargo feature flag. It must be explicitly enabled at compile time:
# Cargo.toml
core-crypto = { version = "...", features = ["proteus"] }
At runtime, the Proteus client must also be initialised before use:
let ctx = cc.new_transaction().await?;
ctx.proteus_init().await?;
ctx.finish().await?;

How Proteus works

Proteus is a double ratchet protocol derived from the Signal specification:
  1. Identity keypair — each device has a long-lived Curve25519 identity keypair stored in the keystore.
  2. PreKeys — a supply of short-lived one-time Curve25519 keypairs published to the server. When a peer wants to initiate a session, it fetches one of these prekeys and performs an X3DH (Extended Triple Diffie-Hellman) handshake.
  3. Session state — once initiated, the session maintains a ratchet chain. Every encrypted message advances the chain, producing a new message key and destroying the old one (forward secrecy).
  4. Last-resort prekey — a permanent prekey at ID u16::MAX (65535) used as a fallback when all one-time prekeys have been consumed.

CoreCrypto types

ProteusCentral

ProteusCentral (in crypto/src/proteus.rs) is the session manager for the Proteus protocol. It is the Proteus counterpart to the MLS Session.
pub struct ProteusCentral {
    proteus_identity: Arc<IdentityKeyPair>,
    proteus_sessions: GroupStore<ProteusConversationSession>,
}
Key difference from Session: ProteusCentral does not own its keystore. It borrows the Database from CoreCrypto on every operation. This means MLS and Proteus share the same encrypted database. ProteusCentral is stored inside CoreCrypto behind an Arc<Mutex<Option<ProteusCentral>>> and is only populated after TransactionContext::proteus_init() is called.

ProteusConversationSession

A ProteusConversationSession wraps a single proteus_wasm::session::Session and its string identifier:
pub struct ProteusConversationSession {
    pub(crate) identifier: SessionIdentifier,  // String
    pub(crate) session: Session<Arc<IdentityKeyPair>>,
}
Each pairwise channel between two devices is one ProteusConversationSession.

Session lifecycle

Initiating from a prekey (outbound first message)

// 1. Fetch the remote device's prekey bundle from the delivery service
let prekey_bundle: Vec<u8> = delivery_service.get_prekey(remote_device_id).await?;

// 2. Open a transaction and create the session
let ctx = cc.new_transaction().await?;
ctx.proteus_session_from_prekey(&session_id, &prekey_bundle).await?;
ctx.finish().await?;

// 3. Encrypt
let ctx = cc.new_transaction().await?;
let ciphertext = ctx.proteus_encrypt(&session_id, plaintext).await?;
ctx.finish().await?;

Receiving a first message (inbound handshake)

When the first message from a new peer arrives, the Proteus session does not yet exist locally. Use proteus_session_from_message to create the session and decrypt in one step:
let ctx = cc.new_transaction().await?;
let (session, plaintext) = ctx
    .proteus_session_from_message(&session_id, &envelope)
    .await?;
ctx.finish().await?;

Ongoing encrypt / decrypt

// Encrypt
let ctx = cc.new_transaction().await?;
let ciphertext = ctx.proteus_encrypt(&session_id, plaintext).await?;
ctx.finish().await?;

// Decrypt
let ctx = cc.new_transaction().await?;
let plaintext = ctx.proteus_decrypt(&session_id, &ciphertext).await?;
ctx.finish().await?;
Sessions are automatically persisted after every encrypt or decrypt operation.

Batch encrypt

To send the same plaintext to multiple devices (the common case in a Wire conversation), use the batch API to minimise FFI round-trips:
let ctx = cc.new_transaction().await?;
let map: HashMap<String, Vec<u8>> = ctx
    .proteus_encrypt_batched(&session_ids, plaintext)
    .await?;
ctx.finish().await?;

PreKey management

// Generate a specific prekey
let ctx = cc.new_transaction().await?;
let bundle: Vec<u8> = ctx.proteus_new_prekey(prekey_id).await?;
ctx.finish().await?;

// Auto-incrementing prekey (fills gaps first, then increments)
let ctx = cc.new_transaction().await?;
let (id, bundle) = ctx.proteus_new_prekey_auto().await?;
ctx.finish().await?;

// Last-resort prekey (ID = 65535)
let last_resort_id = CoreCrypto::proteus_last_resort_prekey_id(); // 65535
Auto-increment prekey IDs fill gaps first before incrementing to new IDs — if IDs 3, 7, and 12 are missing, the next three new_prekey_auto calls return 3, 7, and 12.

MLS vs Proteus — when to use which

Use MLS when...

  • You need true group messaging with a shared cryptographic state
  • Group membership changes frequently (add/remove members)
  • You require forward secrecy and post-compromise security guarantees defined by RFC 9420
  • You are building new features; MLS is the preferred protocol going forward
  • The conversation has more than two participants

Use Proteus when...

  • You need backwards compatibility with existing Wire clients that do not yet support MLS
  • You are dealing with a strictly 1:1 channel and want the lower protocol overhead of a simple double ratchet
  • You need to interoperate with Cryptobox-based (legacy Wire backend) sessions
  • A migration path is in progress and you need Proteus as a temporary fallback

Fingerprinting

Proteus provides hex-encoded key fingerprints useful for out-of-band verification:
// Identity fingerprint of this device
let fp = cc.proteus_fingerprint().await?;

// Fingerprint of a specific session's local identity
let fp_local = cc.proteus_fingerprint_local(&session_id).await?;

// Fingerprint of the remote peer in a session
let fp_remote = cc.proteus_fingerprint_remote(&session_id).await?;

// Fingerprint from a raw serialised prekey bundle
let fp_prekey = ProteusCentral::fingerprint_prekeybundle(&bundle)?;

Build docs developers (and LLMs) love