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", "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
View File
@@ -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",
+40 -6
View File
@@ -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} />
+21 -3
View File
@@ -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>
+22 -1
View File
@@ -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
View File
@@ -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 }),
}); });
} }
+5
View File
@@ -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
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;
}
}