decentrl.
Specifications

DCTRL-0005: Event Encryption and Storage

Event envelopes, transit and storage encryption, dual delivery, encrypted tags, and forward secrecy trade-offs.

FieldValue
RFCDCTRL-0005
TitleEvent Encryption and Storage
StatusDraft
Created2026-03-14
Version0.1
RequiresDCTRL-0002, DCTRL-0003, DCTRL-0004
Required ByDCTRL-0006

Abstract

This document specifies how events are encrypted, signed, transmitted, stored, and queried in the Decentrl Protocol. It defines the event envelope wire format, the dual-delivery pattern (transit encryption for recipients + storage encryption for the sender), pending event processing, encrypted tag-based querying, and the rationale for the protocol's forward secrecy trade-offs.

Status of This Document

This is a draft specification.

Table of Contents

  1. Introduction
  2. Terminology
  3. Event Model
  4. Event Envelope
  5. Transit Encryption
  6. Storage Encryption
  7. Dual Delivery
  8. Publishing Events
  9. Processing Pending Events
  10. Querying Stored Events
  11. Encrypted Tags
  12. Ephemeral Events
  13. Forward Secrecy Trade-offs
  14. Application-Level Patterns
  15. Security Considerations
  16. 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:

  1. Transit path: Events are encrypted with the contract's root secret and sent to the recipient's mediator via the TWO_WAY_PRIVATE channel.
  2. 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 Envelope — The signed, encrypted wrapper around an event, containing the contract ID, encrypted payload, timestamp, and signature.

Transit Encryption — Encryption using the contract's root secret [DCTRL-0003], 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, deterministic identifier derived from a plaintext tag string [DCTRL-0002 Section 11], enabling server-side filtering without metadata exposure.


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
  }
}

The event type namespace and data schema are application-level concerns, not defined by this specification.

3.2 Event Identity

Each event is uniquely identified by the combination of sender DID, recipient DID, timestamp, and payload hash. The mediator generates opaque server-side IDs for stored events.


4. Event Envelope

4.1 Wire Format

When transmitting an event to a recipient, the sender constructs a signed envelope:

{
  "contract_id": "<base64 SHA-256 hash>",
  "event": "<JSON-stringified plaintext event>",
  "timestamp": 1699123456,
  "signature": "<base64 Ed25519 signature>"
}

4.2 Fields

FieldTypeDescription
contract_idstringIdentifies which contract's root secret to use for decryption. Generated per [DCTRL-0003 Section 7].
eventstringThe plaintext event, serialized as a JSON string (JSON.stringify(event_object))
timestampnumberUnix timestamp in seconds when the event was created
signaturestringEd25519 signature over canonical JSON of { contract_id, event, timestamp }, using the sender's signing key [DCTRL-0002 Section 8]

4.3 Signature Scope

The signature covers { contract_id, event, timestamp } — the signature field itself is excluded:

signable = { contract_id, event, timestamp }
signature = sign_json_object(signable, sender_signing_private_key)

4.4 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 tamper with contract_id, causing decryption with the wrong key
  • Events are independently verifiable after storage, regardless of transport
  • Replay of old events as new ones is detectable through signature verification against the expected sender

5. Transit Encryption

5.1 Encryption

Events sent to a recipient are encrypted with the contract's root secret [DCTRL-0003 Section 6]:

function encrypt_for_transit(envelope, root_secret):
    plaintext = JSON.stringify(envelope)               // the full signed envelope
    ciphertext = AES_GCM_encrypt(plaintext, root_secret)  // [DCTRL-0002 Section 6]
    return ciphertext                                  // base64-encoded string

The root_secret is the 32-byte shared secret derived from the ephemeral X25519 key agreement for this contract.

5.2 Decryption

function decrypt_from_transit(ciphertext, root_secret):
    plaintext = AES_GCM_decrypt(ciphertext, root_secret)
    envelope = JSON.parse(plaintext)
    return envelope

5.3 Contract Lookup

When receiving a transit-encrypted event, the recipient identifies the correct root secret using the contract_id from the decrypted envelope. If multiple contracts exist with the same counterpart (during rotation), the recipient SHOULD try contracts in order of expiration (newest first).


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 ciphertext

6.2 Decryption

function decrypt_from_storage(ciphertext, storage_key):
    plaintext = AES_GCM_decrypt(ciphertext, storage_key)
    event_object = JSON.parse(plaintext)
    return event_object

6.3 Key Separation

Transit encryption and storage encryption use different keys:

  • Transit key: The contract's root secret (scoped to one contract, rotated periodically)
  • Storage key: The identity's storage key (local only, identity lifetime)

A compromised root secret exposes one contract's transit traffic. A compromised mediator exposes nothing. Only 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:

  1. Transit delivery: Encrypt the signed event envelope with the contract's root secret. Send to the recipient's mediator via TWO_WAY_PRIVATE [DCTRL-0004 Section 7].

  2. 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.9], with encrypted tags.

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):
    // 1. Find the best contract with this recipient
    contract = find_best_contract(recipient_did)    // most recently expiring
    root_secret = derive_root_secret(contract, identity)

    // 2. Build the signed event envelope
    envelope = {
        contract_id: generate_contract_id(contract),
        event: JSON.stringify(event),
        timestamp: current_time_seconds()
    }
    envelope.signature = sign_json_object(
        { contract_id: envelope.contract_id, event: envelope.event, timestamp: envelope.timestamp },
        identity.signing_private_key
    )

    // 3. Encrypt for transit and send to recipient's mediator
    transit_ciphertext = AES_GCM_encrypt(JSON.stringify(envelope), root_secret)
    send_two_way_private(recipient_mediator, transit_ciphertext)

    // 4. Encrypt for self-storage (unless ephemeral)
    if not event.ephemeral:
        storage_ciphertext = AES_GCM_encrypt(JSON.stringify(event), identity.storage_key)
        encrypted_tags = generate_tags(event, identity.signing_private_key)
        save_events(identity.mediator, [{
            sender_did: identity.did,
            recipient_did: recipient_did,
            contract_id: generate_contract_id(contract),
            timestamp: current_time_seconds(),
            payload: storage_ciphertext,
            encrypted_tags: encrypted_tags
        }])

8.2 Contract Selection

When multiple contracts exist with the same recipient (e.g., during rotation), implementations SHOULD select the contract with the latest expires_at value. This ensures events are sent under the freshest cryptographic material.


9. Processing Pending Events

9.1 Algorithm

To process events received from other identities:

function process_pending_events(identity):
    // 1. Query pending events from own mediator
    pending = query_pending_events(identity.mediator)

    processed_ids = []

    for each event in pending:
        // 2. Find contracts with this sender
        contracts = find_contracts_with(event.sender_did)
        if contracts is empty:
            skip    // unknown sender, no contract

        // 3. Try to decrypt with matching contracts (newest first)
        envelope = null
        for contract in contracts (sorted by expires_at descending):
            root_secret = derive_root_secret(contract, identity)
            try:
                envelope = JSON.parse(AES_GCM_decrypt(event.payload, root_secret))
                break
            catch:
                continue

        if envelope is null:
            skip    // could not decrypt with any contract

        // 4. Verify event-level signature
        sender_signing_key = resolve_did(event.sender_did).signing_public_key
        valid = verify_json_signature(
            { contract_id: envelope.contract_id, event: envelope.event, timestamp: envelope.timestamp },
            envelope.signature,
            sender_signing_key
        )
        if not valid:
            skip    // signature verification failed

        // 5. Re-encrypt with storage key and store
        storage_ciphertext = AES_GCM_encrypt(envelope.event, identity.storage_key)
        encrypted_tags = generate_tags(JSON.parse(envelope.event), identity.signing_private_key)
        save_events(identity.mediator, [{
            sender_did: event.sender_did,
            recipient_did: identity.did,
            contract_id: envelope.contract_id,
            timestamp: envelope.timestamp,
            payload: storage_ciphertext,
            encrypted_tags: encrypted_tags
        }])

        processed_ids.append(event.id)

    // 6. Acknowledge processed events
    acknowledge_pending_events(identity.mediator, processed_ids)

9.2 Decryption Strategy

When trying to decrypt a pending event, implementations MUST try contracts in order of expires_at (newest first). This optimizes for the common case where events arrive under the current contract.

If decryption fails with all known contracts, the event SHOULD be skipped (not acknowledged). It MAY be retried later if new contracts are established.


10. Querying Stored Events

10.1 Query Flow

To query stored events, clients send QUERY_EVENTS [DCTRL-0004 Section 8.10] 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, identity.signing_private_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 events

10.2 State Reconstruction

On application startup, clients rebuild their entire state from the stored event stream:

  1. Query all stored events using QUERY_EVENTS (with appropriate tag filters)
  2. Decrypt each event with the storage key
  3. Apply events in timestamp order through application-defined reducers
  4. 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]:

encrypted_tag = base64_encode(Ed25519_sign(UTF8_encode(tag_string), signing_private_key))

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 exposing metadata 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.11], 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

  1. Sender encrypts and sends via TWO_WAY_PRIVATE
  2. Recipient's mediator stores as a pending event
  3. Recipient retrieves, decrypts, and processes
  4. Recipient acknowledges — the pending event is marked as acknowledged
  5. 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.


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 share the same root secret. This section explains the rationale.

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. The two-stage attack that forward secrecy defends against is bypassed entirely through the storage path.

13.4 The Trade-off

Decentrl trades per-event forward secrecy (neutralized by the storage key) for:

  • Stateless encryption: No sequential counter, chain state, or nonce tracking. Any device with the root secret can encrypt/decrypt independently.
  • Multi-device support: Multiple devices can use the same contract concurrently without coordination.
  • Device-independent history: Any authorized device can access the complete message history.
  • Simpler implementation: No Double Ratchet state machine, no message ordering requirements for key derivation.

13.5 Bounded Exposure via Rotation

Contract rotation [DCTRL-0003 Section 10] provides the meaningful security boundary:

  • Time-based rotation (e.g., every 24 hours) bounds exposure to one day per compromise
  • Event-count rotation (e.g., every 1,000 events) provides a hard cap
  • Rotation on suspicion enables immediate recovery

After rotation, the new contract uses a completely fresh root secret. The old root secret is irrelevant for future events.


14. Application-Level Patterns

14.1 Event Sourcing

Applications built on Decentrl function as event processors:

  1. Define event types and schemas
  2. Define state slices with initial values
  3. Define reducer functions that fold events into state
  4. Query and decrypt events from the mediator
  5. 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

Because events may arrive out of order or be replayed during state reconstruction, all 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. Skipping signature verification allows compromised mediators to inject or substitute events.

15.3 Contract ID Binding

The contract_id in the event envelope binds the event to a specific contract. Recipients MUST verify that the contract_id references a valid contract with the claimed sender. Using the wrong root secret for decryption will fail (AES-GCM authentication), but verifying the contract ID provides an additional check.

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. If the contract's root secret is later compromised or deleted (after rotation), stored events remain accessible through the storage key.

15.5 Tag Privacy

Encrypted tags are Ed25519 signatures and cannot be reversed to plaintext. However, tags are deterministic — the same tag string always produces the same encrypted tag for a given signing key. 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.


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:decentrl DID Method".
  • [DCTRL-0006] "Group Messaging".