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
| Transition | Operator | Meaning |
|---|---|---|
| OUTSIDER → FRIEND | OWNER | Add contact |
| OUTSIDER → BLOCKED | OWNER | Preemptive block |
| FRIEND → OUTSIDER | OWNER | Remove contact |
| FRIEND → BLOCKED | OWNER | Block contact |
| BLOCKED → FRIEND | OWNER | Unblock to friend |
| BLOCKED → OUTSIDER | OWNER | Remove from blocklist |
All transitions are OWNER-only. No Self-targeting Moves, no external actors changing state.
Event-Operator Matrix
| Event | OWNER | OUTSIDER | FRIEND | BLOCKED | Sender |
|---|---|---|---|---|---|
| invite | RD | C | |||
| Gate(invites) | CR | ||||
| message | RD | C | _U_D | UD | |
| sent | CRU | ||||
| rotate | CR | ||||
| Move(OUTSIDER, FRIEND) | CR | ||||
| Move(OUTSIDER, BLOCKED) | CR | ||||
| Move(FRIEND, OUTSIDER) | CR | ||||
| Move(FRIEND, BLOCKED) | CR | ||||
| Move(BLOCKED, FRIEND) | CR | ||||
| Move(BLOCKED, OUTSIDER) | CR | ||||
| Terminate | CR |
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 anEVENT_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 correspondingsent 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:
| Mode | Used in | ECDH peer |
|---|---|---|
| Self-encrypted | Move/rotate payload | owner_pub (own key) |
| Participant-encrypted | invite/message tag | recipient_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₀ᵇᵃ.
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).
Step 4 — Read invite. Alice reads the invite in her enclave (OWNER
) and decryptsepoch₀ᵇᵃ 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 messageepochtag — 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 decryptsepoch₀ᵃᵇ 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):
- Replay own CT → find Move events with
epochfield (each carries a per-contact epoch) +rotateevents (each has atarget) - Self-encrypted →
ECDH(my_priv, epoch.ecdh_pub)whereecdh_pub= own pub deriveKey(..., 'enc:dm:epoch_dist')→ decrypt epoch secret- Build per-contact epoch map:
{contact_pub → {n → epoch_secret}}; highestepoch.nper contact = current epoch for that contact
Other enclaves' epochs (e.g., Alice recovering Bob's epoch secrets):
- Replay own CT → scan received
inviteandmessageevents forepochtags - Participant-encrypted →
ECDH(my_priv, ecdh_pub)whereecdh_pub= sender's pub (fromepochtag) deriveKey(..., 'enc:dm:epoch_dist')→ decrypt epoch secret- Build per-remote-enclave epoch map:
{remote_enclave → {n → epoch_secret}}; highestepoch.nper 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
| Property | Status |
|---|---|
| Per-message key isolation | YES — unique key per message via KDF ratchet |
| Per-contact key isolation | YES — contacts cannot derive each other's keys |
| Per-epoch forward secrecy | YES — independent random epoch secrets |
| Post-compromise security | NO — identity key recovers all epochs from CT |
| Stateless decryption | YES — log-compatible, no stored ratchet state |
| Multi-device support | YES — 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.