lang
This commit is contained in:
@@ -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
128
src/lib/e2ee/deviceLink.ts
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user