DCTRL-0003Communication Contracts
Bilateral cryptographic agreements with ephemeral key exchange, mutual signatures, daily key epochs, and unilateral revocation.
| 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 and mutual Ed25519 signatures proving explicit consent. A contract is consent: it has no expiry and stays valid until either party revokes it. This specification defines the contract schema, the creation and acceptance flow, root secret derivation, contract verification, mediator registration, the daily key epochs that encrypt traffic under a contract, the one-contract-per-pair rule, and revocation.
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
- Key Epochs
- One Contract per Pair
- Revocation
- 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. Consent lasts until either party revokes it [Section 12]; contracts do not expire.
- 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 and used once, to start a chain of daily epoch keys [Section 10].
- Spam prevention: Mediators reject TWO_WAY_PRIVATE events unless the recipient holds a contract with the sender.
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 once to derive the contract's first chain key [Section 10], then wiped. It is never stored and never used as an encryption key.
Epoch — A one-day period of a contract, counted from the contract's timestamp. Each epoch has its own encryption key [Section 10].
Key State — A party's local, secret state for one contract: the current epoch and its chain key, plus the keys of recent earlier epochs [Section 10.2]. Key state is never part of the contract and never leaves the device.
Contract ID — A deterministic identifier derived from contract fields, used to reference contracts in event payloads and envelopes and to order two contracts of the same pair.
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",
"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) |
timestamp | number | Unix timestamp in seconds when the contract was requested. Anchors the key epochs [Section 10] and orders contracts of the same pair [Section 11] |
A contract has no expiry field. It stays valid until one of the parties revokes it [Section 12].
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>",
"invitation": { "admin_did": "<DID>", "admin_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]. |
invitation | object | OPTIONAL. Present when the requestor is a group identity inviting the recipient into its group [Section 3.4] |
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.
3.4 Group Invitation
A group identity invites a member by requesting a contract with it [DCTRL-0006 Section 6]. The group's admin marks the request as an invitation with the invitation field of the request:
| Field | Type | Description |
|---|---|---|
admin_did | string | The inviting admin's personal DID |
admin_signature | string | Ed25519 signature by admin_did's signing key over the canonical JSON [DCTRL-0002] of { type: "decentrl.group.invitation", communication_contract }, where communication_contract is this request's contract (recipient key null) |
invitation = {
admin_did: admin.did,
admin_signature: sign_json_object(
{ type: "decentrl.group.invitation", communication_contract: contract },
admin.signing_private_key
)
}The invitation belongs to the request only. It is never part of the signed contract [Section 3.3], neither party signs it into the contract, and mediators never see it: it travels inside the encrypted request [Section 4, Step 5] and is not stored with the contract.
The recipient verifies it after decrypting the request [Section 5, Step 2]. The invitation holds only if:
admin_didis adid:decentrlidentity whose signing key can be read from the DID, and is not the requestor;admin_signatureverifies over this request's own contract.
Because the signature covers the contract, which names both parties, their ephemeral key and the timestamp, it cannot be copied onto another request. An invitation that does not hold is ignored: the request is then an ordinary contract request, and accepting it does not join a group [DCTRL-0006 Section 6.2]. A request without an invitation is handled exactly as before.
A valid invitation tells the recipient that admin_did invites it to the group named by the requestor's DID. It does not prove that admin_did is the group's current admin [DCTRL-0006 Section 15.3].
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,
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
}A group identity inviting a member adds the admin's invitation [Section 3.4].
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. Once the signed contract arrives and Alice has derived her key state from it, she MUST delete the stored ephemeral private key [Section 6.2].
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.
Bob MUST also reject the request if its timestamp is more than 5 minutes in the future or more than 30 days in the past. The timestamp anchors every key epoch [Section 10], so a request made long ago would start the contract deep in its chain.
If the request carries an invitation, Bob verifies it as in [Section 3.4] and can show whom it comes from before accepting. The invitation plays no part in the rest of the flow.
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: Derive the Key State
Bob derives the root secret right away, turns it into the contract's first chain key, and wipes both the root secret and his ephemeral private key [Section 6]:
root_secret = X25519(bob_ephemeral.private, alice_ephemeral_public)
key_state = { epoch: 0, chain_key: derive_initial_chain_key(root_secret, contract_id), retained: {} }
wipe(root_secret)
wipe(bob_ephemeral.private)
store(key_state)Bob never stores his ephemeral private key.
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 (Bob while accepting, Alice when the signed contract reaches her):
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.
- Used once: The root secret is the input to the contract's first chain key [DCTRL-0002 Section 12] and is wiped immediately afterwards. It is never stored and never encrypts anything.
- 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 Lifetime
An ephemeral private key lives only until the root secret has been derived from it:
- Bob (recipient) derives the root secret while accepting and wipes his ephemeral private key in the same step. It is never stored.
- Alice (requestor) keeps her ephemeral private key, encrypted with her storage key, from the request until the signed contract arrives. She then derives the root secret and the key state, and deletes the stored key:
// Alice, when the signed contract arrives
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)
key_state = { epoch: 0, chain_key: derive_initial_chain_key(root_secret, contract_id), retained: {} }
wipe(root_secret)
wipe(alice_ephemeral_private)
delete(stored_encrypted_key)After this, nobody can derive the root secret again. A device that did not take part in the handshake (for example a second device of the same identity, or one whose local storage was cleared) holds no key state for the contract. The contract is then unkeyed on that device: it still exists, but the device can neither send nor read under it. To reconnect, the device requests a new contract, which replaces the old one [Section 11].
7. Contract ID Generation
The contract ID is a deterministic identifier derived from contract fields. It salts the contract's first chain key [Section 10], and event payloads carry it [DCTRL-0005] so the recipient can pick the contract's key directly.
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.
- Mostly client-side: Clients use the contract ID to select keys. Mediators compute it only to order two contracts of the same pair [Section 11]; they do not index it and do not check the
contract_idthat event payloads carry.
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.
If any step fails, the contract MUST be rejected.
A signed contract carries no expiry, so verification has no time check. The timestamp window of Section 5 applies when a request is accepted, not when a signed contract is verified later.
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 checks the request's
timestampagainst the same window as Section 5 (at most 5 minutes in the future, at most 30 days in the past), then auto-accepts: it generates its own ephemeral key pair, signs the contract, and stores it [Section 11]. - 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 SignedCommunicationContract exists between the identity and the mediator — the identity as requestor_did, the mediator as recipient_did — and the contract is owned by (stored on behalf of) the mediator. Contracts between two identities that happen to be stored on the mediator do not register either party.
Because the mediator's DID is part of this match, changing the mediator's DID (for example its DOMAIN) invalidates every existing registration; identities must register again.
There is no separate registration mechanism, table, or protocol message. The communication contract IS the registration.
9.3 Registration Lifetime
Registration is permanent: the registration contract never expires. An identity that registers again stores a newer registration contract, which replaces the previous one [Section 11].
10. Key Epochs
A contract is long-lived, but the key that encrypts traffic under it is not. Traffic keys roll over daily along a one-way hash chain [DCTRL-0002 Section 12], so a party that has moved past an epoch and deleted its key can no longer decrypt ciphertext from that epoch.
10.1 Epoch Numbering
Epochs are one day long (EPOCH_SECONDS = 86400) and counted from the contract's timestamp:
epoch_at(now) = max(0, floor((now - contract.timestamp) / 86400))Both parties compute the same epoch from the signed contract and their clocks. No coordination messages are exchanged.
10.2 Key State
Each party keeps, per contract and per device:
key_state = {
epoch: <number>, // the epoch chain_key belongs to
chain_key: <base64 32 bytes>, // chain key of that epoch
retained: { <epoch>: <base64 epoch key>, ... } // keys of earlier epochs still needed
}Key state starts at epoch 0 with chain_0 [DCTRL-0002 Section 12.2], derived from the root secret when the contract is completed [Section 6.2]. It is stored on the device, keyed by contract ID, and never sent to a mediator. Implementations MUST persist every advance and every pruning, so a restart never brings back a deleted key.
Advancing the key state hashes the chain forward to a target epoch. The epoch keys of the epochs it passes are kept in retained, within the retention cap [Section 10.5]; the old chain keys are discarded. The state never moves backwards. Implementations MUST refuse to advance more than 3660 epochs (ten years) in one step.
10.3 Sending
To send an event, the sender advances its key state to the current epoch and encrypts under that epoch's key [DCTRL-0005 Section 5]. The advanced state MUST be committed only after the recipient's mediator has accepted the event; a refused send leaves the key state unchanged.
10.4 Receiving
The event payload names the epoch the sender used [DCTRL-0005 Section 4]. With current the epoch by the receiver's clock and state its key state:
Claimed epoch e | Result |
|---|---|
e > current + 1 | Rejected: EPOCH_IN_FUTURE. One epoch of slack covers a sender whose clock runs slightly ahead; beyond that the event is left pending and retried once the receiver's clock catches up |
e < state.epoch and retained[e] exists | Decrypt with retained[e] |
e < state.epoch and no retained key | Rejected: EPOCH_EXPIRED |
e == state.epoch | Decrypt with the current epoch key |
e > state.epoch + 3660 | Rejected: EPOCH_TOO_FAR |
otherwise (e > state.epoch) | Advance a copy of the state to e and decrypt with its key |
An advance made while receiving MUST be committed only after the event has decrypted and its envelope signature has verified [DCTRL-0005 Section 9]. Otherwise forged ciphertext claiming a future epoch could make a receiver discard keys it still needs.
10.5 Retention and Pruning
Old epoch keys are deleted as soon as they are no longer needed:
- A party deletes old epoch keys only after it has read its complete queue of pending events. It keeps the key of the previous epoch (for events still in transit across the day boundary) and the key of any epoch an event still left pending needs.
- Independently of pending events, at most 31 past epoch keys (
MAX_RETAINED_EPOCHS) are kept. - Pruning also moves an idle chain forward to the oldest epoch still kept, so a contract without traffic does not keep a chain key that opens its older ciphertext.
An event whose epoch key is gone can no longer be read and is dropped [DCTRL-0005 Section 9].
10.6 Idle Contracts
A chain advances when an event is sent or received, and after every complete read of the pending queue [Section 10.5]. A device that stops syncing keeps its chain where it last was, and can still decrypt that epoch's ciphertext until it syncs again. Advancing chains on a timer, without a sync, is not specified.
10.7 What Key Epochs Protect
Key epochs give forward secrecy to transit ciphertext: once both parties have moved past an epoch and pruned its key, a later compromise of either device cannot decrypt events from that epoch that were intercepted in transit or left pending on a mediator.
They do not provide:
- Per-message forward secrecy. All events of one epoch share a key; this is not a per-message ratchet.
- Protection of stored history. Events stored on a mediator are encrypted under the owner's storage key [DCTRL-0005 Section 6], which a device compromise exposes, whatever the epoch.
- Replay protection within retained epochs. A captured event whose epoch key is still retained decrypts and verifies again if it is re-queued. Epochs do not prevent this; the envelope's signed event ID does: the repeat is recognised as a duplicate and applied once [DCTRL-0005 Section 15.6].
- Protection on devices that do not sync [Section 10.6]: a chain only moves forward when the device sends, receives, or reads its whole pending queue.
11. One Contract per Pair
11.1 Rule
A mediator keeps at most one contract per owner and counterpart. Whenever it would store a contract (SAVE_COMMUNICATION_CONTRACT, COMMUNICATION_CONTRACT_RESPONSE, or a registration [DCTRL-0004 Section 8]), it compares the new contract with the one it already holds for that owner and pair:
- The newer contract wins: the later
timestamp, and for equal timestamps the greater contract ID (compared as strings). - A new contract that is newer replaces the stored one.
- A new contract that is older than or equal to the stored one is ignored. The command still succeeds, so a replay reveals nothing.
Writes to one owner's contracts are serialized, so concurrent stores cannot leave two contracts behind.
11.2 Crossing Requests
If Alice and Bob request each other at the same time and both accept, two valid contracts exist. Because the order above is total, every mediator and both clients keep the same one. Clients discard the key state of the contract that lost.
11.3 Requesting Again
Requesting a new contract with someone you already have a contract with is how a pair reconnects, for example from a device that holds no key state for the current contract [Section 6.2]. Once accepted, the new contract replaces the old one on both mediators. Existing contacts are not subject to the recipient mediator's limit on requests from strangers [DCTRL-0004 Section 8.1].
12. Revocation
12.1 Unilateral and Hidden
Either party can withdraw consent at any time, without the other's cooperation and without telling them. The revoker sends REVOKE_COMMUNICATION_CONTRACT with the counterpart's DID to its own mediator only [DCTRL-0004 Section 8.7]. That mediator:
- Deletes the revoker's contract with the counterpart
- Deletes pending events from the counterpart to the revoker
- Deletes pending contract requests from the counterpart to the revoker
- Records a revocation for the pair, with the time it happened
The revoker's client deletes its key state for the contract and any ephemeral key kept for an unanswered request to the counterpart.
Revocation is idempotent, and succeeds for a DID the revoker never had a contract with.
12.2 What the Counterpart Sees
Nothing is sent to the counterpart. Its own copy of the contract, on its own mediator, is untouched. The next event it sends to the revoker is refused with COMMUNICATION_CONTRACT_NOT_FOUND, exactly the response a stranger gets.
The counterpart may request a new contract. It is then treated as a stranger, including the recipient mediator's limit on pending requests from strangers [DCTRL-0004 Section 8.1]. If the revoker accepts, the new contract is stored normally.
12.3 Revocation Record
The revocation record makes revocation stick. It holds the revocation time and the revoked contract's position in the contract order [Section 11]. A contract for the pair is stored for the revoker again only if it is newer than the revoked contract and was created no more than the request clock skew (5 minutes) before the revocation time. The first rule needs no clocks and stops replays of the revoked contract; the second stops old requests that were never stored, while letting a new request from a device whose clock runs slightly behind the mediator through. A replayed COMMUNICATION_CONTRACT_RESPONSE or SAVE_COMMUNICATION_CONTRACT is ignored and the command still succeeds, so the replay reveals nothing.
13. Security Considerations
13.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.
13.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)
13.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
A mediator stores a replayed signed contract only if it is newer than the contract it holds for the pair [Section 11] and was created after any revocation of the pair [Section 12.3]. A recipient refuses to accept a request whose timestamp is more than 30 days old [Section 5].
13.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).
13.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. While a request is pending, the mediator stores the encrypted blob without access to the terms inside it; it does see the requestor's DID, the recipient's DID, and the cleartext requestor_ephemeral_public_key.
Once established, however, signed contracts are stored on and verified by mediators in the clear (COMMUNICATION_CONTRACT_RESPONSE, SAVE_COMMUNICATION_CONTRACT [DCTRL-0004 Section 8]). A mediator must verify and enforce contracts, so it learns their terms: participant DIDs, ephemeral public keys, timestamps, and signatures. It therefore knows who has a contract with whom. Each event payload also names its contract ID and epoch [DCTRL-0005 Section 4]; the mediator already knows the pair, so this adds which contract and which day the key belongs to. The revoker's mediator knows about a revocation. A mediator never learns root secrets, chain keys, or epoch keys, which require an ephemeral private key [DCTRL-0004 Section 12.5].
13.6 Forward Secrecy via Key Epochs
The protocol does not provide per-event forward secrecy (see [DCTRL-0005] for rationale). Daily key epochs [Section 10] provide forward secrecy for transit ciphertext at a granularity of one day, once both parties have pruned the old keys. See Section 10.7 for what this does not cover.
13.7 Revocation Is Not Retraction
Revocation withdraws consent for the future. It does not delete events the counterpart already received or stored, nor the counterpart's copy of the contract. It only guarantees that the revoker's mediator stops accepting the counterpart's events and forgets what the counterpart still had queued for the revoker.
14. References
14.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".
14.2 Informative References
- [DCTRL-0004] "Mediator Protocol".
- [DCTRL-0005] "Event Encryption and Storage".
- [DCTRL-0006] "Group Messaging".