Add end-to-end encryption for chat messages (X25519 / NaCl box)

Each device generates its own X25519 key pair on login (tweetnacl); the
private key never leaves localStorage or reaches our server. Messages are
encrypted client-side with the recipient's public key before being sent,
so the backend and anyone intercepting traffic only ever see ciphertext.

- src/lib/e2ee.ts: key generation/registration + encrypt/decrypt (NaCl box)
- Chat thread + matches list: encrypt on send, decrypt on receive/preview,
  with a graceful fallback when a message can't be decrypted on this device
  (e.g. after switching browsers) and full backward compat for legacy
  plaintext messages sent before this change
- Privacy settings page explains the guarantee and its one deliberate
  trade-off: there is no key backup/escrow, so clearing browser data or
  switching devices makes old messages permanently unreadable

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 18:58:21 +03:30
parent d55d61ba81
commit 0118ecc63a
8 changed files with 231 additions and 15 deletions
+20 -3
View File
@@ -51,6 +51,7 @@ export interface UserProfile {
notify_matches: boolean;
notify_messages: boolean;
notify_likes: boolean;
public_key?: string;
}
export interface TokenPair {
@@ -168,7 +169,11 @@ export interface MatchView {
photos: string[];
created_at: string;
last_message?: string;
last_message_cipher_text?: string;
last_message_nonce?: string;
last_at?: string;
/** کلید عمومی X25519 طرف مقابل (base64)، برای رمزنگاری سرتاسری. اگه طرف مقابل هنوز کلید ثبت نکرده باشه خالیه. */
partner_public_key?: string;
}
export interface SwipeResult {
@@ -180,7 +185,10 @@ export interface MessageView {
id: number;
match_id: number;
sender_id: number;
text: string;
/** فقط برای پیام‌های قدیمیِ قبل از رمزنگاری سرتاسری پر می‌شه */
text?: string;
cipher_text?: string;
nonce?: string;
created_at: string;
is_mine: boolean;
}
@@ -494,9 +502,18 @@ export function listMessages(matchId: number) {
return authRequest<MessageView[]>(`/matches/${matchId}/messages`);
}
export function sendMessage(matchId: number, text: string) {
// cipherText/nonce قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل
// رمزنگاری شدن (src/lib/e2ee.ts)؛ متن خام پیام هیچ‌وقت به این تابع نمی‌رسه.
export function sendMessage(matchId: number, cipherText: string, nonce: string) {
return authRequest<MessageView>(`/matches/${matchId}/messages`, {
method: "POST",
body: JSON.stringify({ text }),
body: JSON.stringify({ cipher_text: cipherText, nonce }),
});
}
export function updatePublicKey(publicKey: string) {
return authRequest<null>("/profile/public-key", {
method: "PUT",
body: JSON.stringify({ public_key: publicKey }),
});
}