morphit/docs/CHAT-CRYPTO.md

275 lines
11 KiB
Markdown

# Morphit chat crypto — design notes
This document records what Morphit chat does cryptographically,
what it deliberately does not do, and the reasoning behind both
sets of choices. It exists so that:
- Operators and security researchers reading the code know
exactly what guarantees we claim and which we don't.
- The next person editing `apps/web/src/lib/chat/crypto.ts`
knows the design constraints before changing anything.
- Comparisons with other secure-messaging apps can be made
honestly.
If you only want the user-facing version, see the FAQ entry
"Does Morphit chat have forward secrecy?" — that one is
written for non-experts. This one is for people who are
going to read the source.
---
## The scheme in one paragraph
Each Blurt account has a long-term X25519 chat-identity
keypair derived deterministically from the posting private key
via BLAKE2b-256 (`morphit-chat-v1/identity/<account>`
domain-separated label). The public half is published on chain
in a `morphit_chat_identity_v1` op. To send a message, the
sender generates a fresh X25519 ephemeral keypair, computes
the shared secret with the recipient's long-term pub via
scalarmult, derives a per-message symmetric key by domain-
separated BLAKE2b, generates a random 96-bit nonce, and
encrypts under ChaCha20-Poly1305-IETF. AAD binds (sender,
recipient) accounts. All primitives from libsodium-sumo.
Code: `apps/web/src/lib/chat/crypto.ts` (~400 lines including
comments). The on-wire envelope (`ChatEnvelopeWire`) carries
the ephemeral public, the nonce, and the AEAD output (with
its appended 16-byte Poly1305 tag).
---
## What we claim
1. **Confidentiality.** Without the recipient's long-term
chat-priv, the ciphertext is opaque. Standard X25519 +
ChaCha20-Poly1305 assumptions.
2. **Ciphertext integrity.** The AEAD's MAC catches tampering
by anyone in the relay path.
3. **Sender / recipient binding.** AAD is
`morphit-chat-aad-v1/<sender>\0<recipient>`, so a relay-
attacker cannot redirect a ciphertext to a different
recipient (auth check fails) or re-attribute it to a
different sender (also fails).
4. **One-sided forward secrecy on the SENDER side.** Sender's
ephemeral private is generated, used once, and wiped
(`sodium.memzero`) before the function returns. If the
sender's posting key leaks later, the attacker cannot
recover ephemerals from messages already broadcast; those
ciphertexts are not decryptable from posting key alone.
*Caveat:* this assumes JS engine actually clears the buffer.
We can't guarantee no copies exist in deoptimization slots,
GC nurseries, or paged memory. Best effort.
5. **Domain-separated key derivation.** Different purposes
(identity vs message key vs AAD) use different label
strings. No collision between contexts.
---
## What we don't claim
1. **Receiver-side forward secrecy.** The recipient's long-
term chat-priv is the same forever, until the underlying
Blurt posting key is rotated. Compromise of the chat-priv
reveals every past ciphertext to the recipient that the
attacker can fetch from chain. Per-message-rotation
protocols protect against this; we don't.
2. **Post-compromise security (a.k.a. "self-healing").** If
the attacker has your chat-priv, they decrypt all past
*and* future messages until you rotate keys. No automatic
recovery.
3. **Metadata privacy.** Sender, recipient, and timestamp
are op header fields, public on chain. An observer of the
chain learns who is talking to whom and when. We don't
claim to hide this; metadata privacy on a public ledger is
a different problem we're not solving.
4. **Replay protection beyond what AEAD gives you.** AAD-
binding plus per-message random nonce means a relayed
ciphertext can't be retargeted, but a bit-for-bit replay
of the same message authentication-passes (because it's
the same plaintext under the same nonce/key). In the
on-chain context this is not a meaningful attack — replays
are de-duplicated by op id at the indexer layer — but it's
worth being explicit.
5. **Deniability.** We do not employ any deniability
construction. A sender's signature on the broadcast op
binds them to the ciphertext indelibly.
---
## Why we made this choice
The honest answer is that we evaluated heavier
forward-secrecy protocols seriously, and decided the costs
outweighed the benefit *for this specific use case*. Five
reasons, in descending order of importance:
### 1. Stateless decryption fits chain-anchored chat
Blurt is a public ledger. Messages are op broadcasts. They
may arrive out of order (RPC node lag), in batches (you opened
the app after a week away), or on a brand-new device (you
restored your keys on a new phone — from a seed, a Keyfile, or a
posting key).
A per-message-rotation protocol needs synchronized
per-conversation state on both sides. When state desyncs,
recovery is messy — protocols that try solve this with PreKey
bundles + lots of careful state management. None of the
primitives Blurt provides make that sync easy. We'd be
inventing a parallel state-coordination layer just to host
the protocol.
Morphit's stateless ECIES sidesteps the question entirely.
Any ciphertext is decryptable from the recipient's long-term
chat-priv and the envelope's ephemeral pub — full stop. No
session state, no skew handling, no recovery flow.
### 2. No "first message" bootstrap
Sender encrypts to anyone whose chat identity is on chain —
immediately. Look up their `morphit_chat_identity_v1` record,
ECDH against their pub, send. No prekey-bundle exchange, no
out-of-band step, no "X is using Signal" detection moment.
In a P2P trade context, sender and recipient may never have
talked before — the initial DM is part of the trade flow.
The bootstrap-cost-amortization argument that justifies prekey
bundles in regular messengers doesn't apply.
### 3. Multi-device by default
Chat identity is *deterministically derived* from the Blurt
posting key. Same seed phrase, same chat identity. Phone +
laptop unlocked from the same seed are interchangeable: same
inbox, same outbox, same identity.
A per-message-rotation protocol's session state would have to
be replicated across devices, with the device-pairing UX that
implies. Restored from seed on a new phone? You'd need to
either re-bootstrap every conversation or sync the protocol
state out-of-band. We chose to not sign up for that
complexity.
### 4. Auditable simplicity
`crypto.ts` is roughly 400 lines including comments. All it
does is libsodium calls. Any third-party security researcher
can read it end-to-end in under an hour.
Reference implementations of forward-secrecy protocols are
1500+ lines. The Matrix folks have written publicly about how
many subtle bugs they hit shipping theirs. More code, more
places for a bug to hide, more surface area for a research
auditor to cover.
We took the property the use case *needs* (confidentiality +
integrity + sender-binding) and stopped, instead of bundling
properties we can't deliver well.
### 5. The threat model where per-message rotation helps doesn't really
apply here
Receiver-side per-message rotation defends against silent
compromise: an attacker who steals your chat-priv without you
noticing, preserves your continued use of the app, and
decrypts your archive over time.
On Morphit, your chat-priv is *deterministically derived* from
your Blurt posting key. An attacker who has your chat-priv
also has — or had momentarily — your posting key. With your
posting key they can already:
- Broadcast as you (forge orders, leave fake feedback, edit
your identity record on chain).
- Impersonate you to your existing contacts going forward.
- Drain any BLURT balance via active-key escalation paths.
So the "preserve plausible normalcy while quietly decrypting
old messages" attack is not a meaningful incremental win for
the attacker. They already have the keys to the kingdom.
Adding receiver-side rotation would protect a small,
less-valuable slice (past chat ciphertexts) while leaving
everything else fully exploitable.
The right defense in this threat model is fast key rotation
(swap posting key, re-derive chat identity) rather than
per-message rotation.
---
## What we say to users
Per the FAQ entry, we say:
- "Morphit chat has partial forward secrecy" — true, sender-
side, with the ephemeral wipe.
- "Morphit chat does NOT have full per-message receiver-side
forward secrecy" — true.
- We never claim PFS we don't have.
- We name the use cases where Morphit chat is the wrong tool
(long-horizon activism, state-level surveillance contexts)
and recommend dedicated secure messengers for those.
---
## If you're editing `crypto.ts`
Read this whole document first. Specifically:
- **Don't add a "convenience" function that takes a non-
ephemeral sender key.** The sender ephemeral is the only
PFS property we have; losing it would silently downgrade
every send.
- **Don't change the AAD format without bumping the protocol
version.** AAD binding is the relay-attacker defense;
ciphertexts encrypted under the old AAD must not decrypt
under the new.
- **Don't drop the `sodium.memzero` calls.** Even if a
modern engine eagerly GCs the buffer, we want the explicit
wipe to be the contract. Future engines may not be as
eager.
- **Don't introduce a long-term sender key.** We've discussed
this — it would simplify some code paths but break the
sender-side PFS we currently have. Not worth it.
- **Don't try to add receiver-side per-message rotation
without a full design pass.** If we ever add receiver-side
PFS, it will need session state, sync, recovery, and
migration from existing conversations. All of that is
significant work and will involve an ADR, not an in-flight
PR.
---
## Comparison table
| Property | Morphit | Signal | Matrix (Megolm) |
|-----------------------------------|---------|--------|-----------------|
| Confidentiality | yes | yes | yes |
| Ciphertext integrity | yes | yes | yes |
| Sender-side PFS (per message) | yes | yes | yes |
| Receiver-side PFS (per message) | **no** | yes | yes |
| Post-compromise security | **no** | yes | yes |
| Stateless decryption | yes | no | partial |
| First-message bootstrap latency | zero | high | medium |
| Multi-device without pairing | yes | no | no |
| Per-conversation state to sync | none | yes | yes |
| Public metadata (sender/timestamp)| yes | no | no |
| Lines of crypto code (approx.) | 400 | 1500+ | 800+ |
The Morphit row is intentionally not all "yes." We're not
trying to win on every column; we're trying to be honest
about which columns matter for a P2P-trade use case and
which don't.