This commit is contained in:
payacom
2026-08-13 21:20:23 +03:30
parent ef2e86861a
commit a92a3ca5e6
45 changed files with 79894 additions and 297 deletions

View File

@@ -162,6 +162,62 @@ export async function wrapRoomKeyForRecipient(
);
}
/**
* Wraps the identity private key (as JWK) for transfer to a new device during
* QR-based device linking — same ECIES-style pattern as wrapRoomKeyForRecipient,
* kept separate (own domain-separation label) so a bug in one never cross-affects
* the other. The server only ever sees this ciphertext blob.
*/
export async function wrapIdentityKeyForDevice(
privateJwk: JsonWebKey,
recipientEphemeralPublicKey: CryptoKey
): Promise<string> {
const ephemeral = await crypto.subtle.generateKey(ECDH_PARAMS, true, [
"deriveBits",
]);
const aesKey = await deriveAesKey(
ephemeral.privateKey,
recipientEphemeralPublicKey,
"modstagram-device-link-v1"
);
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 }, aesKey, plaintext);
const epk = await exportPublicKeySpkiB64(ephemeral.publicKey);
return bufToB64(
new TextEncoder().encode(
JSON.stringify({
epk,
iv: bufToB64(iv),
ct: bufToB64(ct),
})
)
);
}
export async function unwrapIdentityKeyForDevice(
wrappedB64: string,
myEphemeralPrivateKey: CryptoKey
): Promise<JsonWebKey> {
const parsed = JSON.parse(new TextDecoder().decode(b64ToBuf(wrappedB64))) as {
epk: string;
iv: string;
ct: string;
};
const epk = await importPublicKeySpkiB64(parsed.epk);
const aesKey = await deriveAesKey(
myEphemeralPrivateKey,
epk,
"modstagram-device-link-v1"
);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64ToBuf(parsed.iv) },
aesKey,
b64ToBuf(parsed.ct)
);
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
}
export async function unwrapRoomKey(
wrappedB64: string,
myPrivateKey: CryptoKey

128
src/lib/e2ee/deviceLink.ts Normal file
View File

@@ -0,0 +1,128 @@
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
generateIdentityKeyPair,
exportPublicKeySpkiB64,
importPublicKeySpkiB64,
exportPrivateKeyJwk,
wrapIdentityKeyForDevice,
unwrapIdentityKeyForDevice,
} from "./crypto";
import { idbSet } from "./storage";
import { fetchPeerPublicKey } from "./keys";
function authHeaders(): HeadersInit {
const token = Cookies.get("token");
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export type DeviceLinkStartResult = {
linkId: string;
ephemeralPrivateKey: CryptoKey;
};
/** New/unlinked device: generates a throwaway ECDH keypair and registers a link session for it. */
export async function startDeviceLinkSession(): Promise<DeviceLinkStartResult> {
const pair = await generateIdentityKeyPair();
const ephemeralPublicKey = await exportPublicKeySpkiB64(pair.publicKey);
const base = getApiBaseUrl();
const res = await fetch(`${base}/account/e2ee/device-link`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ ephemeralPublicKey }),
});
if (!res.ok) throw new Error("Failed to start device link session");
const data = (await res.json()) as { linkId: string };
return { linkId: data.linkId, ephemeralPrivateKey: pair.privateKey };
}
/** New device: polls until the already-linked device has approved and wrapped its identity key. */
export async function pollDeviceLinkSession(
linkId: string
): Promise<string | null> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/account/e2ee/device-link/${encodeURIComponent(linkId)}/poll`,
{ headers: authHeaders() }
);
if (!res.ok) return null;
const data = (await res.json()) as {
status: string;
wrappedIdentityKey?: string;
};
return data.status === "fulfilled" && data.wrappedIdentityKey
? data.wrappedIdentityKey
: null;
}
/** New device: unwraps the received identity key and adopts it as this device's own — same identity, no re-publish needed. */
export async function finishDeviceLink(
userId: string,
wrappedIdentityKey: string,
ephemeralPrivateKey: CryptoKey
): Promise<void> {
const privateJwk = await unwrapIdentityKeyForDevice(
wrappedIdentityKey,
ephemeralPrivateKey
);
const publicKeyB64 = await fetchPeerPublicKey(userId);
if (!publicKeyB64) {
throw new Error("Could not fetch this account's published public key");
}
await idbSet(`identity:${userId}`, {
userId,
privateJwk,
publicKeyB64,
});
}
export type PendingDeviceLink = {
ephemeralPublicKey: string;
status: "pending" | "fulfilled";
};
/** Already-linked device: fetches the new device's ephemeral public key for a scanned/opened link. */
export async function getDeviceLinkSession(
linkId: string
): Promise<PendingDeviceLink | null> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/account/e2ee/device-link/${encodeURIComponent(linkId)}`,
{ headers: authHeaders() }
);
if (!res.ok) return null;
return (await res.json()) as PendingDeviceLink;
}
/** Already-linked device: wraps its own identity private key for the new device and submits it. */
export async function approveDeviceLink(
linkId: string,
myPrivateKey: CryptoKey,
newDeviceEphemeralPublicKeyB64: string
): Promise<void> {
const privateJwk = await exportPrivateKeyJwk(myPrivateKey);
const recipientKey = await importPublicKeySpkiB64(
newDeviceEphemeralPublicKeyB64
);
const wrappedIdentityKey = await wrapIdentityKeyForDevice(
privateJwk,
recipientKey
);
const base = getApiBaseUrl();
const res = await fetch(
`${base}/account/e2ee/device-link/${encodeURIComponent(linkId)}/approve`,
{
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ wrappedIdentityKey }),
}
);
if (!res.ok) throw new Error("Failed to approve device link");
}

View File

@@ -41,17 +41,44 @@ export const LANGUAGE_REGISTRY: Record<string, LanguageDefinition> = {
dateLocale: "en-US",
enabled: true,
},
// Example for future — set enabled: true after adding locale files:
// ar: {
// code: "ar",
// nativeLabel: "العربية",
// englishLabel: "Arabic",
// direction: "rtl",
// htmlLang: "ar",
// ogLocale: "ar_SA",
// dateLocale: "ar-SA",
// enabled: false,
// },
// Below: registered but not yet enabled — no locale files exist for these yet, so
// they render in the picker's "Other languages" section without being selectable.
// Set enabled: true once src/locales/{code}/common.json (+ seo.json) exist and are
// registered in src/lib/i18n/resources.ts.
ar: { code: "ar", nativeLabel: "العربية", englishLabel: "Arabic", direction: "rtl", htmlLang: "ar", ogLocale: "ar_SA", dateLocale: "ar-SA", enabled: false },
ku: { code: "ku", nativeLabel: "کوردی", englishLabel: "Kurdish", direction: "rtl", htmlLang: "ku", ogLocale: "ku_TR", dateLocale: "ku", enabled: false },
tr: { code: "tr", nativeLabel: "Türkçe", englishLabel: "Turkish", direction: "ltr", htmlLang: "tr", ogLocale: "tr_TR", dateLocale: "tr-TR", enabled: false },
az: { code: "az", nativeLabel: "Azərbaycan dili", englishLabel: "Azerbaijani", direction: "ltr", htmlLang: "az", ogLocale: "az_AZ", dateLocale: "az-AZ", enabled: false },
ru: { code: "ru", nativeLabel: "Русский", englishLabel: "Russian", direction: "ltr", htmlLang: "ru", ogLocale: "ru_RU", dateLocale: "ru-RU", enabled: false },
uk: { code: "uk", nativeLabel: "Українська", englishLabel: "Ukrainian", direction: "ltr", htmlLang: "uk", ogLocale: "uk_UA", dateLocale: "uk-UA", enabled: false },
es: { code: "es", nativeLabel: "Español", englishLabel: "Spanish", direction: "ltr", htmlLang: "es", ogLocale: "es_ES", dateLocale: "es-ES", enabled: false },
ro: { code: "ro", nativeLabel: "Română", englishLabel: "Romanian", direction: "ltr", htmlLang: "ro", ogLocale: "ro_RO", dateLocale: "ro-RO", enabled: false },
sk: { code: "sk", nativeLabel: "Slovenčina", englishLabel: "Slovak", direction: "ltr", htmlLang: "sk", ogLocale: "sk_SK", dateLocale: "sk-SK", enabled: false },
pl: { code: "pl", nativeLabel: "Polski", englishLabel: "Polish", direction: "ltr", htmlLang: "pl", ogLocale: "pl_PL", dateLocale: "pl-PL", enabled: false },
hu: { code: "hu", nativeLabel: "Magyar", englishLabel: "Hungarian", direction: "ltr", htmlLang: "hu", ogLocale: "hu_HU", dateLocale: "hu-HU", enabled: false },
hr: { code: "hr", nativeLabel: "Hrvatski", englishLabel: "Croatian", direction: "ltr", htmlLang: "hr", ogLocale: "hr_HR", dateLocale: "hr-HR", enabled: false },
sr: { code: "sr", nativeLabel: "Српски", englishLabel: "Serbian", direction: "ltr", htmlLang: "sr", ogLocale: "sr_RS", dateLocale: "sr-RS", enabled: false },
el: { code: "el", nativeLabel: "Ελληνικά", englishLabel: "Greek", direction: "ltr", htmlLang: "el", ogLocale: "el_GR", dateLocale: "el-GR", enabled: false },
sv: { code: "sv", nativeLabel: "Svenska", englishLabel: "Swedish", direction: "ltr", htmlLang: "sv", ogLocale: "sv_SE", dateLocale: "sv-SE", enabled: false },
de: { code: "de", nativeLabel: "Deutsch", englishLabel: "German", direction: "ltr", htmlLang: "de", ogLocale: "de_DE", dateLocale: "de-DE", enabled: false },
it: { code: "it", nativeLabel: "Italiano", englishLabel: "Italian", direction: "ltr", htmlLang: "it", ogLocale: "it_IT", dateLocale: "it-IT", enabled: false },
hi: { code: "hi", nativeLabel: "हिन्दी", englishLabel: "Hindi", direction: "ltr", htmlLang: "hi", ogLocale: "hi_IN", dateLocale: "hi-IN", enabled: false },
bn: { code: "bn", nativeLabel: "বাংলা", englishLabel: "Bengali", direction: "ltr", htmlLang: "bn", ogLocale: "bn_BD", dateLocale: "bn-BD", enabled: false },
th: { code: "th", nativeLabel: "ไทย", englishLabel: "Thai", direction: "ltr", htmlLang: "th", ogLocale: "th_TH", dateLocale: "th-TH", enabled: false },
ms: { code: "ms", nativeLabel: "Bahasa Melayu", englishLabel: "Malay", direction: "ltr", htmlLang: "ms", ogLocale: "ms_MY", dateLocale: "ms-MY", enabled: false },
fil: { code: "fil", nativeLabel: "Filipino", englishLabel: "Filipino", direction: "ltr", htmlLang: "fil", ogLocale: "fil_PH", dateLocale: "fil-PH", enabled: false },
fr: { code: "fr", nativeLabel: "Français", englishLabel: "French", direction: "ltr", htmlLang: "fr", ogLocale: "fr_FR", dateLocale: "fr-FR", enabled: false },
nl: { code: "nl", nativeLabel: "Nederlands", englishLabel: "Dutch", direction: "ltr", htmlLang: "nl", ogLocale: "nl_NL", dateLocale: "nl-NL", enabled: false },
no: { code: "no", nativeLabel: "Norsk", englishLabel: "Norwegian", direction: "ltr", htmlLang: "no", ogLocale: "nb_NO", dateLocale: "nb-NO", enabled: false },
fi: { code: "fi", nativeLabel: "Suomi", englishLabel: "Finnish", direction: "ltr", htmlLang: "fi", ogLocale: "fi_FI", dateLocale: "fi-FI", enabled: false },
ps: { code: "ps", nativeLabel: "پښتو", englishLabel: "Pashto", direction: "rtl", htmlLang: "ps", ogLocale: "ps_AF", dateLocale: "ps-AF", enabled: false },
uz: { code: "uz", nativeLabel: "O'zbekcha", englishLabel: "Uzbek", direction: "ltr", htmlLang: "uz", ogLocale: "uz_UZ", dateLocale: "uz-UZ", enabled: false },
hy: { code: "hy", nativeLabel: "Հայերեն", englishLabel: "Armenian", direction: "ltr", htmlLang: "hy", ogLocale: "hy_AM", dateLocale: "hy-AM", enabled: false },
"zh-CN": { code: "zh-CN", nativeLabel: "简体中文", englishLabel: "Chinese (Simplified)", direction: "ltr", htmlLang: "zh-CN", ogLocale: "zh_CN", dateLocale: "zh-CN", enabled: false },
"zh-TW": { code: "zh-TW", nativeLabel: "繁體中文(台灣)", englishLabel: "Chinese (Traditional, Taiwan)", direction: "ltr", htmlLang: "zh-TW", ogLocale: "zh_TW", dateLocale: "zh-TW", enabled: false },
"zh-HK": { code: "zh-HK", nativeLabel: "繁體中文(香港)", englishLabel: "Chinese (Traditional, Hong Kong)", direction: "ltr", htmlLang: "zh-HK", ogLocale: "zh_HK", dateLocale: "zh-HK", enabled: false },
ja: { code: "ja", nativeLabel: "日本語", englishLabel: "Japanese", direction: "ltr", htmlLang: "ja", ogLocale: "ja_JP", dateLocale: "ja-JP", enabled: false },
ko: { code: "ko", nativeLabel: "한국어", englishLabel: "Korean", direction: "ltr", htmlLang: "ko", ogLocale: "ko_KR", dateLocale: "ko-KR", enabled: false },
};
export type AppLanguage = keyof typeof LANGUAGE_REGISTRY;

View File

@@ -15,10 +15,11 @@ export type LocaleNamespace = (typeof LOCALE_NAMESPACES)[number];
/**
* Locale bundles — add a new language by importing its JSON files here.
* Partial: most registry entries are `enabled: false` placeholders with no
* locale files yet (see registry.ts) — only enabled languages need a bundle.
*/
export const LOCALE_BUNDLES: Record<
AppLanguage,
Record<LocaleNamespace, Record<string, unknown>>
export const LOCALE_BUNDLES: Partial<
Record<AppLanguage, Record<LocaleNamespace, Record<string, unknown>>>
> = {
fa: {
common: faCommon,