Skip to main content
End-to-End Identity (E2EI) extends Wire’s MLS messaging layer with verifiable identity. Instead of relying on a self-asserted Basic credential (a raw keypair with no attestation), every client can prove who it is and which device it is by presenting an X.509 certificate issued by an ACME server. The certificate encodes two identifiers:
  • Device identity — the Wire client ID (user UUID + device hex ID + domain)
  • User identity — the Wire handle and display name
Because these identifiers are signed by a trusted CA, any participant in an MLS group can verify them without contacting a third-party service.
E2EI requires an ACME server that supports the custom wire-dpop-01 and wire-oidc-01 challenge types. The only production-ready implementation is Wire’s fork of step-ca. A standard ACME server such as Let’s Encrypt will not work.

Enrollment flow overview

Enrollment is a multi-step protocol that involves three parties: the Wire client, the ACME server (step-ca), and the Wire backend server plus an OIDC identity provider.
1

Bootstrap ACME session

The client fetches the ACME directory to discover endpoint URLs, requests a nonce, and creates an ACME account. No user-specific data is committed yet.
2

Create an order with Wire identifiers

The client posts a new order to the ACME server containing two identifiers: a wireapp-device identifier (client ID, handle, display name, domain) and a wireapp-user identifier (handle, display name, domain). The server responds with two authorization URLs.
3

Fetch authorizations and challenges

The client posts to each authorization URL. The server returns one challenge per authorization:
  • A wire-dpop-01 challenge for the device identifier
  • A wire-oidc-01 challenge for the user identifier
4

Complete the DPoP challenge

The client fetches a nonce from the Wire server (GET /clients/{id}/nonce), constructs a DPoP token that binds the ACME challenge token to the client keypair, and trades it at the Wire server’s access-token endpoint (POST /clients/{id}/access-token). The resulting access token is submitted to the ACME server to satisfy the wire-dpop-01 challenge.
5

Complete the OIDC challenge

The client initiates an OIDC Authorization Code + PKCE flow with the user’s identity provider. The IdP embeds keyauth and acme_aud claims in the returned ID token. The client extracts the ID token and submits it to the ACME server to satisfy the wire-oidc-01 challenge.
6

Finalize the order with a CSR

Once both challenges are validated, the client polls the order until it reaches ready state, then submits a CSR. The ACME server issues the certificate chain.
7

Register the certificate with CoreCrypto

The client passes the PEM certificate chain to e2ei_mls_init_only (fresh install) or save_x509_credential + e2ei_rotate (credential rotation). CoreCrypto stores the X.509 credential and replaces the Basic credential in all MLS groups.

Challenge types

wire-dpop-01

Device identity challenge. Proves that the enrolling device controls the Wire client keypair and that the client ID registered with the Wire backend matches the identifier in the order.The Wire server acts as an intermediary: it verifies the DPoP token and signs an access token with a key known to the ACME server. The ACME server then verifies both the outer access token and the inner DPoP proof.

wire-oidc-01

User identity challenge. Proves that the enrolling user authenticated with the organization’s identity provider and that the handle and display name in the IdP match the identifiers in the order.The OIDC flow uses PKCE and requires the IdP to copy keyauth and acme_aud claims into the returned ID token. The ACME server verifies the ID token signature and validates those claims.

DPoP tokens

A DPoP (Demonstrating Proof of Possession) token is a short-lived JWT that proves ownership of the ACME account keypair at the time of the request. The DPoP token for the wire-dpop-01 challenge includes:
ClaimDescription
subWire client URI (wireapp://<user>!<device>@<domain>)
audACME challenge URL
nonceNonce from the Wire server
chalACME challenge token
handleWire user handle URI
htuWire access-token endpoint URL
htmHTTP method (POST)
The token is signed by the ACME account key (Ed25519 by default). CoreCrypto generates this token via the new_dpop_token method on RustyE2eIdentity.

OIDC tokens

The OIDC ID token is issued by the user’s identity provider after successful authentication. For the wire-oidc-01 challenge it must contain two additional claims beyond the standard OIDC set:
ClaimDescription
keyauth<challenge-token>.<acme-account-key-thumbprint>
acme_audURL of the OIDC challenge endpoint on the ACME server
nameUser’s display name
preferred_usernameWire handle (e.g. [email protected])
The ACME server verifies the signature, validates keyauth and acme_aud, and ensures name and preferred_username match the values submitted in the order.

X.509 certificate structure

The certificate issued by step-ca encodes both identifiers as Subject Alternative Name (SAN) URIs:
Subject: O=wire.localhost, commonName=Alice Smith
Subject Alternative Names:
  URI: wireapp://[email protected]  (device)
  URI: wireapp://%[email protected]                              (user)
The certificate’s public key matches the MLS leaf node signing key, which is how the X.509 credential binds to the MLS group member.

Credential types

CoreCrypto supports two MLS credential types:
TypeDescriptionE2EI state
CredentialType::BasicA self-asserted keypair with no external attestation. Used before E2EI enrollment or when a backend does not enforce E2EI.NotEnabled
CredentialType::X509An X.509 certificate bound to a Wire client ID and user handle, issued by the organization’s step-ca instance.Verified (when valid)
A conversation can contain members with different credential types. The E2eiConversationState reflects the aggregate state.

E2eiConversationState

The E2eiConversationState enum describes the E2EI posture of an MLS conversation at a point in time. It is returned by get_credential_in_use and e2ei_verify_group_state.
pub enum E2eiConversationState {
    /// All members have a valid, non-expired X.509 certificate.
    Verified = 1,
    /// At least one member has a Basic credential or an expired certificate.
    NotVerified,
    /// All members are using Basic credentials.
    /// If all certificates are expired, NotVerified is returned instead.
    NotEnabled,
}
E2eiConversationState reflects the committed state of the group. Pending commits and pending proposals are not considered. A commit adding an unverified member will change the state only once the commit is applied.
StateMeaning
VerifiedEvery leaf node in the ratchet tree carries a valid X.509 credential. Safe to display a full verification badge.
NotVerifiedThe group is mixed: some members have X.509 credentials (possibly expired), others have Basic credentials. Display a partial/warning badge.
NotEnabledNo member has an X.509 credential. E2EI has not been deployed for this conversation.

WireIdentity and X509Identity

When you inspect the identity of a group member, CoreCrypto returns a WireIdentity struct:
pub struct WireIdentity {
    /// Unique client identifier, e.g. `T4Coy4vdRzianwfOgXpn6A:[email protected]`
    pub client_id: String,
    /// MLS credential thumbprint
    pub thumbprint: String,
    /// Certificate validity status at the time of inspection
    pub status: DeviceStatus,
    /// Whether this is a Basic or X509 credential
    pub credential_type: CredentialType,
    /// Populated only when credential_type is X509
    pub x509_identity: Option<X509Identity>,
}
For X.509 credentials, x509_identity is populated with:
pub struct X509Identity {
    /// Wire user handle, e.g. `john_wire`
    pub handle: String,
    /// Display name as shown in the app, e.g. `John Fitzgerald Kennedy`
    pub display_name: String,
    /// DNS domain, e.g. `whitehouse.gov`
    pub domain: String,
    /// PEM-encoded X.509 certificate
    pub certificate: String,
    /// Certificate serial number
    pub serial_number: String,
    /// Certificate validity start (Unix timestamp)
    pub not_before: u64,
    /// Certificate validity end (Unix timestamp)
    pub not_after: u64,
}

Checking conversation E2EI state

To check the E2EI state of a conversation you already belong to, use get_credential_in_use on the transaction context:
let state: E2eiConversationState = transaction
    .get_credential_in_use(group_info, CredentialType::X509)
    .await?;

match state {
    E2eiConversationState::Verified => println!("All members verified"),
    E2eiConversationState::NotVerified => println!("Mixed credentials"),
    E2eiConversationState::NotEnabled => println!("No E2EI in this conversation"),
}
To verify a group you are about to join (from a GroupInfo message), use e2ei_verify_group_state. This method applies hardened ratchet tree verification as a sender, so it detects tampered group state:
let state = transaction
    .e2ei_verify_group_state(verifiable_group_info)
    .await?;

Certificate rotation

When an existing client renews its X.509 certificate (e.g. before expiry or after a handle change), it uses save_x509_credential followed by credential rotation in each conversation. After calling save_x509_credential, the returned CredentialRef must be applied to every conversation via ConversationGuard::set_credential_by_ref, new key packages must be generated, and old credentials and key packages must be removed from the backend:
// 1. Complete enrollment (see Enrollment guide) and get the certificate chain.
let (credential_ref, crl_distribution_points) = transaction
    .save_x509_credential(&mut enrollment, certificate_chain_pem)
    .await?;

// 2. Rotate credentials in all conversations.
for conversation_id in my_conversations {
    let guard = transaction.conversation(&conversation_id).await?;
    guard.set_credential_by_ref(&credential_ref).await?;
}

// 3. Generate fresh key packages with the new credential.
transaction.generate_keypackage(/* ... */).await?;

// 4. Upload new key packages and remove old ones from the backend.
Do not call set_credential_by_ref before save_x509_credential returns successfully. If enrollment fails partway through, the old credential remains in use and no group state is modified.

Build docs developers (and LLMs) love