decentrl.
Specifications

DCTRL-0004: Mediator Protocol

HTTP API, command wire format, authentication, WebSocket real-time protocol, and error handling.

FieldValue
RFCDCTRL-0004
TitleMediator Protocol
StatusDraft
Created2026-03-14
Version0.1
RequiresDCTRL-0001, DCTRL-0002, DCTRL-0003
Required ByDCTRL-0005, DCTRL-0006, DCTRL-0007

Abstract

This document specifies the Mediator Protocol — the HTTP API, command wire format, authentication flows, WebSocket real-time protocol, and error handling for Decentrl mediators. Mediators are network participants that provide message routing and encrypted storage services to registered identities while operating encryption-blind.

Status of This Document

This is a draft specification.

Table of Contents

  1. Introduction
  2. Terminology
  3. HTTP API
  4. Command Wire Format
  5. Replay Protection
  6. DIRECT_AUTHENTICATED Channel
  7. TWO_WAY_PRIVATE Channel
  8. Command Reference
  9. Pagination
  10. WebSocket Protocol
  11. Error Codes
  12. Security Considerations
  13. 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].

A mediator is a network participant identified by its own DID (typically did:web) that provides two core services:

  1. Message routing: Accepting encrypted events from senders and making them available to recipients.
  2. Encrypted storage: Storing encrypted events, communication contracts, and contract requests on behalf of registered identities.

Mediators operate encryption-blind: they can verify command signatures, check contract existence, and route messages, but they cannot read message contents, derive encryption keys, or access plaintext data.


2. Terminology

Registered Identity — An identity that has an active, non-expired communication contract with the mediator [DCTRL-0003 Section 9].

Command — A JSON message sent to the mediator's POST / endpoint, containing a header, payload, and signature.

Channel — The communication channel type that determines authentication and routing rules. Three channels are defined: DIRECT_AUTHENTICATED, TWO_WAY_PRIVATE, and ONE_WAY_PUBLIC [DCTRL-0007].

Pending Event — An encrypted event stored by the mediator awaiting retrieval by the recipient.


3. HTTP API

3.1 Endpoints

A conforming mediator MUST implement the following HTTP endpoints:

MethodPathContent-TypeDescription
GET /application/jsonReturns the mediator's DID document
GET /.well-known/did.jsonapplication/jsonReturns the mediator's DID document (did:web resolution)
GET /healthapplication/jsonHealth check
POST /application/jsonAll mediator commands
GET /public/:didapplication/jsonPublic channel events [DCTRL-0007]
GET /wsWebSocket upgrade

3.2 DID Document Endpoint

GET / and GET /.well-known/did.json MUST return the mediator's DID document:

{
  "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
  "id": "<mediator DID>",
  "controller": "<mediator DID>",
  "verificationMethod": [{
    "id": "<mediator DID>#signing",
    "type": "Ed25519VerificationKey2020",
    "controller": "<mediator DID>",
    "publicKeyMultibase": "<z + base58btc Ed25519 public key>"
  }],
  "keyAgreement": [{
    "id": "<mediator DID>#prekey",
    "type": "X25519KeyAgreementKey2020",
    "controller": "<mediator DID>",
    "publicKeyMultibase": "<z + base58btc X25519 public key>"
  }],
  "authentication": ["<mediator DID>#signing"],
  "service": [{
    "id": "#mediator-service",
    "type": "DecentrlMediator",
    "serviceEndpoint": { "uri": "<mediator HTTP URL>" }
  }]
}

3.3 Health Check

GET /health MUST return:

  • 200: { "status": "ok" } — mediator is operational
  • 503: { "status": "error", "detail": "<description>" } — mediator is unavailable

3.4 CORS

Mediators SHOULD allow cross-origin requests with:

  • Allowed origins: * (or configurable)
  • Allowed methods: GET, POST, OPTIONS
  • Allowed headers: Content-Type

4. Command Wire Format

4.1 Universal Command Envelope

All commands sent to POST / MUST use this JSON structure:

{
  "header": {
    "channel": "DIRECT_AUTHENTICATED | TWO_WAY_PRIVATE | ONE_WAY_PUBLIC",
    "sender_did": "<DID string>",
    "sender_signing_key_id": "<DID>#signing",
    "recipient_did": "<DID string>",
    "timestamp": 1699123456000,
    "nonce": "<UUID v4>"
  },
  "payload": <command-specific>,
  "signature": "<base64 Ed25519 signature>"
}

4.2 Header Fields

FieldTypeRequiredDescription
channelstringREQUIRED"DIRECT_AUTHENTICATED", "TWO_WAY_PRIVATE", or "ONE_WAY_PUBLIC" [DCTRL-0007]
sender_didstringREQUIREDDID of the command sender
sender_signing_key_idstringREQUIREDFull key ID of the sender's signing key
recipient_didstringREQUIREDDID of the intended recipient
timestampnumberREQUIREDUnix timestamp in milliseconds (Date.now())
noncestringREQUIREDUUID v4 for deduplication

4.3 Payload

For DIRECT_AUTHENTICATED commands, the payload is a JSON object containing a type field and command-specific data.

For TWO_WAY_PRIVATE commands, the payload is a plain string (the encrypted event blob).

4.4 Signature

The signature field is an Ed25519 signature over the canonical JSON serialization [DCTRL-0002] of { header, payload }:

signable = { header: command.header, payload: command.payload }
signature = sign_json_object(signable, sender_signing_private_key)

The signature field itself is excluded from the signed object.


5. Replay Protection

5.1 Timestamp Validation

The mediator MUST reject commands where the timestamp deviates from the server's current time by more than a configurable window:

if |current_time_ms - command.header.timestamp| > TIMESTAMP_WINDOW_MS:
    reject with TIMESTAMP_OUT_OF_RANGE

The default TIMESTAMP_WINDOW_MS is 300,000 (5 minutes). Implementations MAY make this configurable.

5.2 Nonce Deduplication

The mediator MUST reject commands with a (nonce, sender_did) pair that has already been processed:

if nonce_store.contains(command.header.nonce, command.header.sender_did):
    reject with DUPLICATE_NONCE

After accepting a command, the mediator MUST store the (nonce, sender_did) pair with an expiration time equal to TIMESTAMP_WINDOW_MS.

5.3 Nonce Cleanup

The mediator SHOULD periodically clean up expired nonces. The recommended cleanup interval is 10 minutes.

5.4 Processing Order

Replay protection checks MUST be performed before any command-specific processing:

1. Validate timestamp window
2. Check nonce uniqueness
3. Store nonce
4. Proceed to channel-specific authentication

6. DIRECT_AUTHENTICATED Channel

6.1 Purpose

The DIRECT_AUTHENTICATED channel is used for identity-to-mediator administrative operations. The sender authenticates with their Ed25519 signing key, and the mediator verifies the signature against the sender's DID document.

6.2 Authentication Flow

For commands with channel: "DIRECT_AUTHENTICATED", the mediator MUST:

  1. Resolve the sender's DID to a DID document.

    • If resolution fails → respond with SENDER_NOT_FOUND
  2. Extract the signing public key from the verification method matching sender_signing_key_id.

    • If the key ID is not found → respond with SENDER_SIGNING_KEY_NOT_FOUND
  3. Verify the signature over canonical JSON of { header, payload } using the extracted public key.

    • If verification fails → respond with INVALID_SIGNATURE
  4. Authorization check (depends on command type):

    For REQUEST_COMMUNICATION_CONTRACT and COMMUNICATION_CONTRACT_RESPONSE:

    • If recipient_did equals the mediator's own DID → skip recipient check
    • Otherwise → verify recipient_did is registered with this mediator
    • If not registered → respond with RECIPIENT_NOT_REGISTERED

    For all other commands:

    • Verify recipient_did equals the mediator's own DID
    • If not → respond with UNAUTHORIZED_COMMAND
    • Verify sender_did is registered with this mediator
    • If not → respond with UNAUTHORIZED_COMMAND

7. TWO_WAY_PRIVATE Channel

7.1 Purpose

The TWO_WAY_PRIVATE channel is used for encrypted event delivery between identities. The mediator verifies the sender's signature, checks that the recipient is registered, and confirms a valid communication contract exists.

7.2 Authentication Flow

For commands with channel: "TWO_WAY_PRIVATE", the mediator MUST:

  1. Resolve the sender's DID. Error: SENDER_NOT_FOUND
  2. Extract the signing public key. Error: SENDER_SIGNING_KEY_NOT_FOUND
  3. Verify the signature. Error: INVALID_SIGNATURE
  4. Resolve the recipient's DID. Error: RECIPIENT_NOT_FOUND
  5. Verify the recipient is registered with this mediator. Error: RECIPIENT_NOT_REGISTERED
  6. Verify a communication contract exists: Look up a non-expired SignedCommunicationContract owned by recipient_did where both sender_did and recipient_did appear in the contract (as either requestor or recipient). Error: COMMUNICATION_CONTRACT_NOT_FOUND
  7. Store the pending event: Create a pending event record with the encrypted payload string.

7.3 Response

{
  "type": "SUCCESS",
  "pendingEventId": "<server-generated ID>"
}

The pendingEventId is an opaque string generated by the mediator. Clients use it for acknowledgment.


8. Command Reference

8.1 REQUEST_COMMUNICATION_CONTRACT

Payload:

{
  "type": "REQUEST_COMMUNICATION_CONTRACT",
  "encrypted_contract_request": "<base64 AES-GCM ciphertext>",
  "requestor_ephemeral_public_key": "<base64 X25519 public key>"
}

Behavior:

If recipient_did equals the mediator's DID (registration):

  1. Decrypt the contract request using X25519(mediator_prekey_private, requestor_ephemeral_public) [DCTRL-0003 Section 9]
  2. Generate the mediator's ephemeral key pair
  3. Sign the contract as recipient
  4. Store the signed contract
  5. Return the signed contract

If recipient_did is another identity:

  1. Verify the recipient is registered
  2. Store the encrypted request as a pending contract request for the recipient

Response — registration:

{
  "type": "SUCCESS",
  "code": "MEDIATOR_REGISTRATION_SUCCESS",
  "payload": {
    "signed_communication_contract": { ... }
  }
}

Response — forwarded:

{
  "type": "SUCCESS",
  "code": "REQUESTED"
}

8.2 COMMUNICATION_CONTRACT_RESPONSE

Payload:

{
  "type": "COMMUNICATION_CONTRACT_RESPONSE",
  "signed_communication_contract": { <SignedCommunicationContract> }
}

Behavior:

  1. Verify the sender is a participant in the contract
  2. Verify the other participant is registered with this mediator
  3. Verify both signatures on the contract [DCTRL-0003 Section 8]
  4. Store the contract on behalf of the other participant

Response:

{ "type": "SUCCESS" }

8.3 QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS

Payload:

{
  "type": "QUERY_PENDING_COMMUNICATION_CONTRACT_REQUESTS",
  "pagination": { "page": 0, "page_size": 10 }
}

Returns unacknowledged contract requests where the command sender is the recipient.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "pending_communication_contract_requests": [
      {
        "id": "<server-generated ID>",
        "sender_did": "<DID>",
        "encrypted_contract_request": "<base64>",
        "requestor_ephemeral_public_key": "<base64>"
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 42 }
  }
}

8.4 ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS

Payload:

{
  "type": "ACKNOWLEDGE_PENDING_COMMUNICATION_CONTRACT_REQUESTS",
  "communication_contract_ids": ["<id1>", "<id2>"]
}

Marks the specified contract requests as acknowledged. Requests not belonging to the sender are silently ignored.

Response:

{ "type": "SUCCESS" }

8.5 SAVE_COMMUNICATION_CONTRACT

Payload:

{
  "type": "SAVE_COMMUNICATION_CONTRACT",
  "signed_communication_contract": { <SignedCommunicationContract> }
}

Behavior:

  1. Verify both signatures [DCTRL-0003 Section 8]
  2. Store the contract with owner = sender

Response:

{ "type": "SUCCESS" }

8.6 QUERY_COMMUNICATION_CONTRACTS

Payload:

{
  "type": "QUERY_COMMUNICATION_CONTRACTS",
  "filter": {
    "did": "<DID>",
    "expires_at_before": 1730659200,
    "expires_at_after": 1699123456
  },
  "pagination": { "page": 0, "page_size": 10 }
}

All filter fields are OPTIONAL. did matches as either requestor_did or recipient_did. Returns contracts owned by the command sender.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "communication_contracts": [
      {
        "id": "<server-generated ID>",
        "signed_communication_contract": { ... }
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 7 }
  }
}

8.7 QUERY_PENDING_EVENTS

Payload:

{
  "type": "QUERY_PENDING_EVENTS",
  "filter": {
    "sender_did": "<DID>"
  },
  "pagination": { "page": 0, "page_size": 10 }
}

All filter fields are OPTIONAL. Returns unacknowledged pending events where the command sender is the recipient.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "pending_events": [
      {
        "id": "<server-generated ID>",
        "payload": "<opaque encrypted string>",
        "sender_did": "<DID>"
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 3 }
  }
}

8.8 ACKNOWLEDGE_PENDING_EVENTS

Payload:

{
  "type": "ACKNOWLEDGE_PENDING_EVENTS",
  "event_ids": ["<id1>", "<id2>"]
}

Marks the specified pending events as acknowledged.

Response:

{ "type": "SUCCESS" }

8.9 SAVE_EVENTS

Payload:

{
  "type": "SAVE_EVENTS",
  "events": [
    {
      "sender_did": "<DID>",
      "recipient_did": "<DID>",
      "contract_id": "<optional string>",
      "timestamp": 1699123456,
      "payload": "<base64 storage-encrypted event>",
      "encrypted_tags": ["<base64 tag1>", "<base64 tag2>"]
    }
  ]
}

Events are stored with owner_did = the command sender. If event.sender_did equals the owner (self-authored event), the event is marked as app-processed. Otherwise it is marked unprocessed.

Response:

{ "type": "SUCCESS" }

8.10 QUERY_EVENTS

Payload:

{
  "type": "QUERY_EVENTS",
  "filter": {
    "after_timestamp": 1699000000,
    "before_timestamp": 1699999999,
    "participant_did": "<DID>",
    "encrypted_tags": ["<base64 tag>"],
    "unprocessed_only": true
  },
  "pagination": { "page": 0, "page_size": 10 }
}

All filter fields are OPTIONAL. participant_did matches as either sender_did or recipient_did. encrypted_tags matches events that have at least one matching tag. unprocessed_only returns events not yet processed by the application. Returns events owned by the command sender.

Response:

{
  "type": "SUCCESS",
  "payload": {
    "events": [
      {
        "id": "<server-generated ID>",
        "payload": "<base64 storage-encrypted event>",
        "encrypted_tags": ["<base64 tag1>"],
        "timestamp": 1699123456
      }
    ],
    "pagination": { "page": 0, "page_size": 10, "total": 100 }
  }
}

8.11 UPDATE_EVENT_TAGS

Payload:

{
  "type": "UPDATE_EVENT_TAGS",
  "events": [
    {
      "event_id": "<server-generated ID>",
      "encrypted_tags": ["<base64 tag1>", "<base64 tag2>"]
    }
  ]
}

Replaces all existing tags on the specified events and marks them as app-processed. Events not owned by the command sender are silently skipped.

Response:

{ "type": "SUCCESS" }

9. Pagination

9.1 Request Format

All query commands that return lists MUST support pagination:

{
  "pagination": {
    "page": 0,
    "page_size": 10
  }
}
FieldTypeDefaultDescription
pagenumber0Zero-based page index
page_sizenumber10Number of records per page

The server computes: offset = page * page_size.

9.2 Response Format

{
  "pagination": {
    "page": 0,
    "page_size": 10,
    "total": 42
  }
}
FieldTypeDescription
pagenumberThe requested page index
page_sizenumberThe requested page size
totalnumberTotal number of records matching the filter

Clients determine if more pages exist: has_more = (page + 1) * page_size < total.


10. WebSocket Protocol

10.1 Connection

Clients connect to the mediator's WebSocket endpoint at GET /ws. After the HTTP upgrade, the client MUST authenticate within 10 seconds.

10.2 Authentication

Client → Server:

{
  "type": "AUTHENTICATE",
  "did": "<DID>",
  "signing_key_id": "<DID>#signing",
  "timestamp": 1699123456000,
  "nonce": "<UUID>",
  "signature": "<base64 Ed25519 signature>"
}

The signature is computed over canonical JSON of { did, signing_key_id, timestamp, nonce } using the identity's signing private key [DCTRL-0002].

Server validation:

  1. Verify the message was received within 10 seconds of connection. Error: AUTH_TIMEOUT (close 4001)
  2. Validate message format. Error: INVALID_MESSAGE (close 4002)
  3. Verify timestamp drift < 5 minutes. Error: TIMESTAMP_OUT_OF_RANGE (close 4003)
  4. Resolve the DID. Error: DID_NOT_FOUND (close 4004)
  5. Extract signing key from DID document. Error: SIGNING_KEY_NOT_FOUND (close 4005)
  6. Verify signature. Error: INVALID_SIGNATURE (close 4006)
  7. Verify the identity is registered. Error: NOT_REGISTERED (close 4007)

Server → Client (success):

{ "type": "AUTH_SUCCESS" }

Server → Client (failure):

{ "type": "AUTH_FAILED", "code": "<error code>" }

The server then closes the WebSocket with the corresponding close code.

10.3 Close Codes

CodeErrorDescription
4001AUTH_TIMEOUTNo auth message within 10 seconds
4002INVALID_MESSAGEMalformed auth message
4003TIMESTAMP_OUT_OF_RANGETimestamp drift > 5 minutes
4004DID_NOT_FOUNDCannot resolve DID
4005SIGNING_KEY_NOT_FOUNDKey ID not in DID document
4006INVALID_SIGNATURESignature verification failed
4007NOT_REGISTEREDNo active contract with mediator

10.4 Keepalive

After successful authentication, the server sends a PING message every 30 seconds:

Server → Client:

{ "type": "PING", "timestamp": 1699123456000 }

Client → Server:

{ "type": "PONG", "timestamp": 1699123456000 }

10.5 Push Notifications

After successful authentication, the server pushes notifications for events relevant to the authenticated DID:

PENDING_EVENTS — pushed when a TWO_WAY_PRIVATE event arrives for this identity:

{
  "type": "PENDING_EVENTS",
  "events": [
    {
      "id": "<pending event ID>",
      "sender_did": "<DID>",
      "payload": "<opaque encrypted string>"
    }
  ]
}

CONTRACTS_UPDATED — pushed when a contract involving this identity is created, accepted, or saved:

{ "type": "CONTRACTS_UPDATED" }

This is a signal to refresh contracts via QUERY_COMMUNICATION_CONTRACTS. No contract data is included.

10.6 Notification Triggers

CommandNotificationRecipient
TWO_WAY_PRIVATE (success)PENDING_EVENTSThe event recipient
REQUEST_COMMUNICATION_CONTRACT (success, non-mediator)CONTRACTS_UPDATEDThe contract recipient
COMMUNICATION_CONTRACT_RESPONSE (success)CONTRACTS_UPDATEDThe other contract participant
SAVE_COMMUNICATION_CONTRACT (success)CONTRACTS_UPDATEDThe command sender

11. Error Codes

11.1 Error Response Format

All error responses MUST use this format:

{
  "type": "ERROR",
  "code": "<ERROR_CODE>"
}

11.2 HTTP Status Mapping

Error CodeHTTP StatusDescription
INVALID_COMMAND400Request body fails schema validation or business logic
INVALID_SIGNATURE401Command signature verification failed
INVALID_SIGNATURES401One or both contract signatures failed verification
UNAUTHORIZED_COMMAND401Sender not registered or wrong recipient
TIMESTAMP_OUT_OF_RANGE401Timestamp drift exceeds window
DUPLICATE_NONCE401Nonce already processed for this sender
SENDER_NOT_FOUND404Sender's DID cannot be resolved
SENDER_SIGNING_KEY_NOT_FOUND404Key ID not in sender's DID document
RECIPIENT_NOT_FOUND404Recipient's DID cannot be resolved
RECIPIENT_NOT_REGISTERED404Recipient has no active contract with mediator
COMMUNICATION_CONTRACT_NOT_FOUND404No valid contract between sender and recipient

11.3 Success Response

Successful responses use "type": "SUCCESS" with command-specific additional fields as documented in Section 8.


12. Security Considerations

12.1 Encryption Blindness

Mediators MUST NOT attempt to decrypt event payloads, contract requests, or any encrypted data. Mediators do not hold the key material necessary for decryption (root secrets are derived from ephemeral keys held by the communicating parties; storage keys never leave client devices).

12.2 Replay Prevention

The combination of timestamp validation and nonce deduplication prevents replay attacks. The nonce store MUST be persistent across server restarts within the timestamp window to prevent replay during restarts.

12.3 Signature Verification

Mediators MUST verify Ed25519 signatures on every command before processing. The signature verification ensures command authenticity and integrity.

12.4 Contract Authorization

For TWO_WAY_PRIVATE events, mediators MUST verify that a valid, non-expired communication contract exists. This prevents unauthorized message delivery and provides spam protection.

12.5 Metadata Exposure

Mediators can observe: sender and recipient DIDs, timestamps, message sizes, encrypted tags, and communication patterns. Mediators cannot observe: message contents, contract terms, or tag semantics.

12.6 Server-Generated IDs

All record IDs (pending events, contracts, etc.) are opaque strings generated by the mediator. Clients MUST NOT assume any structure or predictability in these IDs.


13. References

13.1 Normative References

  • [RFC 2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
  • [RFC 6455] Fette, I. and A. Melnikov, "The WebSocket Protocol", RFC 6455, December 2011.
  • [DCTRL-0001] "The did:decentrl DID Method".
  • [DCTRL-0002] "Cryptographic Operations".
  • [DCTRL-0003] "Communication Contracts".

13.2 Informative References

  • [DCTRL-0005] "Event Encryption and Storage".
  • [DCTRL-0006] "Group Messaging".
  • [DCTRL-0007] "Public Channels".