Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

DM Enclave — RBAC v2

Status: Design Based on: rbac-v2.md


Overview

A DM enclave is a personal mailbox for direct messaging. Each user has one DM enclave for ALL conversations. You read from your own enclave; others write to it.

  • Alice's enclave holds messages from Bob, Charlie, and anyone else she's friends with.
  • Each contact is a separate identity in the SMT (Bob=FRIEND, Charlie=FRIEND, etc.).
  • Friends can send messages but cannot read — no FRIEND
    . Only OWNER reads.
  • Sender
    isolates per-sender: Bob can edit/delete Bob's messages, not Charlie's.

Conversation between Alice and Bob spans two enclaves: Bob writes to Alice's enclave, Alice writes to Bob's enclave. Each owner reads from their own.

Manifest

{
  "states": ["OWNER", "FRIEND", "BLOCKED"],
  "traits": [],
  "readers": [
    { "type": "OWNER", "reads": "*" }
  ],
 
  "moves": [
    { "event": "Move", "from": "OUTSIDER", "to": "FRIEND", "operator": "OWNER", "ops": ["C"] },
    { "event": "Move", "from": "OUTSIDER", "to": "BLOCKED", "operator": "OWNER", "ops": ["C"] },
    { "event": "Move", "from": "FRIEND", "to": "OUTSIDER", "operator": "OWNER", "ops": ["C"] },
    { "event": "Move", "from": "FRIEND", "to": "BLOCKED", "operator": "OWNER", "ops": ["C"] },
    { "event": "Move", "from": "BLOCKED", "to": "FRIEND", "operator": "OWNER", "ops": ["C"] },
    { "event": "Move", "from": "BLOCKED", "to": "OUTSIDER", "operator": "OWNER", "ops": ["C"] }
  ],
 
  "grants": [],
  "transfers": [],
  "slots": [],
 
  "lifecycle": [
    { "event": "Terminate", "operator": "OWNER", "ops": ["C"] }
  ],
 
  "customs": [
    { "event": "invite", "operator": "OUTSIDER", "ops": ["C"],
      "alias": "invites", "gate": { "operator": ["OWNER"] } },
    { "event": "invite", "operator": "OWNER", "ops": ["D"] },
 
    { "event": "message", "operator": "OWNER", "ops": ["D"] },
    { "event": "message", "operator": "FRIEND", "ops": ["C"] },
    { "event": "message", "operator": "Sender", "ops": ["U", "D"] },
    { "event": "message", "operator": "BLOCKED", "ops": ["_U", "_D"] },
 
    { "event": "sent", "operator": "OWNER", "ops": ["C", "U"] },
 
    { "event": "rotate", "operator": "OWNER", "ops": ["C"] }
  ],
 
  "init": [
    { "identity": "<owner_pub>", "state": "OWNER", "traits": [] }
  ]
}

Design Rationale

States: ["OWNER", "FRIEND", "BLOCKED"]

  • OUTSIDER (0) = no relationship
  • OWNER (1) = the enclave owner, bootstrapped via init
  • FRIEND (2) = accepted contact, can send messages
  • BLOCKED (3) = blocked by owner

No PENDING state — the owner adds contacts directly. Move(OUTSIDER→FRIEND) is a unilateral owner decision. No Self-targeting Moves needed.

No traits — DM is private messaging, no service accounts or push delivery needed.

Operators:

  • OWNER (State) — reads everything via readers. Manages all contacts (Moves). Deletes invites. Creates rotate events. Admin: Gate, Terminate.
  • FRIEND (State) — can create messages.
  • Sender (Context) — message edit/delete by original sender.
  • OUTSIDER (State) — can create invite events (gated).

Owner-initiated model — Each owner controls who enters their own enclave. The owner adds a contact by Move(OUTSIDER→FRIEND), then notifies the other party via an invite event in their enclave. The other party adds back when ready. No cross-enclave state coupling.

Gate on invites — The invites gate lets the owner close their inbox to new invite events. When closed, invite creation is rejected before RBAC runs.

No moves to OWNER — OWNER is bootstrapped via init, never changes state.

State Transitions

TransitionOperatorMeaning
OUTSIDER → FRIENDOWNERAdd contact
OUTSIDER → BLOCKEDOWNERPreemptive block
FRIEND → OUTSIDEROWNERRemove contact
FRIEND → BLOCKEDOWNERBlock contact
BLOCKED → FRIENDOWNERUnblock to friend
BLOCKED → OUTSIDEROWNERRemove from blocklist

All transitions are OWNER-only. No Self-targeting Moves, no external actors changing state.

Event-Operator Matrix

EventOWNEROUTSIDERFRIENDBLOCKEDSender
inviteRDC
Gate(invites)CR
messageRDC_U_DUD
sentCRU
rotateCR
Move(OUTSIDER, FRIEND)CR
Move(OUTSIDER, BLOCKED)CR
Move(FRIEND, OUTSIDER)CR
Move(FRIEND, BLOCKED)CR
Move(BLOCKED, FRIEND)CR
Move(BLOCKED, OUTSIDER)CR
TerminateCR

Columns: States (OWNER, OUTSIDER, FRIEND, BLOCKED) → Contexts (Sender).

Content Events

invite

Contact initiation from an OUTSIDER. Created via OUTSIDER

. Only identities in OUTSIDER state can create invites — FRIEND, OWNER, and BLOCKED cannot. The owner reads and deletes invites.

Gated — the owner can close the invites gate to stop accepting new invites.

Encrypted content. Both tags and content are encrypted via identity ECDH: ECDH(sender_priv, recipient_pub)deriveKey(..., 'enc:dm:invite').

  • tags: enclave_id (sender's DM enclave ID), epoch (sender's epoch 0, encrypted for recipient)
  • content: greeting (encrypted introduction text from the sender)

message

Incoming DM message. Created by a FRIEND. The sender can edit (Sender

) or retract (Sender
) their own messages. The owner can delete messages (OWNER
) for local cleanup — the sender cannot read the owner's enclave, so the deletion is invisible to them.

If the owner deletes a message and the sender later attempts Sender

or Sender
, the node rejects with an EVENT_DELETED error (the event's 0x01 EventStatus is terminal). The sender's client can infer the message was deleted on the other side. The sender's sent copy in their own enclave is unaffected.

sent

Owner's copy of outgoing messages, for cross-device sync. Owner-only — no one else can read or write. No D — the CT is append-only and sent records are permanent. When the sender retracts a message (Sender

in the recipient's enclave), the client updates the corresponding sent event (OWNER
) in the owner's enclave.

Encryption & Workflow

DM encryption uses the Ratchet_DM scheme: owner-generated random per-contact epoch secrets with symmetric ratchet chains. Each message gets a unique key.

Key Derivation

Epoch distribution — two modes, one recovery pattern:

ModeUsed inECDH peer
Self-encryptedMove/rotate payloadowner_pub (own key)
Participant-encryptedinvite/message tagrecipient_pub
epoch_secret     = random(32)
dist_key         = deriveKey(ECDH(my_priv, peer_pub), 'enc:dm:epoch_dist')
encrypted_secret = XChaCha20-Poly1305(dist_key, nonce, epoch_secret)

Recovery is uniform: ECDH(my_priv, ecdh_pub) — the ecdh_pub field tells you whose key was used.

Ratchet chain — per-contact:

ratchet_seed    = deriveKey(epoch_secret, 'enc:dm:ratchet:init')
chain_key[0]    = ratchet_seed
chain_key[i+1]  = deriveKey(chain_key[i], 'enc:dm:ratchet:advance')
message_key     = deriveKey(chain_key[i], 'enc:dm:ratchet:message')
ciphertext      = XChaCha20-Poly1305(message_key, nonce, plaintext)

For sender_seq = i: derive chain_key[i] by advancing i times from ratchet_seed, then derive message_key from chain_key[i]. Each epoch is per-contact, so sender identity is implicit — no sender_pub in domain separation.

Initial Contact

    Bob's Enclave                           Alice's Enclave
         │                                       │
      1. │ epoch₀ᵇᵃ = random(32)                 │
      2. │ Move(O→F, Alice)                      │
         │   epoch₀ᵇᵃ self-enc                   │
         │                                       │
      3. │ ─────── invite [OUTSIDER:C] ─────────► │
         │   tags: [enclave_id] [epoch₀ᵇᵃ]       │
         │   content: greeting                    │
         │                                       │
         │                                    4. │ reads invite [OWNER:R]
         │                                       │   → ECDH → epoch₀ᵇᵃ ✓
         │                                    5. │ epoch₀ᵃᵇ = random(32)
         │                                    6. │ Move(O→F, Bob)
         │                                       │   epoch₀ᵃᵇ self-enc
         │                                       │
      8. │ ◄──────── message [FRIEND:C] ─────── │ 7.
         │   tags: [epoch₀ᵃᵇ]                    │
         │   content: enc(epoch₀ᵇᵃ, 0, msg)      │
         │                                       │
         │ reads message [OWNER:R]               │
         │   → ECDH → epoch₀ᵃᵇ ✓                 │
         │                                       │
         ▼ Bob: epoch₀ᵇᵃ + epoch₀ᵃᵇ             ▼ Alice: epoch₀ᵇᵃ + epoch₀ᵃᵇ

Epoch subscripts: epoch₀ᵇᵃ = epoch 0 in Bob's enclave for Alice. Each contact gets an independent epoch — when Bob later adds Charlie, Charlie gets epoch₀ᵇᶜ, independent from Alice's epoch₀ᵇᵃ.

Bob initiates (steps 1–3):

Step 1 — Generate epoch. Bob generates epoch₀ᵇᵃ = random(32) — an epoch secret for Alice in Bob's enclave. This happens for every new contact.

Step 2 — Move(OUTSIDER→FRIEND). Every Move(OUTSIDER, FRIEND) carries an epoch field — each contact gets their own epoch 0. Self-encrypted for device sync.

{
  "target": "<alice_pub>",
  "from": "OUTSIDER",
  "to": "FRIEND",
  "epoch": {
    "n": 0,
    "encrypted_secret": "<base64(nonce || AEAD ciphertext)>",
    "ecdh_pub": "<bob_ecdh_pub>"
  }
}

Self-encrypted: ECDH(owner_priv, owner_pub)deriveKey(..., 'enc:dm:epoch_dist'). The owner recovers on sync via ECDH(my_priv, epoch.ecdh_pub) where ecdh_pub = owner's own pub.

Move(BLOCKED, FRIEND) is a pure state change — no new epoch. The unblocked contact uses whatever epoch they last received. The owner delivers the current epoch lazily via the epoch tag on the next outgoing message.

Per-contact monotonicity (client-validated): epoch.n must be 0 for the first epoch for that contact, or strictly greater than the highest epoch.n for the same target identity.

Step 3 — invite. Encrypted greeting. Tags carry the sender's enclave ID and epoch 0 secret. Content and tags are encrypted for the recipient via ECDH(sender_priv, recipient_pub)deriveKey(..., 'enc:dm:invite').

tags: [
  ["enclave_id", "<bob_dm_enclave>"],
  ["epoch", "0", "<base64(encrypted_secret)>", "<bob_ecdh_pub>"]
]
content: "<base64(nonce || encrypted_greeting)>"

The epoch tag delivers the sender's epoch 0 for the sender's enclave, encrypted for the recipient: ECDH(sender_priv, recipient_pub)deriveKey(..., 'enc:dm:epoch_dist'). Format: ["epoch", "<n>", "<encrypted_secret>", "<ecdh_pub>"]. The recipient decrypts via ECDH(my_priv, ecdh_pub).

Alice accepts (steps 4–8):

Step 4 — Read invite. Alice reads the invite in her enclave (OWNER

) and decrypts epoch₀ᵇᵃ from the epoch tag via ECDH(alice_priv, bob_ecdh_pub).

Step 5 — Generate epoch. Alice generates epoch₀ᵃᵇ = random(32) — an epoch secret for Bob in Alice's enclave.

Step 6 — Move(OUTSIDER→FRIEND). Same format as step 2. Alice's Move carries epoch₀ᵃᵇ self-encrypted for her device sync.

Step 7 — message. Encrypted with the ratcheted message key for the current epoch. Tags carry an epoch tag with Alice's epoch secret for Bob.

tags: [
  ["epoch", "0", "<base64(encrypted_secret)>", "<alice_ecdh_pub>"]
]
content: '{"epoch":0,"sender_seq":0,"ciphertext":"<base64(nonce || encrypted_data)>"}'

Two epoch references in a message:

  • content.epoch — the per-contact epoch number for the sender in the recipient's enclave (Bob's), used to encrypt this message
  • epoch tag — the per-contact epoch of the sender's enclave (Alice's epoch for Bob), delivered to Bob for writing back

The epoch tag is present when the sender has a new epoch to deliver (initial contact, rotation, unblock). Encrypted for the recipient: ECDH(sender_priv, recipient_pub)deriveKey(..., 'enc:dm:epoch_dist').

Decryption: parse content JSON, recover epoch secret from CT (via Move/rotate events), derive ratchet chain to sender_seq, derive message key, decrypt ciphertext. Stateless — O(sender_seq) KDF operations per message, mitigated by epoch rotation.

Step 8 — Read message. Bob reads the message in his enclave (OWNER

) and decrypts epoch₀ᵃᵇ from the epoch tag via ECDH(bob_priv, alice_ecdh_pub).

Design notes:
  • Cross-enclave delivery. Each party writes epoch secrets to the OTHER's enclave (in invite/message tags), where the other reads as OWNER. No FRIEND
    needed — only OWNER reads. Steps 1–3 are Bob's action; steps 4–8 are Alice's. Each user acts in their own enclave first (sovereign), then writes to the other's enclave (notification). If a cross-enclave write fails, retry — the local state is already committed.
  • Two storage concerns per epoch secret. Owner's CT — self-encrypted in the Move payload, for device sync. Participant's CT — participant-encrypted in an invite or message tag, delivered cross-enclave.
  • One-directional messaging. After step 4, Alice has Bob's epoch 0 and is FRIEND in Bob's enclave — she can message Bob before accepting (step 6). Alice can preview the relationship without committing. Full bidirectional messaging begins after both sides complete.

Epoch Rotation

Mid-conversation per-contact epoch rotation without state change. Owner-only. Self-encrypted for device sync. The target field identifies which contact's epoch is rotated.

{
  "target": "<contact_pub>",
  "epoch": {
    "n": 2,
    "encrypted_secret": "<base64(nonce || AEAD ciphertext)>",
    "ecdh_pub": "<owner_ecdh_pub>"
  }
}

Self-encrypted: same pattern as Move. ECDH(owner_priv, owner_pub)deriveKey(..., 'enc:dm:epoch_dist').

Per-contact monotonicity (client-validated): epoch.n must be strictly greater than the highest epoch number for the same target identity. Prevents reuse or reordering.

Delivery: The owner piggybacks the new epoch in the epoch tag of the next message to the target contact. Rotation is per-contact, so delivery is O(1) — just the one contact whose epoch was rotated. Until the owner sends a message, the contact continues using the previous epoch — delivery is deferred, not eager.

When to rotate (client-side heuristics, not protocol-enforced):

  • After N messages from a specific contact in an epoch (bounds O(sender_seq) decryption cost)
  • After time elapsed
  • After encryption subkey rotation
  • Manual user action

sent

Self-encrypted with per-counterparty keys derived from the owner's identity key.

tags: [["to", "<recipient_pub_hex>"]]
content: "<base64(nonce || encrypted_data)>"
sent_root  = deriveKey(ECDH(identity_priv, identity_pub), 'enc:dm:sent:root')
sent_key   = deriveKey(sent_root, 'enc:dm:sent:' + to_pub)
ciphertext = XChaCha20-Poly1305(sent_key, nonce, plaintext)

No ratchet — identity key is the trust boundary. Per-counterparty domain separation via to tag. Stateless — any device with identity_priv can decrypt.

Epoch Recovery

On sync (new device or reconnect), the client recovers epoch secrets for two classes of enclaves:

Own enclave's epochs (e.g., Alice recovering Alice's enclave secrets):

  1. Replay own CT → find Move events with epoch field (each carries a per-contact epoch) + rotate events (each has a target)
  2. Self-encrypted → ECDH(my_priv, epoch.ecdh_pub) where ecdh_pub = own pub
  3. deriveKey(..., 'enc:dm:epoch_dist') → decrypt epoch secret
  4. Build per-contact epoch map: {contact_pub → {n → epoch_secret}}; highest epoch.n per contact = current epoch for that contact

Other enclaves' epochs (e.g., Alice recovering Bob's epoch secrets):

  1. Replay own CT → scan received invite and message events for epoch tags
  2. Participant-encrypted → ECDH(my_priv, ecdh_pub) where ecdh_pub = sender's pub (from epoch tag)
  3. deriveKey(..., 'enc:dm:epoch_dist') → decrypt epoch secret
  4. Build per-remote-enclave epoch map: {remote_enclave → {n → epoch_secret}}; highest epoch.n per remote enclave = current epoch for that enclave

Both use the same ECDH(my_priv, epoch.ecdh_pub)deriveKey(..., 'enc:dm:epoch_dist') pattern. The only difference is whose pub is in ecdh_pub (own pub for self-encrypted, sender's pub for participant-encrypted).

Security Properties

PropertyStatus
Per-message key isolationYES — unique key per message via KDF ratchet
Per-contact key isolationYES — contacts cannot derive each other's keys
Per-epoch forward secrecyYES — independent random epoch secrets
Post-compromise securityNO — identity key recovers all epochs from CT
Stateless decryptionYES — log-compatible, no stored ratchet state
Multi-device supportYES — identity key sufficient for all epochs

Forward secrecy: YES at the epoch level — compromising one epoch secret does not reveal other epochs. Per-contact isolation means compromising one contact's epoch reveals nothing about other contacts' epochs, even within the same enclave. NO against identity key compromise — the identity key can recover all epoch secrets from the CT (self-encrypted in Move/rotate, participant-encrypted in epoch tags).

Post-compromise security: NO — because the CT is an append-only log, all epoch secrets (past and future) are recoverable from the CT with the identity key. There is no mechanism to "forget" old keys.

Why these tradeoffs: CT-based systems require stateless decryption — any device with the identity key must be able to read the full history. This is incompatible with PCS (which requires old keys to be irrecoverable) and with forward secrecy against identity compromise (which requires ephemeral keys not derivable from long-term keys). The tradeoff buys multi-device sync and log-compatible verification. DM and group chat share this constraint — any enclave type using a CT makes the same tradeoff.