decentrl.
Specifications

DCTRL-0003: Communication Contracts

Bilateral cryptographic agreements with ephemeral key exchange, mutual signatures, and root secret derivation.

FieldValue
RFCDCTRL-0003
TitleCommunication Contracts
StatusDraft
Created2026-03-14
Version0.1
RequiresDCTRL-0001, DCTRL-0002
Required ByDCTRL-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

  1. Introduction
  2. Terminology
  3. Contract Schema
  4. Contract Creation Flow
  5. Contract Acceptance Flow
  6. Root Secret Derivation
  7. Contract ID Generation
  8. Contract Verification
  9. Mediator Registration
  10. Contract Rotation
  11. Security Considerations
  12. 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:

FieldTypeDescription
requestor_didstringDID of the party initiating the contract
recipient_didstringDID of the party receiving the contract request
requestor_signing_key_idstringFull key ID for the requestor's signing verification method (e.g., did:decentrl:...#signing)
recipient_signing_key_idstringFull key ID for the recipient's signing verification method
requestor_encryption_public_keystringBase64-encoded ephemeral X25519 public key generated by the requestor for this contract
recipient_encryption_public_keystring | nullBase64-encoded ephemeral X25519 public key generated by the recipient. MUST be null in a contract request (before recipient acceptance)
expires_atnumberUnix timestamp in seconds when the contract expires
timestampnumberUnix 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>"
}
FieldTypeDescription
communication_contractCommunicationContractThe contract object with recipient_encryption_public_key set to null
requestor_signaturestringEd25519 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>"
}
FieldTypeDescription
communication_contractCommunicationContractThe contract object with both ephemeral keys present
requestor_signaturestringEd25519 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_signaturestringEd25519 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:

  1. 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
    )
  2. 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
    )
  3. Resolve both DIDs and extract the signing public keys from the verification methods referenced by requestor_signing_key_id and recipient_signing_key_id.

  4. 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

  1. The identity sends a REQUEST_COMMUNICATION_CONTRACT where recipient_did equals the mediator's DID.
  2. The mediator decrypts the contract request using its own pre-key.
  3. The mediator auto-accepts: it generates its own ephemeral key pair, signs the contract, and stores it.
  4. The mediator returns the signed contract in the response.
  5. 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:

  1. Continue accepting and decrypting events that reference the old contract_id
  2. The counterparty has confirmed the switch when they send at least one event under a new contract_id
  3. 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
  4. 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:decentrl identity)

11.3 Contract Replay

A signed contract cannot be replayed because:

  • The timestamp field ensures uniqueness
  • The ephemeral keys are fresh per contract
  • The contract ID is derived from these unique values

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:decentrl DID Method".
  • [DCTRL-0002] "Cryptographic Operations".

12.2 Informative References

  • [DCTRL-0004] "Mediator Protocol".
  • [DCTRL-0005] "Event Encryption and Storage".
  • [DCTRL-0006] "Group Messaging".