DCTRL-0005Event Encryption and Storage
Private payloads, signed envelopes, event IDs and idempotent delivery, daily epoch keys, dual delivery, encrypted tags, and the scope of forward secrecy.
| Field | Value |
|---|---|
| RFC | DCTRL-0005 |
| Title | Event Encryption and Storage |
| Status | Draft |
| Created | 2026-03-14 |
| Version | 0.1 |
| Requires | DCTRL-0002, DCTRL-0003, DCTRL-0004 |
| Required By | DCTRL-0006 |
Abstract
This document specifies how events are encrypted, signed, transmitted, stored, and queried in the Decentrl Protocol. It defines the private payload and signed envelope wire formats, the signed event ID that makes delivery idempotent, how the epoch key for sending and receiving is selected, the dual-delivery pattern (transit encryption for recipients + storage encryption for the sender), the rules for processing pending events, encrypted tag-based querying, and the scope of the protocol's forward secrecy.
Status of This Document
This is a draft specification.
Table of Contents
- Introduction
- Terminology
- Event Model
- Wire Format
- Transit Encryption
- Storage Encryption
- Dual Delivery
- Publishing Events
- Processing Pending Events
- Querying Stored Events
- Encrypted Tags
- Ephemeral Events
- Forward Secrecy Trade-offs
- Application-Level Patterns
- Security Considerations
- References
1. Introduction
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119].
The Decentrl Protocol uses an event-driven architecture where all communication and state changes are represented as cryptographically signed, encrypted events. Events flow through two paths:
- Transit path: Events are encrypted with the contract's key for the current epoch [DCTRL-0003 Section 10] and sent to the recipient's mediator via the
TWO_WAY_PRIVATEchannel. - Storage path: Events are encrypted with the sender's storage key and saved on the sender's own mediator for conversation history.
This dual-delivery pattern ensures both parties maintain complete records on their own infrastructure without coordination between mediators.
2. Terminology
Event — An application-level data unit (e.g., a chat message, a reaction, a presence signal) that is encrypted and transmitted through the protocol.
Event ID — A publisher-chosen identifier of one event, unique per sender and signed with it [Section 3.2]. Retries and re-deliveries of an event carry the same event ID.
Event Envelope — The signed wrapper around an event, containing the envelope version, contract ID, epoch, event ID, the event, a timestamp, and the sender's signature. It travels encrypted inside the private payload.
Private Payload — What the sender hands to the recipient's mediator: the contract ID and epoch in the clear, and the encrypted envelope.
Epoch Key — The AES-256-GCM key of one contract for one day, derived from the contract's hash chain [DCTRL-0002 Section 12, DCTRL-0003 Section 10].
Transit Encryption — Encryption using the contract's epoch key, protecting the event during delivery to the recipient's mediator.
Storage Encryption — Encryption using the sender's or recipient's storage key [DCTRL-0002], protecting the event at rest on the owner's mediator.
Dual Delivery — The pattern of sending the transit-encrypted event to the recipient AND storing the storage-encrypted event on the sender's own mediator.
Pending Event — An encrypted event stored on the recipient's mediator, awaiting retrieval and acknowledgment.
Encrypted Tag — An opaque identifier computed by the owner as an HMAC of a plaintext tag string [DCTRL-0002 Section 11], deterministic per owner, enabling server-side filtering without revealing the tag string.
Dedupe Key — The owner's keyed hash of an event's sender DID and event ID, sent with every stored event so the owner's mediator keeps one row per event without learning the event ID [Section 9.5].
Reserved Event — A private event whose type starts with decentrl.: a protocol message, such as a group key certificate [DCTRL-0006 Section 10], never an application event [Section 9.6].
3. Event Model
3.1 Application Events
Events are application-defined JSON objects. The protocol does not impose constraints on event structure beyond JSON serializability. Applications define their own event schemas, typically using hierarchical type names:
{
"type": "chat.message",
"data": {
"id": "msg_abc123",
"chatId": "chat_xyz",
"content": "Hello!",
"sender": "did:decentrl:...",
"timestamp": 1699123456
},
"meta": {
"senderDid": "did:decentrl:...",
"eventId": "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
"timestamp": 1699123456000
}
}The event type namespace and data schema are application-level concerns, not defined by this specification, with one exception: types starting with decentrl. are reserved for the protocol [Section 9.6], and applications MUST NOT use them. The meta object is required on private events: the recipient rewrites its senderDid and eventId to authenticated values and removes any groupDid [Section 9.4], and meta.ephemeral: true marks an ephemeral event [Section 12]. meta.groupDid is set only on group events, by the member that read them from the group [DCTRL-0006 Section 9.4].
3.2 Event Identity
Each event is identified by its sender DID and its event ID. The publisher chooses the event ID (RECOMMENDED: a random UUID) and MUST NOT reuse it for a different event. A retry of the same event MUST reuse it: that is what lets every layer recognise the retry as the same event.
An event ID is a string of 1 to 128 characters from A-Z a-z 0-9 . _ : -. Anything else, including an empty string, is invalid. | is excluded because dedupe keys use it as a separator [Section 9.5].
Event IDs are unique per sender only: two senders may use the same event ID for unrelated events. Everything that dedupes on an event ID therefore keys on the pair (sender DID, event ID).
The event ID is not the ID of a stored row. The mediator generates its own opaque IDs for stored and pending events [DCTRL-0004 Section 12.6]; commands such as UPDATE_EVENT_TAGS and ACKNOWLEDGE_PENDING_EVENTS take those.
4. Wire Format
An event to a recipient is wrapped twice: a signed envelope, encrypted under the epoch key, inside a private payload that tells the recipient which key to use.
4.1 Private Payload
The string sent as the TWO_WAY_PRIVATE command payload [DCTRL-0004 Section 7], and stored by the recipient's mediator as the pending event's payload:
{
"version": 1,
"contract_id": "<base64 SHA-256 contract ID>",
"epoch": 3,
"ciphertext": "<base64 AES-256-GCM ciphertext of the signed envelope>"
}| Field | Type | Description |
|---|---|---|
version | integer | Private payload version. MUST be 1. |
contract_id | string | The contract the event is sent under [DCTRL-0003 Section 7]. Non-empty. |
epoch | integer | The epoch whose key encrypted ciphertext [DCTRL-0003 Section 10]. Non-negative. |
ciphertext | string | The signed envelope [Section 4.2], JSON-serialized and encrypted with the epoch key [DCTRL-0002 Section 6]. |
contract_id and epoch are in the clear so the recipient can pick the key directly, without trial decryption. The mediator can read them. It already knows the sender and recipient of every event, so they add only which contract and which day's key the event uses.
A payload that is not valid JSON, has another version, or has a missing or ill-typed field is malformed.
4.2 Signed Envelope
Inside ciphertext, the sender places a signed envelope:
{
"version": 3,
"contract_id": "<base64 SHA-256 contract ID>",
"epoch": 3,
"event_id": "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
"event": "<JSON-stringified plaintext event>",
"timestamp": 1699123456,
"signature": "<base64 Ed25519 signature>"
}| Field | Type | Description |
|---|---|---|
version | integer | Envelope format version. MUST be 3 for this specification. |
contract_id | string | MUST equal the private payload's contract_id. |
epoch | integer | MUST equal the private payload's epoch. |
event_id | string | The event ID [Section 3.2]. MUST be a valid event ID. |
event | string | The plaintext event, serialized as a JSON string (JSON.stringify(event_object)) |
timestamp | number | Unix timestamp in seconds when the event was created |
signature | string | Ed25519 signature over canonical JSON of { version, contract_id, epoch, event_id, event, timestamp }, using the sender's signing key [DCTRL-0002 Section 8] |
The event ID lives only inside the encrypted envelope. The private payload stays at version 1 and carries no event ID, so the recipient's mediator never sees it.
4.3 Signature Scope
The signature covers every envelope field except signature itself:
signable = { version, contract_id, epoch, event_id, event, timestamp }
signature = sign_json_object(signable, sender_signing_private_key)4.4 Versioning
The private payload and the envelope are versioned separately. A recipient MUST treat a private payload whose version is not 1 as malformed.
The envelope version is signed, so a mediator cannot downgrade it. The version determines how the envelope is verified, so recipients MUST check it before verifying the signature. A recipient MUST reject an envelope whose version is not a version it supports (UNSUPPORTED_VERSION). Envelope version 1 (no epoch, encrypted directly under the contract's root secret) and version 2 (no event ID) are no longer supported. After the version check, a recipient MUST reject an envelope whose event_id is missing or not a valid event ID (MALFORMED). Rejected envelopes MUST NOT be processed; the recipient SHOULD acknowledge the pending event so it is dropped rather than retried.
4.5 Binding to Contract and Epoch
The envelope repeats contract_id and epoch under the sender's signature. A recipient MUST check that both equal the values of the private payload whose key decrypted it (CONTRACT_MISMATCH, EPOCH_MISMATCH). The mediator can alter the outer fields but not the signed ones, so relabeling an event to another contract or epoch fails.
4.6 Purpose of Event-Level Signatures
The mediator command (transport layer) is already signed, proving the command originated from the claimed sender. The event-level signature provides additional guarantees:
- A compromised mediator cannot swap payloads between events
- A compromised mediator cannot relabel the outer
contract_idorepoch[Section 4.5] - Events are independently verifiable after storage, regardless of transport
- The event ID cannot be changed or stripped, so a mediator cannot present one event as several or make a re-delivery look new [Section 15.6]
5. Transit Encryption
5.1 Encryption
Events sent to a recipient are encrypted with the contract's epoch key for the current epoch [DCTRL-0003 Section 10.3]:
function encrypt_for_transit(envelope, contract, key_state):
epoch = epoch_at(contract.timestamp, now) // [DCTRL-0003 Section 10.1]
advanced = advance(key_state, epoch) // committed only after delivery
key = derive_epoch_key(advanced.chain_key) // [DCTRL-0002 Section 12]
return JSON.stringify({
version: 1,
contract_id: contract.id,
epoch: epoch,
ciphertext: AES_GCM_encrypt(JSON.stringify(envelope), key) // [DCTRL-0002 Section 6]
})5.2 Decryption
function decrypt_from_transit(raw_payload, contracts, key_states):
payload = parse_private_payload(raw_payload) // [Section 4.1]
contract = contracts[payload.contract_id]
key = select_receive_key(key_states[contract.id], payload.epoch, now)
// [DCTRL-0003 Section 10.4]
envelope = JSON.parse(AES_GCM_decrypt(payload.ciphertext, key))
return envelope5.3 Contract Lookup
The recipient looks the contract up by the payload's contract_id and the key by its epoch. There is exactly one contract per pair [DCTRL-0003 Section 11], and no trial decryption. What happens when the contract or key is missing, or the epoch is rejected, is specified in Section 9.2.
6. Storage Encryption
6.1 Encryption
Events stored on the owner's mediator are encrypted with the identity's storage key [DCTRL-0002 Section 3]:
function encrypt_for_storage(event_object, storage_key):
plaintext = JSON.stringify(event_object)
ciphertext = AES_GCM_encrypt(plaintext, storage_key)
return ciphertext6.2 Decryption
function decrypt_from_storage(ciphertext, storage_key):
plaintext = AES_GCM_decrypt(ciphertext, storage_key)
event_object = JSON.parse(plaintext)
return event_object6.3 Key Separation
Transit encryption and storage encryption use different keys:
- Transit key: The contract's epoch key (scoped to one contract and one day)
- Storage key: The identity's storage key (local only, identity lifetime)
A compromised epoch key exposes one contract's transit traffic for that epoch. A compromised chain key exposes that epoch and every later one of the contract, but no earlier epoch whose key has been deleted. A compromised mediator exposes no content. Device compromise (obtaining the storage key) exposes stored events.
7. Dual Delivery
7.1 Pattern
When an identity publishes an event, it MUST deliver the event through two independent paths:
-
Transit delivery: Encrypt the signed event envelope with the contract's current epoch key and wrap it in a private payload [Section 4]. Send to the recipient's mediator via
TWO_WAY_PRIVATE[DCTRL-0004 Section 7]. -
Self-storage: Encrypt the plaintext event with the sender's storage key. Save on the sender's own mediator via
SAVE_EVENTS[DCTRL-0004 Section 8.10], with encrypted tags and the dedupe key of(sender DID, event ID)[Section 9.5].
Both paths use the same event ID.
7.2 Rationale
Dual delivery ensures:
- Sender history: The sender retains a complete record of sent events on their own mediator
- No mediator coordination: The sender's and recipient's mediators are independent; no synchronization is required
- Infrastructure independence: Each party can switch mediators without losing conversation history
7.3 Exception: Ephemeral Events
Events marked as ephemeral (see Section 12) skip the self-storage step. They are sent via TWO_WAY_PRIVATE only.
8. Publishing Events
8.1 Algorithm
To publish an event to a recipient:
function publish_event(event, recipient_did, identity, event_id = random_uuid()):
// 0. A retry passes the event ID of the failed attempt
if event_id is not a valid event ID:
fail INVALID_EVENT_ID // nothing is sent
// 1. The contract with this recipient and this device's key state for it
contract = contract_with(recipient_did) // at most one per pair
key_state = key_states[contract.id]
if contract is none:
fail CONTRACT_NOT_FOUND
if key_state is none:
fail CONTRACT_UNKEYED // contract exists, no keys on this device
// 2. Current epoch and its key; nothing is committed yet
epoch = epoch_at(contract.timestamp, now)
advanced = advance(key_state, epoch)
key = derive_epoch_key(advanced.chain_key)
// 3. Build the signed envelope
envelope = {
version: 3,
contract_id: contract.id,
epoch: epoch,
event_id: event_id,
event: JSON.stringify(event),
timestamp: current_time_seconds()
}
envelope.signature = sign_json_object(envelope, identity.signing_private_key)
// 4. Encrypt, wrap and send to the recipient's mediator
payload = JSON.stringify({
version: 1,
contract_id: contract.id,
epoch: epoch,
ciphertext: AES_GCM_encrypt(JSON.stringify(envelope), key)
})
response = send_two_way_private(recipient_mediator, payload)
if response is not SUCCESS:
fail SEND_FAILED // key state unchanged
// 5. Commit the epoch, on the latest stored state (it may have moved meanwhile)
key_states[contract.id] = advance(key_states[contract.id], epoch)
// 6. Encrypt for self-storage (unless ephemeral); a retry stores nothing new
if not event.ephemeral:
tag_key = derive_tag_key(identity.storage_key)
storage_ciphertext = AES_GCM_encrypt(JSON.stringify(event), identity.storage_key)
encrypted_tags = generate_tags(event, tag_key)
save_events(identity.mediator, [{
sender_did: identity.did,
recipient_did: recipient_did,
contract_id: contract.id,
timestamp: current_time_seconds(),
payload: storage_ciphertext,
encrypted_tags: encrypted_tags,
dedupe_key: dedupe_key(tag_key, identity.did, event_id) // [Section 9.5]
}]) // STORED or DUPLICATE
return event_id8.2 Contract Selection
A pair has at most one contract [DCTRL-0003 Section 11], so there is nothing to choose. If this device holds no key state for it (the contract is unkeyed here [DCTRL-0003 Section 6.2]), the event cannot be sent; the sender has to request a new contract.
8.3 Committing the Epoch
The advanced key state is committed only after the recipient's mediator accepted the event. A refused or failed send leaves the key state as it was, so a send can never move the chain past keys the sender still needs.
8.4 Retries
A publish can fail after part of it took effect: the recipient's mediator queued the event but the reply was lost, or the send succeeded and self-storage failed. The publisher learns the event ID either way (implementations SHOULD return it on success and include it in the error on failure).
To retry, the publisher publishes the same event again with the same event ID. The recipient may then receive two ciphertexts of one event; both carry the same signed event ID, so it stores one row and the application applies it once [Section 9.5]. Self-storage of the retry returns DUPLICATE if the first attempt stored it. Retrying with a new event ID creates a second event.
9. Processing Pending Events
9.1 Algorithm
To process events received from other identities:
function process_pending_events(identity):
// 1. Read every page of pending events before acknowledging any [DCTRL-0004 Section 9.4]
pending, is_complete_drain = query_all_pending_events(identity.mediator)
ack_ids = []
oldest_pending_epoch = {} // contract id -> epoch still needed
for each event in pending:
// 2. Open the private payload and pick the key
payload = parse_private_payload(event.payload)
if payload is null: drop(event, MALFORMED); continue
contract = contracts[payload.contract_id]
if contract is none: continue // leave pending
if contract.participant_did != event.sender_did:
drop(event, CONTRACT_MISMATCH); continue
key_state = key_states[contract.id]
if key_state is none: continue // leave pending
selection = select_receive_key(key_state, payload.epoch,
epoch_at(contract.timestamp, now))
if selection is EPOCH_IN_FUTURE: continue // leave pending: our clock is behind
if selection is rejected: drop(event, selection.reason); continue
// EPOCH_EXPIRED or EPOCH_TOO_FAR
// 3. Decrypt and open the signed envelope
plaintext = AES_GCM_decrypt(payload.ciphertext, selection.key)
if decryption failed: drop(event, UNDECRYPTABLE); continue
envelope = open_envelope(plaintext, sender_signing_key(event.sender_did),
contract.id, payload.epoch) // [Section 4.4, 4.5]
if envelope is rejected: drop(event, envelope.reason); continue
// 4. Bind the authenticated sender and the signed event ID; a 1:1 event has no group
// [Section 9.4]
app_event = bind_authenticated_meta(JSON.parse(envelope.event),
event.sender_did, envelope.event_id,
group_did = null)
if app_event is rejected: drop(event, MALFORMED_EVENT); continue
// 5. Only now may the key state move forward
key_states[contract.id] = advance(key_states[contract.id], payload.epoch)
// 6. Reserved events go to the hook, never to storage or the application [Section 9.6]
if app_event.type starts with "decentrl.":
handle_reserved_event(app_event, event.sender_did)
if it threw:
oldest_pending_epoch[contract.id] = min(..., payload.epoch)
continue // leave pending
ack_ids.append(event.id) // applied, rejected, or no hook
continue
// 7. Re-encrypt with the storage key and store (unless ephemeral) [Section 9.5]
if not app_event.ephemeral:
status = save_events(..., dedupe_key(tag_key, event.sender_did, envelope.event_id))
if storing fails:
oldest_pending_epoch[contract.id] = min(..., payload.epoch)
continue // leave pending
// STORED or DUPLICATE: both are handed to the application
hand_to_application(app_event)
ack_ids.append(event.id)
// drop(event, reason) appends event.id to ack_ids: acknowledged, never processed
acknowledged = acknowledge_pending_events(identity.mediator, ack_ids)
// a failure is logged, not raised
// 8. Delete old epoch keys and move idle chains forward, only after a complete,
// acknowledged drain [Section 9.3]
if is_complete_drain and acknowledged:
prune_epoch_keys(oldest_pending_epoch)9.2 Receive Rules
Every pending event ends in one of three outcomes: processed and acknowledged, acknowledged and dropped, or left pending for a later sync.
| Condition | Outcome |
|---|---|
| Private payload malformed [Section 4.1] | Dropped (MALFORMED) |
No contract with the payload's contract_id on this device | Left pending: the contract may not have been refreshed yet |
| Contract known, but this device holds no key state for it | Left pending |
The pending event's sender_did is not the contract's counterpart | Dropped (CONTRACT_MISMATCH) |
Epoch more than one ahead of the receiver's current epoch (EPOCH_IN_FUTURE) | Left pending: retried once the receiver's clock catches up |
| Epoch older than the key state and its key no longer retained | Dropped (EPOCH_EXPIRED) |
| Epoch more than 3660 ahead of the key state | Dropped (EPOCH_TOO_FAR) |
| AES-GCM decryption fails | Dropped (UNDECRYPTABLE) |
Envelope malformed (including a missing or invalid event_id), unsupported version, missing or invalid signature, or contract/epoch mismatch [Section 4] | Dropped with that reason |
Event is not a JSON object with a meta object [Section 9.4] | Dropped (MALFORMED_EVENT) |
Reserved type (decentrl.*), and the hook returns applied [Section 9.6] | Acknowledged; not stored, not handed to the application |
Reserved type, and the hook returns rejected, or no hook is configured | Dropped |
| Reserved type, and the hook throws | Left pending |
| Decrypted and verified, but storing it fails | Left pending |
Decrypted and verified, stored (STORED) or already stored (DUPLICATE) [Section 9.5] | Processed, then acknowledged |
Dropped events are acknowledged, so the mediator deletes them: they can never become valid. Events left pending stay on the mediator; mediators delete pending events that remain unacknowledged for 30 days [DCTRL-0004 Section 8.15].
If acknowledging fails, the events are still returned to the application and stay pending on the mediator. The next sync delivers them again; storing them then returns DUPLICATE, and the application ignores the repeat [Section 9.5].
An epoch advance is committed only after the event has decrypted and its signature has verified [DCTRL-0003 Section 10.4]. Forged or corrupted ciphertext therefore cannot move the key state forward and destroy keys still needed.
9.3 Pruning Old Epoch Keys
Old epoch keys are deleted only after a complete drain: every page of pending events has been read and processed (the last page came back short), and the acknowledgement succeeded. An event that was not acknowledged comes back and may still need its epoch key. Events pushed over WebSocket [DCTRL-0004 Section 10.5] are processed without a drain and never trigger pruning.
After a complete drain, for each contract, retained epoch keys are deleted below the lower of:
- the epoch before the current one (kept for events still crossing the day boundary), and
- the oldest epoch an event that was left pending for this contract still needs.
The chain itself is moved forward to that same epoch, so a contract without recent traffic does not keep a chain key able to open its older ciphertext. At most 31 past epoch keys are kept in any case [DCTRL-0003 Section 10.5].
9.4 Authenticated Metadata
Application events carry a meta object with, among others, the sender's DID (meta.senderDid) and the event ID (meta.eventId). The sender writes these inside the event, so on their own they are claims: a contact could name another contact as sender, or reuse someone else's event ID to have that event ignored as a duplicate.
Before storing an event or handing it on, the recipient MUST set:
meta.senderDidto the pending event'ssender_did: the sender the mediator authenticated and whose key signed the envelope [Section 15.2, 15.3], andmeta.eventIdto the envelope's signedevent_id.
The recipient MUST also remove meta.groupDid. A 1:1 event never belongs to a group: only a member reading a group's events sets meta.groupDid, to the group it read [DCTRL-0006 Section 9.4]. Without this rule, a former group member could send a 1:1 event claiming a group and have it shown in that group.
A differing claimed value is overwritten or removed, and implementations SHOULD log it with the claimed and authenticated values. Other meta fields are kept. An event that is not a JSON object, or has no meta object, is dropped (MALFORMED_EVENT).
Binding happens before storage, so the stored copy, later queries and state reconstruction all see the same authenticated values as live delivery.
9.5 Idempotent Delivery
An event can reach a recipient more than once:
- the sender retried with the same event ID [Section 8.4];
- the event was stored but the acknowledgement was lost [Section 9.2];
- a WebSocket push [DCTRL-0004 Section 10.5] raced a drain of the same event;
- several consumers of one identity (tabs, devices, a browser extension) processed the same pending event;
- a mediator re-queued a ciphertext it had seen [Section 15.6].
Every stored event carries a dedupe key:
dedupe_key = generate_encrypted_tag("event|" + sender_did + "|" + event_id, derive_tag_key(storage_key))It is the same keyed hash as an encrypted tag [Section 11; DCTRL-0002 Section 11], so the owner's mediator can compare dedupe keys but cannot learn or test the event ID. The sender uses its own DID for its self-stored copy [Section 8.1]; the recipient uses the authenticated sender_did [Section 9.4].
The owner's mediator keeps at most one stored event per (owner, dedupe_key). SAVE_EVENTS reports each event as STORED or DUPLICATE [DCTRL-0004 Section 8.10]. A duplicate changes nothing: not the stored event, its tags, nor whether the application processed it.
A DUPLICATE is still processed and handed to the application: another consumer of the same identity may be the one that stored it, and this consumer has not yet applied it. Applications MUST therefore dedupe on (sender DID, event ID) themselves. An implementation that remembers applied events in memory SHOULD seed that memory from the stored events at startup, so a re-delivery after a restart is recognised.
If a consumer stores an event but stops before the application applies it, the stored event stays unprocessed (no app tags yet [Section 11.4]), so the next start picks it up from the stored events.
9.6 Reserved Events
Events whose type starts with decentrl. are protocol messages carried over ordinary contracts, for example group key certificates and handover bundles [DCTRL-0006 Section 10]. They go through every step of [Section 9.1] up to metadata binding and the epoch advance. Then, instead of being stored:
- the recipient passes the event and the authenticated
sender_didto a reserved-event hook, which returnsappliedorrejected, or throws; - a reserved event is never stored on the recipient's mediator and never handed to the application, whatever the hook's result;
- the pending event is acknowledged only after the hook has returned. If the hook returns
rejected, the event is dropped and SHOULD be logged; - if the hook throws, the event stays pending and its epoch key is kept, exactly as for an event whose storage failed. It is retried on the next sync. A hook MUST throw only for failures worth retrying (for example, persisting its own state failed, or a network read it depends on failed). Otherwise any contact could keep reserved events pending in the recipient's inbox for good;
- if no hook is configured, the event is acknowledged and dropped with a warning.
Reserved events from one sender are applied in the order that sender sent them. While an earlier event from the same sender stays pending (it could not be opened yet, could not be stored, or its hook threw), the sender's later reserved events stay pending too, including events pushed over WebSocket, until the next complete drain sees them all again, oldest first [DCTRL-0006 Section 10.2]. Ordinary events are not held back.
A client that handles reserved events MUST configure its hook before it first processes pending events, whether by a drain or by events pushed over WebSocket. A reserved event processed without a hook is lost [DCTRL-0006 Section 10.3].
Senders of reserved events SHOULD send them as ephemeral [Section 12], so that no copy is stored on the sender's mediator either.
10. Querying Stored Events
10.1 Query Flow
To query stored events, clients send QUERY_EVENTS [DCTRL-0004 Section 8.11] with optional filters:
function query_events(identity, options):
// Build encrypted tag filters
tag_filters = []
for tag_string in options.tags:
tag_filters.append(generate_encrypted_tag(tag_string, derive_tag_key(identity.storage_key)))
// Query mediator
response = send_command(identity.mediator, QUERY_EVENTS, {
filter: {
encrypted_tags: tag_filters,
participant_did: options.participant_did,
after_timestamp: options.after,
before_timestamp: options.before,
unprocessed_only: options.unprocessed_only
},
pagination: options.pagination
})
// Decrypt results
events = []
for stored_event in response.events:
plaintext = AES_GCM_decrypt(stored_event.payload, identity.storage_key)
events.append(JSON.parse(plaintext))
return events10.2 State Reconstruction
On application startup, clients rebuild their entire state from the stored event stream:
- Query all stored events using
QUERY_EVENTS(with appropriate tag filters) - Decrypt each event with the storage key
- Apply events in timestamp order through application-defined reducers
- The resulting state is a complete reconstruction — no backend database needed
This is pure event sourcing: the event log is the source of truth, and the current state is a derived projection.
11. Encrypted Tags
11.1 Tag Generation
Encrypted tags are generated using [DCTRL-0002 Section 11]:
tag_key = HKDF_SHA256(ikm = storage_key, salt = empty, info = "decentrl/tag-key/v1", length = 32)
encrypted_tag = base64_encode(HMAC_SHA256(tag_key, UTF8_encode(tag_string)))Only the owner of the storage key can compute its encrypted tags. The mediator stores and compares them but cannot compute or test them.
11.2 Tag Templates
Applications MAY define tag templates with interpolation variables:
"chat.${chatId}"
"participant.${participants.1}"Template variables are resolved against the event data at publish time. For example, given an event with chatId: "abc123", the template "chat.${chatId}" resolves to the tag string "chat.abc123", which is then encrypted.
Template interpolation is an application-level concern. This specification does not define the interpolation syntax or resolution algorithm.
11.3 Tag Semantics
Tags enable efficient querying without revealing tag strings to the mediator:
- Exact match: The mediator matches encrypted tags using exact byte comparison
- No substring search: Tags are opaque; the mediator cannot perform prefix or pattern matching
- Application-defined: Tag string conventions are determined by the application
11.4 App Processing and Tag Assignment
When events are received from other parties (via pending event processing), they are initially stored without application-specific tags (unprocessed). After the application assigns tags via UPDATE_EVENT_TAGS [DCTRL-0004 Section 8.12], the events become queryable by those tags and are marked as app-processed.
Self-authored events (stored via the self-storage path in dual delivery) are tagged immediately at storage time and marked as app-processed.
12. Ephemeral Events
12.1 Definition
An ephemeral event is sent via TWO_WAY_PRIVATE for real-time delivery but is NOT stored on the sender's mediator. Ephemeral events have no tags (tags: [] or equivalent).
12.2 Lifecycle
- Sender encrypts and sends via
TWO_WAY_PRIVATE - Recipient's mediator stores as a pending event
- Recipient retrieves, decrypts, and processes
- Recipient acknowledges — the mediator deletes the pending event [DCTRL-0004 Section 8.9]
- The event exists nowhere else — it is consumed
12.3 Use Cases
- Typing indicators: Real-time signals that have no value after the moment
- Presence/online status: Transient state that is replaced by the next update
- Read receipts (in some designs): May be stored or ephemeral depending on application needs
12.4 Receiver-Side Storage
The recipient MAY choose to store ephemeral events locally after receiving them (re-encrypting with their storage key). The "ephemeral" designation applies only to the sender's self-storage behavior.
12.5 Duplicates
Ephemeral events carry an event ID like any other and are deduped by the application on (sender DID, event ID) [Section 9.5]. They are not stored, so there is no dedupe key and no durable record: that dedupe lasts as long as the application's memory of applied events. A re-delivery after the application restarts is applied again, which is harmless for the signals ephemeral events are meant for.
13. Forward Secrecy Trade-offs
13.1 No Per-Event Forward Secrecy
The Decentrl Protocol deliberately omits per-event forward secrecy. All events under a contract within one epoch (one day) share the same key. This section explains the rationale, and what daily key epochs do protect.
13.2 Why Signal Needs Per-Event Forward Secrecy
In Signal, messages are stored as plaintext in a local database protected by device-level encryption. The transit ciphertext sits on Signal's server temporarily. These are in different places, creating a two-stage attack: compromise the server to record ciphertext, then later compromise the device to get keys. Per-event forward secrecy (via the Double Ratchet) ensures old chain keys are deleted, protecting recorded ciphertext from later key compromise.
13.3 Why Decentrl Does Not
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 transit encryption construction. For stored history, the two-stage attack that forward secrecy defends against is bypassed entirely through the storage path.
Transit ciphertext is a different matter: an event that was intercepted, or that is still pending on a mediator, is not covered by the storage key. Daily key epochs protect it [Section 13.5].
13.4 The Trade-off
Decentrl trades per-event forward secrecy for:
- Clock-driven key state: The only state is a contract's position on a daily hash chain, computed from the contract timestamp and the clock. No per-message counters, no message ordering requirements for key derivation, no ratchet round trips.
- Device-independent history: Any authorized device can access the complete message history through the storage key.
- Simpler implementation: No Double Ratchet state machine.
The cost of any forward secrecy is that key state cannot be re-derived: it exists only on the devices that completed the handshake. Another device of the same identity holds no keys for the contract and has to request a new one [DCTRL-0003 Section 6.2].
13.5 Daily Key Epochs
Key epochs [DCTRL-0003 Section 10] provide the security boundary for transit traffic:
- Each day of a contract has its own key, derived one-way from the previous day's chain key [DCTRL-0002 Section 12].
- Once both parties have moved past an epoch and pruned its key [Section 9.3], a later compromise of either device cannot decrypt ciphertext from that epoch, whether it was left pending on a mediator or intercepted in transit.
Limits of this protection:
- Granularity is one day, not one message. Every event of the current epoch, and of epochs still retained, is exposed by a device compromise.
- Retention: keys of up to 31 past epochs may be kept while events of those epochs remain pending.
- Devices that do not sync keep their chain where it last was: it moves forward only when the device sends, receives, or reads its whole pending queue [Section 9.3].
- Stored history is protected by the storage key only [Section 13.3].
- Replay within retained epochs is detected by event ID, not by the keys [Section 15.6].
14. Application-Level Patterns
14.1 Event Sourcing
Applications built on Decentrl function as event processors:
- Define event types and schemas
- Define state slices with initial values
- Define reducer functions that fold events into state
- Query and decrypt events from the mediator
- Apply events through reducers to build current state
The event log is the source of truth. Application state is a derived projection that can be rebuilt at any time from the event stream.
14.2 Idempotent Reducers
Delivery is deduplicated by event ID [Section 9.5], but events may still arrive out of order, and state reconstruction re-applies events to fresh state. Reducers MUST be idempotent:
// Good: checks for duplicates before appending
reduce(state, event):
if state already contains event.id → return state unchanged
else → return state with event appended
// Good: takes maximum (naturally idempotent)
reduce(markers, event):
markers[event.chatId] = max(existing, event.timestamp)14.3 Optimistic Updates
Applications MAY apply events to local state immediately after publishing (before the recipient acknowledges). This provides responsive UIs. The published event will be available in the stored event stream for future state reconstruction.
15. Security Considerations
15.1 Storage Key as Single Point of Failure
The storage key protects the identity's entire event history across all contracts. Compromise of the storage key exposes all stored events. This is an acceptable trade-off because device compromise already implies ongoing access. Future versions SHOULD consider hardware-backed key storage.
15.2 Event Signature Verification
Recipients MUST verify event-level signatures after decryption, and MUST reject an envelope whose signature is missing or invalid. Skipping signature verification allows compromised mediators to inject or substitute events.
15.3 Contract and Epoch Binding
The envelope binds the event to one contract and one epoch [Section 4.5]. Recipients MUST verify that the payload's contract belongs to the sender (its counterpart is the pending event's sender_did), and that the envelope's signed contract_id and epoch equal those of the private payload whose key decrypted it. Decrypting with the wrong key fails anyway (AES-GCM authentication); the binding additionally stops a mediator from relabeling the readable outer fields.
15.4 Re-encryption on Receipt
When processing pending events, recipients re-encrypt with their storage key before storing. This ensures that stored events are independent of the transit encryption: epoch keys are deleted as the chain moves on, and stored events remain accessible through the storage key. It is also why key epochs do not protect stored history [Section 13.3].
15.5 Tag Privacy
Encrypted tags are HMACs under the owner's secret tag key [DCTRL-0002 Section 11.2]. The mediator cannot reverse them to plaintext, and cannot test guesses: computing the encrypted tag for a candidate string requires the tag key, which is derived from the storage key and never leaves the owner. (Earlier versions used Ed25519 signatures of the tag string, which anyone could verify against the public key in the DID document, allowing a mediator to confirm guessed tags.)
Tags are still deterministic per owner, so equal tags are linkable: a mediator can observe that two events share a tag (indicating they belong to the same conversation/category) but cannot determine what the tag represents.
15.6 Replay Within Retained Epochs
Key epochs and signatures alone do not prevent replay. Commands themselves are protected against replay [DCTRL-0004 Section 5], but a malicious recipient mediator can re-insert a private payload it has seen as a new pending event. While the recipient still retains that epoch's key, the ciphertext decrypts and its signature is valid.
The signed event ID closes this. A replayed ciphertext carries the same event_id, and the mediator cannot change it without breaking the signature [Section 4.3]. Its dedupe key matches the stored copy, so storing it returns DUPLICATE and nothing changes, and the application recognises (sender DID, event ID) and does not apply it again [Section 9.5].
What remains:
- An ephemeral event is deduped only while the application remembers it [Section 12.5].
- A mediator can still withhold or delay events; event IDs do not reveal a missing event.
15.7 Metadata in the Private Payload
The private payload's contract_id and epoch are readable by the recipient's mediator [Section 4.1]. The mediator already knows who sends to whom, so this reveals which contract of the pair (there is only one [DCTRL-0003 Section 11]) and which day's key the event uses; together with the contract timestamp, the epoch roughly matches the send date the mediator sees anyway.
The event ID is not in the private payload, and the dedupe key is a keyed hash [Section 9.5]. The mediator learns only which saves of one owner repeat an event it already stores, not the event ID.
15.8 Sender Spoofing and Event ID Shadowing
Without binding [Section 9.4], a contact holding a valid contract could set meta.senderDid to another contact's DID and pose as them, copy another sender's meta.eventId so the application drops that sender's real event as a duplicate, or set meta.groupDid so the application shows a 1:1 event as a group event. Binding the authenticated sender and the signed event ID before storage, removing any claimed group, and deduping on (sender DID, event ID) rather than the event ID alone, prevents all three: a sender can only ever speak as itself, shadow only its own events, and reach a group only through the group.
16. References
16.1 Normative References
- [RFC 2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
- [DCTRL-0002] "Cryptographic Operations".
- [DCTRL-0003] "Communication Contracts".
- [DCTRL-0004] "Mediator Protocol".
16.2 Informative References
- [DCTRL-0001] "The
did:decentrlDID Method". - [DCTRL-0006] "Group Messaging".
DCTRL-0004: Mediator Protocol
HTTP API, command wire format, authentication, WebSocket real-time protocol, and error handling.
DCTRL-0006: Group Messaging
Group identities, the signed admin chain, group key certificates, history policy, staff grants, the GROUP mediator channel, group events with hierarchical tags and deletion, counts and presence, joining through the mediator, key parcels, admin handover, and their security properties.