This commit is contained in:
payacom
2026-09-09 10:19:27 +03:30
parent bf41ad4e0f
commit 2d5505065b
28 changed files with 872 additions and 88 deletions

View File

@@ -0,0 +1,34 @@
// پارسر سبک UA — بدون وابستگی خارجی؛ فقط چیزهایی که برای گزارش دستگاه/مرورگر/سیستم‌عامل
// توی آنالیتیکس لازمه (نه یک UA-parser کامل)
export type ParsedUserAgent = {
device: string;
browser: string;
os: string;
};
export function parseUserAgent(ua: string): ParsedUserAgent {
const s = ua || "";
let os = "Other";
if (/windows/i.test(s)) os = "Windows";
else if (/iphone|ipad|ipod/i.test(s)) os = "iOS";
else if (/android/i.test(s)) os = "Android";
else if (/mac os x/i.test(s)) os = "macOS";
else if (/linux/i.test(s)) os = "Linux";
let browser = "Other";
if (/edg\//i.test(s)) browser = "Edge";
else if (/samsungbrowser/i.test(s)) browser = "Samsung Internet";
else if (/firefox\//i.test(s)) browser = "Firefox";
else if (/crios\//i.test(s)) browser = "Chrome (iOS)";
else if (/fxios\//i.test(s)) browser = "Firefox (iOS)";
else if (/chrome\//i.test(s)) browser = "Chrome";
else if (/version\/.+safari/i.test(s)) browser = "Safari";
else if (/safari\//i.test(s)) browser = "Safari";
let device = "Desktop";
if (/ipad|tablet/i.test(s)) device = "Tablet";
else if (/mobile|iphone|android/i.test(s)) device = "Mobile";
return { device, browser, os };
}

View File

@@ -218,6 +218,73 @@ export async function unwrapIdentityKeyForDevice(
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
}
// بک‌آپِ کلیدِ خصوصی با PIN/رمزِ انتخابیِ کاربر — عین Signal: کلیدِ رمزنگاری از خودِ
// PIN با PBKDF2 (تعداد تکرارِ بالا، چون PIN معمولاً کوتاهه و در برابر brute-force
// نیاز به کندسازیِ عمدی داره) مشتق می‌شه؛ سرور فقط بلاکِ رمزنگاری‌شده رو می‌بینه
const BACKUP_PBKDF2_ITERATIONS = 250000;
async function deriveBackupKey(
pin: string,
salt: Uint8Array
): Promise<CryptoKey> {
const pinKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(pin),
"PBKDF2",
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: toArrayBuffer(salt),
iterations: BACKUP_PBKDF2_ITERATIONS,
hash: "SHA-256",
},
pinKey,
{ name: "AES-GCM", length: AES_BITS },
false,
["encrypt", "decrypt"]
);
}
export type KeyBackupPayload = {
salt: string;
iv: string;
ciphertext: string;
iterations: number;
};
export async function encryptPrivateKeyForBackup(
privateJwk: JsonWebKey,
pin: string
): Promise<KeyBackupPayload> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveBackupKey(pin, salt);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(privateJwk));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
return {
salt: bufToB64(salt),
iv: bufToB64(iv),
ciphertext: bufToB64(ct),
iterations: BACKUP_PBKDF2_ITERATIONS,
};
}
/** PIN اشتباه → decrypt با AES-GCM شکست می‌خوره (تگِ احرازهویت جور درنمیاد) و throw می‌کنه */
export async function decryptPrivateKeyFromBackup(
backup: KeyBackupPayload,
pin: string
): Promise<JsonWebKey> {
const salt = b64ToBuf(backup.salt);
const key = await deriveBackupKey(pin, new Uint8Array(salt));
const iv = b64ToBuf(backup.iv);
const ct = b64ToBuf(backup.ciphertext);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
}
export async function unwrapRoomKey(
wrappedB64: string,
myPrivateKey: CryptoKey

View File

@@ -1,4 +1,9 @@
import { deriveDmAesKey, importPublicKeySpkiB64 } from "./crypto";
import {
decryptText,
deriveDmAesKey,
encryptText,
importPublicKeySpkiB64,
} from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
@@ -73,17 +78,24 @@ export function clearDmKeyCache(): void {
dmKeyCache.clear();
}
// E2EE موقتاً کاملاً غیرفعال شده (کلیدهای محلی که از بین می‌رفتن — پاک‌شدنِ
// دیتای مرورگر، نصبِ مجددِ اپ، دستگاهِ جدید — رمزگشایی رو برای همیشه می‌شکوندن،
// بدونِ راهِ بازیابی چون سرور خودِ متن رو نگه نمی‌داره). پیام‌های جدید همیشه
// متنِ ساده ارسال/دریافت می‌شن؛ توابعِ زیرِ کلید (getDmConversationKey و...)
// دست‌نخورده می‌مونن چون جاهای دیگه‌ای importشون می‌کنن، ولی دیگه از اینجا صدا زده نمی‌شن
// دوباره فعال شده — حالا که بک‌آپِ کلید با PIN (keys.ts: saveKeyBackup/
// recoverIdentityFromBackup) و چکِ ایمنِ «هویتِ قبلی روی سرور هست ولی این دستگاه
// کلیدِ محلی نداره» (keys.ts: IdentityRecoveryNeededError) قبل از این وجود دارن، یه
// کلیدِ محلی هیچ‌وقت بی‌صدا جایگزینِ یه هویتِ قدیمی نمی‌شه. اگه کلیدِ طرفِ مقابل در
// دسترس نباشه (fail شدنِ getDmConversationKey)، متنِ ساده ارسال می‌شه — بهتر از
// شکستِ کاملِ ارسالِ پیام، ولی این حالت باید نادر باشه
export async function encryptDmContent(
myUserId: string,
peerUserId: string,
plaintext: string
): Promise<string | null> {
return plaintext;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return plaintext;
try {
return encryptText(key, plaintext);
} catch {
return plaintext;
}
}
export async function decryptDmContent(
@@ -92,11 +104,13 @@ export async function decryptDmContent(
content: string
): Promise<string> {
const raw = normalizeMessageContent(content);
// فقط پیامِ قدیمیِ رمزنگاری‌شده (از قبلِ غیرفعال‌سازیِ E2EE) به این‌جا می‌رسه —
// چون کلیدِ لازم ممکنه دیگه در دسترس نباشه، به‌جایِ تلاشِ بی‌فایده برای
// رمزگشایی، همون‌جا پیامِ روشن نشون می‌دیم
if (isE2eeEnvelope(raw)) return E2EE_LOCKED_PREVIEW;
return raw;
if (!isE2eeEnvelope(raw)) return raw;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return E2EE_LOCKED_PREVIEW;
const plain = await decryptText(key, raw);
return plain ?? E2EE_LOCKED_PREVIEW;
}
export async function decryptDmMessageList<

View File

@@ -1,4 +1,12 @@
export { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
export {
ensureIdentityKeys,
fetchPeerPublicKey,
fetchMyKeyBackup,
saveKeyBackup,
recoverIdentityFromBackup,
createFreshIdentity,
IdentityRecoveryNeededError,
} from "./keys";
export {
encryptDmContent,
decryptDmContent,

View File

@@ -1,11 +1,14 @@
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
decryptPrivateKeyFromBackup,
encryptPrivateKeyForBackup,
exportPrivateKeyJwk,
exportPublicKeySpkiB64,
generateIdentityKeyPair,
importPrivateKeyJwk,
importPublicKeySpkiB64,
type KeyBackupPayload,
} from "./crypto";
import { idbGet, idbSet } from "./storage";
@@ -47,6 +50,39 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> {
});
}
/**
* پرتاب می‌شه وقتی روی سرور از قبل یه کلیدِ عمومی برای این کاربر ثبته، ولی این
* دستگاه هیچ کلیدِ محلی‌ای نداره — یعنی یه هویتِ قبلی وجود داره که این دستگاه بهش
* دسترسی نداره. تولیدِ خودکارِ یه کلیدِ تازه در این حالت یعنی جایگزینیِ هویتِ قدیمی و
* غیرقابل‌بازگشت‌شدنِ همیشگیِ پیام‌های رمزنگاری‌شده‌ی قبلی — دقیقاً همون باگی که E2EE رو
* موقتاً غیرفعال کرده بود. کدِ صداکننده باید این خطا رو بگیره و یا با
* recoverIdentityFromBackup (اگه hasBackup) یا با تاییدِ صریحِ کاربر برای شروعِ یه
* هویتِ تازه (createFreshIdentity) ادامه بده — هیچ‌وقت خودکار
*/
export class IdentityRecoveryNeededError extends Error {
hasBackup: boolean;
constructor(hasBackup: boolean) {
super("E2EE identity recovery needed");
this.name = "IdentityRecoveryNeededError";
this.hasBackup = hasBackup;
}
}
async function storeAndAdopt(
uid: string,
privateKey: CryptoKey,
privateJwk: JsonWebKey,
publicKeyB64: string
): Promise<{ privateKey: CryptoKey; publicKeyB64: string }> {
await idbSet(`identity:${uid}`, {
userId: uid,
privateJwk,
publicKeyB64,
} satisfies StoredIdentity);
memoryPair = { userId: uid, privateKey, publicKeyB64 };
return { privateKey, publicKeyB64 };
}
async function loadOrCreateIdentityKeys(uid: string): Promise<{
privateKey: CryptoKey;
publicKeyB64: string;
@@ -69,22 +105,24 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
return { privateKey, publicKeyB64: stored.publicKeyB64 };
}
// هیچ کلیدِ محلی‌ای نیست — قبل از ساختنِ یه هویتِ تازه، مطمئن شو که این واقعاً یه
// کاربرِ تازه‌ست، نه یه دستگاهِ جدید/دیتای‌پاک‌شده برای یه هویتِ از قبل موجود
const existingPublicKey = await fetchPeerPublicKey(uid);
if (existingPublicKey) {
const backup = await fetchMyKeyBackup();
throw new IdentityRecoveryNeededError(Boolean(backup));
}
const pair = await generateIdentityKeyPair();
const publicKeyB64 = await exportPublicKeySpkiB64(pair.publicKey);
const privateJwk = await exportPrivateKeyJwk(pair.privateKey);
await idbSet(storeKey, {
userId: uid,
privateJwk,
publicKeyB64,
} satisfies StoredIdentity);
memoryPair = { userId: uid, privateKey: pair.privateKey, publicKeyB64 };
const result = await storeAndAdopt(uid, pair.privateKey, privateJwk, publicKeyB64);
try {
await publishPublicKey(publicKeyB64);
} catch {
/* publish best-effort */
}
return { privateKey: pair.privateKey, publicKeyB64 };
return result;
}
export async function ensureIdentityKeys(userId: string): Promise<{
@@ -109,6 +147,77 @@ export async function ensureIdentityKeys(userId: string): Promise<{
return task;
}
/** بک‌آپِ رمزنگاری‌شده‌ی این کاربر رو از سرور می‌گیره — سرور فقط ciphertext می‌بینه */
export async function fetchMyKeyBackup(): Promise<KeyBackupPayload | null> {
const base = getApiBaseUrl();
const res = await fetch(`${base}/account/e2ee/key-backup`, {
headers: authHeaders(),
});
if (!res.ok) return null;
const data = (await res.json()) as { backup?: KeyBackupPayload | null };
return data.backup || null;
}
/** کلیدِ فعلیِ این دستگاه رو با یه PIN رمزنگاری می‌کنه و روی سرور بک‌آپ می‌گیره */
export async function saveKeyBackup(userId: string, pin: string): Promise<void> {
const uid = String(userId);
const stored = await idbGet<StoredIdentity>(`identity:${uid}`);
if (!stored?.privateJwk) throw new Error("No local identity key to back up");
const payload = await encryptPrivateKeyForBackup(stored.privateJwk, pin);
const base = getApiBaseUrl();
const res = await fetch(`${base}/account/e2ee/key-backup`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Failed to save key backup");
}
/**
* بک‌آپِ سرور رو با PIN باز می‌کنه و به‌عنوان کلیدِ این دستگاه می‌پذیره — همون هویتِ
* قدیمی، پس پیام‌های قدیمی دوباره قابل‌رمزگشایی می‌شن. PIN اشتباه → AES-GCM حین
* decrypt (تگِ احرازهویت جور درنمیاد) throw می‌کنه، نه یه نتیجه‌ی نادرستِ بی‌صدا
*/
export async function recoverIdentityFromBackup(
userId: string,
pin: string
): Promise<void> {
const uid = String(userId);
const backup = await fetchMyKeyBackup();
if (!backup) throw new Error("No backup found");
const privateJwk = await decryptPrivateKeyFromBackup(backup, pin);
const privateKey = await importPrivateKeyJwk(privateJwk);
const publicKeyB64 = await fetchPeerPublicKey(uid);
if (!publicKeyB64) throw new Error("Could not fetch published public key");
await storeAndAdopt(uid, privateKey, privateJwk, publicKeyB64);
}
/**
* وقتی هیچ بک‌آپی نیست و هیچ دستگاهِ دیگه‌ای هم برای لینک‌کردن در دسترس نیست — کاربر
* صراحتاً می‌پذیره که پیام‌های قدیمیِ رمزنگاری‌شده برای همیشه غیرقابل‌بازگشت می‌مونن و
* یه هویتِ کاملاً تازه می‌سازه. باید فقط بعدِ تاییدِ آگاهانه‌ی کاربر صدا زده بشه، نه
* خودکار
*/
export async function createFreshIdentity(userId: string): Promise<{
privateKey: CryptoKey;
publicKeyB64: string;
}> {
const uid = String(userId);
const pair = await generateIdentityKeyPair();
const publicKeyB64 = await exportPublicKeySpkiB64(pair.publicKey);
const privateJwk = await exportPrivateKeyJwk(pair.privateKey);
const result = await storeAndAdopt(uid, pair.privateKey, privateJwk, publicKeyB64);
try {
await publishPublicKey(publicKeyB64);
} catch {
/* publish best-effort */
}
return result;
}
export async function fetchPeerPublicKey(
peerUserId: string
): Promise<string | null> {

View File

@@ -8,6 +8,10 @@ export const PWA_THEME_COLOR = "#FF107D";
export const PWA_BACKGROUND_COLOR = "#FF107D";
export const PWA_ID = "modstagram-pwa";
export const PWA_VERSION = "12";
// نسخه‌ی فعلیِ APK اندروید — با هر انتشار نسخه‌ی جدید (و آپلود APK جدید توی
// storage/downloads/modstagram.apk) این مقدار هم دستی به‌روز بشه؛ همون versionName
// توی android/app/build.gradle
export const ANDROID_APP_VERSION = "2.1.2";
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";

View File

@@ -1,5 +1,5 @@
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
export const USERNAME_MIN_LENGTH = 8;
export const USERNAME_MIN_LENGTH = 6;
export const USERNAME_MAX_LENGTH = 30;
export const USERNAME_REGEX = new RegExp(