Skip to content

feat: persist outbound DM plaintext in the chat delegate #256

@sanity

Description

@sanity

Problem

In-room DMs (shipped in #244 / #243) carry only ECIES ciphertext on the wire — only the recipient can decrypt the body. As a side effect, the sender's UI shows their own sent DMs as the placeholder "sent — ciphertext only":

Direct messages with zorolin
sent — ciphertext only — 2026-05-16 15:48:05

You typed the message, you sent it, but you can't see what you typed afterwards. That's terrible UX, and it's the same in riverctl dm list (outbound shows as ciphertext-only too).

Why we didn't just store plaintext locally

The current design keeps zero extra plaintext anywhere: the ciphertext in ChatRoomStateV1.direct_messages is canonical for both sender and recipient. Adding in-memory plaintext to the UI would:

  • Mislead users into thinking sent DMs persist across reloads / devices — they don't, the map evaporates.
  • Add a special-cased lifecycle (populate on send, evict on purge, lose on reload) that's hard to reason about.
  • Be the wrong shape if/when the same user opens River on a second device: device A's outbound DMs would still be ciphertext-only on device B.

Proposed solution

Persist outbound DM plaintext in the chat delegate, the same way the delegate already stores room secrets / signing keys / migrated rooms. That gives us:

  • Persistence across reloads. Open River next week, your sent DMs are still there with the original plaintext you typed.
  • Multi-device convergence. Both your devices already share the chat-delegate state — sent-DM plaintext should ride the same channel.
  • Correct purge semantics. When a recipient signs a purge envelope tombstoning your DM, the contract drops the ciphertext on merge; we should drop the matching plaintext from the delegate on the same event.
  • Encrypted at rest. Delegate storage already encrypts data tied to room secrets, so we don't widen the threat model.

Storage shape

Per chat-delegate convention, add a new key namespace:

// in `common/src/chat_delegate.rs`
pub const OUTBOUND_DMS_STORAGE_KEY: &[u8] = b\"outbound_dms\";

// stored value (serialized CBOR):
pub struct OutboundDmStore {
    // Keyed by (room_owner_vk, recipient_member_id, purge_token).
    // Vec<(...)> not HashMap so it serialises through any wire format
    // including JSON — see existing `purge_versions` precedent in
    // `DirectMessagesSummary` for why.
    pub entries: Vec<OutboundDmEntry>,
}
pub struct OutboundDmEntry {
    pub room_owner_vk: VerifyingKey,
    pub recipient: MemberId,
    pub purge_token: PurgeToken,
    pub timestamp: u64,
    pub plaintext: String,
}

Lifecycle

Event Action
UI/CLI sends DM After compose_direct_message succeeds and the state delta is applied locally, append an OutboundDmEntry to the delegate store.
UI renders own outbound bubble Look up (room, peer, token) in OUTBOUND_DMS. Hit → render as BodyKind::Plaintext. Miss → render as BodyKind::Placeholder ("sent — ciphertext only") — this is the fall-through for DMs sent before the delegate started tracking them.
Recipient purges; tombstone arrives post_apply_cleanup already drops the ciphertext from contract state. Mirror that in the delegate: after the state-merge handler runs, prune any OutboundDmEntry whose (recipient, purge_token) is now in the recipient's purge envelope.
Per-pair cap reached on send Apply the same eviction policy the contract uses — drop the oldest outbound entry for that (sender, recipient) pair. Keeps the store bounded.
Delegate WASM key changes The existing legacy-delegates migration path (legacy_delegates.toml) already moves rooms_data across keys. Add outbound_dms to that migration so a delegate WASM rebuild doesn't orphan sent plaintext.

Out of scope

  • Backfilling pre-delegate outbound DMs. DMs sent under the current production UI before this lands stay as "sent — ciphertext only" forever. Documented limitation.
  • Edit/delete of sent DMs. That's a different design conversation (the contract doesn't currently support DM edits — only purge-by-recipient).
  • End-to-end encrypted device-to-device sync of the outbound store. The chat-delegate already encrypts its state tied to the room secret, so co-room devices converge; cross-room delegate sync is the same scope as room-secret sync.

Implementation sketch

  1. common/src/chat_delegate.rs: add OUTBOUND_DMS_STORAGE_KEY, OutboundDmStore, OutboundDmEntry. Pure types, no contract changes.
  2. ui/src/components/app/chat_delegate.rs: add save_outbound_dm() / load_outbound_dms() / prune_outbound_dms_for_purges(), all going through the existing ChatDelegateKey machinery.
  3. ui/src/components/direct_messages/dm_thread_modal.rs: on send path, append to local cache + queue a delegate write. On render path, consult the cache for outbound bubbles.
  4. cli/src/commands/dm.rs: same change — dm list should consult the equivalent outbound-store via the delegate when the user is running with delegate-backed signing.
  5. delegates/chat-delegate/src/: handle the new storage key in the existing handler chain. Add migration entry if WASM hash changes.

Estimate

~1-2 days. No protocol change (room contract WASM stays as-is), so no migration tag is needed for the contract.

Related

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions