diff --git a/app/components/Call/Call.tsx b/app/components/Call/Call.tsx index aa63880..7538217 100644 --- a/app/components/Call/Call.tsx +++ b/app/components/Call/Call.tsx @@ -3,8 +3,9 @@ import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars"; import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider"; import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime"; import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation"; -import { Avatar, Box, Flex, Text } from "@mantine/core"; +import { Avatar, Box, Flex, Popover, Text, useMantineTheme } from "@mantine/core"; import { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react"; +import { KeyImage } from "../KeyImage/KeyImage"; export interface CallProps { context: CallContextValue; @@ -20,10 +21,14 @@ export function Call(props: CallProps) { setSound, setMuted, setShowCallView, - muted} = props.context; + muted, + getKeyCast, + accept + } = props.context; const [userInfo] = useUserInformation(activeCall); const avatars = useAvatars(activeCall); const colors = useRosettaColors(); + const theme = useMantineTheme(); return ( Back - + + + + + + + + This call is secured by 256 bit end-to-end encryption. Only you and the recipient can read or listen to the content of this call. + + + + + {translateDurationToTime(duration)})} {callState == CallState.CONNECTING && (Connecting...)} {callState == CallState.INCOMING && (Incoming call...)} + {callState == CallState.KEY_EXCHANGE && (Exchanging encryption keys...)} - {callState == CallState.ACTIVE || callState == CallState.CONNECTING && ( + {callState == CallState.ACTIVE || callState == CallState.CONNECTING || callState == CallState.KEY_EXCHANGE && ( <> setSound(!sound)} style={{ borderRadius: 25, @@ -93,7 +117,7 @@ export function Call(props: CallProps) { )} - diff --git a/app/dev.html b/app/dev.html index 969a459..dd03527 100644 --- a/app/dev.html +++ b/app/dev.html @@ -6,6 +6,9 @@
+ diff --git a/app/providers/CallProvider/CallProvider.tsx b/app/providers/CallProvider/CallProvider.tsx index e532c14..57e3743 100644 --- a/app/providers/CallProvider/CallProvider.tsx +++ b/app/providers/CallProvider/CallProvider.tsx @@ -1,5 +1,11 @@ import { Call } from "@/app/components/Call/Call"; -import { createContext, useState } from "react"; +import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; +import { createContext, useEffect, useRef, useState } from "react"; +import nacl from 'tweetnacl'; +import { useSender } from "../ProtocolProvider/useSender"; +import { PacketSignal, SignalType } from "../ProtocolProvider/protocol/packets/packet.signal"; +import { usePacket } from "../ProtocolProvider/usePacket"; +import { usePublicKey } from "../AccountProvider/usePublicKey"; export interface CallContextValue { @@ -13,15 +19,29 @@ export interface CallContextValue { setSound: (sound: boolean) => void; duration: number; setShowCallView: (show: boolean) => void; + getKeyCast: () => string; + accept: () => void; } export enum CallState { CONNECTING, + KEY_EXCHANGE, ACTIVE, ENDED, INCOMING } +export enum CallRole { + /** + * Вызывающая сторона, которая инициирует звонок + */ + CALLER, + /** + * Принимающая сторона, которая отвечает на звонок и принимает его + */ + CALLEE +} + export const CallContext = createContext(null); export interface CallProviderProps { children: React.ReactNode; @@ -34,18 +54,142 @@ export function CallProvider(props : CallProviderProps) { const [sound, setSound] = useState(true); const [duration, setDuration] = useState(0); const [showCallView, setShowCallView] = useState(callState == CallState.INCOMING); + const {info} = useConsoleLogger("CallProvider"); + const [sessionKeys, setSessionKeys] = useState(null); + const send = useSender(); + const publicKey = usePublicKey(); + + const roleRef = useRef(null); + const [sharedSecret, setSharedSecret] = useState(""); + + useEffect(() => { + console.info("TRACE -> ", sharedSecret) + }, [sharedSecret]); + + usePacket(26, (packet: PacketSignal) => { + const signalType = packet.getSignalType(); + if(activeCall){ + /** + * У нас уже есть активный звонок, игнорируем все сигналы, кроме сигналов от текущего звонка + */ + if(packet.getSrc() != activeCall){ + info("Received signal for another call, ignoring"); + return; + } + } + if(signalType == SignalType.CALL){ + /** + * Нам поступает звонок + */ + setActiveCall(packet.getSrc()); + setCallState(CallState.INCOMING); + setShowCallView(true); + } + if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLER){ + /** + * Другая сторона сгенерировала ключи для сессии и отправила нам публичную часть, + * теперь мы можем создать общую секретную сессию для шифрования звонка + */ + const sharedPublic = packet.getSharedPublic(); + if(!sharedPublic){ + info("Received key exchange signal without shared public key"); + return; + } + 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')); + /** + * Нам нужно отправить свой публичный ключ другой стороне, чтобы она тоже могла создать общую секретную сессию + */ + const signalPacket = new PacketSignal(); + signalPacket.setSrc(publicKey); + signalPacket.setDst(packet.getDst()); + signalPacket.setSignalType(SignalType.KEY_EXCHANGE); + signalPacket.setSharedPublic(Buffer.from(sessionKeys.publicKey).toString('hex')); + send(signalPacket); + } + if(signalType == SignalType.KEY_EXCHANGE && roleRef.current == CallRole.CALLEE){ + /** + * Мы отправили свою публичную часть ключа другой стороне, + * теперь мы получили ее публичную часть и можем создать общую + * секретную сессию для шифрования звонка + */ + const sharedPublic = packet.getSharedPublic(); + if(!sharedPublic){ + info("Received key exchange signal without shared public key"); + return; + } + if(!sessionKeys){ + info("Received key exchange signal but session keys are not generated"); + 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')); + } + if(signalType == SignalType.ACTIVE_CALL) { + setCallState(CallState.ACTIVE); + } + }, []); + + const generateSessionKeys = () => { + const sessionKeys = nacl.box.keyPair(); + info("Generated keys for call session, len: " + sessionKeys.publicKey.length); + setSessionKeys(sessionKeys); + return sessionKeys; + } const call = (dialog: string) => { setActiveCall(dialog); setCallState(CallState.CONNECTING); setShowCallView(true); + const signalPacket = new PacketSignal(); + signalPacket.setDst(dialog); + signalPacket.setSignalType(SignalType.CALL); + send(signalPacket); + roleRef.current = CallRole.CALLER; } const close = () => { setActiveCall(""); setCallState(CallState.ENDED); setShowCallView(false); + setSessionKeys(null); setDuration(0); + roleRef.current = null; + } + + const accept = () => { + if(callState != CallState.INCOMING){ + /** + * Нечего принимать + */ + return; + } + /** + * Звонок принят, генерируем ключи для сессии и отправляем их другой стороне для установления защищенного канала связи + */ + const keys = generateSessionKeys(); + const signalPacket = new PacketSignal(); + signalPacket.setDst(activeCall); + signalPacket.setSignalType(SignalType.KEY_EXCHANGE); + signalPacket.setSharedPublic(Buffer.from(keys.publicKey).toString('hex')); + send(signalPacket); + setCallState(CallState.KEY_EXCHANGE); + roleRef.current = CallRole.CALLEE; + } + + /** + * Получает слепок ключа для отображения в UI + * чтобы не показывать настоящий ключ + * @returns + */ + const getKeyCast = () => { + if(!sessionKeys){ + return ""; + } + return Buffer.from(sessionKeys.secretKey).toString('hex'); } const context = { @@ -58,7 +202,9 @@ export function CallProvider(props : CallProviderProps) { setMuted, setSound, duration, - setShowCallView + setShowCallView, + getKeyCast, + accept }; return ( diff --git a/app/providers/ProtocolProvider/protocol/packets/packet.signal.ts b/app/providers/ProtocolProvider/protocol/packets/packet.signal.ts new file mode 100644 index 0000000..5a6ce5f --- /dev/null +++ b/app/providers/ProtocolProvider/protocol/packets/packet.signal.ts @@ -0,0 +1,83 @@ +import Packet from "../packet"; +import Stream from "../stream"; + +export enum SignalType { + CALL = 1, + KEY_EXCHANGE = 2, + ACTIVE_CALL = 3, + END_CALL = 4 +} + +export class PacketSignal extends Packet { + + private src: string = ""; + /** + * Назначение + */ + private dst: string = ""; + /** + * Используется если SignalType == KEY_EXCHANGE, для идентификации сессии обмена ключами + */ + private sharedPublic: string = ""; + + private signalType: SignalType = SignalType.CALL; + + + public getPacketId(): number { + return 26; + } + + public _receive(stream: Stream): void { + this.signalType = stream.readInt8(); + this.src = stream.readString(); + this.dst = stream.readString(); + if(this.signalType == SignalType.KEY_EXCHANGE){ + this.sharedPublic = stream.readString(); + } + } + + public _send(): Promise | Stream { + const stream = new Stream(); + stream.writeInt16(this.getPacketId()); + stream.writeInt8(this.signalType); + stream.writeString(this.src); + stream.writeString(this.dst); + if(this.signalType == SignalType.KEY_EXCHANGE){ + stream.writeString(this.sharedPublic); + } + return stream; + } + + public setDst(dst: string) { + this.dst = dst; + } + + public setSharedPublic(sharedPublic: string) { + this.sharedPublic = sharedPublic; + } + + public setSignalType(signalType: SignalType) { + this.signalType = signalType; + } + + public getDst(): string { + return this.dst; + } + + public getSharedPublic(): string { + return this.sharedPublic; + } + + public getSignalType(): SignalType { + return this.signalType; + } + + public getSrc(): string { + return this.src; + } + + public setSrc(src: string) { + this.src = src; + } + +} \ No newline at end of file diff --git a/app/providers/ProtocolProvider/protocol/protocol.ts b/app/providers/ProtocolProvider/protocol/protocol.ts index 2189a6a..90d2fe0 100644 --- a/app/providers/ProtocolProvider/protocol/protocol.ts +++ b/app/providers/ProtocolProvider/protocol/protocol.ts @@ -25,6 +25,7 @@ import { PacketDeviceNew } from "./packets/packet.device.new"; import { PacketDeviceList } from "./packets/packet.device.list"; import { PacketDeviceResolve } from "./packets/packet.device.resolve"; import { PacketSync } from "./packets/packet.sync"; +import { PacketSignal } from "./packets/packet.signal"; export default class Protocol extends EventEmitter { private serverAddress: string; @@ -125,6 +126,7 @@ export default class Protocol extends EventEmitter { this._supportedPackets.set(0x17, new PacketDeviceList()); this._supportedPackets.set(0x18, new PacketDeviceResolve()); this._supportedPackets.set(25, new PacketSync()); + this._supportedPackets.set(26, new PacketSignal()); } private _findWaiters(packetId: number): ((packet: Packet) => void)[] { diff --git a/package.json b/package.json index 4c591dc..16e87de 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,8 @@ "@noble/ciphers": "^1.2.1", "@noble/secp256k1": "^3.0.0", "@tabler/icons-react": "^3.31.0", + "@types/crypto-js": "^4.2.2", + "@types/diffie-hellman": "^5.0.3", "@types/elliptic": "^6.4.18", "@types/node-forge": "^1.3.11", "@types/npm": "^7.19.3", @@ -90,7 +92,6 @@ "bip39": "^3.1.0", "blurhash": "^2.0.5", "buffer": "^6.0.3", - "crypto-browserify": "^3.12.1", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", "elliptic": "^6.6.1", @@ -103,9 +104,11 @@ "i": "^0.3.7", "jsencrypt": "^3.3.2", "jszip": "^3.10.1", + "libsodium": "^0.8.2", "lottie-react": "^2.4.1", "node-forge": "^1.3.1", "node-machine-id": "^1.1.12", + "npm": "^11.11.0", "pako": "^2.1.0", "react-router-dom": "^7.4.0", "react-syntax-highlighter": "^16.1.0", @@ -115,6 +118,8 @@ "recharts": "^2.15.1", "sql.js": "^1.13.0", "sqlite3": "^5.1.7", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "wa-sqlite": "^1.0.0", "web-bip39": "^0.0.3" },