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:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user