Encryption
Two layers of encryption protect every event — transit encryption between parties and storage encryption at rest.
Formal specs: DCTRL-0002 — Cryptographic Operations and DCTRL-0005 — Event Encryption and Storage define the exact algorithms, wire formats, and security trade-offs.
Decentrl uses two independent layers of encryption. Transit encryption protects data in flight between Alice and Bob. Storage encryption protects data at rest on each party's mediator. The two layers use different keys and serve different threat models.
Cryptographic Primitives
The protocol builds on these primitives:
| Primitive | Purpose |
|---|---|
| Ed25519 | Digital signatures — authentication, non-repudiation, identity verification |
| X25519 | Key agreement — derives shared encryption keys via elliptic curve Diffie-Hellman |
| AES-256-GCM | Authenticated encryption — each operation generates a fresh random 96-bit nonce |
| HKDF-SHA256 | Key derivation — the daily epoch key chain of each contract, and the tag key |
| HMAC-SHA256 | Encrypted tags — keyed with a tag key derived from the storage key via HKDF-SHA256 |
AES-GCM output format: nonce(12 bytes) || ciphertext || tag(16 bytes), base64-encoded.
Transit Encryption
Events sent to a recipient are encrypted with the contract's epoch key for the current day. The ephemeral X25519 key agreement during contract establishment produces a root secret, which is used once to start a one-way hash chain and then wiped. Each day (epoch) of the contract gets its own key from that chain — see Key Epochs:
epoch = floor((now - contract.timestamp) / 86400)
ciphertext = AES_GCM_encrypt(signed_envelope, epoch_key[epoch])All events under a contract on the same day share that day's key. AES-GCM generates a fresh random nonce for each encryption operation, producing unique ciphertext even for identical plaintext.
Storage Encryption
Events stored on your own mediator use your storage key — a local AES-256 key that never leaves your device:
storage_ciphertext = AES_GCM_encrypt(plaintext_event, storage_key)This decouples transit encryption (scoped to one contract and one day) from storage encryption (using a local-only key). The implications:
- Compromised epoch key → only that contract's transit traffic for that day is exposed. Stored data is safe. A leaked chain key exposes that day and later ones, never earlier days whose keys are gone.
- Compromised mediator → exposes no event content. The mediator has neither the epoch keys nor the storage key. It does see metadata (who talks to whom, timing, sizes) — see Mediator Security.
- Compromised storage key → requires device access. Attacker can read stored events but cannot impersonate you or intercept live traffic.
Wire Format
What the recipient's mediator receives is a small JSON private payload:
{
"version": 1,
"contract_id": "<SHA256 contract ID>",
"epoch": 3,
"ciphertext": "<AES-256-GCM ciphertext of the signed envelope>"
}contract_id and epoch are in the clear, so the recipient picks the right key directly, without trying keys one by one. The mediator can read them too. It already knows who is sending to whom, so what they add is which contract and which day's key.
Inside the ciphertext is the signed envelope:
{
"version": 3,
"contract_id": "<same as outside>",
"epoch": 3,
"event_id": "<sender-chosen event ID>",
"event": "<JSON-stringified plaintext event>",
"timestamp": 1699123456,
"signature": "<Ed25519 over canonical JSON of { version, contract_id, epoch, event_id, event, timestamp }>"
}The version is signed along with the rest of the envelope; recipients reject envelopes with a version they don't support. The envelope repeats contract_id and epoch under the signature, and the recipient checks that they match the outer ones, so a mediator can't relabel an event. The event-level signature makes each event independently verifiable and stops a compromised mediator from swapping payloads.
Event IDs and Duplicates
Every event carries an event ID chosen by its sender: 1–128 characters from A-Z a-z 0-9 . _ : -, a random UUID by default. It sits inside the signed envelope, so the recipient's mediator never sees it and can't change it.
The same event can reach you more than once: the sender retried, your acknowledgement got lost, a WebSocket push raced a sync, two of your tabs or devices processed the same pending event, or a mediator re-delivered a ciphertext it had seen. All copies carry the same signed ID, so they're recognised:
- Your mediator stores one copy. Each saved event carries a
dedupe_key, an HMAC ofevent|<sender>|<event ID>under your tag key (the same construction as tags below). The mediator keeps one row per key and answers a repeat withDUPLICATE, without learning the ID. - Your app applies it once. The SDK remembers
(sender, event ID)pairs and skips repeats, ephemeral events included.
Before anything is stored, the recipient also overwrites the event's claimed meta.senderDid and meta.eventId with the authenticated sender and the signed ID. A contact can't pose as another contact, or reuse someone else's event ID to get their message dropped as a duplicate.
Encrypted Tags
Events stored on mediators carry encrypted tags that enable querying without revealing the tag strings:
tag_key = HKDF_SHA256(storage_key, salt = empty, info = "decentrl/tag-key/v1", length = 32)
encrypted_tag = base64(HMAC_SHA256(tag_key, "chat.abc"))The result is deterministic for a given identity and tag string, allowing exact-match lookups. The mediator stores and indexes these opaque blobs without knowing what they represent. Only the identity that holds the storage key can compute a tag — for storing or for a query.
When Alice wants to query "all messages in chat abc", she computes the HMAC of "chat.abc" with her tag key and sends that as a tag filter. The mediator performs an exact-match lookup on opaque byte strings — it has no idea what "chat.abc" means, and because it doesn't have the tag key it can't test guesses either. (Earlier versions used Ed25519 signatures as tags; those can be verified with the public key in the DID document, so a mediator could confirm a guessed tag. HMAC closes that gap.)
What still leaks: equal tags are linkable. The mediator can see that two events share a tag, just not what the tag is.
Why Not Per-Event Forward Secrecy?
Protocols like Signal use sequential key derivation chains (the Double Ratchet) where each message key is derived from the previous chain state and old keys are deleted. This protects against a two-stage attack: compromise the server to record ciphertext, then later compromise the device to get keys.
In Decentrl, events are re-encrypted with the storage key and stored back on the mediator. Device compromise exposes the storage key, which decrypts every stored event regardless of how transit encryption was constructed. For stored history, the two-stage attack that forward secrecy defends against does not apply.
Transit ciphertext is different: an intercepted event, or one still pending on a mediator, isn't covered by the storage key. For that, Decentrl uses daily key epochs. Once both sides have moved past a day and deleted its key, a stolen device can't decrypt that day's transit ciphertext. This is a deliberate trade-off: day-sized rather than per-message forward secrecy, in exchange for key state that needs no coordination between the parties, device-independent message history, and a much simpler implementation.
| Property | Signal | Decentrl |
|---|---|---|
| 1:1 key agreement | X3DH + Double Ratchet | Ephemeral X25519 per contract |
| Forward secrecy | Per message | Per day, for transit ciphertext (key epochs) |
| Break-in recovery | Per-reply (DH ratchet) | None until a new contract |
| Multi-device | Limited (linked devices) | Keys live on the device that made the contract; others request a new one |
| Storage encryption | Device-level (local SQLite) | Storage key (re-encrypted on mediator) |