Skip to content

feat: e2e encryption for direct messages#134

Closed
encryptedDegen wants to merge 1 commit into
mainfrom
encrypted-messages
Closed

feat: e2e encryption for direct messages#134
encryptedDegen wants to merge 1 commit into
mainfrom
encrypted-messages

Conversation

@encryptedDegen
Copy link
Copy Markdown
Collaborator

Summary

  • End-to-end encrypts DM bodies using X25519 ECDH + XChaCha20-Poly1305. Encryption is client-side only — wallet private keys never leave the wallet, derived messaging keys never leave the browser.
  • Each user derives a deterministic X25519 keypair from a wallet signature on a fixed message, so the same wallet always produces the same keypair (cross-device, on re-install) without needing to back anything up.
  • Each user publishes only the public half of that keypair plus a binding signature so peers can cryptographically verify the pubkey came from the wallet address — protects against a compromised backend silently substituting keys for a MITM.
  • Old plaintext messages remain readable; new messages ride in the existing body field as enc:v1:<base64> and are detected by prefix.

What's in the PR

  • src/lib/crypto/ — X25519 derivation, XChaCha20-Poly1305 cipher, IndexedDB keystore, enc:v1 wire format, peer-verify helpers.
  • src/hooks/chat/useMessagingKeys.ts + messagingKeysSingleton.ts + MessagingKeysMount — owns keypair lifecycle, auto-derives on auth, publishes pubkey + binding sig once per account.
  • Send path (useSendMessage): refuses to fall back to plaintext if peer key is missing — explicit error instead.
  • Receive path (useChatSocket, useChatMessages, useChatsInbox): decrypts incoming WS messages, history pages, and inbox previews.
  • UI: messageRow and chatRow render decrypted_body ?? body with [Unable to decrypt] fallback. threadView shows banners + disables the composer when own keys aren't ready or the peer hasn't enrolled.
  • Profile reducer / auth flow pick up publicEncryptionKey + publicEncryptionKeySignature from /auth/me.
  • Unrelated drive-by: bumped @noble/hashes to v2 forced one import path fix in imageUploadModal.tsx (sha256 moved from sha256.js to sha2.js).

Crypto choices

  • Curve: X25519 (separate keypair from wallet's secp256k1 — wallets don't expose ECDH).
  • AEAD: XChaCha20-Poly1305 with 24-byte random nonce (no nonce-reuse concerns).
  • KDF: HKDF-SHA256 for both seed-derivation and ECDH shared-secret expansion.
  • Library: @noble/curves, @noble/ciphers, @noble/hashes (audited, dependency-free, ~30kb).

Backend changes required (api.grails.app)

Documented inline in src/api/user/publishEncryptionKey.ts and getEncryptionKey.ts. Quick version:

  1. New columns on user model: public_encryption_key (string, ~44 chars base64) and public_encryption_key_signature (string, 132 chars 0x...).
  2. PUT /users/me/encryption-key — body { publicKey, signature }. Server must verify recoverAddress(bindingMessage(addr, publicKey), signature) === addr before persisting. Exact message format: src/lib/crypto/derive.ts (bindingMessage).
  3. GET /users/:address/encryption-key — returns { address, publicKey, signature } or 404.
  4. Include both fields in GET /auth/me and any chat participant payloads (the ChatParticipant type now expects public_encryption_key and public_encryption_key_signature).
  5. Existing message endpoints don't change — ciphertext rides in the body field as before.

Threat model

  • Encrypted: DM body content.
  • Not encrypted (visible to backend): who is messaging whom, timestamps, read receipts, typing indicators, message frequency. Standard tradeoff matching Signal/iMessage metadata exposure.
  • Out of scope for v1: forward secrecy / message-level ratcheting, group-chat encryption (no groups exist), encrypted attachments, key rotation, smart-contract wallet support (requires ERC-1271 — different design).

UX

  • First sign-in after deploy: 3 wallet prompts (SIWE + derivation + binding). Derivation signature is the seed and never leaves the browser. Binding signature publishes the pubkey with proof-of-wallet.
  • New device for an existing account: 2 prompts (SIWE + derivation; binding sig already on backend).
  • Subsequent sign-ins on a device with cached keypair: 1 prompt (SIWE only — IndexedDB hit).
  • If the user dismisses the messaging signature, threadView shows a "Retry" button instead of the composer.

Test plan

  • Backend: ship the two endpoints + columns + include fields in /auth/me and chat participants
  • First sign-in: 3 wallet prompts, IndexedDB has keypair, backend has pubkey + binding sig
  • Send a DM: request body to POST /chats/:id/messages contains enc:v1:...
  • Receive a DM via WS: bubble renders plaintext, no enc:v1: visible
  • Inbox last-message preview shows plaintext for encrypted messages
  • Same wallet on a second browser/device: 2 prompts, all past messages decrypt and render
  • Send to a peer who hasn't enrolled: composer is replaced with the "hasn't enabled encrypted messaging" banner
  • Old plaintext messages from before the deploy still render
  • Cancel the messaging signature prompt: thread shows Retry button, send is blocked
  • Substituted-key MITM check: manually corrupt the backend's stored signature → client refuses to encrypt to that peer

🤖 Generated with Claude Code

Encrypts DM bodies client-side with X25519 + XChaCha20-Poly1305. Each
user derives a deterministic messaging keypair from a wallet signature
(seed never leaves the browser) and publishes the public key with a
binding signature so peers can verify it against the wallet address
before encrypting. Old plaintext messages remain readable; new messages
ride in the existing `body` field as `enc:v1:<base64>`.

Backend must add `public_encryption_key` + `public_encryption_key_signature`
columns and the corresponding GET/PUT endpoints — see JSDoc in
src/api/user/{publishEncryptionKey,getEncryptionKey}.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
grails Ready Ready Preview, Comment May 6, 2026 6:20pm

Request Review

@encryptedDegen
Copy link
Copy Markdown
Collaborator Author

PR#135 gets that done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant