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:
Generated
+13
-1
@@ -15,7 +15,9 @@
|
|||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -6462,6 +6464,16 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,9 @@
|
|||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { cn } from "@/lib/utils";
|
||||||
import { formatApiError } from "@/lib/hooks";
|
import { formatApiError } from "@/lib/hooks";
|
||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
@@ -15,8 +15,24 @@ import {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
photoUrl,
|
photoUrl,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { ensureKeyPair, encryptMessage, decryptMessage } from "@/lib/e2ee";
|
||||||
|
|
||||||
const POLL_MS = 4000;
|
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() {
|
export default function ChatPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -28,6 +44,7 @@ export default function ChatPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
@@ -49,6 +66,9 @@ export default function ChatPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
refreshMessages();
|
refreshMessages();
|
||||||
|
ensureKeyPair()
|
||||||
|
.then((kp) => setMySecretKey(kp.secretKey))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
pollRef.current = setInterval(refreshMessages, POLL_MS);
|
pollRef.current = setInterval(refreshMessages, POLL_MS);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -60,13 +80,16 @@ export default function ChatPage() {
|
|||||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
}, [messages?.length]);
|
}, [messages?.length]);
|
||||||
|
|
||||||
|
const canEncrypt = !!mySecretKey && !!match?.partner_public_key;
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending || !mySecretKey || !match?.partner_public_key) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
try {
|
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]));
|
setMessages((prev) => (prev ? [...prev, msg] : [msg]));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(formatApiError(err));
|
setError(formatApiError(err));
|
||||||
@@ -118,6 +141,10 @@ export default function ChatPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col gap-3 px-4 py-4 overflow-y-auto">
|
<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 ? (
|
{messages.length === 0 ? (
|
||||||
<p className="text-[13px] text-(--color-content-tertiary) text-center py-8">
|
<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"
|
: "bg-(--color-bg-secondary) text-(--color-content-primary) rounded-2xl rounded-bl-md"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m.text}
|
{messageText(m, match?.partner_public_key, mySecretKey)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -146,17 +173,24 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
<ErrorBanner message={error} />
|
<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)]">
|
<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
|
<input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
placeholder="پیامت رو بنویس..."
|
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
|
<button
|
||||||
onClick={handleSend}
|
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"
|
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} />
|
<Send size={17} />
|
||||||
|
|||||||
@@ -8,16 +8,33 @@ import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
|||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
import { formatApiError } from "@/lib/hooks";
|
import { formatApiError } from "@/lib/hooks";
|
||||||
import { MatchView, listMatches, photoUrl } from "@/lib/api";
|
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() {
|
export default function MessagesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [matches, setMatches] = useState<MatchView[] | null>(null);
|
const [matches, setMatches] = useState<MatchView[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mySecretKey, setMySecretKey] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listMatches()
|
listMatches()
|
||||||
.then(setMatches)
|
.then(setMatches)
|
||||||
.catch((err) => setError(formatApiError(err)));
|
.catch((err) => setError(formatApiError(err)));
|
||||||
|
ensureKeyPair()
|
||||||
|
.then((kp) => setMySecretKey(kp.secretKey))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error && !matches) {
|
if (error && !matches) {
|
||||||
@@ -29,8 +46,9 @@ export default function MessagesPage() {
|
|||||||
}
|
}
|
||||||
if (!matches) return <FullScreenLoader />;
|
if (!matches) return <FullScreenLoader />;
|
||||||
|
|
||||||
const newMatches = matches.filter((m) => !m.last_message);
|
const hasLastMessage = (m: MatchView) => !!m.last_message || !!m.last_message_cipher_text;
|
||||||
const conversations = matches.filter((m) => !!m.last_message);
|
const newMatches = matches.filter((m) => !hasLastMessage(m));
|
||||||
|
const conversations = matches.filter(hasLastMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-full">
|
<div className="flex flex-col min-h-full">
|
||||||
@@ -112,7 +130,7 @@ export default function MessagesPage() {
|
|||||||
{c.first_name}
|
{c.first_name}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[13px] truncate mt-0.5 text-(--color-content-tertiary)">
|
<p className="text-[13px] truncate mt-0.5 text-(--color-content-tertiary)">
|
||||||
{c.last_message}
|
{previewFor(c, mySecretKey)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 { Card } from "@/components/ui/Card";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
@@ -67,6 +67,10 @@ function ConfirmDeleteSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FACTS = [
|
const FACTS = [
|
||||||
|
{
|
||||||
|
icon: <KeyRound size={16} className="text-(--color-brand-500)" />,
|
||||||
|
text: "پیامهای چتت با رمزنگاری سرتاسری (end-to-end) بین تو و طرف مقابل رد و بدل میشن؛ فقط با کلید روی گوشی/مرورگر شما دو نفر باز میشن. نه سرور، نه ادمینهای دلدل، نه هیچکس دیگهای نمیتونه بخوندشون — حتی اگه کسی وسط راه ترافیک شبکه رو گوش بده.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <Phone size={16} className="text-(--color-brand-500)" />,
|
icon: <Phone size={16} className="text-(--color-brand-500)" />,
|
||||||
text: "شماره موبایلت هیچوقت به کاربرهای دیگه نشون داده نمیشه.",
|
text: "شماره موبایلت هیچوقت به کاربرهای دیگه نشون داده نمیشه.",
|
||||||
@@ -132,6 +136,23 @@ export default function PrivacySettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</Card>
|
</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">
|
<Card className="flex flex-col gap-3 border-2 !border-red-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[14px] font-bold text-(--color-status-error)">حذف حساب کاربری</p>
|
<p className="text-[14px] font-bold text-(--color-status-error)">حذف حساب کاربری</p>
|
||||||
|
|||||||
+20
-3
@@ -51,6 +51,7 @@ export interface UserProfile {
|
|||||||
notify_matches: boolean;
|
notify_matches: boolean;
|
||||||
notify_messages: boolean;
|
notify_messages: boolean;
|
||||||
notify_likes: boolean;
|
notify_likes: boolean;
|
||||||
|
public_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@@ -168,7 +169,11 @@ export interface MatchView {
|
|||||||
photos: string[];
|
photos: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_message?: string;
|
last_message?: string;
|
||||||
|
last_message_cipher_text?: string;
|
||||||
|
last_message_nonce?: string;
|
||||||
last_at?: string;
|
last_at?: string;
|
||||||
|
/** کلید عمومی X25519 طرف مقابل (base64)، برای رمزنگاری سرتاسری. اگه طرف مقابل هنوز کلید ثبت نکرده باشه خالیه. */
|
||||||
|
partner_public_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SwipeResult {
|
export interface SwipeResult {
|
||||||
@@ -180,7 +185,10 @@ export interface MessageView {
|
|||||||
id: number;
|
id: number;
|
||||||
match_id: number;
|
match_id: number;
|
||||||
sender_id: number;
|
sender_id: number;
|
||||||
text: string;
|
/** فقط برای پیامهای قدیمیِ قبل از رمزنگاری سرتاسری پر میشه */
|
||||||
|
text?: string;
|
||||||
|
cipher_text?: string;
|
||||||
|
nonce?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
is_mine: boolean;
|
is_mine: boolean;
|
||||||
}
|
}
|
||||||
@@ -494,9 +502,18 @@ export function listMessages(matchId: number) {
|
|||||||
return authRequest<MessageView[]>(`/matches/${matchId}/messages`);
|
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`, {
|
return authRequest<MessageView>(`/matches/${matchId}/messages`, {
|
||||||
method: "POST",
|
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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { tokenStore } from "./token-store";
|
import { tokenStore } from "./token-store";
|
||||||
|
import { ensureKeyPair } from "./e2ee";
|
||||||
import {
|
import {
|
||||||
UserProfile,
|
UserProfile,
|
||||||
OnboardingStatus,
|
OnboardingStatus,
|
||||||
@@ -58,6 +59,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
apiCheckIn()
|
apiCheckIn()
|
||||||
.then((r) => setStreak(r.streak_count))
|
.then((r) => setStreak(r.streak_count))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
// کلید رمزنگاری سرتاسری پیامها رو مطمئن میشه که آماده و با سرور sync شده؛
|
||||||
|
// fire-and-forget، چون چت هنوز باز نشده و لازم نیست منتظرش بمونیم.
|
||||||
|
ensureKeyPair().catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
tokenStore.clear();
|
tokenStore.clear();
|
||||||
setStatus("guest");
|
setStatus("guest");
|
||||||
@@ -81,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
apiCheckIn()
|
apiCheckIn()
|
||||||
.then((r) => setStreak(r.streak_count))
|
.then((r) => setStreak(r.streak_count))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
ensureKeyPair().catch(() => {});
|
||||||
return data.user;
|
return data.user;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
+107
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user