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
+13 -1
View File
@@ -15,7 +15,9 @@
"lucide-react": "^1.45.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -6462,6 +6464,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"node_modules/tweetnacl-util": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz",
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+3 -1
View File
@@ -16,7 +16,9 @@
"lucide-react": "^1.45.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+40 -6
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { ChevronRight, Send, User } from "lucide-react";
import { ChevronRight, Send, User, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatApiError } from "@/lib/hooks";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
@@ -15,8 +15,24 @@ import {
sendMessage,
photoUrl,
} from "@/lib/api";
import { ensureKeyPair, encryptMessage, decryptMessage } from "@/lib/e2ee";
const POLL_MS = 4000;
const UNREADABLE = "🔒 این پیام رو نمی‌تونی روی این دستگاه بخونی";
// پیام رمزنگاری‌شده رو برای نمایش رمزگشایی می‌کنه؛ پیام قدیمیِ قبل از E2EE
// همینجوری برگردونده می‌شه.
function messageText(
m: MessageView,
partnerPublicKey: string | undefined,
mySecretKey: Uint8Array | null
): string {
if (m.cipher_text && m.nonce) {
if (!mySecretKey || !partnerPublicKey) return UNREADABLE;
return decryptMessage(m.cipher_text, m.nonce, partnerPublicKey, mySecretKey) ?? UNREADABLE;
}
return m.text ?? "";
}
export default function ChatPage() {
const params = useParams<{ id: string }>();
@@ -28,6 +44,7 @@ export default function ChatPage() {
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -49,6 +66,9 @@ export default function ChatPage() {
.catch(() => {});
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshMessages();
ensureKeyPair()
.then((kp) => setMySecretKey(kp.secretKey))
.catch(() => {});
pollRef.current = setInterval(refreshMessages, POLL_MS);
return () => {
@@ -60,13 +80,16 @@ export default function ChatPage() {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages?.length]);
const canEncrypt = !!mySecretKey && !!match?.partner_public_key;
async function handleSend() {
const text = draft.trim();
if (!text || sending) return;
if (!text || sending || !mySecretKey || !match?.partner_public_key) return;
setSending(true);
setDraft("");
try {
const msg = await sendMessage(matchId, text);
const { cipherText, nonce } = encryptMessage(text, match.partner_public_key, mySecretKey);
const msg = await sendMessage(matchId, cipherText, nonce);
setMessages((prev) => (prev ? [...prev, msg] : [msg]));
} catch (err) {
setError(formatApiError(err));
@@ -118,6 +141,10 @@ export default function ChatPage() {
</header>
<div className="flex-1 flex flex-col gap-3 px-4 py-4 overflow-y-auto">
<p className="flex items-center justify-center gap-1.5 text-[11px] text-(--color-content-tertiary) text-center py-2 px-4 mx-auto">
<Lock size={11} />
پیام‌ها با رمزنگاری سرتاسری محافظت می‌شن؛ هیچ‌کس جز شما دو نفر، حتی ادمین‌های دلدل، نمی‌تونه بخوندشون.
</p>
{messages.length === 0 ? (
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8">
هنوز پیامی نیست؛ اولین نفر باش که سلام می‌ده 👋
@@ -136,7 +163,7 @@ export default function ChatPage() {
: "bg-(--color-bg-secondary) text-(--color-content-primary) rounded-2xl rounded-bl-md"
)}
>
{m.text}
{messageText(m, match?.partner_public_key, mySecretKey)}
</div>
</div>
))
@@ -146,17 +173,24 @@ export default function ChatPage() {
<ErrorBanner message={error} />
{!canEncrypt && (
<p className="text-[12px] text-(--color-content-tertiary) text-center px-4 pb-1 flex items-center justify-center gap-1.5">
<Lock size={12} />
طرف مقابل هنوز رمزنگاری سرتاسری رو فعال نکرده؛ تا فعال نشه نمی‌تونی پیام بفرستی.
</p>
)}
<div className="flex items-center gap-2 px-4 py-3 border-t border-(--color-border-subtle) pb-[calc(env(safe-area-inset-bottom)+12px)]">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="پیامت رو بنویس..."
className="flex-1 h-11 rounded-(--radius-full) bg-(--color-bg-secondary) px-4 text-[14px] focus:outline-none focus:ring-2 focus:ring-(--color-brand-200)"
disabled={!canEncrypt}
className="flex-1 h-11 rounded-(--radius-full) bg-(--color-bg-secondary) px-4 text-[14px] focus:outline-none focus:ring-2 focus:ring-(--color-brand-200) disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!draft.trim() || sending}
disabled={!draft.trim() || sending || !canEncrypt}
className="w-11 h-11 rounded-full bg-(--color-brand-500) text-white flex items-center justify-center disabled:opacity-40 shrink-0"
>
<Send size={17} />
+21 -3
View File
@@ -8,16 +8,33 @@ import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
import { formatApiError } from "@/lib/hooks";
import { MatchView, listMatches, photoUrl } from "@/lib/api";
import { ensureKeyPair, decryptMessage } from "@/lib/e2ee";
// پیش‌نمایش آخرین پیام هر مچ رو برمی‌گردونه. سرور نمی‌تونه رمزگشایی کنه، پس
// اگه پیام رمزنگاری‌شده باشه، همینجا با کلید محلی رمزگشایی می‌شه؛ اگه کلید
// جور نبود (مثلاً این پیام روی یه دستگاه دیگه رمزنگاری شده) یه نشونه‌ی قفل نشون می‌ده.
function previewFor(m: MatchView, mySecretKey: Uint8Array | null): string | undefined {
if (m.last_message_cipher_text && m.last_message_nonce) {
if (!mySecretKey || !m.partner_public_key) return "🔒 پیام رمزنگاری‌شده";
const plain = decryptMessage(m.last_message_cipher_text, m.last_message_nonce, m.partner_public_key, mySecretKey);
return plain ?? "🔒 این پیام رو نمی‌تونی روی این دستگاه ببینی";
}
return m.last_message;
}
export default function MessagesPage() {
const router = useRouter();
const [matches, setMatches] = useState<MatchView[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
useEffect(() => {
listMatches()
.then(setMatches)
.catch((err) => setError(formatApiError(err)));
ensureKeyPair()
.then((kp) => setMySecretKey(kp.secretKey))
.catch(() => {});
}, []);
if (error && !matches) {
@@ -29,8 +46,9 @@ export default function MessagesPage() {
}
if (!matches) return <FullScreenLoader />;
const newMatches = matches.filter((m) => !m.last_message);
const conversations = matches.filter((m) => !!m.last_message);
const hasLastMessage = (m: MatchView) => !!m.last_message || !!m.last_message_cipher_text;
const newMatches = matches.filter((m) => !hasLastMessage(m));
const conversations = matches.filter(hasLastMessage);
return (
<div className="flex flex-col min-h-full">
@@ -112,7 +130,7 @@ export default function MessagesPage() {
{c.first_name}
</span>
<p className="text-[13px] truncate mt-0.5 text-(--color-content-tertiary)">
{c.last_message}
{previewFor(c, mySecretKey)}
</p>
</div>
</button>
+22 -1
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRight, ShieldCheck, Phone, MapPin, Lock, Trash2 } from "lucide-react";
import { ChevronRight, ShieldCheck, Phone, MapPin, Lock, Trash2, KeyRound, TriangleAlert } from "lucide-react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/ErrorBanner";
@@ -67,6 +67,10 @@ function ConfirmDeleteSheet({
}
const FACTS = [
{
icon: <KeyRound size={16} className="text-(--color-brand-500)" />,
text: "پیام‌های چتت با رمزنگاری سرتاسری (end-to-end) بین تو و طرف مقابل رد و بدل می‌شن؛ فقط با کلید روی گوشی/مرورگر شما دو نفر باز می‌شن. نه سرور، نه ادمین‌های دلدل، نه هیچ‌کس دیگه‌ای نمی‌تونه بخوندشون — حتی اگه کسی وسط راه ترافیک شبکه رو گوش بده.",
},
{
icon: <Phone size={16} className="text-(--color-brand-500)" />,
text: "شماره موبایلت هیچ‌وقت به کاربرهای دیگه نشون داده نمی‌شه.",
@@ -132,6 +136,23 @@ export default function PrivacySettingsPage() {
))}
</Card>
<Card className="flex flex-col gap-2.5 border-2 !border-amber-100">
<div className="flex items-start gap-2.5">
<TriangleAlert size={16} className="text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-[14px] font-bold text-(--color-content-primary)">
کلید رمزنگاری فقط روی همین دستگاهه
</p>
<p className="text-[13px] text-(--color-content-tertiary) mt-1 leading-6">
هیچ نسخه‌ی پشتیبانی از کلیدهای رمزنگاری روی سرور نگه داشته نمی‌شه. اگه
کش/اطلاعات مرورگر رو پاک کنی یا از یه گوشی/مرورگر دیگه وارد بشی، پیام‌های
قبلی برای همیشه غیرقابل‌خوندن می‌شن — همین‌جوری که توی واتساپ و سیگنال هم
همینه.
</p>
</div>
</div>
</Card>
<Card className="flex flex-col gap-3 border-2 !border-red-100">
<div>
<p className="text-[14px] font-bold text-(--color-status-error)">حذف حساب کاربری</p>
+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 }),
});
}
+5
View File
@@ -9,6 +9,7 @@ import {
ReactNode,
} from "react";
import { tokenStore } from "./token-store";
import { ensureKeyPair } from "./e2ee";
import {
UserProfile,
OnboardingStatus,
@@ -58,6 +59,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
apiCheckIn()
.then((r) => setStreak(r.streak_count))
.catch(() => {});
// کلید رمزنگاری سرتاسری پیام‌ها رو مطمئن می‌شه که آماده و با سرور sync شده؛
// fire-and-forget، چون چت هنوز باز نشده و لازم نیست منتظرش بمونیم.
ensureKeyPair().catch(() => {});
} catch {
tokenStore.clear();
setStatus("guest");
@@ -81,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
apiCheckIn()
.then((r) => setStreak(r.streak_count))
.catch(() => {});
ensureKeyPair().catch(() => {});
return data.user;
}, []);
+107
View File
@@ -0,0 +1,107 @@
// رمزنگاری سرتاسری (end-to-end) پیام‌های چت با NaCl box (X25519-XSalsa20-Poly1305).
// کلید خصوصی هیچ‌وقت از این فایل بیرون نمی‌ره و هیچ‌وقت به سرور فرستاده نمی‌شه؛
// فقط توی localStorage همین مرورگر می‌مونه. یعنی اگه کاربر localStorage رو پاک
// کنه یا از یه مرورگر/دستگاه دیگه وارد بشه، کلید قبلی از بین می‌ره و پیام‌های
// قبلی دیگه قابل رمزگشایی نیستن — این یه تصمیم آگاهانه‌ست، نه باگ: هیچ نسخه‌ی
// پشتیبان یا escrow از کلید خصوصی روی سرور نگه داشته نمی‌شه.
import nacl from "tweetnacl";
import util from "tweetnacl-util";
import { updatePublicKey } from "./api";
const SECRET_KEY_STORAGE = "dating_app_e2ee_secret_key";
function isBrowser() {
return typeof window !== "undefined";
}
export interface LocalKeyPair {
publicKey: Uint8Array;
secretKey: Uint8Array;
}
function readLocalSecretKey(): Uint8Array | null {
if (!isBrowser()) return null;
const stored = localStorage.getItem(SECRET_KEY_STORAGE);
if (!stored) return null;
try {
return util.decodeBase64(stored);
} catch {
return null;
}
}
/** آیا این مرورگر از قبل کلید محلی داره؟ (بدون تولید کلید جدید) */
export function hasLocalKeyPair(): boolean {
return readLocalSecretKey() !== null;
}
/** کلید محلی رو بدون هیچ درخواست شبکه‌ای برمی‌گردونه؛ اگه هنوز ساخته نشده null. */
export function getLocalKeyPair(): LocalKeyPair | null {
const secretKey = readLocalSecretKey();
if (!secretKey) return null;
return nacl.box.keyPair.fromSecretKey(secretKey);
}
/**
* کلید محلی رو برمی‌گردونه؛ اگه وجود نداشته باشه یه جفت‌کلید تازه می‌سازه و
* توی localStorage ذخیره می‌کنه. در هر دو حالت، کلید عمومی رو (دوباره) با
* سرور sync می‌کنه تا اگه ثبت قبلی به هر دلیلی (قطعی شبکه و…) ناموفق بوده،
* این بار درست بشه.
*/
export async function ensureKeyPair(): Promise<LocalKeyPair> {
let secretKey = readLocalSecretKey();
let keyPair: nacl.BoxKeyPair;
if (secretKey) {
keyPair = nacl.box.keyPair.fromSecretKey(secretKey);
} else {
keyPair = nacl.box.keyPair();
secretKey = keyPair.secretKey;
if (isBrowser()) {
localStorage.setItem(SECRET_KEY_STORAGE, util.encodeBase64(secretKey));
}
}
try {
await updatePublicKey(util.encodeBase64(keyPair.publicKey));
} catch {
// آفلاین یا خطای موقت سرور؛ کلید محلی معتبره و دفعه‌ی بعد دوباره sync می‌شه.
}
return keyPair;
}
/** پیام متنی رو با کلید عمومی طرف مقابل رمزنگاری می‌کنه. */
export function encryptMessage(
plainText: string,
theirPublicKeyB64: string,
mySecretKey: Uint8Array
): { cipherText: string; nonce: string } {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const messageBytes = util.decodeUTF8(plainText);
const theirPublicKey = util.decodeBase64(theirPublicKeyB64);
const box = nacl.box(messageBytes, nonce, theirPublicKey, mySecretKey);
return {
cipherText: util.encodeBase64(box),
nonce: util.encodeBase64(nonce),
};
}
/** پیام رمزنگاری‌شده رو رمزگشایی می‌کنه؛ اگه کلید جور نباشه null برمی‌گردونه. */
export function decryptMessage(
cipherTextB64: string,
nonceB64: string,
theirPublicKeyB64: string,
mySecretKey: Uint8Array
): string | null {
try {
const cipherText = util.decodeBase64(cipherTextB64);
const nonce = util.decodeBase64(nonceB64);
const theirPublicKey = util.decodeBase64(theirPublicKeyB64);
const opened = nacl.box.open(cipherText, nonce, theirPublicKey, mySecretKey);
if (!opened) return null;
return util.encodeUTF8(opened);
} catch {
return null;
}
}