DCTRL-0003: Communication Contracts
Bilateral cryptographic agreements with ephemeral key exchange, mutual signatures, and root secret derivation.
| Field | Value |
|---|---|
| RFC | DCTRL-0003 |
| Title | Communication Contracts |
| Status | Draft |
| Created | 2026-03-14 |
| Version | 0.1 |
| Requires | DCTRL-0001, DCTRL-0002 |
| Required By | DCTRL-0004, DCTRL-0005, DCTRL-0006 |
Abstract
This document specifies communication contracts — bilateral cryptographic agreements that establish secure communication channels between Decentrl identities. A communication contract contains ephemeral encryption keys, mutual Ed25519 signatures proving explicit consent, and expiration terms. This specification defines the contract schema, the creation and acceptance flow, root secret derivation, contract verification, contract rotation, and mediator registration.
Status of This Document
This is a draft specification.
Table of Contents
- Introduction
- Terminology
- Contract Schema
- Contract Creation Flow
- Contract Acceptance Flow
- Root Secret Derivation
- Contract ID Generation
- Contract Verification
- Mediator Registration
- Contract Rotation
- 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].
Communication contracts are the foundation of all private communication in the Decentrl Protocol. Before two identities can exchange encrypted events via the TWO_WAY_PRIVATE channel [DCTRL-0004], they MUST establish a communication contract.
A contract provides:
- Mutual consent: Both parties sign the contract with their Ed25519 signing keys, proving explicit agreement to communicate.
- Shared encryption material: Each party contributes an ephemeral X25519 key pair. The root secret is derived via X25519 key agreement between the two ephemeral keys.
- Expiration: Contracts have a defined lifetime. After expiration, events under this contract are no longer accepted by mediators.
- Spam prevention: Mediators reject TWO_WAY_PRIVATE events unless a valid, non-expired contract exists between sender and recipient.
2. Terminology
Requestor — The party that initiates a communication contract.
Recipient — The party that receives and optionally accepts a contract request.
Ephemeral Key — A fresh X25519 key pair generated for a single communication contract. Each party generates their own ephemeral key pair.
Root Secret — A 32-byte shared secret derived via X25519(own_ephemeral_private, counterpart_ephemeral_public). Used as the AES-256-GCM key for event encryption under this contract.
Contract ID — A deterministic identifier derived from contract fields, used to reference contracts in event envelopes.
Contract Request — A partially signed contract where only the requestor has signed and the recipient's ephemeral key is null.
Signed Contract — A fully signed contract where both parties have contributed ephemeral keys and signatures.
3. Contract Schema
3.1 CommunicationContract
The core contract object that is signed by both parties:
{
"requestor_did": "<DID string>",
"recipient_did": "<DID string>",
"requestor_signing_key_id": "<DID>#signing",
"recipient_signing_key_id": "<DID>#signing",
"requestor_encryption_public_key": "<base64 X25519 ephemeral public key>",
"recipient_encryption_public_key": "<base64 X25519 ephemeral public key> | null",
"expires_at": 1730659200,
"timestamp": 1699123456
}Field definitions:
| Field | Type | Description |
|---|---|---|
requestor_did | string | DID of the party initiating the contract |
recipient_did | string | DID of the party receiving the contract request |
requestor_signing_key_id | string | Full key ID for the requestor's signing verification method (e.g., did:decentrl:...#signing) |
recipient_signing_key_id | string | Full key ID for the recipient's signing verification method |
requestor_encryption_public_key | string | Base64-encoded ephemeral X25519 public key generated by the requestor for this contract |
recipient_encryption_public_key | string | null | Base64-encoded ephemeral X25519 public key generated by the recipient. MUST be null in a contract request (before recipient acceptance) |
expires_at | number | Unix timestamp in seconds when the contract expires |
timestamp | number | Unix timestamp in seconds when the contract was created |
3.2 CommunicationContractRequest
A partial contract signed only by the requestor:
{
"communication_contract": { <CommunicationContract with recipient_encryption_public_key = null> },
"requestor_signature": "<base64 Ed25519 signature>"
}| Field | Type | Description |
|---|---|---|
communication_contract | CommunicationContract | The contract object with recipient_encryption_public_key set to null |
requestor_signature | string | Ed25519 signature of the communication_contract object (with null recipient key), using the requestor's signing private key. Encoded per [DCTRL-0002 Section 8]. |
3.3 SignedCommunicationContract
A fully signed bilateral contract:
{
"communication_contract": { <CommunicationContract with both keys present> },
"requestor_signature": "<base64 Ed25519 signature>",
"recipient_signature": "<base64 Ed25519 signature>"
}| Field | Type | Description |
|---|---|---|
communication_contract | CommunicationContract | The contract object with both ephemeral keys present |
requestor_signature | string | Ed25519 signature of the contract with recipient_encryption_public_key set to null. This is the original signature from the request phase and does NOT change. |
recipient_signature | string | Ed25519 signature of the complete contract (with both ephemeral keys present), using the recipient's signing private key. |
The asymmetric signature scopes are critical: the requestor signs the contract before the recipient's key is known (null), and the recipient signs the completed contract (both keys present). This ensures the requestor's signature cannot be forged after the recipient contributes their key.
4. Contract Creation Flow
When Alice (requestor) wants to establish a contract with Bob (recipient):
Step 1: Generate Ephemeral Key Pair
Alice MUST generate a fresh X25519 key pair for this contract:
alice_ephemeral = X25519_generate_keypair()This key pair MUST be unique to this contract. Implementations MUST NOT reuse ephemeral keys across contracts.
Step 2: Build the Contract Object
contract = {
requestor_did: alice.did,
recipient_did: bob.did,
requestor_signing_key_id: alice.did + "#signing",
recipient_signing_key_id: bob.did + "#signing",
requestor_encryption_public_key: base64_encode(alice_ephemeral.public),
recipient_encryption_public_key: null,
expires_at: current_time + contract_duration,
timestamp: current_time
}The recipient_encryption_public_key MUST be null at this stage.
Step 3: Sign the Contract
requestor_signature = sign_json_object(contract, alice.signing_private_key)The signature covers the contract object with recipient_encryption_public_key = null.
Step 4: Build the Contract Request
contract_request = {
communication_contract: contract,
requestor_signature: requestor_signature
}Step 5: Encrypt the Contract Request
To prevent the mediator from reading the contract details, Alice encrypts the entire contract request:
wrapping_key = X25519(alice_ephemeral.private, bob.pre_key_public)
encrypted_contract_request = AES_GCM_encrypt(
JSON.stringify(contract_request),
wrapping_key
)The wrapping key is derived from Alice's ephemeral private key and Bob's pre-key (from his DID document). Only Bob can derive the same wrapping key using X25519(bob.pre_key_private, alice_ephemeral.public).
Step 6: Store Ephemeral Private Key
Alice MUST store her ephemeral private key, encrypted with her storage key:
encrypted_private = AES_GCM_encrypt(alice_ephemeral.private, alice.storage_key)
store(encrypted_private)This is needed later to derive the root secret when Bob accepts.
Step 7: Send to Mediator
Alice sends the following to Bob's mediator via the DIRECT_AUTHENTICATED channel [DCTRL-0004]:
{
"type": "REQUEST_COMMUNICATION_CONTRACT",
"encrypted_contract_request": "<base64 AES-GCM ciphertext>",
"requestor_ephemeral_public_key": "<base64 X25519 public key>"
}The requestor_ephemeral_public_key is sent in cleartext so Bob can use it (along with his pre-key private) to derive the wrapping key for decryption.
5. Contract Acceptance Flow
When Bob receives a pending contract request:
Step 1: Decrypt the Contract Request
wrapping_key = X25519(bob.pre_key_private, alice_ephemeral_public)
contract_request = JSON.parse(
AES_GCM_decrypt(encrypted_contract_request, wrapping_key)
)The alice_ephemeral_public is the cleartext requestor_ephemeral_public_key from the command payload.
Step 2: Verify the Requestor's Signature
Bob MUST resolve Alice's DID document and extract her signing public key:
alice_signing_public = resolve_did(contract_request.communication_contract.requestor_did)
.verificationMethod[requestor_signing_key_id].publicKey
valid = verify_json_signature(
contract_request.communication_contract, // with recipient_encryption_public_key = null
contract_request.requestor_signature,
alice_signing_public
)If verification fails, Bob MUST reject the contract request.
Step 3: Generate Bob's Ephemeral Key Pair
bob_ephemeral = X25519_generate_keypair()Step 4: Complete the Contract
contract_request.communication_contract.recipient_encryption_public_key =
base64_encode(bob_ephemeral.public)Step 5: Sign the Completed Contract
Bob signs the contract with both ephemeral keys present:
recipient_signature = sign_json_object(
contract_request.communication_contract, // with BOTH keys present
bob.signing_private_key
)Step 6: Build the Signed Contract
signed_contract = {
communication_contract: contract_request.communication_contract,
requestor_signature: contract_request.requestor_signature, // unchanged
recipient_signature: recipient_signature
}Step 7: Store Ephemeral Private Key
Bob MUST store his ephemeral private key, encrypted with his storage key:
encrypted_private = AES_GCM_encrypt(bob_ephemeral.private, bob.storage_key)
store(encrypted_private)Step 8: Distribute the Signed Contract
Bob sends the signed contract to Alice's mediator via COMMUNICATION_CONTRACT_RESPONSE [DCTRL-0004] and stores a copy on his own mediator via SAVE_COMMUNICATION_CONTRACT [DCTRL-0004].
6. Root Secret Derivation
After a contract is fully signed, both parties independently derive the root secret:
Alice: root_secret = X25519(alice_ephemeral_private, bob_ephemeral_public)
Bob: root_secret = X25519(bob_ephemeral_private, alice_ephemeral_public)Both computations produce the identical 32-byte root secret due to the commutative property of X25519 key agreement.
6.1 Properties
- No secret transport: The root secret is never transmitted over the network. Each party derives it locally.
- Pre-key independence: After derivation, the pre-key is no longer involved. The pre-key can be rotated in the DID document without affecting existing contracts.
- Contract isolation: Each contract has its own ephemeral key pair per party, producing a unique root secret. Compromising one contract's root secret does not affect any other contract.
6.2 Ephemeral Key Recovery
To derive the root secret, each party needs their own ephemeral private key (stored encrypted with the storage key) and the counterpart's ephemeral public key (stored in the signed contract's communication_contract object).
// Alice recovering the root secret
alice_ephemeral_private = AES_GCM_decrypt(stored_encrypted_key, alice.storage_key)
bob_ephemeral_public = base64_decode(
signed_contract.communication_contract.recipient_encryption_public_key
)
root_secret = X25519(alice_ephemeral_private, bob_ephemeral_public)7. Contract ID Generation
The contract ID is a deterministic identifier derived from contract fields. It is used in event envelopes [DCTRL-0005] to reference which contract's root secret to use for decryption.
7.1 Algorithm
function generate_contract_id(contract):
input = contract.requestor_did
+ contract.recipient_did
+ string(contract.timestamp)
+ contract.requestor_encryption_public_key
return base64_encode(SHA256(UTF8_encode(input)))The input is the string concatenation (not JSON) of the four fields. The result is the SHA-256 hash of the UTF-8 encoded concatenation, base64-encoded.
7.2 Properties
- Deterministic: Both parties compute the same contract ID from the same contract fields.
- Unique: The combination of both DIDs, timestamp, and ephemeral key is unique per contract.
- Client-side only: The contract ID is computed by clients. Mediators do not generate, validate, or index contract IDs.
8. Contract Verification
8.1 Verifying a Signed Contract
To verify a SignedCommunicationContract, implementations MUST:
-
Verify the requestor's signature against the contract with
recipient_encryption_public_key = null:contract_for_requestor_verification = { ...contract, recipient_encryption_public_key: null } valid = verify_json_signature( contract_for_requestor_verification, requestor_signature, requestor_signing_public_key ) -
Verify the recipient's signature against the complete contract (both keys present):
valid = verify_json_signature( contract, // with both ephemeral keys recipient_signature, recipient_signing_public_key ) -
Resolve both DIDs and extract the signing public keys from the verification methods referenced by
requestor_signing_key_idandrecipient_signing_key_id. -
Check expiration:
contract.expires_at > current_time_in_seconds.
If any step fails, the contract MUST be rejected.
8.2 Signature Scope Rationale
The requestor signs the contract before the recipient's ephemeral key is known. The recipient signs the completed contract. This asymmetry is by design:
- The requestor's signature proves they initiated this specific contract with this specific ephemeral key. Their signature cannot be transplanted to a different contract because it covers the requestor's ephemeral key and both DIDs.
- The recipient's signature proves they accepted this specific contract and contributed their specific ephemeral key. Their signature covers the complete contract including the requestor's ephemeral key, preventing substitution attacks.
9. Mediator Registration
Registration with a mediator is a special case of the communication contract flow. An identity registers by establishing a contract with the mediator's own DID.
9.1 Flow
- The identity sends a
REQUEST_COMMUNICATION_CONTRACTwhererecipient_didequals the mediator's DID. - The mediator decrypts the contract request using its own pre-key.
- The mediator auto-accepts: it generates its own ephemeral key pair, signs the contract, and stores it.
- The mediator returns the signed contract in the response.
- The identity stores the signed contract.
9.2 Registration Check
An identity is considered "registered" with a mediator if and only if a non-expired SignedCommunicationContract exists where the identity's DID appears as either requestor_did or recipient_did and the contract is owned by (stored on behalf of) the mediator.
There is no separate registration mechanism, table, or protocol message. The communication contract IS the registration.
9.3 Registration Expiration
When the registration contract expires, the identity is no longer registered. The identity MUST establish a new contract with the mediator to re-register.
10. Contract Rotation
Contract rotation replaces an existing contract with a new one, establishing fresh cryptographic material. Rotation is the primary mechanism for limiting the exposure window of a compromised root secret.
10.1 Rotation Is Standard Contract Creation
Rotation is NOT a special protocol operation. It follows the standard contract creation flow from Section 4. The initiator creates a new contract request with fresh ephemeral keys, the counterpart accepts, and both derive a new root secret.
10.2 Rotation Triggers
Rotation is a client-side policy decision. Each client independently tracks contract state and initiates rotation when its threshold is reached:
- Time-based: A configurable interval (e.g., every 24 hours of active use)
- Event-count-based: After a configurable number of events (e.g., every 1,000 events)
- Manual: Either party explicitly triggers rotation
- Suspicion: Rotation SHOULD be triggered immediately if a compromise is suspected
10.3 Concurrent Contracts
Multiple contracts MAY exist between the same two parties simultaneously (during rotation transitions). The contract_id in event envelopes [DCTRL-0005] disambiguates which root secret to use for decryption.
If both parties initiate rotation simultaneously, two new contracts are created. Both are valid. The parties converge naturally as each starts sending under their own new contract.
10.4 Old Contract Cleanup
After a new contract is established, the old contract's root secret MUST be retained until it is safe to discard:
- Continue accepting and decrypting events that reference the old
contract_id - The counterparty has confirmed the switch when they send at least one event under a new
contract_id - Once all pending events under the old contract have been processed and the counterparty has confirmed the switch, the old root secret and ephemeral private key SHOULD be deleted
- If the counterparty has not confirmed within 7 days, implementations SHOULD delete the old key material regardless
10.5 Deterministic Initiator
To prevent redundant simultaneous rotations, implementations MAY use a deterministic initiator rule: the party whose DID is lexicographically lower initiates rotation. This is a client-side convention, not a protocol requirement.
11. Security Considerations
11.1 Ephemeral Key Uniqueness
Each contract MUST use freshly generated ephemeral X25519 key pairs. Reusing ephemeral keys across contracts would allow an attacker who compromises one contract's root secret to derive root secrets for all contracts sharing the same ephemeral key.
11.2 Pre-Key Compromise
If the pre-key is compromised, an attacker can decrypt inbound contract requests (by deriving the wrapping key). However:
- Existing contracts are unaffected (they use independent ephemeral keys)
- The attacker cannot complete a man-in-the-middle attack without also compromising the signing key (both parties verify signatures)
- Remediation: Rotate the pre-key in the DID document (requires creating a new
did:decentrlidentity)
11.3 Contract Replay
A signed contract cannot be replayed because:
- The
timestampfield ensures uniqueness - The ephemeral keys are fresh per contract
- The contract ID is derived from these unique values
11.4 Bilateral Consent
The dual-signature requirement ensures both parties explicitly consent to the communication relationship. A party cannot be added to a contract without their active participation (generating an ephemeral key and signing).
11.5 Mediator Blindness
The contract request payload is encrypted with a wrapping key derived from the requestor's ephemeral key and the recipient's pre-key. The mediator stores the encrypted blob without access to contract terms, ephemeral keys, or signatures.
11.6 Forward Secrecy via Rotation
The protocol does not provide per-event forward secrecy (see [DCTRL-0005] for rationale). Contract rotation provides bounded forward secrecy: compromising a root secret exposes only events within that contract's rotation window.
12. References
12.1 Normative References
- [RFC 2119] Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.
- [DCTRL-0001] "The
did:decentrlDID Method". - [DCTRL-0002] "Cryptographic Operations".
12.2 Informative References
- [DCTRL-0004] "Mediator Protocol".
- [DCTRL-0005] "Event Encryption and Storage".
- [DCTRL-0006] "Group Messaging".