275 lines
11 KiB
Markdown
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.
|