git
This commit is contained in:
34
src/lib/analytics/parseUserAgent.ts
Normal file
34
src/lib/analytics/parseUserAgent.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
export {
|
||||
ensureIdentityKeys,
|
||||
fetchPeerPublicKey,
|
||||
fetchMyKeyBackup,
|
||||
saveKeyBackup,
|
||||
recoverIdentityFromBackup,
|
||||
createFreshIdentity,
|
||||
IdentityRecoveryNeededError,
|
||||
} from "./keys";
|
||||
export {
|
||||
encryptDmContent,
|
||||
decryptDmContent,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user