Skip to main content
E2EI enrollment is a stateful protocol. CoreCrypto tracks all intermediate values inside an E2eiEnrollment handle (exposed as RustyE2eIdentity in the lower-level crate). You must hold onto this handle for the entire enrollment session and pass it to the final completion step.
The enrollment steps must be performed in order. Each ACME request uses a replay-nonce from the previous response. Reusing or skipping nonces causes the ACME server to reject the request.

Prerequisites

Before starting enrollment:
  1. The PKI environment must be set up. Register the ACME root CA with e2ei_register_acme_ca and any intermediate CAs with e2ei_register_intermediate_ca_pem.
  2. You must know the client ID in Wire format (wireapp://<user-b64url>!<device-hex>@<domain>), the user’s display name, and the user’s Wire handle.
// Check if the PKI environment is ready.
let ready = transaction.e2ei_is_pki_env_setup().await;
assert!(ready, "register the ACME CA first");

Full enrollment flow

1

Create an enrollment handle

Call e2ei_new_enrollment on the transaction context. This generates the ACME account keypair and the MLS signing keypair internally.
let mut enrollment = transaction
    .e2ei_new_enrollment(
        client_id,           // ClientId — e.g. the Wire client URI bytes
        "Alice Smith".into(),  // display_name
        "[email protected]".into(), // handle
        Some("wire".into()),  // team (optional)
        90 * 24 * 3600,       // expiry_sec — certificate lifetime in seconds
        Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519,
    )
    .await?;
Parameters:
ParameterTypeDescription
client_idClientIdWire client identifier (user UUID + device hex + domain)
display_nameStringHuman-readable name shown in the app
handleStringWire user handle
teamOption<String>Wire team slug, if applicable
expiry_secu32Requested certificate lifetime in seconds
ciphersuiteCiphersuiteMLS ciphersuite determining the signature algorithm
TypeScript (WASM) and Kotlin/Swift (UniFFI) clients access the same logic through their respective FFI wrappers. The method names and parameter order match the Rust API.
2

Fetch the ACME directory

Make a GET request to the ACME directory endpoint and pass the JSON response body to acme_directory_response.
GET https://<acme-host>/acme/<provisioner>/directory
let directory_json = /* HTTP GET response body as serde_json::Value */;
let directory = enrollment.acme_directory_response(directory_json)?;
// directory.new_nonce, directory.new_account, directory.new_order are now populated
const directoryJson = await fetch(`${acmeHost}/acme/${provisioner}/directory`)
  .then(r => r.json());
const directory = enrollment.acmeDirectoryResponse(directoryJson);
3

Get the first nonce

Make a HEAD request to the new-nonce URL from the directory. The nonce is in the Replay-Nonce response header.
HEAD https://<acme-host>/acme/<provisioner>/new-nonce
let previous_nonce: String = /* Replay-Nonce header value */;
Every subsequent ACME request consumes the nonce from the previous response’s Replay-Nonce header. Keep track of the latest nonce after each call.
4

Create an ACME account

Build the signed account-creation request and POST it to new-account.
let account_request = enrollment.acme_new_account_request(&directory, previous_nonce)?;
// POST account_request body to directory.new_account
// Save the Replay-Nonce header from the response

let account_response_json = /* HTTP 201 response body */;
let account = enrollment.acme_new_account_response(account_response_json)?;
// previous_nonce = Replay-Nonce from response headers
const accountRequest = enrollment.acmeNewAccountRequest(directory, previousNonce);
const accountResponse = await fetch(directory.newAccount, {
  method: 'POST',
  headers: { 'Content-Type': 'application/jose+json' },
  body: JSON.stringify(accountRequest),
});
const account = enrollment.acmeNewAccountResponse(await accountResponse.json());
previousNonce = accountResponse.headers.get('Replay-Nonce');
5

Create a new order

Submit an order that specifies both the device identifier and the user identifier.
let order_request = enrollment.acme_new_order_request(
    "Alice Smith",                           // display_name
    "wireapp://[email protected]", // client_id (qualified Wire URI)
    "[email protected]",             // handle
    std::time::Duration::from_secs(90 * 24 * 3600), // expiry
    &directory,
    &account,
    previous_nonce,
)?;
// POST order_request to directory.new_order

let new_order_json = /* HTTP 201 response body */;
let new_order = enrollment.acme_new_order_response(new_order_json)?;
// new_order.authorizations contains two URLs: one device, one user
// previous_nonce = Replay-Nonce from response headers
The response contains two authorization URLs in new_order.authorizations. The order of the two authorization URLs is not guaranteed — you must check the identifier.type field of each authorization to determine which is wireapp-device and which is wireapp-user.
6

Fetch authorizations and challenges

Post to each authorization URL to retrieve the associated challenge.
for authz_url in &new_order.authorizations {
    let authz_request = enrollment.acme_new_authz_request(
        authz_url,
        &account,
        previous_nonce,
    )?;
    // POST authz_request to authz_url

    let authz_json = /* HTTP 200 response body */;
    let authz = enrollment.acme_new_authz_response(authz_json)?;
    // previous_nonce = Replay-Nonce from response headers

    match authz {
        E2eiAcmeAuthorization::Device { challenge, identifier } => {
            dpop_challenge = challenge;
        }
        E2eiAcmeAuthorization::User { challenge, keyauth, identifier } => {
            oidc_challenge = challenge;
            // keyauth must be included in the OIDC authorization request
        }
    }
}
The E2eiAcmeAuthorization::User variant also provides a keyauth string. You must pass this value and the challenge’s target URL to the OIDC authorization request so the IdP embeds them in the ID token.
7

Fetch a nonce from the Wire server

Before creating the DPoP token, fetch a fresh nonce from the Wire backend. This nonce prevents replay attacks against the access-token endpoint.
GET https://<wire-backend>/clients/<device-id>/nonce
let backend_nonce: String = /* response body (plain text) */;
val backendNonce = wireApi.getClientNonce(deviceId)
8

Create the DPoP token

Generate a DPoP JWT that binds the Wire backend nonce, the ACME challenge token, and the client identity.
let dpop_token: String = enrollment.new_dpop_token(
    "wireapp://[email protected]", // client_id (qualified)
    "Alice Smith",                              // display_name
    &dpop_challenge,                            // from acme_new_authz_response
    backend_nonce,                              // from Wire server
    "[email protected]",                // handle
    Some("wire".into()),                        // team
    std::time::Duration::from_secs(30),         // token expiry
)?;
const dpopToken = enrollment.newDpopToken(
  clientId,
  displayName,
  dpopChallenge,
  backendNonce,
  handle,
  team,
  30, // expiry in seconds
);
The DPoP token is a dpop+jwt signed with the ACME account keypair. Its aud claim is the ACME challenge URL and its htu is the Wire access-token endpoint.
9

Get an access token from the Wire server

Exchange the DPoP token for a Wire access token by posting to the access-token endpoint. Include the DPoP token in the DPoP request header.
POST https://<wire-backend>/clients/<device-id>/access-token
DPoP: <dpop_token>
let access_token: String = /* token field from the JSON response body */;
The access token is a at+jwt signed by a Wire backend key that the ACME server trusts. It embeds the original DPoP token as the proof claim.
10

Complete the DPoP challenge

Submit the access token to the ACME server to satisfy the wire-dpop-01 challenge.
let dpop_chall_request = enrollment.acme_dpop_challenge_request(
    access_token,
    &dpop_challenge,
    &account,
    previous_nonce,
)?;
// POST dpop_chall_request to dpop_challenge.url

let chall_response_json = /* HTTP 200 response body */;
enrollment.acme_new_challenge_response(chall_response_json)?;
// previous_nonce = Replay-Nonce from response headers
A successful response has "status": "valid" for the challenge.
11

Complete the OIDC challenge

Initiate an OIDC Authorization Code + PKCE flow with the organization’s identity provider. The authorization request must include the keyauth and acme_aud values from the earlier authorization step using the OIDC claims parameter.After the user authenticates, extract the ID token from the IdP’s token response and submit it to the ACME server.
let oidc_chall_request = enrollment.acme_oidc_challenge_request(
    id_token,          // ID token string from the IdP
    &oidc_challenge,   // from acme_new_authz_response
    &account,
    previous_nonce,
)?;
// POST oidc_chall_request to oidc_challenge.url

let chall_response_json = /* HTTP 200 response body */;
enrollment.acme_new_challenge_response(chall_response_json)?;
// previous_nonce = Replay-Nonce from response headers
let oidcChallRequest = try enrollment.acmeOidcChallengeRequest(
    idToken: idToken,
    oidcChallenge: oidcChallenge,
    account: account,
    previousNonce: previousNonce
)
let challResponse = try await acmeClient.post(oidcChallenge.url, body: oidcChallRequest)
try enrollment.acmeNewChallengeResponse(challResponse)
Both challenges must be completed before you check the order status. The ACME server validates them independently. Checking too early will return pending state even if one challenge is valid.
12

Check order status

Poll the order until its status transitions to ready.
let check_order_request = enrollment.acme_check_order_request(
    order_url,     // "location" header URL from the new-order response
    &account,
    previous_nonce,
)?;
// POST check_order_request to order_url

let order_json = /* HTTP 200 response body */;
let order = enrollment.acme_check_order_response(order_json)?;
// order.finalize_url is where you submit the CSR
// previous_nonce = Replay-Nonce from response headers

// order status should now be "ready"
13

Finalize the order (submit CSR)

Generate and submit a CSR to the ACME server’s finalize URL. CoreCrypto constructs the CSR automatically from the enrollment keypair.
let finalize_request = enrollment.acme_finalize_request(
    &order,
    &account,
    previous_nonce,
)?;
// POST finalize_request to order.finalize_url

let finalize_json = /* HTTP 200 response body */;
let finalize = enrollment.acme_finalize_response(finalize_json)?;
// finalize.certificate_url is where you fetch the certificate chain
// previous_nonce = Replay-Nonce from response headers
14

Fetch the certificate chain

Fetch the issued certificate chain from the URL provided in the finalize response.
let certificate_request = enrollment.acme_x509_certificate_request(
    finalize,
    account,
    previous_nonce,
)?;
// POST certificate_request to finalize.certificate_url

let certificate_pem: String = /* HTTP 200 response body (PEM certificate chain) */;
The response body is a PEM-encoded certificate chain (end-entity certificate followed by any intermediate certificates).
15

Complete enrollment

Pass the PEM certificate chain to CoreCrypto. Use e2ei_mls_init_only for fresh installs (the MLS client has not been initialized yet) or save_x509_credential for credential rotation on an existing client.Fresh install:
let (credential_ref, crl_distribution_points) = transaction
    .e2ei_mls_init_only(
        &mut enrollment,
        certificate_pem,
        transport,  // Arc<dyn MlsTransport>
    )
    .await?;
// The MLS session is now initialized with the X.509 credential.
// Register any new CRL distribution points from crl_distribution_points.
Credential rotation on an existing client:
let (credential_ref, crl_distribution_points) = transaction
    .save_x509_credential(&mut enrollment, certificate_pem)
    .await?;

// Apply the new credential to every conversation.
for conversation_id in my_conversations {
    let guard = transaction.conversation(&conversation_id).await?;
    guard.set_credential_by_ref(&credential_ref).await?;
}

// Generate fresh key packages and replace old ones in the backend.
transaction.generate_keypackage(/* ciphersuite, credential_type */).await?;

// Remove stale credentials.
transaction.remove_credential(old_credential_ref).await?;
After e2ei_mls_init_only or save_x509_credential returns, the enrollment handle is consumed. Do not reuse it. Start a new enrollment if you need to renew the certificate.

CRL distribution points

Both e2ei_mls_init_only and save_x509_credential return a set of CRL distribution point URLs found in the certificate chain. You should fetch each CRL and register it:
for dp_url in crl_distribution_points {
    let crl_der = /* HTTP GET dp_url — raw DER bytes */;
    let registration = transaction
        .e2ei_register_crl(dp_url.to_string(), crl_der)
        .await?;
    // registration.dirty is true if the CRL has changed since last registered
    // registration.expiration is a Unix timestamp for the CRL's next-update
}

Checking E2EI status

After enrollment, verify that E2EI is active for the client:
let enabled = transaction
    .e2ei_is_enabled(Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519)
    .await?;
assert!(enabled);
See E2EI overview for how to inspect the E2EI state of conversations and individual group members.

Build docs developers (and LLMs) love