Обмен ключами шифрования DH
This commit is contained in:
@@ -3,8 +3,9 @@ import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
|
|||||||
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
|
import { CallContextValue, CallState } from "@/app/providers/CallProvider/CallProvider";
|
||||||
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
|
||||||
import { useUserInformation } from "@/app/providers/InformationProvider/useUserInformation";
|
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 { IconChevronLeft, IconMicrophone, IconMicrophoneOff, IconPhone, IconPhoneX, IconQrcode, IconVolume, IconVolumeOff, IconX } from "@tabler/icons-react";
|
||||||
|
import { KeyImage } from "../KeyImage/KeyImage";
|
||||||
|
|
||||||
export interface CallProps {
|
export interface CallProps {
|
||||||
context: CallContextValue;
|
context: CallContextValue;
|
||||||
@@ -20,10 +21,14 @@ export function Call(props: CallProps) {
|
|||||||
setSound,
|
setSound,
|
||||||
setMuted,
|
setMuted,
|
||||||
setShowCallView,
|
setShowCallView,
|
||||||
muted} = props.context;
|
muted,
|
||||||
|
getKeyCast,
|
||||||
|
accept
|
||||||
|
} = props.context;
|
||||||
const [userInfo] = useUserInformation(activeCall);
|
const [userInfo] = useUserInformation(activeCall);
|
||||||
const avatars = useAvatars(activeCall);
|
const avatars = useAvatars(activeCall);
|
||||||
const colors = useRosettaColors();
|
const colors = useRosettaColors();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
|
<Box pos={'absolute'} top={0} left={0} w={'100%'} h={'100vh'} style={{
|
||||||
@@ -39,7 +44,25 @@ export function Call(props: CallProps) {
|
|||||||
<Text fw={500}>Back</Text>
|
<Text fw={500}>Back</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex>
|
<Flex>
|
||||||
|
<Popover width={300} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
<IconQrcode size={24}></IconQrcode>
|
<IconQrcode size={24}></IconQrcode>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p={'xs'}>
|
||||||
|
<Flex direction={'row'} align={'center'} gap={'xs'}>
|
||||||
|
<Text maw={300} c={'dimmed'} fz={'xs'}>
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
<KeyImage radius={0} colors={[
|
||||||
|
theme.colors.blue[1],
|
||||||
|
theme.colors.blue[2],
|
||||||
|
theme.colors.blue[3],
|
||||||
|
theme.colors.blue[4],
|
||||||
|
theme.colors.blue[5]
|
||||||
|
]} size={80} keyRender={getKeyCast()}></KeyImage>
|
||||||
|
</Flex>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex direction={'column'} mt={'xl'} style={{
|
<Flex direction={'column'} mt={'xl'} style={{
|
||||||
@@ -50,8 +73,9 @@ export function Call(props: CallProps) {
|
|||||||
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
|
{callState == CallState.ACTIVE && (<Text fz={14} c={'#FFF'}>{translateDurationToTime(duration)}</Text>)}
|
||||||
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
|
{callState == CallState.CONNECTING && (<Text fz={14} c={'#FFF'}>Connecting...</Text>)}
|
||||||
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
|
{callState == CallState.INCOMING && (<Text fz={14} c={'#FFF'}>Incoming call...</Text>)}
|
||||||
|
{callState == CallState.KEY_EXCHANGE && (<Text fz={14} c={'#FFF'}>Exchanging encryption keys...</Text>)}
|
||||||
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
|
<Flex gap={'xl'} align={'center'} justify={'center'} mt={'xl'}>
|
||||||
{callState == CallState.ACTIVE || callState == CallState.CONNECTING && (
|
{callState == CallState.ACTIVE || callState == CallState.CONNECTING || callState == CallState.KEY_EXCHANGE && (
|
||||||
<>
|
<>
|
||||||
<Box w={50} onClick={() => setSound(!sound)} style={{
|
<Box w={50} onClick={() => setSound(!sound)} style={{
|
||||||
borderRadius: 25,
|
borderRadius: 25,
|
||||||
@@ -93,7 +117,7 @@ export function Call(props: CallProps) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={close} style={{
|
<Box w={userInfo.title != "Rosetta" ? 50 : 100} onClick={accept} style={{
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
|
}} h={userInfo.title != "Rosetta" ? 50 : 100} bg={colors.success}>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
||||||
<script type="module" src="/renderer.tsx"></script>
|
<script type="module" src="/renderer.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Call } from "@/app/components/Call/Call";
|
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 {
|
export interface CallContextValue {
|
||||||
@@ -13,15 +19,29 @@ export interface CallContextValue {
|
|||||||
setSound: (sound: boolean) => void;
|
setSound: (sound: boolean) => void;
|
||||||
duration: number;
|
duration: number;
|
||||||
setShowCallView: (show: boolean) => void;
|
setShowCallView: (show: boolean) => void;
|
||||||
|
getKeyCast: () => string;
|
||||||
|
accept: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CallState {
|
export enum CallState {
|
||||||
CONNECTING,
|
CONNECTING,
|
||||||
|
KEY_EXCHANGE,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
ENDED,
|
ENDED,
|
||||||
INCOMING
|
INCOMING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CallRole {
|
||||||
|
/**
|
||||||
|
* Вызывающая сторона, которая инициирует звонок
|
||||||
|
*/
|
||||||
|
CALLER,
|
||||||
|
/**
|
||||||
|
* Принимающая сторона, которая отвечает на звонок и принимает его
|
||||||
|
*/
|
||||||
|
CALLEE
|
||||||
|
}
|
||||||
|
|
||||||
export const CallContext = createContext<CallContextValue | null>(null);
|
export const CallContext = createContext<CallContextValue | null>(null);
|
||||||
export interface CallProviderProps {
|
export interface CallProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,18 +54,142 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
const [sound, setSound] = useState<boolean>(true);
|
const [sound, setSound] = useState<boolean>(true);
|
||||||
const [duration, setDuration] = useState<number>(0);
|
const [duration, setDuration] = useState<number>(0);
|
||||||
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
const [showCallView, setShowCallView] = useState<boolean>(callState == CallState.INCOMING);
|
||||||
|
const {info} = useConsoleLogger("CallProvider");
|
||||||
|
const [sessionKeys, setSessionKeys] = useState<nacl.BoxKeyPair | null>(null);
|
||||||
|
const send = useSender();
|
||||||
|
const publicKey = usePublicKey();
|
||||||
|
|
||||||
|
const roleRef = useRef<CallRole | null>(null);
|
||||||
|
const [sharedSecret, setSharedSecret] = useState<string>("");
|
||||||
|
|
||||||
|
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) => {
|
const call = (dialog: string) => {
|
||||||
setActiveCall(dialog);
|
setActiveCall(dialog);
|
||||||
setCallState(CallState.CONNECTING);
|
setCallState(CallState.CONNECTING);
|
||||||
setShowCallView(true);
|
setShowCallView(true);
|
||||||
|
const signalPacket = new PacketSignal();
|
||||||
|
signalPacket.setDst(dialog);
|
||||||
|
signalPacket.setSignalType(SignalType.CALL);
|
||||||
|
send(signalPacket);
|
||||||
|
roleRef.current = CallRole.CALLER;
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
setActiveCall("");
|
setActiveCall("");
|
||||||
setCallState(CallState.ENDED);
|
setCallState(CallState.ENDED);
|
||||||
setShowCallView(false);
|
setShowCallView(false);
|
||||||
|
setSessionKeys(null);
|
||||||
setDuration(0);
|
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 = {
|
const context = {
|
||||||
@@ -58,7 +202,9 @@ export function CallProvider(props : CallProviderProps) {
|
|||||||
setMuted,
|
setMuted,
|
||||||
setSound,
|
setSound,
|
||||||
duration,
|
duration,
|
||||||
setShowCallView
|
setShowCallView,
|
||||||
|
getKeyCast,
|
||||||
|
accept
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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> | 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { PacketDeviceNew } from "./packets/packet.device.new";
|
|||||||
import { PacketDeviceList } from "./packets/packet.device.list";
|
import { PacketDeviceList } from "./packets/packet.device.list";
|
||||||
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
import { PacketDeviceResolve } from "./packets/packet.device.resolve";
|
||||||
import { PacketSync } from "./packets/packet.sync";
|
import { PacketSync } from "./packets/packet.sync";
|
||||||
|
import { PacketSignal } from "./packets/packet.signal";
|
||||||
|
|
||||||
export default class Protocol extends EventEmitter {
|
export default class Protocol extends EventEmitter {
|
||||||
private serverAddress: string;
|
private serverAddress: string;
|
||||||
@@ -125,6 +126,7 @@ export default class Protocol extends EventEmitter {
|
|||||||
this._supportedPackets.set(0x17, new PacketDeviceList());
|
this._supportedPackets.set(0x17, new PacketDeviceList());
|
||||||
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
this._supportedPackets.set(0x18, new PacketDeviceResolve());
|
||||||
this._supportedPackets.set(25, new PacketSync());
|
this._supportedPackets.set(25, new PacketSync());
|
||||||
|
this._supportedPackets.set(26, new PacketSignal());
|
||||||
}
|
}
|
||||||
|
|
||||||
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
"@noble/ciphers": "^1.2.1",
|
"@noble/ciphers": "^1.2.1",
|
||||||
"@noble/secp256k1": "^3.0.0",
|
"@noble/secp256k1": "^3.0.0",
|
||||||
"@tabler/icons-react": "^3.31.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/elliptic": "^6.4.18",
|
||||||
"@types/node-forge": "^1.3.11",
|
"@types/node-forge": "^1.3.11",
|
||||||
"@types/npm": "^7.19.3",
|
"@types/npm": "^7.19.3",
|
||||||
@@ -90,7 +92,6 @@
|
|||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"crypto-browserify": "^3.12.1",
|
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"elliptic": "^6.6.1",
|
"elliptic": "^6.6.1",
|
||||||
@@ -103,9 +104,11 @@
|
|||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"libsodium": "^0.8.2",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
"node-machine-id": "^1.1.12",
|
"node-machine-id": "^1.1.12",
|
||||||
|
"npm": "^11.11.0",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"react-router-dom": "^7.4.0",
|
"react-router-dom": "^7.4.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
@@ -115,6 +118,8 @@
|
|||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"sql.js": "^1.13.0",
|
"sql.js": "^1.13.0",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
|
"tweetnacl-util": "^0.15.1",
|
||||||
"wa-sqlite": "^1.0.0",
|
"wa-sqlite": "^1.0.0",
|
||||||
"web-bip39": "^0.0.3"
|
"web-bip39": "^0.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user