13 KiB
ADR-0015: Chat crypto — key derivation and transport cipher
Status: Accepted Date: 2026-04-23 Deciders: project maintainer Supersedes: — Related: ADR-0014 (chat and counterparty reputation) — this is the sub-ADR for ADR-0014 Q1 (chat identity keys) and Q3 (replay protection) so that ADR-0014 Q2 and Q4 can be resolved independently.
Context
ADR-0014 specified the problem shape for E2E-encrypted chat between Morphit counterparties:
- Ciphertext stored on-chain via
morphit_chat_v1custom_json ops (ADR-0001 transport). - Plaintext never visible to the operator, Morphit, or chain observers.
- Each account has some sort of long-term chat identity whose public half is discoverable.
ADR-0014 left the key-identity mechanism (Q1) and the transport cipher family (an unlisted but implicit sub- decision) unresolved. Three options were laid out for Q1 with different cost shapes; the ADR leaned toward "publish a chat identity as a custom_json" (Option 2) but explicitly flagged the decision as needing a sub-ADR.
The maintainer stated four requirements when this decision was revisited (2026-04-23):
- Chat history must be immutable.
- Chats must be end-to-end encrypted — no one outside the conversation can read them.
- UX must be lightning fast.
- Do NOT make the user set up a new crypto key (no additional private key to lose).
Requirement 4 rules out Option 2 (publish a separate chat identity). Requirement 3 rules out Option 3 (per-session handshake — two messages of latency before content flows). Requirement 1 is satisfied by any on-chain storage approach (already decided in ADR-0014).
That leaves Option 1 from ADR-0014-Q1 (derive from posting key) as the only option consistent with the stated requirements.
An adjacent decision ADR-0014 did NOT enumerate: which transport cipher family? ADR-0014 originally mentioned a heavy WASM forward-secrecy library as the default assumption, citing audit and battle-testing. But its extra guarantees (forward secrecy of individual messages, post-compromise security, out-of-order handling) come with real costs — the WASM bundle is ~2MB, meaningfully impacting first-load performance (requirement 3). None of those forward-secrecy-only properties are listed in the four requirements above.
Decision
Key derivation. Each account's chat identity is an X25519
keypair derived deterministically from the account's Blurt
posting private key via BLAKE2b-256 with domain separation,
matching the pattern used in apps/web/src/lib/crypto/keygen.ts
for per-role key derivation:
chat_priv_scalar = BLAKE2b(
output_len = 32 bytes,
key = posting_priv_key_bytes (32 bytes — secp256k1 scalar),
message = UTF-8("morphit-chat-v1/identity/" + account_name)
)
The output is reinterpreted as an X25519 scalar, then clamped
per RFC 7748 (bits 0 and 1 of byte 0 cleared; bit 7 of byte 31
set). X25519's clamping means every 32-byte value is a valid
scalar post-clamp — unlike secp256k1 where a small fraction
must be rejected — so no retry loop is needed. The public half
is X25519(chat_priv_scalar_clamped, basepoint).
Library note: sodium.crypto_generichash(32, message, key)
from libsodium-wrappers-sumo (already a dep) implements the
BLAKE2b step exactly. sodium.crypto_scalarmult_base(scalar)
gives the X25519 public key after clamping.
Chat pubkey publication. Because X25519 and secp256k1 are
different curves, Alice cannot derive Bob's chat pubkey from
Bob's posting pubkey — she can only derive it from Bob's
posting PRIVATE key, which she doesn't have. Each account
therefore publishes its chat pubkey once, via a new
morphit_chat_identity_v1 custom_json op whose payload is
{"chat_pub": <base64 X25519 pubkey>}. The indexer records
the latest chat_pub per account in a chat_identities table.
Publication is triggered automatically by the client — the user never sees or manages this:
-
First-use publish. The moment the user first opens
/chat,/chat/[peer], or clicks a Message button from anywhere, the client checks whether their chat identity is already published (viaGET /v1/chat-identity/:account). If not, it derives the key locally and broadcastsmorphit_chat_identity_v1with no prompting. Takes ~3 seconds to confirm; during that window, outbound sends queue client-side. -
Existing-user migration. Any account whose chat identity is not published triggers publication on next login, in the background, as soon as the LiveIdentity is unlocked. Over a few days, the active user base becomes chat-addressable.
-
Key rotation. If the user ever rotates their posting key (which re-derives the chat key as a side effect, because the BLAKE2b input changes), the client detects the mismatch and re-publishes. Historical messages encrypted under the old chat key are still decryptable because the client retains a derivation-history record keyed by posting-key epoch — but for chat sessions where the posting key hasn't rotated, this code path is dormant.
First-contact UX. If Alice tries to message Bob before Bob has ever published a chat pubkey, the send fails with a clear message ("@bob hasn't set up chat yet — try again in a few seconds after they open Morphit"). In practice, Bob publishing is triggered by ANYTHING that lands him on a chat surface, including a notification about Alice's pending message (if notifications are enabled) or a direct link. Once his publish lands, Alice's client retries automatically on its next poll.
Transport cipher. Each message is an individual ECIES-like envelope:
ephemeral_priv, ephemeral_pub = X25519 keygen
shared_secret = X25519(ephemeral_priv, recipient_chat_pub)
message_key = HKDF-SHA256(
salt = "morphit-chat-msg-v1",
ikm = shared_secret,
info = concat(sender_account, recipient_account),
length = 32 bytes
)
nonce = random 12 bytes
ciphertext = ChaCha20-Poly1305(message_key, nonce, plaintext, aad=sender_account)
The on-chain envelope stores:
ciphertext: base64-encoded ChaCha20-Poly1305 output (including the 16-byte auth tag appended).header.ephemeral_pub: base64-encoded 32-byte X25519 public key.header.nonce: base64-encoded 12-byte nonce.header.client_tag: 32-char hex client tag (existing, for optimistic reconciliation — see docs/CHAT-UI-DESIGN.md).recipient: the counterparty's Blurt account name (existing, from themorphit_chat_v1op).
Decryption:
shared_secret = X25519(my_chat_priv, header.ephemeral_pub)
message_key = HKDF-SHA256(
salt = "morphit-chat-msg-v1",
ikm = shared_secret,
info = concat(sender_account, recipient_account),
length = 32 bytes
)
plaintext = ChaCha20-Poly1305-Open(
message_key, header.nonce, ciphertext, aad=sender_account
)
AEAD authentication (ChaCha20-Poly1305's built-in MAC over
aad=sender_account) prevents an attacker who relays a
ciphertext from claiming different authorship. The sender's
Blurt posting-signature on the op itself (enforced by
Graphene) separately binds the envelope to an account.
Replay protection (ADR-0014 Q3). The indexer's
source_trx_id unique constraint handles on-chain replay:
re-submitting the same op fails with duplicate_message.
Clients don't need to carry extra replay state.
Alternatives considered
-
Option 2 (ADR-0014-Q1): publish separate chat identity. Each user broadcasts a
morphit_chat_identity_v1op containing an ephemeral long-term chat public key. Rejected because it violates requirement 4 (new user key). The user-facing cost ("you need to publish your chat identity before anyone can message you") breaks the zero-setup expectation for chat. -
Option 3 (ADR-0014-Q1): per-session ephemeral keys via dlog-signed handshake. No persistent chat identity; first message of each conversation is a handshake. Rejected because it violates requirement 3 — two on-chain round-trips (~6 seconds at Blurt's 3s block time) before content can flow. "Hi" would land 6 seconds later than necessary.
-
Full forward-secrecy protocol via heavy WASM library. Would give forward secrecy (past messages safe if the current message key leaks) and post-compromise security (future messages safe after a key rotation following compromise). Rejected because (a) the WASM bundle is ~2MB which meaningfully hurts first-load performance (requirement 3); (b) neither FS nor PCS is listed in the maintainer's four requirements; (c) the specific compromise scenarios where FS/PCS would save the user (e.g., attacker briefly reads session state without taking the posting key) are less common in our threat model than posting-key compromise, which defeats FS/PCS regardless (attacker can still decrypt every prior message whose chat key they can re-derive).
-
Roll our own forward-secrecy protocol. Write a simplified per-message-rotation scheme from scratch to get some forward secrecy at lower bundle cost. Rejected because rolling cryptographic protocols is how bugs land in production (same reason ADR-0014 cited for rejecting it earlier).
-
Symmetric pre-shared keys out-of-band. Users exchange a secret via QR/voice/etc. before chatting. Rejected because the counterparty pair often hasn't met before (they're trading on a public marketplace); the whole point is that strangers should be able to open a private channel.
Consequences
Positive
- Zero-friction UX: two users who both have Morphit accounts can chat immediately. Anyone's chat pubkey is derivable from their posting pubkey — the indexer already knows every signer's posting pubkey from chain replay, so the UI can even encrypt to someone who has never opened Morphit's chat UI.
- Small bundle: X25519 + ChaCha20-Poly1305 are available in
every modern crypto library including
@noble/curvesand libsodium (already a dep for Blurt operations). Zero new net bundle weight. - Fast encrypt/decrypt: <5ms per message on mobile. No noticeable UI lag.
- Key loss impossible in a new way: losing chat history requires losing the posting key, and losing the posting key already means losing the Morphit account. No additional recovery surface.
Negative
-
No forward secrecy. An attacker who obtains a user's posting key can re-derive their chat key and decrypt every past message they can fetch from chain. This is the tradeoff ADR-0014 flagged; it's accepted here because posting-key compromise already ends the account's security story (attacker can post orders, sign feedback, etc. as the victim). Users who care about forward secrecy for chat in particular are advised to rotate their Blurt keys periodically, which rotates the derived chat key as a side effect.
-
No post-compromise security. Similar: if the attacker sees past message keys but not the posting key, they don't learn anything beyond the already-decryptable past messages, so there's no new loss. But future messages use a new random ephemeral key so the attacker doesn't gain future reading power either — just for the wrong reason (ephemerals, not PCS).
-
Metadata visible on chain: (sender, recipient, timestamp, approximate message size) are all public, because they're part of the
morphit_chat_v1op header, not the encrypted payload. This is unchanged from ADR-0014 and was always the case. The ciphertext itself reveals nothing about content. -
Keystore side-effect. The in-memory keystore must make the posting private key available to a new module (chat crypto) not just the broadcast-signing path. This is a minor widening of the key surface inside the client, kept inside the existing LiveIdentity abstraction. The key does not leave the client.
Follow-up work
- Implement
derivePostingChatKey(live)in a new$lib/chat/crypto.ts, using@noble/curvesor libsodium (whichever is already in-tree). - Implement
encryptToRecipient(plaintext, recipientPub, senderAccount, recipientAccount)returning{ciphertext, ephemeralPub, nonce}. - Implement
decryptFromSender(envelope, senderPub, myPriv, senderAccount, recipientAccount)returning plaintext or throwingDecryptError. - Implement
fetchPeerChatPubkey(peerAccount)— resolve the peer's posting-pubkey via Blurt chain query, then derive their chat pubkey locally. - Wire these into
chatService.ts(two one-line changes:decryptOrPlaceholdercalls decrypt; send-path calls encrypt + populates the envelope). - Add
/v1/conversationsinbox endpoint on the indexer:SELECT peer, MAX(created_at), SUM(unread_count_where_recipient_is_me) FROM chat_messages WHERE sender=$1 OR recipient=$1 GROUP BY peer ORDER BY last DESC. Replace the recent-peers localStorage stub on/chat/+page.svelte. - FAQ
how_to_sellcopy rollback — "open a chat with you" wording becomes accurate once real encrypt/decrypt ships.
References
- ADR-0014 — chat and counterparty reputation (parent)
- ADR-0010 — key custody (constrains identity-key handling)
- ADR-0007 — keygen curve and dblurt package (Blurt uses secp256k1, so chat uses a separate derived X25519 key — the HKDF output is reinterpreted as an X25519 scalar)
- RFC 7748 — Elliptic Curves for Security (X25519 and X448, including the clamping rules)
- RFC 7539 — ChaCha20 and Poly1305 for IETF Protocols
- RFC 5869 — HKDF: HMAC-based Extract-and-Expand KDF