DCTRL-0004: Mediator Protocol
HTTP API, command wire format, authentication, WebSocket real-time protocol, and error handling.
| Field | Value |
|---|---|
| RFC | DCTRL-0004 |
| Title | Mediator Protocol |
| Status | Draft |
| Created | 2026-03-14 |
| Version | 0.1 |
| Requires | DCTRL-0001, DCTRL-0002, DCTRL-0003 |
| Required By | DCTRL-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
- Introduction
- Terminology
- HTTP API
- Command Wire Format
- Replay Protection
- DIRECT_AUTHENTICATED Channel
- TWO_WAY_PRIVATE Channel
- Command Reference
- Pagination
- WebSocket Protocol
- Error Codes
- 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].
A mediator is a network participant identified by its own DID (typically did:web) that provides two core services:
- Message routing: Accepting encrypted events from senders and making them available to recipients.
- 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:
| Method | Path | Content-Type | Description |
|---|---|---|---|
GET / | application/json | Returns the mediator's DID document | |
GET /.well-known/did.json | application/json | Returns the mediator's DID document (did:web resolution) | |
GET /health | application/json | Health check | |
POST / | application/json | All mediator commands | |
GET /public/:did | application/json | Public channel events [DCTRL-0007] | |
GET /ws | — | WebSocket 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
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | REQUIRED | "DIRECT_AUTHENTICATED", "TWO_WAY_PRIVATE", or "ONE_WAY_PUBLIC" [DCTRL-0007] |
sender_did | string | REQUIRED | DID of the command sender |
sender_signing_key_id | string | REQUIRED | Full key ID of the sender's signing key |
recipient_did | string | REQUIRED | DID of the intended recipient |
timestamp | number | REQUIRED | Unix timestamp in milliseconds (Date.now()) |
nonce | string | REQUIRED | UUID 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_RANGEThe 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_NONCEAfter 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 authentication6. 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:
-
Resolve the sender's DID to a DID document.
- If resolution fails → respond with
SENDER_NOT_FOUND
- If resolution fails → respond with
-
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
- If the key ID is not found → respond with
-
Verify the signature over canonical JSON of
{ header, payload }using the extracted public key.- If verification fails → respond with
INVALID_SIGNATURE
- If verification fails → respond with
-
Authorization check (depends on command type):
For
REQUEST_COMMUNICATION_CONTRACTandCOMMUNICATION_CONTRACT_RESPONSE:- If
recipient_didequals the mediator's own DID → skip recipient check - Otherwise → verify
recipient_didis registered with this mediator - If not registered → respond with
RECIPIENT_NOT_REGISTERED
For all other commands:
- Verify
recipient_didequals the mediator's own DID - If not → respond with
UNAUTHORIZED_COMMAND - Verify
sender_didis registered with this mediator - If not → respond with
UNAUTHORIZED_COMMAND
- If
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:
- Resolve the sender's DID. Error:
SENDER_NOT_FOUND - Extract the signing public key. Error:
SENDER_SIGNING_KEY_NOT_FOUND - Verify the signature. Error:
INVALID_SIGNATURE - Resolve the recipient's DID. Error:
RECIPIENT_NOT_FOUND - Verify the recipient is registered with this mediator. Error:
RECIPIENT_NOT_REGISTERED - Verify a communication contract exists: Look up a non-expired
SignedCommunicationContractowned byrecipient_didwhere bothsender_didandrecipient_didappear in the contract (as either requestor or recipient). Error:COMMUNICATION_CONTRACT_NOT_FOUND - 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):
- Decrypt the contract request using
X25519(mediator_prekey_private, requestor_ephemeral_public)[DCTRL-0003 Section 9] - Generate the mediator's ephemeral key pair
- Sign the contract as recipient
- Store the signed contract
- Return the signed contract
If recipient_did is another identity:
- Verify the recipient is registered
- 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:
- Verify the sender is a participant in the contract
- Verify the other participant is registered with this mediator
- Verify both signatures on the contract [DCTRL-0003 Section 8]
- 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:
- Verify both signatures [DCTRL-0003 Section 8]
- 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
}
}| Field | Type | Default | Description |
|---|---|---|---|
page | number | 0 | Zero-based page index |
page_size | number | 10 | Number of records per page |
The server computes: offset = page * page_size.
9.2 Response Format
{
"pagination": {
"page": 0,
"page_size": 10,
"total": 42
}
}| Field | Type | Description |
|---|---|---|
page | number | The requested page index |
page_size | number | The requested page size |
total | number | Total 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:
- Verify the message was received within 10 seconds of connection. Error:
AUTH_TIMEOUT(close 4001) - Validate message format. Error:
INVALID_MESSAGE(close 4002) - Verify timestamp drift < 5 minutes. Error:
TIMESTAMP_OUT_OF_RANGE(close 4003) - Resolve the DID. Error:
DID_NOT_FOUND(close 4004) - Extract signing key from DID document. Error:
SIGNING_KEY_NOT_FOUND(close 4005) - Verify signature. Error:
INVALID_SIGNATURE(close 4006) - 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
| Code | Error | Description |
|---|---|---|
| 4001 | AUTH_TIMEOUT | No auth message within 10 seconds |
| 4002 | INVALID_MESSAGE | Malformed auth message |
| 4003 | TIMESTAMP_OUT_OF_RANGE | Timestamp drift > 5 minutes |
| 4004 | DID_NOT_FOUND | Cannot resolve DID |
| 4005 | SIGNING_KEY_NOT_FOUND | Key ID not in DID document |
| 4006 | INVALID_SIGNATURE | Signature verification failed |
| 4007 | NOT_REGISTERED | No 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
| Command | Notification | Recipient |
|---|---|---|
TWO_WAY_PRIVATE (success) | PENDING_EVENTS | The event recipient |
REQUEST_COMMUNICATION_CONTRACT (success, non-mediator) | CONTRACTS_UPDATED | The contract recipient |
COMMUNICATION_CONTRACT_RESPONSE (success) | CONTRACTS_UPDATED | The other contract participant |
SAVE_COMMUNICATION_CONTRACT (success) | CONTRACTS_UPDATED | The 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 Code | HTTP Status | Description |
|---|---|---|
INVALID_COMMAND | 400 | Request body fails schema validation or business logic |
INVALID_SIGNATURE | 401 | Command signature verification failed |
INVALID_SIGNATURES | 401 | One or both contract signatures failed verification |
UNAUTHORIZED_COMMAND | 401 | Sender not registered or wrong recipient |
TIMESTAMP_OUT_OF_RANGE | 401 | Timestamp drift exceeds window |
DUPLICATE_NONCE | 401 | Nonce already processed for this sender |
SENDER_NOT_FOUND | 404 | Sender's DID cannot be resolved |
SENDER_SIGNING_KEY_NOT_FOUND | 404 | Key ID not in sender's DID document |
RECIPIENT_NOT_FOUND | 404 | Recipient's DID cannot be resolved |
RECIPIENT_NOT_REGISTERED | 404 | Recipient has no active contract with mediator |
COMMUNICATION_CONTRACT_NOT_FOUND | 404 | No 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:decentrlDID 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".