decentrl/docs
Communication

Message Routing

How encrypted events are routed between identities through the mediator network using dual-delivery.

The Dual-Delivery Pattern

When Alice sends Bob a message, two things happen:

  1. The message is encrypted with the contract's key for today's epoch and sent to Bob's mediator for delivery
  2. The message is re-encrypted with Alice's storage key and saved on Alice's mediator for her records
// 1. Build the event; a retry reuses the same event ID
event_id = random_uuid()
event = { type: "chat.message", data: { chatId: "abc", content: "Hello Bob" } }

// 2. Sign the event envelope for today's epoch
epoch = floor((now - contract.timestamp) / 86400)
envelope = { version: 3, contract_id, epoch, event_id, event: JSON.stringify(event), timestamp: now }
signature = Ed25519_sign(envelope, alice_signing_private)

// 3. Encrypt for transit → send to Bob's mediator
ciphertext = AES_GCM_encrypt(JSON.stringify(signed_envelope), epoch_key[epoch])
transit_payload = JSON.stringify({ version: 1, contract_id, epoch, ciphertext })
send_command(bob_mediator, TWO_WAY_PRIVATE, { payload: transit_payload })
// Alice saves her advanced key state only once Bob's mediator accepted

// 4. Encrypt for storage → save on Alice's mediator
storage_payload = AES_GCM_encrypt(JSON.stringify(event), alice_storage_key)
encrypted_tags = [hmac(tag_key, "chat"), hmac(tag_key, "chat.abc")]  // tag_key = HKDF(alice_storage_key)
dedupe_key = hmac(tag_key, "event|" + alice_did + "|" + event_id)       // one row per event
send_command(alice_mediator, SAVE_EVENTS, { payload: storage_payload, tags: encrypted_tags, dedupe_key })

This ensures both parties always have complete records, regardless of which mediator they use or whether they later switch providers.

Receiving Events

When Bob connects to his mediator (HTTP poll or WebSocket push):

// 1. Query pending events
pending = query_pending_events(bob_mediator)

for each pending_event:
    // 2. The payload names the contract and epoch: pick the key directly
    payload = JSON.parse(pending_event.payload)     // { version, contract_id, epoch, ciphertext }
    contract = contracts[payload.contract_id]       // unknown → leave pending
    key = epoch_key(bob_key_states[contract.id], payload.epoch)

    // 3. Decrypt and verify; the signed envelope must repeat contract_id and epoch
    envelope = AES_GCM_decrypt(payload.ciphertext, key)
    verify(envelope.signature, envelope, alice_signing_public)

    // 4. The sender and event ID are the authenticated ones, whatever the event claims
    event = JSON.parse(envelope.event)
    event.meta.senderDid = pending_event.sender_did
    event.meta.eventId   = envelope.event_id

    // 5. Only now move Bob's key state forward, then re-encrypt for storage
    storage_payload = AES_GCM_encrypt(event, bob_storage_key)
    dedupe_key = hmac(tag_key, "event|" + pending_event.sender_did + "|" + envelope.event_id)
    save_events(bob_mediator, { payload: storage_payload, tags: [...], dedupe_key })
                                                    // STORED, or DUPLICATE if already stored
    hand_to_app(event)                              // the app skips a (sender, event ID) it has seen

// 6. Acknowledge processed events and those that can never be valid
acknowledge_pending_events(bob_mediator, { ids: [...] })

// 7. After reading the whole queue, delete epoch keys nothing needs any more

Bob decrypts with the epoch key, verifies Alice's signature, then re-encrypts with his own storage key. The mediator never sees plaintext at any point.

Events for a contract Bob doesn't know yet, or has no keys for on this device, stay pending for a later sync. Events from a sender whose clock is more than a day ahead also wait until Bob's clock catches up. Events that can never become valid — malformed, from the wrong sender for the contract, for an epoch whose key is already deleted, undecryptable, or badly signed — are acknowledged and dropped.

A copy of an event Bob already has — Alice retried, an acknowledgement got lost, a push raced a sync, another of Bob's tabs got there first, or a mediator re-delivered it — carries the same signed event ID. Bob's mediator answers DUPLICATE instead of storing it again, and his app applies it only once. See DCTRL-0005 §9 for the full rules.

Real-Time Delivery

Mediators support WebSocket connections for push-based event delivery:

1. Client connects via WebSocket
2. Client sends: { type: AUTHENTICATE, did, signing_key_id, timestamp, nonce, signature }
3. Mediator verifies: timestamp drift < 5 min, resolves DID, verifies signature
4. Mediator responds: { type: AUTH_SUCCESS }
5. On new events: mediator pushes { type: PENDING_EVENTS, events: [...] } directly

This eliminates polling. The mediator pushes notifications for new pending events, contract updates, and other state changes to connected clients in real time.

Cross-Mediator Delivery

Alice on mediator A, Bob on mediator B — no problem. The protocol doesn't require mediators to coordinate. Alice's client simply sends the transit-encrypted payload directly to Bob's mediator's HTTP endpoint. Bob's mediator stores it as a pending event. Bob's client picks it up on next sync.

There is no mediator-to-mediator protocol. Each mediator is an independent server that accepts signed commands from registered identities and stores encrypted data. The "cross-mediator" magic is simply that clients know where to deliver messages (the mediator endpoint is in the recipient's DID).