122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
const STORAGE_PREFIX = 'lendry-e2e-private:';
|
||
const ALGORITHM = { name: 'ECDH', namedCurve: 'P-256' } as const;
|
||
const AES = { name: 'AES-GCM', length: 256 } as const;
|
||
|
||
interface StoredKeyMaterial {
|
||
privateJwk: JsonWebKey;
|
||
publicKey: string;
|
||
}
|
||
|
||
function toBase64(buffer: ArrayBuffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = '';
|
||
bytes.forEach((byte) => {
|
||
binary += String.fromCharCode(byte);
|
||
});
|
||
return btoa(binary);
|
||
}
|
||
|
||
function fromBase64(value: string) {
|
||
const binary = atob(value);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let index = 0; index < binary.length; index += 1) {
|
||
bytes[index] = binary.charCodeAt(index);
|
||
}
|
||
return bytes.buffer;
|
||
}
|
||
|
||
async function exportPublicKey(key: CryptoKey) {
|
||
const raw = await crypto.subtle.exportKey('spki', key);
|
||
return toBase64(raw);
|
||
}
|
||
|
||
async function importPublicKey(base64: string) {
|
||
return crypto.subtle.importKey('spki', fromBase64(base64), ALGORITHM, true, []);
|
||
}
|
||
|
||
async function loadStoredKeys(userId: string) {
|
||
const stored = localStorage.getItem(`${STORAGE_PREFIX}${userId}`);
|
||
if (!stored) return null;
|
||
const parsed = JSON.parse(stored) as StoredKeyMaterial;
|
||
const privateKey = await crypto.subtle.importKey('jwk', parsed.privateJwk, ALGORITHM, true, ['deriveKey']);
|
||
return { privateKey, publicKey: parsed.publicKey };
|
||
}
|
||
|
||
async function saveStoredKeys(userId: string, privateKey: CryptoKey, publicKey: string) {
|
||
const privateJwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||
localStorage.setItem(
|
||
`${STORAGE_PREFIX}${userId}`,
|
||
JSON.stringify({ privateJwk, publicKey } satisfies StoredKeyMaterial)
|
||
);
|
||
}
|
||
|
||
export async function ensureE2EKeyPair(userId: string) {
|
||
const existing = await loadStoredKeys(userId);
|
||
if (existing) return existing;
|
||
|
||
const keyPair = await crypto.subtle.generateKey(ALGORITHM, true, ['deriveKey']);
|
||
const publicKey = await exportPublicKey(keyPair.publicKey);
|
||
await saveStoredKeys(userId, keyPair.privateKey, publicKey);
|
||
return { privateKey: keyPair.privateKey, publicKey };
|
||
}
|
||
|
||
async function deriveAesKey(privateKey: CryptoKey, peerPublicKeyBase64: string) {
|
||
const peerPublicKey = await importPublicKey(peerPublicKeyBase64);
|
||
return crypto.subtle.deriveKey({ name: 'ECDH', public: peerPublicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
|
||
}
|
||
|
||
export async function encryptDirectMessage(userId: string, peerPublicKeyBase64: string, plaintext: string) {
|
||
const keys = await ensureE2EKeyPair(userId);
|
||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const encoded = new TextEncoder().encode(plaintext);
|
||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, encoded);
|
||
return JSON.stringify({
|
||
v: 1,
|
||
iv: toBase64(iv.buffer),
|
||
ciphertext: toBase64(ciphertext)
|
||
});
|
||
}
|
||
|
||
export async function decryptDirectMessage(userId: string, peerPublicKeyBase64: string, payload: string) {
|
||
const keys = await loadStoredKeys(userId);
|
||
if (!keys) {
|
||
return '🔒 Не удалось расшифровать сообщение';
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(payload) as { iv?: string; ciphertext?: string };
|
||
if (!parsed.iv || !parsed.ciphertext) {
|
||
return payload;
|
||
}
|
||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||
const iv = new Uint8Array(fromBase64(parsed.iv));
|
||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, fromBase64(parsed.ciphertext));
|
||
return new TextDecoder().decode(decrypted);
|
||
} catch {
|
||
return '🔒 Не удалось расшифровать сообщение';
|
||
}
|
||
}
|
||
|
||
export async function encryptBinary(userId: string, peerPublicKeyBase64: string, data: ArrayBuffer) {
|
||
const keys = await ensureE2EKeyPair(userId);
|
||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, data);
|
||
const packed = new Uint8Array(iv.byteLength + ciphertext.byteLength);
|
||
packed.set(iv, 0);
|
||
packed.set(new Uint8Array(ciphertext), iv.byteLength);
|
||
return packed.buffer;
|
||
}
|
||
|
||
export async function decryptBinary(userId: string, peerPublicKeyBase64: string, packed: ArrayBuffer) {
|
||
const keys = await loadStoredKeys(userId);
|
||
if (!keys) {
|
||
throw new Error('Локальный E2E-ключ не найден');
|
||
}
|
||
const bytes = new Uint8Array(packed);
|
||
const iv = bytes.slice(0, 12);
|
||
const ciphertext = bytes.slice(12);
|
||
const aesKey = await deriveAesKey(keys.privateKey, peerPublicKeyBase64);
|
||
return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ciphertext);
|
||
}
|