319 lines
13 KiB
Markdown
319 lines
13 KiB
Markdown
# 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_v1` custom_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):
|
|
|
|
1. Chat history must be immutable.
|
|
2. Chats must be end-to-end encrypted — no one outside the
|
|
conversation can read them.
|
|
3. UX must be lightning fast.
|
|
4. 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 (via `GET /v1/chat-identity/:account`). If not, it
|
|
derives the key locally and broadcasts `morphit_chat_identity_v1`
|
|
with 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 the `morphit_chat_v1` op).
|
|
|
|
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_v1` op
|
|
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/curves` and
|
|
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_v1` op 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/curves` or 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
|
|
throwing `DecryptError`.
|
|
- 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:
|
|
`decryptOrPlaceholder` calls decrypt; send-path calls
|
|
encrypt + populates the envelope).
|
|
- Add `/v1/conversations` inbox 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_sell` copy 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
|