decentrl/docs
Protocol

Alice and Bob

The complete protocol lifecycle — from identity creation to encrypted messaging — in a single walkthrough.

This is the complete Decentrl lifecycle. We'll follow Alice and Bob from key generation to encrypted conversation, showing how every piece fits together.

1. Alice Creates Her Identity

Alice's application generates three cryptographic keys on her device:

signing_keypair = Ed25519_generate()    // proves identity
pre_keypair     = X25519_generate()     // enables first contact
storage_key     = random_bytes(32)      // encrypts stored data

These keys produce her DID — a self-contained identifier that embeds her public keys and chosen mediator:

did:decentrl:{base64(alias)}:{base58btc(signing_pub)}:{base58btc(pre_pub)}:{base64(mediator_did)}

Anyone with Alice's DID can immediately extract her public signing key, her pre-key for initiating contact, and her mediator's address. No registry lookup. No network request.

2. Alice Registers with Her Mediator

Before Alice can receive messages, she must register with her mediator. Registration is itself a communication contract — Alice establishes a bilateral agreement with the mediator's own DID:

1. Alice creates a contract request where recipient_did = mediator_did
2. She encrypts it using X25519(her_ephemeral_private, mediator_prekey)
3. She sends it to the mediator via DIRECT_AUTHENTICATED channel
4. The mediator decrypts, auto-accepts, signs the contract
5. Both store the signed bilateral contract

There is no special registration table. A contract with the mediator is registration, and it doesn't expire. The same primitive that handles user-to-user relationships handles infrastructure relationships.

3. Alice and Bob Establish a Communication Contract

When Alice wants to message Bob, she must first establish a contract. No contract, no communication.

Alice initiates:

alice_ephemeral = X25519_generate()   // fresh key pair for this contract only

contract = {
  requestor_did:                   alice_did,
  recipient_did:                   bob_did,
  requestor_encryption_public_key: alice_ephemeral.public,
  recipient_encryption_public_key: null,    // Bob fills this in
  timestamp:                       now       // no expiry: valid until revoked
}

requestor_signature = Ed25519_sign(contract, alice_signing_private)

// Encrypt so only Bob can read it — not Bob's mediator
wrapping_key = X25519(alice_ephemeral.private, bob_prekey_public)
encrypted_request = AES_GCM_encrypt(contract, wrapping_key)

Alice sends { encrypted_request, alice_ephemeral.public } to Bob's mediator. The mediator stores it as an opaque blob. It can verify Alice's identity from the command signature, but cannot read the contract contents.

Bob accepts:

// Decrypt using his pre-key
wrapping_key = X25519(bob_prekey_private, alice_ephemeral.public)
contract = AES_GCM_decrypt(encrypted_request, wrapping_key)

// Verify Alice's signature, and that the request is at most 5 min ahead / 30 days old
verify(requestor_signature, contract, alice_signing_public)

// Generate Bob's ephemeral key pair
bob_ephemeral = X25519_generate()
contract.recipient_encryption_public_key = bob_ephemeral.public

// Sign the completed contract
recipient_signature = Ed25519_sign(contract, bob_signing_private)

Bob sends the fully signed contract back to Alice's mediator and stores a copy on his own.

Root secret and key chain:

Both parties independently derive the identical shared secret, and use it once to start a daily key chain:

Alice:  root_secret = X25519(alice_ephemeral.private, bob_ephemeral.public)
Bob:    root_secret = X25519(bob_ephemeral.private, alice_ephemeral.public)

chain_0 = HKDF(root_secret, salt = contract_id, info = "decentrl/epoch-chain/v1")
wipe(root_secret); wipe(own_ephemeral.private)

No secret was transported. The root secret exists only for a moment on each device, and is gone once chain_0 is derived. From chain_0, each day of the contract gets its own key, and old keys are deleted as the days pass — see Key Epochs. The ephemeral keys are unique to this contract — compromising one contract doesn't affect any other.

4. Alice Sends Bob a Message

With a contract established, Alice can send an encrypted event:

// 1. Build the event and pick its ID (a retry reuses it)
event_id = random_uuid()
event = { type: "chat.message", data: { chatId: "abc", content: "Hello Bob" } }

// 2. Build a signed envelope for today's epoch
epoch = floor((now - contract.timestamp) / 86400)
envelope = { version: 3, contract_id, epoch, event_id, event: JSON.stringify(event), timestamp: now }
signature = Ed25519_sign(envelope, alice_signing_private)

// 3. Encrypt for transit with today's epoch key → send to Bob's mediator
ciphertext = AES_GCM_encrypt(signed_envelope, epoch_key[epoch])
transit_payload = { version: 1, contract_id, epoch, ciphertext }
send_command(bob_mediator, TWO_WAY_PRIVATE, { payload: transit_payload })

// 4. Encrypt for self-storage → save on Alice's mediator
storage_payload = AES_GCM_encrypt(event, alice_storage_key)
encrypted_tags  = [hmac(tag_key, "chat"), hmac(tag_key, "chat.abc")]  // tag_key = HKDF(alice_storage_key)
dedupe_key      = hmac(tag_key, "event|" + alice_did + "|" + event_id)  // one stored copy per event
send_command(alice_mediator, SAVE_EVENTS, { payload: storage_payload, tags: encrypted_tags, dedupe_key })

This dual-delivery ensures both parties maintain complete records on their own infrastructure. Alice's mediator has her copy, Bob's mediator has his — each encrypted with different keys.

5. Bob Receives the Message

When Bob's client connects (HTTP poll or WebSocket push):

// 1. Query pending events
pending = query_pending_events(bob_mediator)

for each pending_event:
    // 2. The payload names the contract and epoch; pick that epoch's key
    { contract_id, epoch, ciphertext } = pending_event.payload
    key = epoch_key(bob_key_states[contract_id], epoch)

    // 3. Decrypt and verify (the signed envelope must repeat contract_id and epoch)
    envelope = AES_GCM_decrypt(ciphertext, key)
    verify(envelope.signature, envelope, alice_signing_public)

    // 4. Bind the authenticated sender and signed event ID, then re-encrypt for self-storage
    event = with_meta(envelope.event, sender = pending_event.sender_did, id = envelope.event_id)
    storage_payload = AES_GCM_encrypt(event, bob_storage_key)
    save_events(bob_mediator, { payload: storage_payload, tags: [...],
                                dedupe_key: hmac(tag_key, "event|" + sender + "|" + id) })
                                // a copy he already has comes back DUPLICATE

// 5. Acknowledge processed events
acknowledge(bob_mediator, processed_ids)

Bob decrypts with the epoch key, verifies Alice's Ed25519 signature, then re-encrypts with his own storage key. His mediator has never seen the plaintext. Neither has Alice's mediator. The only devices that ever see "Hello Bob" are Alice's phone and Bob's phone.

What Just Happened

In five steps, Alice and Bob established an encrypted communication channel with these properties:

  • No server saw the message — mediators stored and forwarded ciphertext only
  • No server saw the encryption keys — the root secret was derived locally via Diffie-Hellman, and the daily keys from it
  • Old transit traffic stays sealed — once both sides have deleted a day's key, a stolen device can't decrypt that day's ciphertext
  • No server can forge messages — every event carries an Ed25519 signature
  • Nothing arrives twice — a retried or re-delivered event carries the same signed ID, so it's stored and applied once
  • Both parties consented — the bilateral contract required both signatures
  • Both parties have complete records — dual-delivery on independent infrastructure
  • Either party can revoke — silently, on their own mediator; the other side's messages are refused from then on, as if they were a stranger
  • The relationship is portable — switch mediators anytime without losing contacts