CoreCrypto
CoreCrypto is a simplified abstraction over the MLS and Proteus cryptographic protocols for end-to-end encrypted communication. In addition, it provides end-to-end identity (E2EI) support via X509 credentials.
This book describes both core concepts and some platform-specific implementation notes. For detailed API references for a given platform and release, see https://wireapp.github.io/core-crypto.
Proteus
Proteus is Wire’s implementation of the Signal Protocol: a double-ratchet scheme providing forward secrecy and break-in recovery for one-to-one encrypted sessions.
Proteus is intrinsically a direct protocol: every message is individually encrypted for each recipient. Groups in Proteus are a fiction managed collaboratively between the backend and the client; they do not exist at the protocol level. Encrypting a message for a group of n clients requires sending n individually encrypted copies. This effect is compounded by the fact that a logical user in the sense of a human is very likely to have multiple clients in terms of individual devices. This O(n²) overhead is manageable for small groups, but becomes expensive as they scale, and it was the primary motivation for adopting MLS.
Proteus remains supported for backwards compatibility, but has not received direct development work in some time. Its implementation is already feature-gated for easy removal, and is intended to be removed as soon as we can confirm that a sufficiency of clients have upgraded their conversations to MLS.
Initialization
Before any Proteus operation, the Proteus subsystem must be explicitly initialized within a transaction:
await ctx.proteusInit()
try await ctx.proteusInit()
ctx.proteusInit()
This loads the device’s Proteus identity from the keystore, creating it if it does not yet exist. All other Proteus
methods will return an error if called before proteusInit() has succeeded.
Identity and Fingerprints
A Proteus identity is a long-lived Ed25519 keypair tied to the device. CoreCrypto exposes several fingerprint accessors, each returning a hex string:
proteusFingerprint() — The local device’s public key. Remains stable for the lifetime of the keystore.
proteusFingerprintLocal(sessionId) — The local public key as seen from within a specific session. Equivalent to
proteusFingerprint() but scoped to a session for API symmetry.
proteusFingerprintRemote(sessionId) — The remote peer’s public key within a session. Can be used to verify the
peer’s identity out-of-band.
proteusFingerprintPrekeybundle(prekey) — Extracts the public key fingerprint from a serialized prekey bundle
without opening a session. Useful for verifying a peer’s identity before establishing a session.
Prekeys
Prekeys are the mechanism that allows a sender to open a Proteus session with a recipient who is offline. The recipient publishes a set of one-time prekey bundles to the delivery service in advance; the sender fetches one and uses it to bootstrap the session.
CoreCrypto provides two ways to generate prekeys:
proteusNewPrekey(id) — Generates a prekey with an explicit numeric ID (a u16). Use this when your app manages
the prekey ID space itself.
proteusNewPrekeyAuto() — Generates a prekey with an automatically incremented ID and returns both the ID and the
serialized bundle. Prefer this unless you have a specific reason to control IDs manually.
Both return a CBOR-serialized prekey bundle ready to upload to the delivery service.
The Last Resort Prekey
Proteus reserves prekey ID 65535 (u16::MAX) as a last resort prekey. Unlike one-time prekeys, the last resort prekey
is never consumed — it stays in the keystore indefinitely so that a sender can always open a session even when all
one-time prekeys have been exhausted.
const prekey = await ctx.proteusLastResortPrekey()
let prekey = try await ctx.proteusLastResortPrekey()
val prekey = ctx.proteusLastResortPrekey()
The constant proteusLastResortPrekeyId() returns 65535 and can be used to exclude this ID when generating ordinary
prekeys.
Sessions
A Proteus session represents an established encrypted channel with one remote client, identified by a caller-supplied
sessionId string. Session state is cached in memory and persisted to the keystore.
There are two ways to establish a new session, depending on which side initiates:
proteusSessionFromPrekey(sessionId, prekey) — Used by the sender to initiate a session. Takes the remote
client’s serialized prekey bundle (fetched from the delivery service) and creates a local session ready to encrypt.
proteusSessionFromMessage(sessionId, envelope) — Used by the recipient upon receiving the first message.
Decrypts the initial envelope, establishes the session, and returns the plaintext in a single step.
Once established, a session can be checked for existence with proteusSessionExists(sessionId) and explicitly removed
with proteusSessionDelete(sessionId). Manual saves via proteusSessionSave(sessionId) are available but not normally
required — sessions are persisted automatically when encrypting or decrypting.
Encrypting and Decrypting
With a session established, encryption and decryption are straightforward:
const ciphertext = await ctx.proteusEncrypt(sessionId, plaintext)
const plaintext = await ctx.proteusDecrypt(sessionId, ciphertext)
let ciphertext = try await ctx.proteusEncrypt(sessionId: sessionId, plaintext: plaintext)
let plaintext = try await ctx.proteusDecrypt(sessionId: sessionId, ciphertext: ciphertext)
val ciphertext = ctx.proteusEncrypt(sessionId, plaintext)
val plaintext = ctx.proteusDecrypt(sessionId, ciphertext)
Batched Encryption
Because a group message in Proteus requires one encrypted copy per recipient client, CoreCrypto provides a batched variant to reduce FFI round-trips:
const map = await ctx.proteusEncryptBatched(sessionIds, plaintext)
// map: { sessionId -> ciphertext }
let map = try await ctx.proteusEncryptBatched(sessions: sessionIds, plaintext: plaintext)
// map: { sessionId -> ciphertext }
val map = ctx.proteusEncryptBatched(sessionIds, plaintext)
// map: { sessionId -> ciphertext }
This is more efficient than calling proteusEncrypt in a loop and should be preferred whenever sending to multiple
sessions simultaneously.
Safe Decrypt
proteusDecryptSafe(sessionId, ciphertext) is a convenience wrapper that handles the common case where you do not know
in advance whether the session already exists. It opens the session from the message envelope if necessary, then
decrypts, in a single call. For high-volume decryption to an existing session, the plain proteusDecrypt call is
slightly more efficient.
Error Handling
Proteus errors are surfaced as a ProteusError with the following variants:
SessionNotFound — The requested session ID does not exist in the keystore or in-memory cache.
DuplicateMessage — The ciphertext has already been decrypted. The double-ratchet discards keys after use, so
replaying a message is always an error.
RemoteIdentityChanged — The remote peer’s identity key no longer matches what was recorded when the session was
established. This typically indicates a device reset or, in the worst case, a key compromise.
Other { error_code } — A lower-level Proteus error that does not map to one of the above categories. The numeric
error_code corresponds to the
proteus-traits error table.
MLS
RFC 9420, the Messaging Layer Security standard, solves the group-scaling problem that limits Proteus. Rather than maintaining an independent session per client pair, MLS establishes a single shared group secret from which all members independently derive the same encryption keys. Membership changes are managed through a ratchet tree — a binary tree structure that lets any member compute the new shared secret after an add or remove using only O(log n) key operations rather than O(n²) messages.
The fundamental unit of time in an MLS group is the epoch. Each epoch has a distinct shared secret; when membership changes (or when keys are rotated for other reasons), the group advances to a new epoch and new keys are computed. The two-phase proposal / commit model makes this safe in asynchronous networks: proposals accumulate from multiple senders, and a single commit bundles them into an atomic epoch transition.
Cryptographic Properties
Security Guarantees
MLS provides two complementary security properties that together defend against both historical and future exposure of messages.
Forward Secrecy
Because each epoch derives a distinct shared secret and prior epoch secrets are deleted after use, compromise of the current epoch does not expose messages from any prior epoch. An attacker who obtains today’s keys learns nothing about yesterday’s conversations.
Post-Compromise Security
Forward secrecy protects the past; post-compromise security (PCS) protects the future. If an attacker obtains a member’s key material — for example by compromising a device — they gain access to the current epoch. MLS limits this exposure: once any member contributes a commit that includes a fresh path secret (an Update proposal, or any commit authored by the compromised member), new randomness is injected into the key schedule. The attacker’s copy of the old keys cannot be used to derive the new epoch’s secrets, so the group heals automatically as conversation continues.
Together, these guarantees mean that an MLS group is resilient over time: past messages remain private even after compromise, and future messages recover privacy as soon as keys are rotated.
Efficiency
While changes in the group structure require O(log n) key operations, application messages are more efficient. Because each group has a shared group state governing its current shared secret, application messages encrypt in O(1). A significant improvement over Proteus!
Initialization
Before any MLS operation, MLS must be explicitly initialized within a transaction:
await ctx.mlsInit(clientId, transport)
try await ctx.mlsInit(clientId: clientId, transport: transport)
ctx.mlsInit(clientId, transport)
Client Identity
ClientId is an opaque byte array. CoreCrypto treats it as an identifier and does not inspect or validate its contents.
Wire’s convention is to encode the client ID as a UTF-8 string of the form userId:clientId@domain — for example
alice_user_id:device1@wire.com — but this is a Wire convention, not a CoreCrypto requirement.
MlsTransport
CoreCrypto intentionally abstains from communication with the Wire backend; instead, the caller provides an implementation of this interface that CoreCrypto calls on demand.
sendCommitBundle(commitBundle) — Called whenever a commit is produced — for example when creating a conversation,
adding or removing members, or rotating keys. The commitBundle contains the commit message, a welcome message for any
newly added members, and the updated group info. All three should be forwarded to the delivery service in a single
request.
The implementor of sendCommitBundle may throw an MlsTransportError indicating the reason for a rejected message.
prepareForTransport(historySecret) — Called before a history secret is transmitted to a new history client. The
implementation should package the secret in the application’s transport format (e.g., JSON or Protobuf) and return the
serialized bytes. CoreCrypto will then encrypt and send those bytes as an application message.
Observers
CoreCrypto provides two optional observer interfaces that allow the application to react to significant MLS events
without polling. Both are registered on the CoreCrypto instance directly (not on a transaction) and persist for the
lifetime of the session.
EpochObserver
registerEpochObserver(observer) registers a callback that fires whenever a conversation advances to a new epoch — that
is, whenever a commit is merged. The callback receives the conversationId and the new epoch number.
Note
The
epochChangedcallback must return promptly. CoreCrypto holds internal locks while dispatching it. If your observer needs to do significant work, dispatch it to a background task rather than doing it inline.
HistoryObserver
registerHistoryObserver(observer) registers a callback that fires when a new history client has been created and
accepted by the delivery service. The callback receives the conversationId and a HistorySecret — an opaque bundle of
key material that the application should forward to the history client so it can decrypt past messages.
The history client feature is Wire’s mechanism for allowing newly added devices to read conversation history; the
HistoryObserver is the hook through which the application participates in that handoff.
Credentials and Key Packages
Before a device can join or create MLS groups, it needs a credential — a proof of identity that other group members can verify. CoreCrypto supports two credential types:
Basic — A locally generated keypair with no external attestation. Suitable whenever end-to-end identity
verification is not required. This is the default.
X509 — A certificate chain obtained through the E2EI enrollment process, binding the device’s MLS identity to a
verified Wire user identity. Required for deployments with end-to-end identity enabled.
Credentials are created and stored via addCredential() on the transaction, which returns a CredentialRef — a short
handle used to refer to the credential in subsequent operations.
Key packages — MLS-level advertisements of a device’s identity and capabilities. Other devices fetch a target
device’s key package from the delivery service when adding it to a group. Generate them with
generateKeypackage(credentialRef) inside a transaction.
Cipher Suites
An MLS cipher suite is a composite designation which specifies the KEM, AEAD, hash, and signature algorithms. CoreCrypto formally supports any of the cipher suites specified in the RFC, but is primarily tested on cipher suite 1 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519).
The cipher suite is fixed per conversation at the conversation’s creation.
The Database
The Database is CoreCrypto’s keystore: the persistent store for all key material and cryptographic state, as well as
some non-cryptographic internal data. It is opened before constructing a CoreCrypto instance and passed in as an
argument, making the storage layer an explicit dependency rather than something CoreCrypto manages internally.
What the Database Stores
The database holds all state that must survive process restarts:
- MLS group state — the serialized ratchet tree and secrets for every conversation the device participates in
- Credentials and key packages — the device’s identity material and the key packages it has published to the delivery service
- Pending messages — application messages and commits that arrived during an epoch transition and are waiting to be merged
- Proteus identity and sessions — the device’s long-lived Proteus identity keypair and the state of every active Proteus session
- Proteus prekeys — including the last-resort prekey
- E2EI state — data about the ACME certificate authority, various certificates and CRLs, refresh tokens, and the like
All entries are encrypted at rest using the DatabaseKey provided at open time.
Backing Storage
On non-browser platforms the database is backed by SQLCipher, an encrypted SQLite variant. On the browser, the database sits atop IndexedDB and uses item-level encryption managed by CoreCrypto. In both cases, data is encrypted at rest.
Future work on the database is intended to unify the backing storages, such that CoreCrypto will internally see an encrypted SQLite connection for all platforms. That work will be part of CC 11, but not CC 10. This change should be invisible to clients.
The Database Key
The DatabaseKey is a 32-byte symmetric key used to encrypt all data at rest. Construct it from a byte array:
const key = DatabaseKey.new(bytes) // bytes must be exactly 32 bytes
let key = DatabaseKey(bytes: bytes) // bytes must be exactly 32 bytes
val key = DatabaseKey(bytes) // bytes must be exactly 32 bytes
CoreCrypto does not generate or store the key — that is entirely the client’s responsibility. The key should be kept in the platform’s secure credential storage.
Note
The database key is never written to disk by CoreCrypto. If the key is lost, the database contents are unrecoverable.
Opening the Database
All platforms define an async Database.open static method accepting a location and key. On platforms with an OS, the
location is a filesystem path. On WASM, the location is the IndexedDB store name.
All platforms define an async static method to open a transient database in memory. On WASM, this is
Database.inMemory. For Kotlin and Swift, this is an overload of Database.open.
Key Rotation
The database can be re-encrypted with a new key without closing and reopening it:
await db.updateKey(newKey)
try await db.updateKey(key: newKey)
db.updateKey(newKey)
This re-encrypts all stored data in place.
Lifetime and Closing
On browser platforms, explicitly close the database when tearing down:
await db.close()
Single-Transaction Constraint
Only one transaction may write to the database at a time. If a transaction is requested while another transaction is in flight, the call will block until the first transaction concludes.
For more detail, see Transactions → Concurrency.
Exporting a Copy
On native platforms, a fully vacuumed copy of the database can be written to a new file:
try await exportDatabaseCopy(database: db, destinationPath: destinationPath)
exportDatabaseCopy(db, destinationPath)
The copy is encrypted with the same key as the source. This is useful for backup or for migrating the database to a new location. This function is not available on browser platforms.
The Client
In MLS terminology a client is a single device acting on behalf of a user — each phone, desktop, or web session that
participates in MLS groups is a distinct client with its own identity and key material. CoreCrypto maps this concept to
the CoreCrypto object itself. One CoreCrypto instance, backed by one Database, represents one MLS client.
Note
The Rust library internally calls this type
Sessionto avoid ambiguity — “client” means something different at the Wire application layer (where it refers to a registered device in the Wire backend sense) and at the MLS protocol layer. The nameSessiondoes not appear in platform bindings; from Swift, Kotlin, and TypeScript, the type is alwaysCoreCrypto.
Sharing a CoreCrypto Instance
CoreCrypto is cheap to clone. All internal state — the database connection, the MLS session, the Proteus state — is
reference-counted, so cloning the object creates a new handle to the same shared state rather than copying anything.
This means CoreCrypto can be freely passed to background tasks or across threads without wrapping it in an additional
mutex or reference type.
Read-Only Operations
Most MLS work happens inside a transaction (see Transactions and the TransactionContext), but a
handful of read-only operations are available directly on CoreCrypto without opening one. The set of read-only
operations available on CoreCrypto is intentionally kept narrow.
Any operation that might change state — creating or deleting a conversation, adding members, encrypting or decrypting a message — requires a transaction.
Lifecycle
A CoreCrypto instance is typically created once at application startup and kept alive for the life of the process. The
initialization sequence is:
const db = await Database.open(path, key); // open the keystore
const cc = CoreCrypto.new(db); // construct CoreCrypto
await cc.transaction(async (ctx) => {
await ctx.mlsInit(clientId, transport); // initialize the MLS session
});
let db = try await Database.open(location: path, key: key) // open the keystore
let cc = CoreCrypto(database: db) // construct CoreCrypto
try await cc.transaction { ctx in
try await ctx.mlsInit(clientId: clientId, transport: transport) // initialize the MLS session
}
val db = Database.open(path, key) // open the keystore
val cc = CoreCrypto(db) // construct CoreCrypto
cc.transaction { ctx ->
ctx.mlsInit(clientId, transport) // initialize the MLS session
}
After mlsInit() is committed, the instance is ready to use.
Multiple Instances
Only one CoreCrypto instance (and its clones) should be active against a given database file at a time. The iOS client
has a file lock which prevents two instances of the CoreCrypto from interfering with each other. On other clients
opening the same database from two independent CoreCrypto instances — whether in the same process or different
processes — is not supported and will produce undefined behavior.
Transactions and the TransactionContext
Every operation in CoreCrypto that can potentially mutate state must happen inside a transaction. Transactions provide an atomicity guarantee: either all of the operations in a transaction are persisted together, or none of them are. This prevents the keystore from being left in an inconsistent state if an operation fails partway through — for example, if a commit is produced but the delivery service rejects it before the local group state is updated.
Opening a Transaction
Transactions are opened with a callback pattern. Call transaction() on the CoreCrypto instance and pass an async
function; CoreCrypto calls that function with a CoreCryptoContext (the transaction handle), and commits or rolls back
the transaction automatically based on whether the function succeeds or throws:
await cc.transaction(async (ctx) => {
await ctx.mlsInit(clientId, transport);
// more operations...
});
try await cc.transaction { ctx in
try await ctx.mlsInit(clientId: clientId, transport: transport)
// more operations...
}
cc.transaction { ctx ->
ctx.mlsInit(clientId, transport)
// more operations...
}
If the callback completes without throwing, the transaction is committed — all buffered operations are written to the keystore in a single atomic database transaction. If the callback throws, the transaction is rolled back and no changes are persisted.
There is no explicit finish() or abort() to call; both are handled automatically by transaction().
The CoreCryptoContext
The ctx parameter is the CoreCryptoContext: the object through which all mutating operations are performed. It is
only valid for the lifetime of the callback — using it after the callback returns will produce an
InvalidTransactionContext error.
All operations are buffered in memory inside the context. Nothing is written to the database until the callback returns successfully. This means reads within the same transaction will observe the in-memory state, not the on-disk state — if you create a conversation and immediately query it within the same transaction, the query will find it.
Concurrency
Only one transaction may be active at a time. Calling cc.transaction() while another transaction is already running
will block until the first transaction finishes. This is a consequence of the single-writer constraint in the keystore;
see the Database chapter for more detail.
The practical implication is that large batches of work — for example, decrypting a backlog of incoming messages — will block any concurrent attempt to encrypt and send a new message. During an initial sync, this is desirable, because there are notable performance improvements to performing a large number of operations in a single transaction. On the other hand, once a client is fully synced and active, the opposite advice applies: because they are blocking, it is advisable to structure transactions to be as short-lived as possible.
Error Handling
If an operation inside a transaction fails, it is usually best to let the error propagate out of the callback.
transaction() will catch it, roll back automatically, and rethrow — the caller can then handle the error without
worrying about cleanup:
try {
await cc.transaction(async (ctx) => {
await ctx.decryptMessage(conversationId, incomingCiphertext);
await ctx.encryptMessage(conversationId, outgoingPlaintext);
});
} catch (err) {
// The transaction was rolled back. No state was changed.
}
do {
try await cc.transaction { ctx in
try await ctx.decryptMessage(conversationId: conversationId, payload: incomingCiphertext)
try await ctx.encryptMessage(conversationId: conversationId, message: outgoingPlaintext)
}
} catch {
// The transaction was rolled back. No state was changed.
}
try {
cc.transaction { ctx ->
ctx.decryptMessage(conversationId, incomingCiphertext)
ctx.encryptMessage(conversationId, outgoingPlaintext)
}
} catch (e: Exception) {
// The transaction was rolled back. No state was changed.
}
Retry After Delivery Failure
Some operations — those that produce a commit — invoke MlsTransport.sendCommitBundle() inside the transaction. If the
delivery service returns Retry, CoreCrypto propagates this as an MlsError and the transaction is rolled back. The
caller should fetch and process all pending incoming messages and then retry the operation in a new transaction.
Reuse After Completion
Once transaction() returns (whether by success or failure), the CoreCryptoContext passed to the callback is
permanently invalidated. Any attempt to call methods on a finished context returns InvalidTransactionContext. Always
perform all work within the callback scope.
Arbitrary Data Storage
The context provides two methods for storing a single blob of arbitrary bytes in the keystore, associated with the device:
await ctx.setData(bytes)
const bytes = await ctx.getData()
try await ctx.setData(data: bytes)
let bytes = try await ctx.getData()
ctx.setData(bytes)
val bytes = ctx.getData()
These were implemented for the purpose of checkpointing during initial sync / batch decryption, but are not limited to that use.
What’s New in CC 10
New APIs
With CC10 we introduce multiple new types that provide their own new API.
Database API
-
Database.getLocation()allows getting the location of a persistent database instance. It returnsnullif the database is in-memory. -
Typescript Only: Added
Database.close()and removedCoreCrypto.close(). ADatabaseshould be closed if it is not used anymore. Closing a database makes anyPkiEnvironmentorCoreCryptoinstance unusable. Calls to these instances will return aCoreCryptoError.Other.CoreCryptoinstances do not need to be closed anymore.
PKI Environment API
- Added
PkiEnvironmentconstructed viacreatePkiEnvironment(database: Database, hooks: PkiEnvironmentHooks) - Added
PkiEnvironmentHooksinterface which has to be implemented by a client and will be used by CoreCrypto during e2ei flow - Added
CoreCrypto.setPkiEnvironment()to set a PkiEnvironment on aCoreCryptoinstance - Added
CoreCrypto.getPkiEnvironment()to get the PkiEnvironment of aCoreCryptoinstance
Credential API
Credentialis a first-class type representing a cryptographic identity.- It can be created at any time and lives in memory.
- There are two variants of credential: basic and x509. Basic credentials are created with
Credential.basicstatic method. X509 credentials are obtained through the acquisition flow described in the X509 Credential Acquisition section of the migration guide.
- Initializing an MLS client no longer automatically generates any credentials. Any stored credentials will be automatically loaded on MLS init.
- To add a credential to the set MLS knows about, after initializing MLS, call
addCredentialon a transaction context.- This adds it to the working set, and stores it to the database.
- Due to limitations inherent in the current implementation, credentials added to a client must currently be distinct
on the
(credential type / signature scheme / unix timestamp of creation)tuple.- The time resolution is limited to 1 second.
- If you have need of multiple credentials for a given signature scheme and credential type, just wait 1 full second between adding each of them.
- We expect this limitation to be relaxed in the future.
- This also returns a more lightweight
CredentialRefwhich can be used elsewhere in the credential API, uniquely referring to a single credential which has already been added to that client.
CredentialRefis a means of uniquely referring to a single credential without transferring the actual credential data back and forth across FFI all the time.- Each credential ref is aware of basic information about the credential it references:
- client id
- public key
- credential type
- signature scheme
- earliest validity
- Each credential ref is aware of basic information about the credential it references:
- To remove a credential from the set MLS knows about, call
removeCredentialon a transaction context, handing it the appropriateCredentialRef.- Ensures the credential is not currently in use by any conversation.
- Removes all key packages generated from this credential.
- Removes the credential from the current working set and also from the keystore.
- Added a new method to transaction context:
getCredentials, which produces aCredentialReffor each credential known by this client. - Added a new method to transaction context:
findCredentials, which produces aCredentialReffor each credential known by this client, efficiently filtering them by the specified criteria.
Note
CC v10.0 introduces lots of changes. We provide a migration guide.
Migrating from v9.x to v10.0
This page covers breaking changes that are identical across all platforms. For platform-specific migration steps, see the sub-pages:
MLS Initialization
-
mlsInit()was decoupled from key package creation. To create key packages after initializing MLS, callCoreCryptoContext.generateKeyPackage()in a transaction. -
Removed
CoreCrypto.provideTransport(), addedtransportparameter toCoreCryptoContext.mlsInit(). Instead of providing transport separately from MLS initialization, provide it when callingmlsInit().
Key Packages
-
We removed
CoreCryptoContext.clientKeypackages(). To generate a desired amount of key packages, make repeated calls toCoreCryptoContext.generateKeyPackage(). -
We removed
CoreCryptoContext.clientValidKeypackagesCount(). To count remaining key packages, callCoreCryptoContext.getKeyPackages(), filter the results as desired, and count the remaining items. -
We aligned key package spelling to
KeyPackage:- renamed
Keypackage→KeyPackage - renamed
KeypackageRef→KeyPackageRef - renamed
generateKeypackage→generateKeyPackage - renamed
getKeypackages→getKeyPackages - renamed
removeKeypackage→removeKeyPackage - renamed
removeKeypackagesFor→removeKeyPackagesFor
- renamed
Cipher Suites
-
We aligned cipher suite spelling to
CipherSuite:- renamed
Ciphersuite→CipherSuite - renamed
ciphersuiteFromU16→cipherSuiteFromU16 - renamed
ciphersuiteDefault→cipherSuiteDefault - renamed
conversationCiphersuite→conversationCipherSuite - renamed any parameters and fields
ciphersuite→cipherSuite
- renamed
Higher-Level Newtypes
-
GroupInfo.new()andWelcome.new()are now fallible constructors. Previously, both accepted any byte sequence unconditionally. They now validate the input as a TLS-encoded MLS structure at construction time and throw if the bytes are malformed. -
We removed
GroupInfo.copyBytes()andWelcome.copyBytes(). The underlying types no longer store raw bytes and cannot be round-tripped back to a byte array. -
Added
Welcome::serialize(). We had test functions which required the serialized bytes given aWelcomeinstance, so we added the ability to recreate those bytes. -
GroupInfoandWelcomeno longer support equality comparisons, hashing, or hex string display in generated bindings. -
exportSecretKey()now returns aSecretKeyobject instead of a byte array. To access the raw bytes, callsecretKey.copyBytes(). -
createConversation()now takes a parsedExternalSenderobject instead of raw bytes. Parse the external sender ahead of time withExternalSender.parseJwk()for the JWK form,ExternalSender.parsePublicKey()for the legacy raw public-key form, orExternalSender.parse()to try both in turn. Parse errors are reported at parse time rather than during conversation creation. CallexternalSender.serialize()to recover the raw bytes when needed.
X509 Credential Acquisition
The enrollment API that previously drove the ACME and OIDC exchanges step-by-step from the client has been removed.
The whole ACME / DPoP / OIDC sequence is now hidden behind a single object, X509CredentialAcquisition, which the
client constructs once and then calls finalize() on to obtain a Credential. CoreCrypto reaches back into the client
only via well-defined hook points; the client no longer threads nonces, account responses, order requests, or challenge
payloads through its own code.
Where you previously called e2eiNewEnrollment, e2eiNewActivationEnrollment, e2eiNewRotateEnrollment,
directoryResponse, newAccountRequest/Response, newOrderRequest/Response, newAuthzRequest/Response,
createDpopToken, newDpopChallengeRequest/Response, newOidcChallengeRequest/Response,
checkOrderRequest/Response, finalizeRequest/Response, or certificateRequest — delete all of it. The new flow
replaces every one of those calls.
Acquiring an X509 credential
-
Implement
PkiEnvironmentHooks. CoreCrypto will call these hooks during acquisition:httpRequest— perform HTTP requests against the ACME server, CRL distributors, and similar.authenticate— drive the IdP authorization code flow with PKCE and return the resulting ID token.getBackendNonce— obtain a nonce from the Wire backend.fetchBackendAccessToken— exchange the DPoP token for a backend access token.
-
Create a
PkiEnvironmentwithPkiEnvironment.new(hooks, database)(TypeScript) orcreatePkiEnvironment(hooks, database)(Swift/Kotlin). TheDatabasecan be the same used by theCoreCryptoinstance or distinct; they use unrelated tables. That said, it is typically more convenient to use a single database. -
Build an
X509CredentialAcquisitionConfigurationdescribing the certificate you want:acmeUrl- the URL of the ACME servercipher_suite— must be one of the four with a JWS-compatible signature scheme: Ed25519, P256, P384, or P521. Other cipher suites will fail at construction.displayName,clientId,handle,domain, optionalteamvalidityPeriodSecs
-
Construct the acquisition:
X509CredentialAcquisition.new(pkiEnvironment, configuration). -
Call
await acquisition.finalize(). This drives the DPoP and OIDC challenges to completion, calling your hooks as needed, and returns the acquiredCredential. The acquisition can only be finalized once; a second call throws. -
Attach the credential to the client with
transactionContext.addCredential(credential), exactly as for a basic credential. This persists it to the internal database and returns aCredentialRef.
Pausing across the IdP redirect
The IdP authentication flow typically requires an external redirect, after which the app may have been suspended,
backgrounded, or restarted. To support this, the authenticate hook receives an acquisitionSnapshot: bytes parameter
capturing the acquisition state at the point the DPoP challenge has completed and OIDC is about to begin. Persist these
bytes to encrypted storage before launching the IdP flow.
When the app resumes and is ready to complete acquisition, reconstruct the acquisition from the snapshot with
X509CredentialAcquisition.fromBytes(pkiEnvironment, snapshot) and call finalize() on the reconstructed instance.
There is no client-visible serialization method on X509CredentialAcquisition itself; the snapshot bytes are delivered
to you exclusively through the authenticate hook’s acquisitionSnapshot parameter.
MlsTransport Interface
Instead of returning an MlsTransportResponse to communicate the reason why a message was rejected by the DS, throw an
MlsTransportError instead. MlsTransportResponse was removed.
No More Buffering of Unmerged Changes While Decrypting
During decryption, CoreCrypto would previously automatically replay previously executed but unmerged (i.e., not yet accepted by the delivery service) operations. This behavior has changed: the responsibility of replaying any unmerged operations is delegated to the consumer.
Other Changes
-
CoreCrypto.e2eiIsEnvSetup()can’t throw anymore and will always return a boolean. -
removed
.proteusFingerprintPrekeybundle()and.proteusLastResortPrekeyId()fromCoreCryptoContext. Both are available as static methods onCoreCrypto. -
removed
CoreCryptoContext.proteusReloadSessions(). Proteus sessions are now loaded on demand by the new per-session cache, so explicit reloads are no longer needed. Delete any call sites. -
GroupInfoBundle.payloadnow contains a byte array instead of a class instance.
Migrating to CC 10: TypeScript
See the common migration guide for changes that apply to all platforms.
Browser Module Location
We want to prepare the ground for a native module, which runs on node or bun. Therefore, the browser module is now
exported as @wireapp/core-crypto/browser. Update your imports:
// before
import { ... } from "@wireapp/core-crypto";
// after
import { ... } from "@wireapp/core-crypto/browser";
CoreCrypto Instantiation
-
The
CoreCryptoconstructor now takes aDatabaseinstance instead of aDatabaseKeyand a path. To instantiate a database, callopenDatabase(). -
Deferred init is now the only way to instantiate
CoreCrypto. We replacedCoreCrypto.init(database: Database)with the static functionCoreCrypto.new(database: Database). Instead of callingCoreCrypto.deferredInit(), callCoreCrypto.new(). As before, callmlsInit()in a transaction to initialize MLS.
Logging
-
We removed
CoreCrypto.setLogger(logger: CoreCryptoLogger, level: CoreCryptoLogLevel)andCoreCrypto.setMaxLogLevel(level: CoreCryptoLogLevel), as logging is configured globally and not tied to aCoreCryptoinstance. To set the log level, use the free functionsetMaxLogLevel(level: CoreCryptoLogLevel). -
We renamed
setLoggerOnly(logger: CoreCryptoLogger)tosetLogger(logger: CoreCryptoLogger).
Errors
Error Structure
We adjusted the TypeScript error structure. Whenever matching on errors using version >= 9.1.0 type guards, update
their usage as shown in the examples below.
Note
For more info, see the corresponding section of the ubrn docs
Extracting the abort reason given via throwing an MlsTransportError:
import { CoreCryptoError, MlsError } from "core-crypto";
try {
// send a commit that is rejected by the DS
} catch (err) {
if (CoreCryptoError.Mls.instanceOf(err) &&
MlsError.MessageRejected.instanceOf(err.inner.mlsError)) {
const rejectReason = err.inner.mlsError.inner.reason;
// other things you want to do with this error...
} else {
// log error
}
}
Optional: use switch to handle multiple errors in one go:
import { CoreCryptoError, MlsError, MlsError_Tags } from "core-crypto";
try {
// send a commit that is rejected by the DS
} catch (err) {
if (CoreCryptoError.Mls.instanceOf(err)) {
switch (err.inner.mlsError.tag) {
case MlsError_Tags.MessageRejected: {
const rejectedReason = err.inner.mlsError.inner.reason;
// other things you want to do with this error...
break;
}
// handle other mls errors
}
}
}
Catch a proteus error:
import { CoreCryptoError, ProteusError } from "core-crypto";
try {
// look for a proteus session that doesn't exist
} catch (err) {
if (CoreCryptoError.Proteus.instanceOf(err) &&
ProteusError.SessionNotFound.instanceOf(err.inner.exception)) {
let message = err.inner.exception.message;
// other things you want to do with this error...
} else {
// log error
}
}
Proteus Error Codes
The proteusErrorCode field was removed from the root error type. There is a deterministic mapping from error code to
error type. If you were using the error code, use the error type instead (see above). See
the proteus-traits error table for
the mapping.
Other
-
CoreCryptoContext.generateKeyPackage()now returns aKeyPackageinstance instead of anUint8Array. If you need the underlyingUint8Array, call theserializeproperty on theKeyPackage. -
CustomConfiguration.keyRotationSpannow defines milliseconds instead of seconds.
Migrating to CC 10: Swift
See the common migration guide for changes that apply to all platforms.
CoreCrypto Instantiation
-
The
CoreCryptoconstructor now takes aDatabaseinstance instead of aDatabaseKeyand a path. To instantiate a database, call theDatabase.new()static method. -
Deferred init is now the only way to instantiate
CoreCrypto. Instead of callingdeferredInit(), call theCoreCryptoconstructor. As before, callmlsInit()in a transaction to initialize MLS.
Higher-Level Newtypes
CoreCryptoContext.getExternalSender() now returns an ExternalSenderKey object instead of a byte array. To access the
raw bytes, call externalSenderKey.copyBytes().
Logging
-
We removed
CoreCrypto.setLogger(logger: CoreCryptoLogger, level: CoreCryptoLogLevel)andCoreCrypto.setMaxLogLevel(level: CoreCryptoLogLevel), as logging is configured globally and not tied to aCoreCryptoinstance. To set the log level, use the free functionsetMaxLogLevel(level: CoreCryptoLogLevel). -
We renamed
setLoggerOnly(logger: CoreCryptoLogger)tosetLogger(logger: CoreCryptoLogger).
Other
-
Removed
CoreCryptoFfi.reseedRng()andCoreCryptoFfi.randomBytes(). -
Removed the following static methods from
CoreCryptothat were globally available:version()buildMetadata()
Migrating to CC 10: Kotlin
See the common migration guide for changes that apply to all platforms.
Renames and Moves
-
CoreCryptoClienthas been renamed toCoreCrypto. -
historyClient(historySecret: HistorySecret)has been moved intoCoreCryptoCompanion functions. -
The entire read-only API is now exposed on the
CoreCryptotype, allowing data to be read without opening a transaction.
CoreCrypto Instantiation
-
The
CoreCryptoconstructor now takes aDatabaseinstance instead of aDatabaseKeyand a path. To instantiate a database, call theDatabase.new()static method. -
Deferred init is now the only way to instantiate
CoreCrypto. Instead of callingdeferredInit(), call theCoreCryptoconstructor. As before, callmlsInit()in a transaction to initialize MLS.
Higher-Level Newtypes
CoreCryptoContext.getExternalSender() now returns an ExternalSenderKey object instead of a byte array. To access the
raw bytes, call externalSenderKey.copyBytes().
Logging
-
We removed
CoreCrypto.setLogger(logger: CoreCryptoLogger, level: CoreCryptoLogLevel), as logging is configured globally and not tied to aCoreCryptoinstance. To set the log level, use the free functionsetMaxLogLevel(level: CoreCryptoLogLevel). -
We renamed
setLoggerOnly(logger: CoreCryptoLogger)tosetLogger(logger: CoreCryptoLogger).
Other
- Removed
CoreCryptoFfi.reseedRng()andCoreCryptoFfi.randomBytes().
CoreCrypto Architecture
block-beta
columns 1
block:system
Kotlin
Swift
TSN["TS Native"]
TSB["TS Browser"]
end
block:uniffi
UniFFI
UBRN
end
RUSTFFI["CoreCrypto FFI"]
block:crypto
CRYPTO["Crypto<br/>MLS • Proteus"]
E2EI["E2E Identity"]
end
STORAGE["Storage"]
block:database
Native["SQLite + SQLCipher"]
Browser["IndexedDB"]
end
classDef highlighted fill:#969,stroke:#333,stroke-width:3px;
class TSB highlighted
class Browser highlighted
CoreCrypto FFI
Allows other programming languages and platforms to embed and interact with CoreCrypto
- For iOS and Android, UniFFI is used to produce the relevant Kotlin and Swift bindings
- For Typescript we additionally use UBRN to create a WASM binary for browser and a native binary for native TS from our UniFFI bindings.
Data Types
| Rust | Swift | Kotlin | TypeScript |
|---|---|---|---|
bool | Bool | Boolean | boolean |
u8 | UInt8 | UByte | number |
u16 | UInt16 | UShort | number |
u32 | UInt32 | UInt | number |
u64 | UInt64 | ULong | bigint |
i8 | Int8 | Byte | number |
i16 | Int16 | Short | number |
i32 | Int32 | Int | number |
i64 | Int64 | Long | bigint |
f32 | Float | Float | number |
f64 | Double | Double | number |
String / &str | String | String | string |
std::time::SystemTime | Date | java.time.Instant | Date |
std::time::Duration | TimeInterval | java.time.Duration | number (in milliseconds) |
Option<T> | Optional<T> | Optional<T> | T? |
Vec<T> | Array<T> | List<T> | Array<T> |
HashMap<String, T> | Dictionary<String, T> | Map<String, T> | Record<string, T> |
() | nil | null | null |
Result<T, E> | func placeholder() throws E -> T | T placeholder() throws E | function placeholder(): T // @throws E |
For more information, see UniFFI Docs and UBRN Docs
CoreCrypto
CoreCrypto provides a unified abstraction layer over MLS and Proteus. It exposes a simplified interface built around application-level entities rather than protocol-specific concepts.
Database
Encrypted Keystore powered by SQLCipher on native platforms. WASM uses an IndexedDB-backed, encrypted store with AES256-GCM. It provides a persistent data storage layer with encryption at-rest.
Native (iOS, Android, Ts-Native)
Pretty much everything is handed off to SQLCipher:
- Backing store: Encrypted SQLite database (with SQLCipher)
- Encryption: AES256-CBC with per-page IV (provided by SQLCipher).
- Value-Level Encryption is also possible on the Commercial or Enterprise editions if we want additional security guarantees
- Crypto primitives provider: OpenSSL
- PRNG provider: OpenSSL
graph LR
RS[Rusqlite] --> S[SQLCipher]
SC{AES-256-CBC}
S -.->|Encrypts| SC
SC -.->|Decrypts| S
SC -->|Stores| SF[File]
See SQLCipher design
Summary:
- SQLCipher’s file page size by default is 4096 bytes
- When using a passphrase (our case), the provided passphrase is derived using PBKDF2-HMAC-SHA512.
The salt of this KDF is stored in the 16 first bytes of the file.- Note: This cannot be kept as-is on iOS as iOS needs to be able to read the first 16-32 bytes of SQLite databases to
“magically” guess they are SQLite databases
and to allow reading them from the background. This is very useful in the case of background work on iOS such as encrypted data in notifications needing access to the keystore.
- Note: This cannot be kept as-is on iOS as iOS needs to be able to read the first 16-32 bytes of SQLite databases to
“magically” guess they are SQLite databases
- Each page is encrypted or decrypted on-the-fly using AES256-CBC
- Provided by OpenSSL -
v1.1.1pas of 29/06/22- in our case, but the crypto provider can be changed to NSS, LibTomCrypt or Security.framework
- Provided by OpenSSL -
- Each page is written with a unique, random IV (initialization vector). This IV is regenerated on each page write. This IV is appended at the end of each page.
- Page ciphertexts are authenticated using an authentication tag using HMAC-SHA512. This tag is also appended at the each of the page.
WASM
- Backing store:
IndexedDB(with theidbcrate) - Encryption: AES256-GCM Value-Level-Encryption with random, non-reused 96-bits nonces and embedded authentication tag
(AAD) of the AEAD
- Caveat: Primary IDs are not encrypted, as this would compromise lookup and cause whole table
scans. It is thus not recommended to store sensitive or identifying data in the primary ID. - Caveat: Indexed searches do work, in two steps, an optimistic step fetching an unencrypted record,
and a fallback step iterating on all records, decrypting the targeted field and checking it. Worst case it will run a whole table scan.
- Caveat: Primary IDs are not encrypted, as this would compromise lookup and cause whole table
- Crypto primitives provider: RustCrypto -
aes-gcmcrate - PRNG provider:
randcrate withgetrandom(uses Crypto.getRandomValues under the hood)
graph LR
direction LR
B(Keystore Entities)
C{AES-256-GCM} -->|Stores| I[IndexedDB]
B -.->|Encrypts| C
C -.->|Decrypts| B
How the value-level encryption works
- Consumers of the library are required to provide 32 bytes, generated by a CSPRNG or a hardware RNG, to be used as an
AES-256 key
- Entities (i.e. Models in an ORM environment) dictate which fields are encrypted and with which AAD
through their implementation of theEntitytrait. - By default, the AAD is the primary ID of the IndexedDB collection (i.e Table in a SQL database environment)
- AES256-GCM is used to encrypt the aforementioned fields
- A random 96-bit (12 bytes) Nonce is generated
- The AAD is fetched through
Entity::aad() - Together they are fed to
aes-gcmto create a ciphertext with embedded authentication tag
- The ciphertext is then stored along with its nonce with the following data layout:
- Cleartext: A buffer of N bytes (
[u8; N]) - Ciphertext:
[12 bytes of nonce..., ...ciphertext]
- Cleartext: A buffer of N bytes (
- When decrypting, the stored nonce is picked apart from the ciphertext, the AAD is also fetched, then the cleartext is
decrypted and returned - Note: All the fields from all entities are zeroed on drop for security reasons
Rust Crypto Dependencies
Cryptographic primitives & spec implementations
| Primitive | Repository | Known audits |
|---|---|---|
| P256 | RustCrypto/p256 | N/A |
| P384 | RustCrypto/p384 | N/A |
| Curve25519 | dalek-cryptography/curve25519-dalek | N/A |
| Ed25519 | dalek-cryptography/ed25519-dalek | N/A |
| X25519 | dalek-cryptography/x25519-dalek | N/A |
| SHA2 | RustCrypto/sha2 | N/A |
| HMAC | RustCrypto/hmac | N/A |
| AES-GCM | RustCrypto/aes-gcm | Audit by NCC Group |
| ChaCha20Poly1305 | RustCrypto/chacha20poly1305 | Audit by NCC Group |
| HKDF | RustCrypto/hkdf | N/A |
| HPKE | rozbb/rust-hpke | No formal audits, but Cloudflare reviewed it |
CSPRNG
We use rand in combination with rand-chacha to achieve a proper CSPRNG
- No audits are known of this crate, but it is the de facto for the Rust ecosystem and is used by pretty much any crate needing a randomness source.
- Note: We use getrandom under the hood to retrieve random data. Other than de facto OS entropy sources, we can inject
entropy at will
- On this topic, the entropy sources per platform are detailed here
CI Builds via GNU make and GitHub Actions
Make-based actions & workflows
Our CI pipeline is heavily driven by make rules (in the root Makefile). To avoid duplicating logic, the
CI uses small GitHub composite actions that invoke specific make targets with consistent artifact caching via GitHub
artifatcs.
The directory .github/actions/make/ houses:
- A generic composite action (
.github/actions/make/action.yml) - Specialized wrappers in subdirectories which call the generic action
The workflow file .github/workflows/pipeline.yml orchestrates CI jobs, and uses
those make actions in steps.
Generic make action
This action produces an artifact which corresponds to a rule in the root Makefile, or downloads it if
any subsequent workflow run already produced the artifact with unchanged prerequisites.
File Hashes of Dependencies
The prerequisites of ceach artifact are defined in the Makefile itself. Each artifact that is produced
using this action, has a corresponding variable in the Makefile, called <target-name>-deps, which
lists the artifact’s dependencies.
The Makefile contains a wildcard rule that uses this variable to calculate an aggregated sha256sum of
all dependencies. This is then appended to the artifact key, identifying an artifact when downloading or uploading.
Artifact Caching
Each caller of the generic make action must provide an artifact key, required to identify an artifact. This is
necessary downloading a previously produced artifact or uploading the artifact just produced. The aggregated sha256sum
(see above) is then appended to that key to ensure artifacts with different dependencies don’t share a key.
In case an artifact was successfully downloaded, the action touches them, because otherwise the checked out source
files would be newer than the downloaded artifacts. This is necessary because another subsequent call of this actions
for another rule might require that the downloaded artifact is newer than the source files.
Otherwise, the artifact is produced by calling make <rule argument>.
In both cases (artifact was downloaded or produced), the artifact is uploaded only if it hasn’t been uploaded for this
workflow run of pipeline.yml. This is relevant because the make action may be
called with the same parameters multiple times in a single workflow run.
Specialized make actions
Since most artifacts (except those produced by a job representing a “leaf” in the make dependency tree) are reused
during the pipeline.yml workflow, the generic action is frequently called with
the same arguments. The parameters of the make action are the artifact key, the make rule, and the target path. To
avoid having to repeat the arguments when an artifact is reused, we’re using sepcialized make actions, located in
subfolders of .github/actions/make. These specialized actions are parameterless, except for the github token, which
they just forward. The github token is needed to use the gh CLI on the runner machine.
Any reused artifact should have such a specialized action. Whenever a new artifact is introduced in the workflow and it needs to be reused, an action should be added.
Manually publish release artifacts
If one of the publishing jobs fails due to some temporary error, it might be necessary to publish the release artifacts for a platform manually to avoid having to restart the whole release process.
Android / JVM (Kotlin)
Preparation
- Checkout the release tag
- Open the Core Libraries vault on 1Password and copy the secrets from
Maven Central Publishing
and
CoreCrypto Sonatype PGP Signing Key
export ORG_GRADLE_PROJECT_mavenCentralPassword <secret> export ORG_GRADLE_PROJECT_mavenCentralUsername <secret> export ORG_GRADLE_PROJECT_signingInMemoryKeyId <secret> export ORG_GRADLE_PROJECT_signingInMemoryKey <secret> export ORG_GRADLE_PROJECT_signingInMemoryKeyPassword <secret>
Android
- Download the android.zip from the release on https://github.com/wireapp/core-crypto/releases
- Extract the archive and copy the Android artifacts into the root of the core-crypto project
cp -r ~/downloads/android/* core-crypto - Publish the project
cd crypto-ffi/bindings ./gradlew android:publishAllPublicationsToMavenCentralRepository --no-configuration-cache
JVM
- Download the jvm.zip from the release on https://github.com/wireapp/core-crypto/releases
- Extract the archive and copy the JVM artifacts into the root of the core-crypto project
cp -r ~/downloads/jvm/* core-crypto - Publish the project
cd crypto-ffi/bindings ./gradlew jvm:publishAllPublicationsToMavenCentralRepository --no-configuration-cache
iOS (Swift)
iOS artifacts aren’t distributed through a centralized package manager. If the artifact has been uploaded to the GitHub release, it is considered to be published.
NPM (Typescript)
- Download the
wireapp-core-crypto-x.y.z.tgzfrom the release on https://github.com/wireapp/core-crypto/releases - Publish:
cd crypto-ffi/bindings/js bun publish ~/downloads/wireapp-core-crypto-x.y.z.tgz