diff --git a/package-lock.json b/package-lock.json index e722472..6347983 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6f28bd4..afbd9d5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(main)/messages/[id]/page.tsx b/src/app/(main)/messages/[id]/page.tsx index 6d0e453..6b3cda9 100644 --- a/src/app/(main)/messages/[id]/page.tsx +++ b/src/app/(main)/messages/[id]/page.tsx @@ -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(null); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); + const [mySecretKey, setMySecretKey] = useState(null); const bottomRef = useRef(null); const pollRef = useRef | 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() {
+

+ + پیام‌ها با رمزنگاری سرتاسری محافظت می‌شن؛ هیچ‌کس جز شما دو نفر، حتی ادمین‌های دلدل، نمی‌تونه بخوندشون. +

{messages.length === 0 ? (

هنوز پیامی نیست؛ اولین نفر باش که سلام می‌ده 👋 @@ -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)}

)) @@ -146,17 +173,24 @@ export default function ChatPage() { + {!canEncrypt && ( +

+ + طرف مقابل هنوز رمزنگاری سرتاسری رو فعال نکرده؛ تا فعال نشه نمی‌تونی پیام بفرستی. +

+ )}
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" /> diff --git a/src/app/(main)/settings/privacy/page.tsx b/src/app/(main)/settings/privacy/page.tsx index 89a840b..1f60e1b 100644 --- a/src/app/(main)/settings/privacy/page.tsx +++ b/src/app/(main)/settings/privacy/page.tsx @@ -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: , + text: "پیام‌های چتت با رمزنگاری سرتاسری (end-to-end) بین تو و طرف مقابل رد و بدل می‌شن؛ فقط با کلید روی گوشی/مرورگر شما دو نفر باز می‌شن. نه سرور، نه ادمین‌های دلدل، نه هیچ‌کس دیگه‌ای نمی‌تونه بخوندشون — حتی اگه کسی وسط راه ترافیک شبکه رو گوش بده.", + }, { icon: , text: "شماره موبایلت هیچ‌وقت به کاربرهای دیگه نشون داده نمی‌شه.", @@ -132,6 +136,23 @@ export default function PrivacySettingsPage() { ))} + +
+ +
+

+ کلید رمزنگاری فقط روی همین دستگاهه +

+

+ هیچ نسخه‌ی پشتیبانی از کلیدهای رمزنگاری روی سرور نگه داشته نمی‌شه. اگه + کش/اطلاعات مرورگر رو پاک کنی یا از یه گوشی/مرورگر دیگه وارد بشی، پیام‌های + قبلی برای همیشه غیرقابل‌خوندن می‌شن — همین‌جوری که توی واتساپ و سیگنال هم + همینه. +

+
+
+
+

حذف حساب کاربری

diff --git a/src/lib/api.ts b/src/lib/api.ts index 8026d4f..ed897d4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(`/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(`/matches/${matchId}/messages`, { method: "POST", - body: JSON.stringify({ text }), + body: JSON.stringify({ cipher_text: cipherText, nonce }), + }); +} + +export function updatePublicKey(publicKey: string) { + return authRequest("/profile/public-key", { + method: "PUT", + body: JSON.stringify({ public_key: publicKey }), }); } diff --git a/src/lib/auth-context.tsx b/src/lib/auth-context.tsx index b600dbb..6aea3c3 100644 --- a/src/lib/auth-context.tsx +++ b/src/lib/auth-context.tsx @@ -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; }, []); diff --git a/src/lib/e2ee.ts b/src/lib/e2ee.ts new file mode 100644 index 0000000..d75d858 --- /dev/null +++ b/src/lib/e2ee.ts @@ -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 { + 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; + } +}