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.
To generate a prekey, call proteusNewPrekeyAuto(). It picks the prekey ID automatically and returns both the ID
and 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 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.
Lifetimes
The lifetime for objects created by CoreCrypto vary slightly depending on the platform. Objects have a rust
counterpart, and the rust counterpart might hold on to resources like files or database connections. When exactly the
rust counterpart and its resources are freed depends on the platform.
Swift
When last reference to the object is dropped and deinit runs.
Kotlin
When the object is garbage collected or explicitly closed by calling close()
(Autocloseable).
More details: https://mozilla.github.io/uniffi-rs/latest/kotlin/lifetimes.html
TypeScript
When the object is garbage collected or explicitly destroyed by calling uniffiDestroy().
More Details: https://jhugman.github.io/uniffi-bindgen-react-native/idioms/gc.html#garbage-collected-objects-trigger-a-drop-call-into-rust
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.
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
- Data needed for X509 credentials — X509 trust anchors, intermediate and leaf certificates, CRLs 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 can also open a transient database in memory. In-memory databases are never encrypted, so they take no
key. In TypeScript, this is Database.inMemory(). For Kotlin and Swift, this is an overload of Database.open without
arguments.
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.
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.
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.
Operations are written to the database as they are performed, inside a real database transaction which is opened when the callback starts and committed when it returns. Nothing becomes visible to anyone else until that commit, but reads within the same transaction observe the writes that preceded them — if you create a conversation and immediately query it within the same transaction, the query will find it.
Reads performed outside the transaction — on CoreCrypto or Database rather than on ctx — also go through the
in-flight transaction while one exists, so they too observe its not-yet-committed writes. If the transaction is
subsequently rolled back, those writes are undone.
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.
}
Where Errors Surface
A keystore error is raised by the operation which caused it, not by transaction(). A uniqueness or foreign key
violation, for example, is thrown from the ctx call that attempted the offending write.
Such a rejected write does not poison the transaction. Nothing was written, but everything that already succeeded is still there and the transaction remains usable: if you catch the error inside the callback and carry on, the callback can still return successfully and the transaction commits the rest.
Rarely, SQLite rolls a transaction back on its own initiative. Every subsequent operation on that transaction then fails with an “unexpectedly rolled back” keystore error rather than potentially mutating the database directly without the opportunity for rollback.
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:
ctx.setData(bytes)
const bytes = await ctx.getData()
try 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.
Transaction Cancellation (Swift, Kotlin)
The foreign-language wrappers can associate a CoreCryptoCancellationToken with a transaction, i.e., this is invisible
to users of the wrappers. However, currently only Swift and Kotlin bindings support cancellable transactions. The
CoreCryptoCancellationToken is created through FFI but lives on the Rust side. Once the transaction owns the
transaction semaphore, Rust publishes the token in the CoreCrypto cancellation slot and, when a PKI environment
exists, in its separate slot. The returned guards keep the slots filled for exactly the lifetime of the transaction.
The following shows a transaction with a cancellable foreign callback, such as MlsTransport.sendCommitBundle() or a
PkiEnvironment hook. A transaction that does not invoke such a callback skips the callback portion of the sequence.
sequenceDiagram
autonumber
participant Foreign as Foreign language Wrapper
participant Tx as Rust transaction
participant Slots as Rust cancellation slots
participant Callback as Foreign callback
Foreign->>Foreign: create cancellation token
Foreign->>Tx: start cancellable transaction
Tx->>Tx: race cancellation against semaphore acquisition
alt Token is cancelled before acquisition
Foreign->>Tx: cancel token
Tx-->>Foreign: TransactionCanceled (slots remain empty)
else Transaction semaphore is acquired
Tx->>Slots: enter token in CoreCrypto slot
Slots-->>Tx: cancellation guard
opt PKI environment exists
Tx->>Slots: enter token in PKI slot
Slots-->>Tx: cancellation guard
end
Tx->>Foreign: execute command with context
Foreign->>Tx: context operation
Tx->>Slots: read active token
Slots-->>Tx: shared token
Tx->>Callback: invoke callback and race its result against cancellation
alt Command finishes first
Callback-->>Tx: callback result
Tx-->>Foreign: context operation result
Foreign-->>Tx: command result
alt Command succeeded
Tx->>Tx: commit
else Command failed
Tx->>Tx: abort
end
else Token is cancelled while callback is running
Foreign->>Tx: cancel token
Tx-xCallback: drop callback future, causing UniFFI to cancel the foreign task
Tx->>Tx: drop command future and abort
end
Tx->>Slots: drop guards and clear both slots
Tx->>Tx: drop context and release semaphore
Tx-->>Foreign: success, command error, or TransactionCanceled
end
The guards are deliberately created after the transaction context. Rust drops local values in reverse declaration order, so both slots are cleared before dropping the context that releases the semaphore. Consequently, the next transaction cannot observe the previous transaction’s token. If cancellation wins a callback race, dropping the Rust future also causes UniFFI’s generated foreign-language binding to cancel the task executing that callback.
What’s New in CC 11
CC 11 adds two new kinds of MLS message and a set of post-quantum cipher suites. It also includes various smaller changes.
Note
Some of these changes break existing code. We provide a migration guide.
Transient and Targeted Messages
Regular MLS application messages go to every member of a conversation and advance the group’s cryptographic state. That’s the right choice for chat messages, but it’s a poor fit for content that only matters to some members or only matters right now. CC 11 adds two new kinds of message for these cases. Neither of them changes the group’s cryptographic state.
Transient messages
encryptTransientMessage(conversationId, message) encrypts a message for all members of a conversation. Transient
messages are meant to go only to members who are online at the time they are sent.
Targeted messages
encryptTargetedMessage(conversationId, recipient, policy, message) encrypts a message for a single member of a
conversation, identified by their ClientId. This is what makes read receipts possible in MLS conversations: sending
each receipt as a regular MLS message would flood every member with messages that only matter to one of them.
The policy argument tells the delivery service how to handle the message:
TargetedMessagePolicy.Transient: delivered only to the recipient if they are online, and dropped otherwise.TargetedMessagePolicy.Persisted: may be stored by the delivery service, so a recipient who is offline still gets it later.
Receiving
There is no new decryption API. decryptMessage handles all of these messages and returns them as the new
DecryptedMessage.Transient, DecryptedMessage.TransientTargeted and DecryptedMessage.PersistedTargeted variants.
Each one carries the plaintext, the sender’s client id and the sender’s identity, just like
DecryptedMessage.ApplicationMessage. The migration guide shows how to match on
them.
Warning
Transient and targeted messages have weaker security guarantees than regular MLS application messages. Any feature built on them must explain why that is acceptable for its use case, or how the difference is mitigated.
Post-Quantum Cipher Suites
CoreCrypto now supports 11 post-quantum cipher suites, as defined in the MLS post-quantum cipher suites draft.
Six of them use a hybrid key exchange, combining ML-KEM with a classical algorithm:
MLS_128_MLKEM768X25519_AES128GCM_SHA256_Ed25519MLS_128_MLKEM768X25519_AES256GCM_SHA384_Ed25519MLS_128_MLKEM768P256_AES128GCM_SHA256_P256MLS_128_MLKEM768P256_AES256GCM_SHA384_P256MLS_128_MLKEM768X25519_CHACHA20POLY1305_SHA384_MLDSA44MLS_192_MLKEM1024P384_AES256GCM_SHA384_P384
The other five use a pure post-quantum key exchange:
MLS_128_MLKEM768_AES256GCM_SHA384_Ed25519MLS_128_MLKEM768_AES256GCM_SHA384_P256MLS_192_MLKEM1024_AES256GCM_SHA384_P384MLS_192_MLKEM768_AES256GCM_SHA384_MLDSA65MLS_256_MLKEM1024_AES256GCM_SHA384_MLDSA87
Only the three suites ending in MLDSA* also use post-quantum signatures. The others keep a classical signature scheme.
X509 credentials don’t work yet with post-quantum signatures, so the MLDSA* suites only support basic credentials.
Migrating from v10 to v11
This page covers changes that apply to all platforms. Some changes only affect one platform; they are covered on the TypeScript, Swift and Kotlin pages.
DecryptedMessage
DecryptedMessage.Text and BufferedDecryptedMessage.Text have been renamed to DecryptedMessage.ApplicationMessage
and BufferedDecryptedMessage.ApplicationMessage, respectively. Both enums now also include Transient,
TransientTargeted and PersistedTargeted variants. Match on all DecryptedMessage variants before accessing their
data:
if (DecryptedMessage.ApplicationMessage.instanceOf(decryptedMessage)) {
const { plaintext, senderClientId, identity } = decryptedMessage.inner;
// Handle the application message.
} else if (DecryptedMessage.Commit.instanceOf(decryptedMessage)) {
const { isActive, bufferedMessages, identity } = decryptedMessage.inner;
// Handle the commit.
} else if (DecryptedMessage.Proposal.instanceOf(decryptedMessage)) {
const { delay, identity } = decryptedMessage.inner;
// Handle the proposal.
} else if (DecryptedMessage.Transient.instanceOf(decryptedMessage)) {
const { plaintext, senderClientId, identity } = decryptedMessage.inner;
// Handle the transient message.
} else if (DecryptedMessage.TransientTargeted.instanceOf(decryptedMessage)) {
const { plaintext, senderClientId, identity } = decryptedMessage.inner;
// Handle the transient targeted message.
} else if (DecryptedMessage.PersistedTargeted.instanceOf(decryptedMessage)) {
const { plaintext, senderClientId, identity } = decryptedMessage.inner;
// Handle the persisted targeted message.
}
switch decryptedMessage {
case let .applicationMessage(plaintext, senderClientId, identity):
// Handle the application message.
case let .commit(isActive, bufferedMessages, identity):
// Handle the commit.
case let .proposal(delay, identity):
// Handle the proposal.
case let .transient(plaintext, senderClientId, identity):
// Handle the transient message.
case let .transientTargeted(plaintext, senderClientId, identity):
// Handle the transient targeted message.
case let .persistedTargeted(plaintext, senderClientId, identity):
// Handle the persisted targeted message.
}
when (decryptedMessage) {
is DecryptedMessage.ApplicationMessage -> {
val (plaintext, senderClientId, identity) = decryptedMessage
// Handle the application message.
}
is DecryptedMessage.Commit -> {
val (isActive, bufferedMessages, identity) = decryptedMessage
// Handle the commit.
}
is DecryptedMessage.Proposal -> {
val (delay, identity) = decryptedMessage
// Handle the proposal.
}
is DecryptedMessage.Transient -> {
val (plaintext, senderClientId, identity) = decryptedMessage
// Handle the transient message.
}
is DecryptedMessage.TransientTargeted -> {
val (plaintext, senderClientId, identity) = decryptedMessage
// Handle the transient targeted message.
}
is DecryptedMessage.PersistedTargeted -> {
val (plaintext, senderClientId, identity) = decryptedMessage
// Handle the persisted targeted message.
}
}
Other
-
e2eiIsPkiEnvSetuphas been removed. You can callgetPkiEnvironmentto see if it is set. -
e2eiIsEnabledhas been removed. You can usefindCredentialsto check whether an X509 credential for a given cipher suite exists. -
proteusNewPrekeyis deprecated; useproteusNewPrekeyAutoinstead. -
ClientId.deserializecan now fail. Callingdeserializeon history client ids throws aCoreCryptoError. -
The documentation for
addCredentialpreviously stated that credentials registered with a single client must be distinct in credential type, signature scheme, and creation timestamp, and that you therefore had to wait a full second between registrations. This was never true of any released version; we fixed it before releasing 10.0.0. If you added a delay betweenaddCredentialcalls to comply, you can remove it. -
CoreCryptoContext.setDatais now a synchronous function, not async.
Migrating to CC 11: TypeScript
See the common migration guide for changes that apply to all platforms.
Browser and Native Packages
Browser bindings are published as @wireapp/core-crypto. The @wireapp/core-crypto/browser import remains available as
an alias. Native bindings are published separately as @wireapp/core-crypto-native, which automatically installs its
@ubjs/core and @ubjs/node runtime dependencies.
If you previously used the native subpath, install @wireapp/core-crypto-native and update your imports:
// before
import { ... } from "@wireapp/core-crypto/native";
// after
import { ... } from "@wireapp/core-crypto-native";
Database
Deprecated Database.close(). Database references are now automatically destroyed when the garbage collector cleans up
the object. If you need to explicitly close the database, you can call uniffiDestroy() on the Database instance.
Database.inMemory() is now a synchronous function, not async.
Reseed (Browser)
CoreCrypto.reseed now requires exactly 32 bytes. Callers that pass a longer seed will now get an error.
Migrating to CC 11: Swift
See the common migration guide for changes that apply to all platforms.
Database Path and Salt
The database path is no longer required to stay the same across CoreCrypto instances. In previous versions of CoreCrypto, the database path was used to look up salt values for the database encryption. This is no longer the case, and the salt values are now stored in a file in the same directory as the CoreCrypto database. For existing clients the database must be opened once with the old path in order for a migration to be performed.
The salt file is named after the database, with a .salt suffix: <database path>.salt. The database can’t be
decrypted without it, so whenever you move, back up or delete the database, do the same with the salt file.
Migrating to CC 11: Kotlin
See the common migration guide for changes that apply to all platforms.
Maven Repository
The JVM, Android and KMP packages are now published to Wire’s Maven repository, https://maven.wire.com, instead of
Maven Central. Add maven { url = uri("https://maven.wire.com") } to your repositories. Earlier releases remain on
Maven Central.
Timestamps
Timestamps now use kotlin.time.Instant instead of kotlinx.datetime.Instant. This affects X509Identity.notBefore
and X509Identity.notAfter.
Database Salt on iOS
On the iOS targets, the database salt has moved from the keychain into a file next to the database. See the Swift migration guide for what this means for existing clients.
Coming from v9
If you are still on CoreCrypto 9, you can upgrade straight to CC 11. Follow the CC 10 migration guide first, and then the CC 11 migration guide. Some steps in the CC 10 guide were changed again in CC 11, so you can skip them or take a shorter path. This page lists those steps.
Upgrading straight to CC 11 also gets you the fixes for the v9 data migration. Some clients that upgraded from v9 to v10 lost credentials, and with them their conversations. CC 11 fixes this. See the release notes for details.
DecryptedMessage
In v10.3.0, DecryptedMessage changed from a struct with optional fields to an enum with Text, Commit and
Proposal variants. CC 11 renames Text to ApplicationMessage and adds three more variants. Skip the Text step and
go straight to the CC 11 enum, as shown in the CC 11 migration guide.
The v9 fields map onto the variants as follows:
| v9 field | CC 11 |
|---|---|
message | ApplicationMessage.plaintext |
senderClientId | ApplicationMessage.senderClientId |
isActive, bufferedMessages | Commit.isActive, Commit.bufferedMessages |
commitDelay | Proposal.delay |
identity | identity on every variant |
hasEpochChanged | removed in CC 10, use the EpochObserver interface |
E2EI Checks
The CC 10 guide says that e2eiIsPkiEnvSetup can no longer throw. You can skip that step: CC 11 removes the function
completely, along with e2eiIsEnabled. Replace both v9 calls directly:
e2eiIsPkiEnvSetup: callgetPkiEnvironmentand check whether it returns an environment.e2eiIsEnabled: callfindCredentialsand check whether an X509 credential for the cipher suite exists.
TypeScript
Imports
CC 10 moved the browser bindings to @wireapp/core-crypto/browser. In CC 11, @wireapp/core-crypto contains the
browser bindings again, so you can keep your v9 imports.
Running outside the browser
If you ran the wasm bindings in Node or another non-browser runtime under v9, switch to @wireapp/core-crypto-native.
You don’t need the @wireapp/core-crypto/native subpath from CC 10. The new package also installs its @ubjs/core and
@ubjs/node dependencies, so you don’t need to add them yourself.
Closing a Database
CC 10 removed CoreCrypto.close() and added Database.close(). CC 11 deprecates Database.close() again, because
database references are now destroyed when they are garbage-collected. Remove your v9 close() calls, and don’t adopt
Database.close(). If you need to close a database explicitly without relying on the garbage collector, call
uniffiDestroy() on the Database instance.
Reseeding
CC 10 renamed reseedRng() to reseed(). In CC 11, reseed() also requires the seed to be exactly 32 bytes long. Make
both changes at once.
Release Notes
CoreCrypto 11
Unreleased
-
The
thumbprintof aWireIdentityis now always computed with SHA-256, regardless of the cipher suite. It used to be computed with the cipher suite’s hash algorithm. -
CoreCrypto now has support for 11 post-quantum cipher suites, as defined by the draft. Specifically, support has been added for the 6 cipher suites that use hybrid (post-quantum + classical) key-exchange mechanisms:
MLS_128_MLKEM768X25519_AES128GCM_SHA256_Ed25519MLS_128_MLKEM768X25519_AES256GCM_SHA384_Ed25519MLS_128_MLKEM768P256_AES128GCM_SHA256_P256MLS_128_MLKEM768P256_AES256GCM_SHA384_P256MLS_128_MLKEM768X25519_CHACHA20POLY1305_SHA384_MLDSA44MLS_192_MLKEM1024P384_AES256GCM_SHA384_P384
as well as the 5 cipher suites that use pure post-quantum key-exchange mechanisms:
MLS_128_MLKEM768_AES256GCM_SHA384_Ed25519MLS_128_MLKEM768_AES256GCM_SHA384_P256MLS_192_MLKEM1024_AES256GCM_SHA384_P384MLS_192_MLKEM768_AES256GCM_SHA384_MLDSA65MLS_256_MLKEM1024_AES256GCM_SHA384_MLDSA87
Note that only 3 of the 11 cipher suites use post-quantum signatures (ML-DSA), and that using x509 credentials with post-quantum signatures is not yet supported.
-
e2eiIsPkiEnvSetuphas been removed. You can callgetPkiEnvironmentto see if it is set. -
e2eiIsEnabledhas been removed. You can usefindCredentialsto check whether an X509 credential for a given cipher suite exists. -
proteusNewPrekeyis deprecated; useproteusNewPrekeyAutoinstead. -
Rendering a
DeserializedClientId(itstoString/Display) now reproduces the client id it was deserialized from. It previously rendered the device id as a fixed-width 16-character hex string, so for a device id with leading zero nibbles — about one in sixteen — the result did not match the client id, and did not match thewireapp://subject alternative name of the device’s E2EI certificate either.DeviceId.toHexStringis unchanged and remains fixed-width; a client id is not a hex-encoded binary value, and only the unpadded form appears in a client id. -
ClientId.deserializecan now fail. Callingdeserializeon history client ids throws aCoreCryptoError. -
CoreCrypto now supports encrypting and decrypting transient messages. Transient messages are encrypted and decrypted for/from group members without affecting the cryptographic state of the group, which enables their use case: to be distributed only to currently online group members.
-
CoreCrypto now also supports encrypting and decrypting targeted messages. A Targeted message is encrypted for a single group member specifically. This enables the long-awaited read receipts feature for MLS. Regular MLS messages aren’t a good fit for read receipts because of the massive amount of irrelevant messages that would need to be distributed.
-
The documentation for
addCredentialpreviously stated that credentials registered with a single client must be distinct in credential type, signature scheme, and creation timestamp, and that you therefore had to wait a full second between registrations. This was never true of any released version; we fixed it before releasing 10.0.0. If you added a delay betweenaddCredentialcalls to comply, you can remove it. -
CoreCryptoContext.setDatais now a synchronous function, not async.
TypeScript
-
browser and native packages are now released separately under
@wireapp/core-cryptoand@wireapp/core-crypto-native, respectively. -
Database.inMemoryis now a synchronous function, not async. -
Database.close()is deprecated. -
Browser:
reseednow requires the entropy seed to be exactly 32 bytes long. It previously accepted any seed of at least 32 bytes and silently used only the first 32, discarding the rest; a caller who gathered more entropy than CoreCrypto consumes had no way to discover that portions were ignored. Seeds shorter than 32 bytes were already rejected, and a 32-byte seed behaves exactly as before, so only callers who were passing a longer seed are affected. Note also thatreseedreplaces the CSPRNG’s state outright rather than mixing the new seed into it.
iOS/Swift
- The database path is no longer required to stay the same across CoreCrypto instances. In previous versions of CoreCrypto, the database path was used to look up salt values for the database encryption. This is no longer the case, and the salt values are now stored in a file in the same directory as the CoreCrypto database. For existing clients the database must be opened once with the old path in order for a migration to be performed.
Kotlin
-
the JVM, Android and KMP packages are now published to Wire’s Maven repository, https://maven.wire.com, instead of Maven Central. Add
maven { url = uri("https://maven.wire.com") }to your repositories. Earlier releases remain on Maven Central. -
Timestamps (Instant) are now using
kotlin.timeinstead ofkotlinx.datetime. -
transactions are now cancellable. This applies to all Kotlin bindings: jvm, android and kmp.
-
The macos/ios targets now have a file lock guarding against concurrent access from separate CoreCrypto instances.
Bug Fixes
-
Fixed a bug in the data migrations. This applies to clients upgrading from v9. The bug caused exactly one credential to be migrated to the v10 data format, effectively at random. The fix causes them all to be propagated.
-
Fixed a bug in the data migrations. This applies to clients upgrading from v9. This bug caused e2ei-enrolled clients to lose all credentials for which both the basic and x509 versions were present in the database, causing them to additionally lose all conversations using those credentials.
CoreCrypto 10
v10.5.3 - 2026-09-23
-
Fixed duplicate existing pending messages leading to database migration to fail. From now on, duplicate pending messages will be ignored.
-
Fixed receiving too many out-of-order buffered messages could result in processing the commit to fail, putting the group into an invalid and irrecoverable state. Now, processing the buffered messages is done on a best-effort basis and cannot on its own put the group into an invalid state.
v10.5.2 - 2026-09-09
-
Fixed a migration issue impacting Web only. Web clients who had previously upgraded to a version between 10.0.0 and 10.5.1 silently lost all their credentials, and the absence of these credentials caused their conversation state to be deleted, essentially forcing the client to recreate itself and rejoin every conversation.
This fix cannot restore the lost credentials, but clients newly upgrading from a pre-10.x DB to 10.x after this fix no longer lose their credentials or conversations.
v10.5.1 - 2026-09-08
- The 10.5.0 release fixed a migration issue for clients that had never run a CoreCrypto version in the range from 10.2.0 to 10.4.0. This release fixes it for clients coming from that version range.
v10.5.0 - 2026-09-04
-
Operations in a CoreCrypto transaction now write to the database as they happen, within a real database transaction, instead of being buffered in memory and replayed at commit time. Atomicity is unchanged: nothing is visible to another process until the transaction commits, and a rollback still undoes everything. The observable differences are:
- A keystore error is raised by the operation which caused it, rather than by
transaction()at commit time. - A rejected write no longer poisons the transaction. If you catch the error inside the callback and carry on, everything else in the transaction still commits.
- Reads performed outside the transaction, while a transaction is in flight, observe its uncommitted writes. Previously they observed the state as of the start of the transaction.
- If SQLite rolls a transaction back on its own initiative, subsequent operations on that transaction now fail with an “unexpectedly rolled back” error instead of operating directly on the database without the possibility of rollback.
- A keystore error is raised by the operation which caused it, rather than by
-
Failures to delete keystore records are now reported. Several cleanup paths — clearing a conversation’s buffered messages and commits, discarding superseded encryption keypairs — previously discarded the error and reported success, as a consequence of the old behavior where errors only propagated at commit time.
-
When
processWelcomeMessagefails to persist the new conversation, the key package the welcome consumed is restored reliably. Previously restoration only worked if the key package had been created in an earlier transaction; a key package created in the same transaction was lost, along with its private keys. -
Relax a DB primary key constraint that caused migration from basic to x509 credentials to break.
-
Fix a DB migration issue that surfaced during client initialization.
v10.4.0 - 2026-08-19
-
PkiEnvironmentnow supports adding multiple trust anchors. Previously,addTrustAnchorreplaced any existing trust anchor. Get all added trust anchor certificates viagetTrustAnchors. Remove a trust anchor viaremoveTrustAnchorby providing the Subject Public Key Info (SPKI) fingerprint. -
Fixes a bug which caused MLS messages that arrived out of order to be discarded instead of buffered for replay.
When a message for epoch
n + 1arrived while the conversation was still in epochn, CoreCrypto buffered it in the database, but a stale foreign key constraint rejected that write for every conversation which had already been established. The message was never replayed once the epoch advanced, producing a keystore error based on SqliteConstraintViolation, 787instead.This bug affected versions 10.0.0-10.3.0; earlier versions had foreign key constraint checks disabled.
-
Wiping a conversation, or abandoning an external join which the delivery service rejected, now also discards the messages and commits that conversation had buffered. Previously those rows stayed in the database indefinitely, with nothing able to read or remove them. This does not remove existing stale rows, but prevents new ones from leaking into the database.
-
Operations within a CoreCrypto transaction are now applied to the database in the order they were performed, and all reads within a transaction reflect earlier writes in that same order. Previously reads would sometimes but not always reflect writes within the transaction, and operations would be played back into the database in arbitrary order (hashmap).
-
proteusNewPrekeyAutostill reuses the ids of deleted prekeys before allocating new ones, but no longer ensures that those ids are reused in strictly ascending order.
v10.3.0 - 2026-08-07
-
DecryptedMessageis now an enum withText,Commit, andProposalvariants. For migration, see the migration guide. -
Fixes a bug which caused stored Proteus sessions to be established afresh rather than loaded, discarding the session state already in the database. Every release from v10.0.0 through v10.2.0 is affected.
Proteus sessions are keyed by a text identifier, which the generated lookup query bound as binary data. SQLite never considers a binary value equal to a text one, so the query matched no rows, and a stored session appeared not to exist. CoreCrypto holds up to 200 recently used sessions in memory, which hid this until that cache went cold: on a restart, on a new CoreCrypto instance, or once more than 200 sessions had been used. Code which asks
proteusSessionExistsbefore deciding whether to establish a session — asproteusDecryptSafedoes — was answered “no” for every stored session, and the replacement session it then established overwrote the state that was in the database all along. The same fault causedproteusSessionDeleteto delete nothing while reporting success, and affected lookups of CRLs and E2EI intermediate certificates by their string identifiers.CoreCrypto cannot repair an affected database. It has no way to distinguish a session which ought to still be there from one whose deletion silently failed, and overwritten session state is not recoverable. Clients which have that context may wish to implement a repair pass of their own.
-
proteusSessionExistsnow returns an error, rather thanfalse, when a session is stored but cannot be loaded. Treating a failed load as an absent session is what allowed the bug above to overwrite live state, so code which establishes a session in response tofalseno longer does so in that case.proteusDecryptSafelikewise returns the error instead of establishing a replacement session.
v10.2.0 - 2026-07-28
- Key packages are retained in case of a
ConversationAlreadyExistserror duringprocessWelcomeMessage(), however, only if the key package was created in an earlier CoreCrypto transaction. - Unified Database layer for browser and native platforms
v10.1.1 - 2026-07-21
- Swift/iOS: CoreCrypto transactions are now cancellable
v10.1.0 - 2026-07-20
- Skipped this release due to issues with the release pipeline. Do not use this CoreCrypto version.
v10.0.0 - 2026-06-23
- Reworked E2EI
- New Database API
- New PKI Environment API
- New Credential API
- TS-Native library
- Reworked client id constructor to take typed data instead of a byte string
CoreCrypto 9
v9.3.5 - 2026-10-08
-
Fixes an issue with how commit delays are calculated which could lead to collisions between other members of a group, which in turn leads to commit congestion.
-
web: Fixes a wasm panic when a transaction callback throws a value that can’t be serialized. The panic left the cc instance unusable. Only values that look like a
CoreCryptoErrorreached the affected code, so this was unlikely to be hit in practice.
v9.3.4 - 2026-04-30
Fixes an issue that could cause epoch observer events to be emitted for epoch changes that would not (yet) actually be persisted to the CoreCrypto database. This is relevant if the CoreCrypto instance is used inside the event handler of the epoch observer (e.g., to update the exported secret). If you created a CoreCrypto transaction inside the handler and didn’t use the CoreCrypto instance directly, this fix is irrelevant.
v9.3.3 - 2026-03-31
- no more errors when deleting a non-existent credential
v9.3.2 - 2026-03-18
- serialize structs into camelCase
v9.3.1 - 2026-03-18
Fixes an enum representation bug on web.
v9.3.0 - 2026-02-20
Lidl compat: Ios and Android can now export a compacted version of the CC database, retaining its encryption. This is only relevant for Lidl builds and should be ignored by everyone else.
v9.2.1 - 2026-02-17
Upgrades openssl to version 3.5.5 (used on Android and iOS for encryption at rest).
v9.2.0 - 2026-02-05
Kotlin changes:
-
expose the enitre read-only API on the
CoreCryptotype. This allows performing arbitrary read-only operations on data while a transaction is running (e.g., in anEpochObserverevent). -
Introduce
KotlinInstanttype
v9.1.3 - 2025-12-18
Upgrades the binding generator (uniffi 0.29.5) to include a crash fix for Android
v9.1.2 - 2025-11-05
This release fixes a bug in the TypeScript bindings where the commit delay could in some situations be undefined when receiving a proposal.
v9.1.1 - 2025-10-24
This release fixes the issue where libcore_crypto_ffi.so had segments aligned on 4k instead of 16k on Android
platforms.
v9.1.0 - 2025-09-29
Note
Even though this is a minor version bump, it contains a breaking change. See below for more information.
- added typescript structured errors
- fix the message rejected reason not being propagated on web
- improvements to logs when epochs advance
Features
-
Web: structural errors
Example Usage
Extract the abort reason given via an
MlsTransportResponsetry { // send a commit that is rejected by the DS } catch (err) { if(isMlsMessageRejectedError(err)) { const rejectReason = err.context.context.reason; // other things you want to do with this error... } else { // log error } }Extract the proteus error code
try { // look for a proteus session that doesn't exist } catch (err) { if(isProteusSessionNotFoundError(err)) { const errorCode = err.context.context.errorCode; // other things you want to do with this error... } else { // log error } }
Bug Fixes
- Web: fixed the abort reason of an
MlsTransportResponsenot being forwarded to rust.
Breaking Changes
proteusErrorCodefield was removed from the root error type, you can get it from the nested context now (see above). Affected platforms: web
v9.0.1 - 2025-09-18
Breaking Changes
-
v9.0.0 had erroneously renamed
migrateDatabaseKeyTypeToBytestomigrateDbKeyTypeToBytes. This has been fixed, andmigrateDatabaseKeyTypeToBytesis usable again on all platforms.Affected platforms: Android
Bug Fixes
- Kotlin documentation is now correctly generated and deployed.
v9.0.0 - 2025-09-16
- we’re now tying the Kotlin wrapper more closely to the generated bindings which allows for greater velocity when making changes in code that affects our API - this causes most of the breaking changes in this release
- removed cryptobox migration API
- in Swift, added protection against concurrent access from multiple core crypto instances
- added implicit obfuscation of sensitive data in logs
- reworked the entire build system and CI
Note
In this release we include a fix for missing artifacts in our Web release. The faulty release process affects all
8.xversions. Therefore, instead of migrating from any version <8.xto8.x, directly migrate to this version.
Breaking Changes
-
Removed support for migrating CoreCrypto database to version 1.
Affected platforms: Web
Databases saved by CoreCrypto versions older than 2.0 cannot be migrated anymore.
-
Removed
proteusCryptoboxMigrate.Affected platforms: all
Support for Cryptobox migration has been removed.
Migration: remove all calls to
proteusCryptoboxMigrate. -
Renamed
CoreCryptoContext.proteusDecrypttoCoreCryptoContext.proteusDecryptSafe(...).Affected platforms: Android
It used to be the case that the Kotlin bindings hid the actual behavior of
proteusDecryptby adding a higher-level behavior, trading away some efficiency for ease-of-use. With this change, we have exposed the low-level behavior ofproteusDecrypt, enabling for more efficient uses when decrypting many proteus messages at once. The old higher-level behavior ofproteusDecryptis now exposed asproteusDecryptSafe.Migration: replace all calls to
proteusDecryptwith calls toproteusDecryptSafe. -
Eliminated wrapper
class E2EIEnrollmentin favor of generatedclass E2eiEnrollment.Affected platforms: Android
We’ve brought the uniffi-generated code to very near parity with the older high-level bindings. The following breaking changes were necessary to eliminate the old binding class:
Name changes:
These methods have had their names changed. To migrate, simply rename all calls to these functions.
accountResponse->newAccountResponseauthzResponse->newAuthzResponsedpopChallengeResponse->newDpopChallengeResponsecontextOidcChallengeResponse->newOidcChallengeResponse
-
Eliminated (hand-written) wrapper
class CoreCryptoContextin favor of (uniffi-generated)class CoreCryptoContext.Affected platforms: Android
We’ve brought the uniffi-generated code to very near parity with the older high-level bindings. The following breaking changes were necessar to eliminate the old binding class:
Name changes:
These methods have had their names changed. To migrate, simply rename all calls to these functions.
getPublicKey->clientPublicKeygenerateKeyPackages->clientKeypackagesvalidKeyPackageCount->clientValidKeypackagesCountaddMember->addClientsToConversationremoveMember->removeClientsFromConversationmembers->getClientIdsderiveAvsSecret->exportSecretKeyproteusGetLocalFingerprint->proteusFingerprintproteusGetRemoteFingerprint->proteusFingerprintRemoteproteusGetPrekeyFingerprint->proteusFingerprintPrekeyBundleproteusDoesSessionExist->proteusSessionExistsproteusCreateSession->proteusSessionFromPrekeyproteusDeleteSession->proteusSessionDelete
Parameter order changes:
These methods have had the order of their parameters changed. To migrate, either name the arguments in the caller or reorder the parameters appropriately.
generateKeyPackages/clientKeypackages:amountis now the final parameter, not the firstjoinByExternalCommit:credentialTypeandconfigurationhave swapped positionse2eiNewEnrollment:teamnow appears afterhandleand beforeexpirySece2eiNewActivationEnrollment:teamnow appears afterhandleand beforeexpirySece2eiNewRotateEnrollment: new param order:(displayName, handle, team, expirySec, ciphersuite)proteusCreateSession/proteusSessionFromPrekey: params swappedproteusDecrypt: params swappedproteusEncrypt: params swapped
Other Parameter changes:
These methods have had the set of their parameters changed. To migrate, see instructions for each changed method.
createConversation: accepts(ConversationId, CredentialType, ConversationConfiguration). Conversation configuration must be constructed externally.
Removed Methods:
These methods no longer exist.
proteusNewPrekeys: similar tofrom.until(from + count).map { cc.proteusNewPrekey(it.toUShort()) }proteusNewLastPrekey: similar tocc.proteusLastResortPrekey()proteusEncryptWithPreKey: similar to:cc.proteusSessionFromPrekey(sessionId, preKey) val encryptedMessage = cc.proteusEncrypt(sessionId, message) cc.proteusSessionSave(sessionId) return encryptedMessage
-
Stopped duplicating generated code in kotlin bindings.
Affected platforms: Android
Hand-written wrappers have largely been removed. The following items have been renamed:
Ciphersuites.DEFAULT->CIPHERSUITES_DEFAULTCiphersuite.DEFAULT->CIPHERSUITE_DEFAULTCredentialType.Basic->CredentialType.BASICMLSGroupId->ConversationIdMLSKeyPackage->KeyPackageDeviceStatus.Valid->DeviceStatus.VALIDDeviceStatus.Expired->DeviceStatus.EXPIREDDeviceStatus.Revoked->DeviceStatus.REVOKEDE2eiConversationState.Verified->E2eiConversationState.VERIFIEDE2eiConversationState.NotVerified->E2eiConversationState.NOT_VERIFIEDE2eiConversationState.NotEnabled->E2eiConversationState.NOT_ENABLED
-
Changed exposed error type structures
Affected platforms: iOS
Migration
- When pattern-matching the affected error types, add argument labels
- When accessing inner error values, add field names
Features
- In our Swift bindings we are now protecting against concurrent access from multiple core crypto instances.
- In the decode tool we add support for listing members or identities present in a group info.
CoreCrypto 8
v8.0.3 - 2025-08-12
This is only relevant for Kotlin.
Fixes page size alignment for all supported linkers.
Adds ClientId.copyBytes().
Adds ClientId.toString().
Changes ClientId.value from ByteArray to the generated FFI type com.wire.crypto.uniffi.ClientId
v8.0.2 - 2025-07-23
This is only relevant for Kotlin.
Adds MLSKeyPackage.copyBytes().
v8.0.1 - 2025-07-23
This release is relevant only for Kotlin. It adds several pseudo-constructors and accessors for newtypes around byte vectors.
For other platforms, no relevant changes are included.
v8.0.0 - 2025-07-17
This release contains the complete API necessary for history sharing in conversations. We’ve improved the generated types in bindings to be more typesafe, and we’ve added the feature to rotate the key used for the core crypto database.
Breaking changes
-
Removed
canClose(), andisLocked().Affected platforms: Web
Migration: Only needed if you were relying on the output of
canClose()before callingclose():Call
close()directly. Instead of handling thefalsecase ofcanClose(), catch the error thatclose()may throw:Cannot close as multiple strong refs exist, then try again.The behavior of
close()was adjusted, so that it waits for any running transaction to finish, instead of throwing an error. -
Removed
mlsInitWithClientId,mlsGenerateKeypairs,e2eiDumpPKIEnv,deleteKeypackages,getCredentialInUseAffected platforms: Web, Android, iOS
Migration: not needed, no client is using these functions.
-
Changed the location of the Wasm bytecode file
Affected platforms: Web
The Wasm bytecode file,
core-crypto-ffi_bg.wasm, has been moved to a subdirectory namedautogenerated. While this is an internal change and should normally not be breaking, in reality it may break the Web client, which assumes the location of that internal file.Migration: update relevant paths in the Web client to point to the new location, under
autogenerated, including in any calls toinitWasmModule. -
Added a parameter
refreshTokentonewOidcChallengeRequestAffected platforms: Web
This fixes an inconsistency between Web and other platforms.
Migration: pass the refresh token received from the identity provider when calling
newOidcChallengeRequest. -
Added a parameter
contexttonewOidcChallengeResponseAffected platforms: Web
Migration: pass the transaction context when calling
newOidcChallengeResponse. -
Added
encryptedMessagefield toCommitBundleAffected platforms: Web, Android, iOS
This field is used to bundle encrypted history secrets with a commit that adds a new history client.
Migration: update any pattern-matching or other code that depends on the structure of
MlsCommitBundleto include the new field. Also, make sure to update your implementation of theMlsTransportprotocol/interface to include this field in the payload sent to the Delivery Service. -
ClientIdis a newtype, not a bare byte array.Affected platforms: Web, Swift
Migration: call
new ClientId(id)to construct aClientId, andid.copyBytes()to get a byte array out. -
ClientIdwrapper accepts a byte array, not a string.Affected platforms: Android
Migration: call
.toByteArray()on the input. -
Ciphersuiteis an exported public enum, not an integerAffected platforms: all
Migration: use the relevant enum variant instead of an integer.
-
SecretKey,ExternalSenderKey,GroupInfo,ConversationId,KeyPackage,Welcomeare now newtypesAffected items:
CoreCryptoContext.exportSecretKey(akaCoreCryptoContext.deriveAvsSecret) now returns aSecretKey- (kotlin)
AvsSecretnewtype removed in favor ofSecretKey CoreCryptoContext.getExternalSendernow returns anExternalSenderKeyConversationConfiguration::external_sendersnow acceptsExternalSenderKeysCoreCryptoContext.joinByExternalCommitnow accepts aGroupInfoGroupInfoBundlenow contains aGroupInfo- Many
CoreCryptoContextmethods now accept aConversationIdnewtype instead of a byte array HistoryObserverandEpochObservernow produceConversationIdinstances instead of byte arraysCoreCryptoContext.clientKeypackagesnow producesKeyPackagesCoreCryptoContext.addClientsToConversationnow acceptsKeyPackagesCommitBundlenow might contain aWelcomeCoreCryptoContext.processWelcomeMessagenow accepts aWelcome
Affected platforms: all
Migration: call
.copyBytes()on the newtype to get access to the raw byte vector. To construct the newtype from a byte array, just use the appropriate constructor.In the past, Android (but only Android) had newtypes in these instances; other clients needed to work with a raw byte vector. We’ve decided to expand the use of newtypes around byte vectors in the FFI interface. This has several benefits:
- Increased consistency between client FFI libraries
- Reduced thickness of the high-level FFI wrappers
- In some cases, we can avoid bidirectional data transfers across the FFI boundary, and just move pointers around instead.
-
Removed
PlaintextMessage,MlsMessageandSignaturePublicKeynewtypes in favor ofByteArrayAffected platforms: Android
The Message newtypes were only used in
CoreCryptoContext.encryptMessageandCoreCryptoContext.decryptMessage.SignaturePublicKeywas used only for the return value offun getPublicKey. The only usage we found was an immediate access of the byte vector.These types appear to provide no type safety benefits, instead only adding a bit of friction.
Features
- Support Android environments with 16k page size
- Added a module-level function
updateDatabaseKey, to update the key of an existing CoreCrypto database - Support for history sharing which can be enabled by calling
enableHistorySharing()and disabled again by callingdisableHistorySharing().
CoreCrypto 7
v7.0.2 - 2025-07-07
Upgrade OpenMLS to fix a bug where the ratchet tree would sometimes become corrupt leading to broken MLS groups.
Bug Fixes
- update openmls [WPB-18569] (7ca7ba7)
v7.0.1 - 2025-06-02
Bug Fixes
- initWasm was being called with the wrong property field. (ca1706d)
- allow registering epoch observer before calling mls_init (3f0605a)
v7.0.0 - 2025-05-21
Breaking changes
The typescript bindings no longer implicitly load the wasm module when importing the core crypto module. To replace this
behaviour the async initWasmModule() function has been added, which must be called before any other core crypto
function.
Features
- remove top level await and expose async init method instead (ce6e566)
- expose
historyClientconstructor to swift (22d98de) - expose
historyClientconstructor to kotlin (b16839d) - expose
historyClientconstructor to wasm (3c2531a) - expose
history_clientconstructor publicly (c24fcc3) - restore history secrets (5643e87)
- add
fn history_client(HistorySecret) -> CoreCrypto(8b7b7d4) - add
fn generate_history_secret(a998dec)
Bug Fixes
- crypto-ffi: fix naming and attributes of WelcomeBundle fields on Wasm (1b2e88b)
- prevent cancellations during transactions (Kotlin) (75217d5)
- prevent cancellations during transactions (Kotlin) (ae621b7)
CoreCrypto 6
v6.0.1 - 2025-05-07
Bug Fixes
- swift publishing CI action (d1030e1)
v6.0.0 - 2025-05-07
- Changed the core crypto database key format, to enable validation of the same and ensure consistency between platforms
- Added a function for each platform to migrate from the old to the new key type
- Several more bug fixes, including prevention of the pending commit error
Breaking changes
-
Changed the core crypto database key format
Affected platforms: Web, Android, iOS
Migration: before instantiating this version of core crypto for the first time, call
migrateDatabaseKeyTypeToBytes()with the appropiate arguments (old key and your new key) exactly once. Then, instantiate core crypto with the new key.Note: Make sure the new key is not based on a string, and provide full 256 bits of entropy.
Note: Instantiating this version of core crypto will fail before you call the migration function.
v5.4.0 - 2025-05-14
Kotlin bindings only: transactions are now
NonCancellable,
as required by Uniffi. This prevents a
category of bug where Kotlin thinks a transaction has been cancelled, while Rust thinks it is still running.
v5.3.0- 2025-04-29
Bug Fixes
- re-throw inner error which cancelled the transaction (Swift) (0c282b2)
v5.2.0 - 2025-04-15
Bug Fixes
- add registerEpochObserver to CoreCryptoProtocol (eadf388)
- create an interface for
ConversationConfiguration(b1e82bf) - swift publishing failing due to not running on latest macos runner (dcc1890)
v5.1.0 - 2025-04-03
Bug Fixes
- broken swift bindings by publishing uniffi framework separately (2b950cc)
- don’t refer to the internal uniffi EpochObserver type in the public API (7833300)
- re-expose proteus_reload_session which removed by mistake (36f2b87)
v5.0.0 - 2025-03-21
New Swift bindings which are more ergonomic and allows for better testing by exposing the transaction context as a protocol.
New API for observing epoch changes through a callback API: registerEpochObserver. After adopting this API clients can
remove their own epoch observers.
Breaking changes
- New Swift bindings are replacing the old Swift bindings.
CoreCrypto 4
v4.2.3 - 2025-03-14
- fix android publishing to maven central
v4.2.2 - 2025-03-14
- fix publishing to maven central
v4.2.1 - 2025-03-14
- expose
proteusCryptoboxMigrate()[WPB-16549] (682b9fe)
v4.2.0 - 2025-02-28
- The Android release once again bundles API docs.
- The Kotlin bindings have received several API fixes in particular:
- AcmeChallenge was missing the target property.
- proteusGetPrekeyFingerprint was missing.
- The Typescript bindings now correctly expose WireIdentity and X509Identity.
- The code base has migrated to Rust 2024 edition.
v4.1.0 - 2025-02-07
- Add the capability to handle the case where a proposal-referencing commit arrives before the proposals it references.
(Semi-) Breaking changes
- For the case mentioned above, the corresponding error type
BufferedCommithas been added.- Depending on the error model, this can be a breaking change.
v4.0.1 - 2025-02-05
- support entity derive for tables with hex ids (0bd3676)
v4.0.0 - 2025-01-28
- All errors crossing the FFI boundary are now logged.
- An iOS client has been added to internal interop tests, which means we now test the entire FFI stack on iOS.
- A new interface for MLS transport has been added, allowing for a much simpler and more robust CoreCrypto API.
- Removal of a number of deprecated and unnecessary functions and types.
- Completely reworked internal error handling, to allow for more precise errors.
- A number of improvements to Kotlin and Javascript bindings, making the bindings more consistent.
- The
decodetool gained support for decoding MLS messages.
Breaking changes
-
Deprecated functions on the
CoreCryptotype that were automatically creating transactions have been removed.Affected platforms: Web, Android, iOS
Migration: replace calls to functions on
CoreCryptowith calls to corresponding functions onCoreCryptoContext, which is created when you explicitly create a transaction. Transactions have to be explicitly created now. -
The low-level uniffi-generated Kotlin bindings code is no longer publicly available. It should never have been used in application code directly.
Affected platforms: Android
Migration: make sure to use the Kotlin high-level API only.
-
The Wasm bytecode generated by
wasm-bindgenis now imported directly when importing thecorecryptomodule. This makes sure that the Wasm module is immediately initialised, without any additional steps. There is no need for the client app to know or handle the path to Wasm bytecode file.Additionally, it is now possible to use the same CoreCrypto module in both browser and non-browser contexts.
Affected platforms: Web
Migration: drop any references to
core-crypto-ffi_bg.wasmand do not set thewasmFilePathargument to theCoreCrypto.initfunction – it is no longer used. Additionally, make sure there is no special handling or separate hosting of the Wasm bytecode file. CoreCrypto release artifacts should be used without any changes. -
Validation callbacks, as well as related error variants, have been removed.
Affected platforms: Web, Android, iOS
Migration: remove all implementations of
authorize,userAuthorizeandclientIsExistingGroupUser, as well as calls toCoreCrypto.setCallbacks. -
The
MlsTransportinterface has been added. This is another milestone in the effort to simplify the public API and make it more robust.All client applications have to provide an implementation of the new interface, which comprises only two functions,
sendMessageandsendCommitBundle.Affected platforms: Web, Android, iOS
Migration: implement the
MlsTransportinterface and callCoreCrypto.provideTransportto make CoreCrypto use your implementation. -
Functions
CoreCrypto.wipeandCoreCrypto.unloadhave been removed. They were not providing any value.Affected platforms: Web, Android, iOS
Migration: drop calls to
wipeandunload. Client applications wishing to make their keys and conversations inaccessible should remove the CoreCrypto database explicitly. -
The function
CoreCrypto.proteusLastErrorCodehas been removed. We now have Proteus error codes attached to errors that are emitted by CoreCrypto.Affected platforms: Web, Android, iOS
Migration: drop calls to
proteusLastErrorCodeand instead check the returned error object. -
The functions
CoreCrypto.buildMetadataandCoreCrypto.versionhave been moved to the module level. It is no longer required to create aCoreCryptoinstance to call them.Affected platforms: Web
Migration: use module-level
buildMetadataandversionfunctions. -
Several changes have been made to the E2EI API.
The function
CoreCrypto.e2eiRotateAllhas been removed. Client applications should instead go over each conversation individually and callCoreCrypto.e2eiRotate.Two new functions have been added,
CoreCrypto.saveX509CredentialandCoreCrypto.deleteStaleKeyPackages. The former should be used after getting a new X509 credential, while the latter should be called after generating keypackages for the new credential and replacing the stale ones in the backend.Affected platforms: Web, Android, iOS
Migration: replace calls to
e2eiRotateAllwith iterations over conversations, callinge2eiRotateon every conversation and checking for errors. UsesaveX509CredentialanddeleteStaleKeyPackagesas appropriate (more details in API documentation). -
The proposal API has been removed, simplifying the public API a great deal. This includes functions like
newProposal,newExternalProposal,clearPendingProposal,joinConversationetc.Affected platforms: Web, Android, iOS
Migration: drop all calls to removed functions as they are no longer necessary with the new MLS transport interface.
CoreCrypto 3
v3.1.1 - 2025-04-15
- This release bumps the version of rusty-jwt-tools to 0.13.0, which includes additional end-to-end identity tests and test markers relevant to Bund.
v3.1.0 - 2025-02-12
-
Add a test case mimicking a real life bug ([WPB-15810]), demonstrating that in some cases it was possible to generate errors by swapping the ordering of two messages.
-
Add a new layer of buffering to handle that situation.
Note
Decrypting a message can now potentially return a
MlsError::Othervariant with the messageIncoming message is a commit for which we have not yet received all the proposals. Buffering until all proposals have arrived.
Clients do not need to take any action in response to this message. This error simply indicates that the commit has been buffered, and will be automatically unbuffered when possible.
If the required proposal is never delivered, however, the client will eventually desync as the commit will never be processed. Clients should be on the lookout for this case and trigger their rejoin protocol in that event.
v3.0.2 - 2025-01-31
-
Fix a bug which could cause certain errors to generate spurious log lines of the form
Cannot build CoreCryptoError, falling back to standard Error! ctx: Incoming message is from an epoch too far in the future to buffer.
v3.0.1 - 2025-01-27
- Emit info log with context when buffering, restoring, or clearing buffered messages
v3.0.0 - 2024-12-11
- Fix the ‘transaction in progress’ error when there was an attempt to perform multiple transactions in parallel. This will no longer throw an error, instead the transactions will be queued and performed serially one after another.
Breaking changes
- Added the missing MLS error case OrphanWelcome.
CoreCrypto 2
v2.0.0 - 2024-12-02
- The number of public errors has been reduced and simplified. It’s no longer necessary to use the
proteus_last_error_codefunction, since thrown error should contain all the information. - The logger callback now includes an additional context parameter which contains additional context for a log event in the form of a JSON Object string.
- It’s now possible to change the logger and log level at runtime (see
setLogLevelandsetLogger).
Breaking changes
- Dropped support for
i686-linux-androidtarget. CoreCryptoLoggertakes an additionalcontextparameter.CoreCryptoErrorand its child errors have been refactored to reduce the amount of error we expose and provide explicit errors for Proteus errors. The errors we have removed will appear under theOthercase.enum ProteusError { SessionNotFound, DuplicateMessage, RemoteIdentityChanged, Other(Int), } pub enum MlsError { ConversationAlreadyExists, DuplicateMessage, BufferedFutureMessage, WrongEpoch, MessageEpochTooOld, SelfCommitIgnored, UnmergedPendingGroup, StaleProposal, StaleCommit, Other(String) }
Release Artifacts
Core-Crypto publishes releases to a variety of platforms.
Typescript
TypeScript releases are published on NPM as separate packages:
- @wireapp/core-crypto for browsers.
- @wireapp/core-crypto-native for native javascript runtimes.
Kotlin (JVM / Android / KMP)
Kotlin bindings are published to Wire’s Maven repository, https://maven.wire.com, in the com.wire group:
core-crypto-jvmfor the JVMcore-crypto-androidfor Androidcore-crypto-kmpfor Kotlin Multiplatform
Add the repository to your build:
repositories {
maven { url = uri("https://maven.wire.com") }
}
or, for Maven:
<repositories>
<repository>
<id>wire</id>
<url>https://maven.wire.com</url>
</repository>
</repositories>
Earlier releases were published to Maven Central, where they remain; keep mavenCentral() among your repositories to
resolve them.
Swift
Swift bindings are published as a pair of .xcframework.zip files on the
Github release.
Rust
We do not publish releases to crates.io. Instead, if you need to include CC as a dependency, add it via the repo:
core-crypto = { version = "10.0.0", tag = "v10.0.0", git = "https://github.com/wireapp/core-crypto" }
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 / KMP (Kotlin)
These cannot be published manually. Wire’s Maven repository accepts uploads only from CI runs triggered by pushing a release tag, and never overwrites or deletes a published file.
If publish-jvm, publish-android or publish-kmp fails, re-run the failed jobs of the tag’s pipeline run. A release
job is safe to re-run after a partial upload: it accepts files that are already published with identical content, and
uploads the rest. It releases what prepare-publish staged earlier in the same run, and staged artifacts are kept for 7
days.
If a release job fails because a file is already published with different content, or the staged artifacts have expired, that version cannot be completed. Release a new patch version instead.
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