diff --git a/app/hooks/useSound.ts b/app/hooks/useSound.ts index 44ffac8..f4473e2 100644 --- a/app/hooks/useSound.ts +++ b/app/hooks/useSound.ts @@ -14,7 +14,7 @@ export function useSound() { audioRef.current.load(); }; - const playSound = (sound : string, loop: boolean = false) => { + const playSound = async (sound : string, loop: boolean = false) => { try { if(loop){ if (!loopingAudioRef.current) { @@ -24,7 +24,7 @@ export function useSound() { loopingAudioRef.current.loop = true; } - const url = window.mediaApi.getSoundUrl(sound); + const url = await window.mediaApi.getSoundUrl(sound); const player = loopingAudioRef.current; player.src = url; @@ -43,7 +43,7 @@ export function useSound() { audioRef.current.loop = loop; } - const url = window.mediaApi.getSoundUrl(sound); + const url = await window.mediaApi.getSoundUrl(sound); const player = audioRef.current; stopSound(); diff --git a/app/providers/CallProvider/CallProvider.tsx b/app/providers/CallProvider/CallProvider.tsx index 36202f1..c0ee1d3 100644 --- a/app/providers/CallProvider/CallProvider.tsx +++ b/app/providers/CallProvider/CallProvider.tsx @@ -12,6 +12,7 @@ import { modals } from "@mantine/modals"; import { Button, Flex, Text } from "@mantine/core"; import { useSound } from "@/app/hooks/useSound"; import useWindow from "@/app/hooks/useWindow"; +import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE"; export interface CallContextValue { call: (callable: string) => void; @@ -73,7 +74,8 @@ export function CallProvider(props : CallProviderProps) { const roomIdRef = useRef(""); const roleRef = useRef(null); - const [sharedSecret, setSharedSecret] = useState(""); + //const [sharedSecret, setSharedSecret] = useState(""); + const sharedSecretRef = useRef(""); const iceServersRef = useRef([]); const remoteAudioRef = useRef(null); const iceCandidatesBufferRef = useRef([]); @@ -245,8 +247,8 @@ export function CallProvider(props : CallProviderProps) { } const sessionKeys = generateSessionKeys(); const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey); - setSharedSecret(Buffer.from(computedSharedSecret).toString('hex')); - info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex')); + sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex'); + info("Generated shared secret for call session: " + sharedSecretRef.current); /** * Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию */ @@ -283,8 +285,8 @@ export function CallProvider(props : CallProviderProps) { return; } const computedSharedSecret = nacl.box.before(Buffer.from(sharedPublic, 'hex'), sessionKeys.secretKey); - info("Generated shared secret for call session: " + Buffer.from(computedSharedSecret).toString('hex')); - setSharedSecret(Buffer.from(computedSharedSecret).toString('hex')); + sharedSecretRef.current = Buffer.from(computedSharedSecret).toString('hex'); + info("Generated shared secret for call session: " + sharedSecretRef.current); setCallState(CallState.WEB_RTC_EXCHANGE); } if(signalType == SignalType.CREATE_ROOM) { @@ -297,7 +299,9 @@ export function CallProvider(props : CallProviderProps) { * Нужно отправить свой SDP оффер другой стороне, чтобы установить WebRTC соединение */ peerConnectionRef.current = new RTCPeerConnection({ - iceServers: iceServersRef.current + iceServers: iceServersRef.current, + // @ts-ignore + encodedInsertableStreams: true }); /** * Подписываемся на ICE кандидат @@ -321,7 +325,12 @@ export function CallProvider(props : CallProviderProps) { } } - peerConnectionRef.current.ontrack = (event) => { + peerConnectionRef.current.ontrack = async (event) => { + try { + await attachReceiverE2EE(event.receiver, Buffer.from(sharedSecretRef.current, "hex")); + } catch (e) { + console.error("attachReceiverE2EE failed:", e); + } /** * При получении медиа-трека с другой стороны */ @@ -339,12 +348,17 @@ export function CallProvider(props : CallProviderProps) { * Запрашиваем Аудио поток с микрофона и добавляем его в PeerConnection, чтобы другая сторона могла его получить и воспроизвести, * когда мы установим WebRTC соединение */ - const localStream = await navigator.mediaDevices.getUserMedia({ - audio: true - }); - localStream.getTracks().forEach(track => { - peerConnectionRef.current?.addTrack(track, localStream); + const localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioTrack = localStream.getAudioTracks()[0]; + + + const tx = peerConnectionRef.current.addTransceiver(audioTrack, { + direction: "sendrecv", + streams: [localStream] }); + + await attachSenderE2EE(tx.sender, Buffer.from(sharedSecretRef.current, "hex")); + /** * Отправляем свой оффер другой стороне */ @@ -470,10 +484,10 @@ export function CallProvider(props : CallProviderProps) { * @returns */ const getKeyCast = () => { - if(!sharedSecret){ + if(!sharedSecretRef.current){ return ""; } - return sharedSecret; + return sharedSecretRef.current; } diff --git a/app/providers/CallProvider/audioE2EE.ts b/app/providers/CallProvider/audioE2EE.ts new file mode 100644 index 0000000..216d065 --- /dev/null +++ b/app/providers/CallProvider/audioE2EE.ts @@ -0,0 +1,105 @@ +function toArrayBuffer(src: Buffer | Uint8Array): ArrayBuffer { + const u8 = src instanceof Uint8Array ? src : new Uint8Array(src); + return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer; +} + +type KeyInput = Buffer | Uint8Array; + + +async function importAesCtrKey(input: KeyInput): Promise { + const keyBytes = toArrayBuffer(input); + if (keyBytes.byteLength !== 32) { + throw new Error(`E2EE key must be 32 bytes, got ${keyBytes.byteLength}`); + } + + return crypto.subtle.importKey( + "raw", + keyBytes, + { name: "AES-CTR" }, + false, + ["encrypt", "decrypt"] + ); +} + +function toBigIntTs(ts: unknown): bigint { + if (typeof ts === "bigint") return ts; + if (typeof ts === "number") return BigInt(ts); + return 0n; +} + +/** + * 16-byte counter: + * [0..3] direction marker + * [4..11] frame timestamp + * [12..15] reserved + */ +function buildCounter(direction: number, timestamp: unknown): ArrayBuffer { + const iv = new Uint8Array(16); + const dv = new DataView(iv.buffer); + dv.setUint32(0, direction >>> 0, false); + dv.setBigUint64(4, toBigIntTs(timestamp), false); + dv.setUint32(12, 0, false); + return toArrayBuffer(iv); +} + +export async function attachSenderE2EE(sender: RTCRtpSender, keyInput: KeyInput): Promise { + const key = await importAesCtrKey(keyInput); + + const anySender = sender as unknown as { + createEncodedStreams?: () => { readable: ReadableStream; writable: WritableStream }; + }; + + if (!anySender.createEncodedStreams) { + throw new Error("createEncodedStreams is not available on RTCRtpSender"); + } + + const { readable, writable } = anySender.createEncodedStreams(); + + const enc = new TransformStream({ + async transform(frame, controller) { + const counter = buildCounter(1, frame.timestamp); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-CTR", counter, length: 64 }, + key, + frame.data + ); + frame.data = encrypted; // same length + controller.enqueue(frame); + } + }); + + readable.pipeThrough(enc).pipeTo(writable).catch((e) => { + console.error("Sender E2EE pipeline failed:", e); + }); +} + +export async function attachReceiverE2EE(receiver: RTCRtpReceiver, keyInput: KeyInput): Promise { + const key = await importAesCtrKey(keyInput); + + const anyReceiver = receiver as unknown as { + createEncodedStreams?: () => { readable: ReadableStream; writable: WritableStream }; + }; + + if (!anyReceiver.createEncodedStreams) { + throw new Error("createEncodedStreams is not available on RTCRtpReceiver"); + } + + const { readable, writable } = anyReceiver.createEncodedStreams(); + + const dec = new TransformStream({ + async transform(frame, controller) { + const counter = buildCounter(1, frame.timestamp); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-CTR", counter, length: 64 }, + key, + frame.data + ); + frame.data = decrypted; // same length + controller.enqueue(frame); + } + }); + + readable.pipeThrough(dec).pipeTo(writable).catch((e) => { + console.error("Receiver E2EE pipeline failed:", e); + }); +} \ No newline at end of file diff --git a/app/version.ts b/app/version.ts index 92dfb77..1bf51b8 100644 --- a/app/version.ts +++ b/app/version.ts @@ -1,13 +1,8 @@ -export const APP_VERSION = "1.1.0"; -export const CORE_MIN_REQUIRED_VERSION = "1.5.2"; +export const APP_VERSION = "1.1.1"; +export const CORE_MIN_REQUIRED_VERSION = "1.5.3"; export const RELEASE_NOTICE = ` -**Обновление v1.1.0** :emoji_1f631: -- Добавлена поддержка звонков -- Прозрачным аватаркам добавлена подложка -- Фикс ошибки чтения -- Подложка к вложению аватарки -- Обмен ключами шифрования DH -- Поддерджка WebRTC -- Событийные звуки звонка (сбросить, мутинг, и прочее...) +**Обновление v1.1.1** :emoji_1f631: +- Добавлено сквозное шифрование звонков +- Исправлена проблема с звуком в звонках на некоторых устройствах `; \ No newline at end of file diff --git a/lib/main/ipcs/ipcRuntime.ts b/lib/main/ipcs/ipcRuntime.ts new file mode 100644 index 0000000..5c13ceb --- /dev/null +++ b/lib/main/ipcs/ipcRuntime.ts @@ -0,0 +1,13 @@ +import { app, ipcMain } from "electron"; +import path from "path"; + +/** + * Получить директорию с ресурсами приложения + */ +ipcMain.handle('runtime:get-resources', () => { + const isDev = !app.isPackaged && process.env['ELECTRON_RENDERER_URL']; + if(isDev){ + return path.join(process.cwd(), "resources") + } + return path.join(process.resourcesPath, "resources"); +}); \ No newline at end of file diff --git a/lib/main/main.ts b/lib/main/main.ts index 8a550ef..90c71db 100644 --- a/lib/main/main.ts +++ b/lib/main/main.ts @@ -8,6 +8,7 @@ import './ipcs/ipcUpdate' import './ipcs/ipcNotification' import './ipcs/ipcDevice' import './ipcs/ipcCore' +import './ipcs/ipcRuntime' import { Tray } from 'electron/main' import { join } from 'path' import { Logger } from './logger' diff --git a/lib/preload/index.d.ts b/lib/preload/index.d.ts index 284361b..ed3d8e1 100644 --- a/lib/preload/index.d.ts +++ b/lib/preload/index.d.ts @@ -14,7 +14,7 @@ declare global { deviceName: string; deviceId: string; mediaApi: { - getSoundUrl: (fileName: string) => string; + getSoundUrl: (fileName: string) => Promise; }; } } diff --git a/lib/preload/preload.ts b/lib/preload/preload.ts index dbe848a..288699a 100644 --- a/lib/preload/preload.ts +++ b/lib/preload/preload.ts @@ -6,12 +6,9 @@ import path from 'node:path' import fs from "node:fs"; -function resolveSound(fileName: string) { - const isDev = !process.env.APP_PACKAGED; // или свой флаг dev - const fullPath = isDev - ? path.join(process.cwd(), "resources", "sounds", fileName) - : path.join(process.resourcesPath, "resources", "sounds", fileName); - +async function resolveSound(fileName: string) { + const resourcesPath = await ipcRenderer.invoke('runtime:get-resources'); + const fullPath = path.join(resourcesPath, "sounds", fileName); if (!fs.existsSync(fullPath)) { throw new Error(`Sound not found: ${fullPath}`); } @@ -32,7 +29,7 @@ const exposeContext = async () => { } }); contextBridge.exposeInMainWorld("mediaApi", { - getSoundUrl: (fileName: string) => { + getSoundUrl: async (fileName: string) => { return resolveSound(fileName); } }); @@ -44,7 +41,7 @@ const exposeContext = async () => { window.api = api; window.shell = shell; window.mediaApi = { - getSoundUrl: (fileName: string) => { + getSoundUrl: async (fileName: string) => { return resolveSound(fileName); } } diff --git a/package.json b/package.json index 985f4ec..9332cd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Rosetta", - "version": "1.5.2", + "version": "1.5.3", "description": "Rosetta Messenger", "main": "./out/main/main.js", "license": "MIT", diff --git a/tsconfig.web.json b/tsconfig.web.json index eb4c3b3..9b4503a 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -5,6 +5,7 @@ "composite": true, "jsx": "react-jsx", "baseUrl": ".", + "lib": ["DOM"], "esModuleInterop": true, "types": ["electron-vite/node"], "paths": {