mirror of
https://github.com/RoboSats/robosats.git
synced 2025-01-05 22:10:10 +00:00
Merge pull request #147 from Reckless-Satoshi/auditable-e2e-encryption
Implement end-to-end auditable encryption and new user login methods.
This commit is contained in:
commit
f1ed560f86
@ -9,6 +9,7 @@ RUN python -m pip install --upgrade pip
|
|||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN pip install git+git://github.com/django/django.git
|
||||||
|
|
||||||
# copy current dir's content to container's WORKDIR root i.e. all the contents of the robosats app
|
# copy current dir's content to container's WORKDIR root i.e. all the contents of the robosats app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
@ -425,6 +425,20 @@ def delete_lnpayment_at_order_deletion(sender, instance, **kwargs):
|
|||||||
class Profile(models.Model):
|
class Profile(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
# PGP keys, used for E2E chat encrytion. Priv key is encrypted with user's passphrase (highEntropyToken)
|
||||||
|
public_key = models.TextField(
|
||||||
|
max_length=999,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
encrypted_private_key = models.TextField(
|
||||||
|
max_length=999,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Total trades
|
# Total trades
|
||||||
total_contracts = models.PositiveIntegerField(null=False, default=0)
|
total_contracts = models.PositiveIntegerField(null=False, default=0)
|
||||||
|
|
||||||
|
@ -80,6 +80,43 @@ class UpdateOrderSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
amount = serializers.DecimalField(max_digits=18, decimal_places=8, allow_null=True, required=False, default=None)
|
amount = serializers.DecimalField(max_digits=18, decimal_places=8, allow_null=True, required=False, default=None)
|
||||||
|
|
||||||
|
class UserGenSerializer(serializers.Serializer):
|
||||||
|
# Mandatory fields
|
||||||
|
token_sha256 = serializers.CharField(max_length=64,
|
||||||
|
allow_null=False,
|
||||||
|
allow_blank=False,
|
||||||
|
required=True,
|
||||||
|
help_text="SHA256 of user secret")
|
||||||
|
public_key = serializers.CharField(max_length=999,
|
||||||
|
allow_null=False,
|
||||||
|
allow_blank=False,
|
||||||
|
required=True,
|
||||||
|
help_text="Armored ASCII PGP public key block")
|
||||||
|
encrypted_private_key = serializers.CharField(max_length=999,
|
||||||
|
allow_null=False,
|
||||||
|
allow_blank=False,
|
||||||
|
required=True,
|
||||||
|
help_text="Armored ASCII PGP encrypted private key block")
|
||||||
|
|
||||||
|
# Optional fields
|
||||||
|
ref_code = serializers.CharField(max_length=30,
|
||||||
|
allow_null=True,
|
||||||
|
allow_blank=True,
|
||||||
|
required=False,
|
||||||
|
default=None)
|
||||||
|
counts = serializers.ListField(child=serializers.IntegerField(),
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
default=None)
|
||||||
|
length = serializers.IntegerField(allow_null=True,
|
||||||
|
default=None,
|
||||||
|
required=False,
|
||||||
|
min_value=1)
|
||||||
|
unique_values = serializers.IntegerField(allow_null=True,
|
||||||
|
default=None,
|
||||||
|
required=False,
|
||||||
|
min_value=1)
|
||||||
|
|
||||||
class ClaimRewardSerializer(serializers.Serializer):
|
class ClaimRewardSerializer(serializers.Serializer):
|
||||||
invoice = serializers.CharField(max_length=2000,
|
invoice = serializers.CharField(max_length=2000,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
|
157
api/views.py
157
api/views.py
@ -7,9 +7,11 @@ from rest_framework.views import APIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from django.contrib.auth import authenticate, login, logout
|
from django.contrib.auth import authenticate, login, logout
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from api.serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer, ClaimRewardSerializer, PriceSerializer
|
from api.serializers import ListOrderSerializer, MakeOrderSerializer, UpdateOrderSerializer, ClaimRewardSerializer, PriceSerializer, UserGenSerializer
|
||||||
from api.models import LNPayment, MarketTick, Order, Currency, Profile
|
from api.models import LNPayment, MarketTick, Order, Currency, Profile
|
||||||
from control.models import AccountingDay
|
from control.models import AccountingDay
|
||||||
from api.logics import Logics
|
from api.logics import Logics
|
||||||
@ -520,7 +522,6 @@ class OrderView(viewsets.ViewSet):
|
|||||||
|
|
||||||
return self.get(request)
|
return self.get(request)
|
||||||
|
|
||||||
|
|
||||||
class UserView(APIView):
|
class UserView(APIView):
|
||||||
NickGen = NickGenerator(lang="English",
|
NickGen = NickGenerator(lang="English",
|
||||||
use_adv=False,
|
use_adv=False,
|
||||||
@ -528,9 +529,15 @@ class UserView(APIView):
|
|||||||
use_noun=True,
|
use_noun=True,
|
||||||
max_num=999)
|
max_num=999)
|
||||||
|
|
||||||
# Probably should be turned into a post method
|
serializer_class = UserGenSerializer
|
||||||
|
|
||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED
|
||||||
|
The old way to generate a robot and login.
|
||||||
|
Only for login. No new users allowed. Only available using API endpoint.
|
||||||
|
Frontend does not support it anymore.
|
||||||
|
|
||||||
Get a new user derived from a high entropy token
|
Get a new user derived from a high entropy token
|
||||||
|
|
||||||
- Request has a high-entropy token,
|
- Request has a high-entropy token,
|
||||||
@ -538,6 +545,77 @@ class UserView(APIView):
|
|||||||
- Creates login credentials (new User object)
|
- Creates login credentials (new User object)
|
||||||
Response with Avatar and Nickname.
|
Response with Avatar and Nickname.
|
||||||
"""
|
"""
|
||||||
|
context = {}
|
||||||
|
# If an existing user opens the main page by mistake, we do not want it to create a new nickname/profile for him
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
context = {"nickname": request.user.username}
|
||||||
|
not_participant, _, _ = Logics.validate_already_maker_or_taker(
|
||||||
|
request.user)
|
||||||
|
|
||||||
|
# Does not allow this 'mistake' if an active order
|
||||||
|
if not not_participant:
|
||||||
|
context[
|
||||||
|
"bad_request"] = f"You are already logged in as {request.user} and have an active order"
|
||||||
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Deprecated, kept temporarily for legacy reasons
|
||||||
|
token = request.GET.get("token")
|
||||||
|
|
||||||
|
value, counts = np.unique(list(token), return_counts=True)
|
||||||
|
shannon_entropy = entropy(counts, base=62)
|
||||||
|
bits_entropy = log2(len(value)**len(token))
|
||||||
|
|
||||||
|
# Hash the token, only 1 iteration.
|
||||||
|
hash = hashlib.sha256(str.encode(token)).hexdigest()
|
||||||
|
|
||||||
|
# Generate nickname deterministically
|
||||||
|
nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0]
|
||||||
|
context["nickname"] = nickname
|
||||||
|
|
||||||
|
# Payload
|
||||||
|
context = {
|
||||||
|
"token_shannon_entropy": shannon_entropy,
|
||||||
|
"token_bits_entropy": bits_entropy,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Do not generate a new user for the old method! Only allow login.
|
||||||
|
if len(User.objects.filter(username=nickname)) == 1:
|
||||||
|
user = authenticate(request, username=nickname, password=token)
|
||||||
|
if user is not None:
|
||||||
|
login(request, user)
|
||||||
|
# Sends the welcome back message, only if created +3 mins ago
|
||||||
|
if request.user.date_joined < (timezone.now() -
|
||||||
|
timedelta(minutes=3)):
|
||||||
|
context["found"] = "We found your Robot avatar. Welcome back!"
|
||||||
|
return Response(context, status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
# It is unlikely, but maybe the nickname is taken (1 in 20 Billion change)
|
||||||
|
context["found"] = "Bad luck, this nickname is taken"
|
||||||
|
context["bad_request"] = "Enter a different token"
|
||||||
|
return Response(context, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
elif len(User.objects.filter(username=nickname)) == 0:
|
||||||
|
context["bad_request"] = "User Generation with explicit token deprecated. Only token_sha256 allowed."
|
||||||
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def post(self, request, format=None):
|
||||||
|
"""
|
||||||
|
Get a new user derived from a high entropy token
|
||||||
|
|
||||||
|
- Request has a hash of a high-entropy token
|
||||||
|
- Request includes pubKey and encrypted privKey
|
||||||
|
- Generates new nickname and avatar.
|
||||||
|
- Creates login credentials (new User object)
|
||||||
|
|
||||||
|
Response with Avatar, Nickname, pubKey, privKey.
|
||||||
|
"""
|
||||||
|
context = {}
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
|
||||||
|
# Return bad request if serializer is not valid
|
||||||
|
if not serializer.is_valid():
|
||||||
|
context = {"bad_request": "Invalid serializer"}
|
||||||
|
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# If an existing user opens the main page by mistake, we do not want it to create a new nickname/profile for him
|
# If an existing user opens the main page by mistake, we do not want it to create a new nickname/profile for him
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
@ -551,26 +629,45 @@ class UserView(APIView):
|
|||||||
"bad_request"] = f"You are already logged in as {request.user} and have an active order"
|
"bad_request"] = f"You are already logged in as {request.user} and have an active order"
|
||||||
return Response(context, status.HTTP_400_BAD_REQUEST)
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
token = request.GET.get("token")
|
# The new way. The token is never sent. Only its SHA256
|
||||||
ref_code = request.GET.get("ref_code")
|
token_sha256 = serializer.data.get("token_sha256")
|
||||||
|
public_key = serializer.data.get("public_key")
|
||||||
|
encrypted_private_key = serializer.data.get("encrypted_private_key")
|
||||||
|
ref_code = serializer.data.get("ref_code")
|
||||||
|
|
||||||
|
if not public_key or not encrypted_private_key:
|
||||||
|
context["bad_request"] = "Must provide valid 'pub' and 'enc_priv' PGP keys"
|
||||||
|
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Compute token entropy
|
# Now the server only receives a hash of the token. So server trusts the client
|
||||||
value, counts = np.unique(list(token), return_counts=True)
|
# with computing length, counts and unique_values to confirm the high entropy of the token
|
||||||
shannon_entropy = entropy(counts, base=62)
|
# In any case, it is up to the client if they want to create a bad high entropy token.
|
||||||
bits_entropy = log2(len(value)**len(token))
|
|
||||||
# Payload
|
|
||||||
context = {
|
|
||||||
"token_shannon_entropy": shannon_entropy,
|
|
||||||
"token_bits_entropy": bits_entropy,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Deny user gen if entropy below 128 bits or 0.7 shannon heterogeneity
|
# Submitting the three params needed to compute token entropy is not mandatory
|
||||||
if bits_entropy < 128 or shannon_entropy < 0.7:
|
# If not submitted, avatars can be created with garbage entropy token. Frontend will always submit them.
|
||||||
context["bad_request"] = "The token does not have enough entropy"
|
try:
|
||||||
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
unique_values = serializer.data.get("unique_values")
|
||||||
|
counts = serializer.data.get("counts")
|
||||||
|
length = serializer.data.get("length")
|
||||||
|
|
||||||
# Hash the token, only 1 iteration.
|
shannon_entropy = entropy(counts, base=62)
|
||||||
hash = hashlib.sha256(str.encode(token)).hexdigest()
|
bits_entropy = log2(unique_values**length)
|
||||||
|
|
||||||
|
# Payload
|
||||||
|
context = {
|
||||||
|
"token_shannon_entropy": shannon_entropy,
|
||||||
|
"token_bits_entropy": bits_entropy,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny user gen if entropy below 128 bits or 0.7 shannon heterogeneity
|
||||||
|
if bits_entropy < 128 or shannon_entropy < 0.7:
|
||||||
|
context["bad_request"] = "The token does not have enough entropy"
|
||||||
|
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Hash the token_sha256, only 1 iteration. (this is the second SHA256 of the user token)
|
||||||
|
hash = hashlib.sha256(str.encode(token_sha256)).hexdigest()
|
||||||
|
|
||||||
# Generate nickname deterministically
|
# Generate nickname deterministically
|
||||||
nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0]
|
nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0]
|
||||||
@ -586,40 +683,46 @@ class UserView(APIView):
|
|||||||
with open(image_path, "wb") as f:
|
with open(image_path, "wb") as f:
|
||||||
rh.img.save(f, format="png")
|
rh.img.save(f, format="png")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Create new credentials and login if nickname is new
|
# Create new credentials and login if nickname is new
|
||||||
if len(User.objects.filter(username=nickname)) == 0:
|
if len(User.objects.filter(username=nickname)) == 0:
|
||||||
User.objects.create_user(username=nickname,
|
User.objects.create_user(username=nickname,
|
||||||
password=token,
|
password=token_sha256,
|
||||||
is_staff=False)
|
is_staff=False)
|
||||||
user = authenticate(request, username=nickname, password=token)
|
user = authenticate(request, username=nickname, password=token_sha256)
|
||||||
login(request, user)
|
login(request, user)
|
||||||
|
|
||||||
context['referral_code'] = token_urlsafe(8)
|
context['referral_code'] = token_urlsafe(8)
|
||||||
user.profile.referral_code = context['referral_code']
|
user.profile.referral_code = context['referral_code']
|
||||||
user.profile.avatar = "static/assets/avatars/" + nickname + ".png"
|
user.profile.avatar = "static/assets/avatars/" + nickname + ".png"
|
||||||
|
user.profile.public_key = public_key
|
||||||
|
user.profile.encrypted_private_key = encrypted_private_key
|
||||||
|
|
||||||
# If the ref_code was created by another robot, this robot was referred.
|
# If the ref_code was created by another robot, this robot was referred.
|
||||||
queryset = Profile.objects.filter(referral_code=ref_code)
|
queryset = Profile.objects.filter(referral_code=ref_code)
|
||||||
if len(queryset) == 1:
|
if len(queryset) == 1:
|
||||||
user.profile.is_referred = True
|
user.profile.is_referred = True
|
||||||
user.profile.referred_by = queryset[0]
|
user.profile.referred_by = queryset[0]
|
||||||
|
|
||||||
user.profile.save()
|
user.profile.save()
|
||||||
|
|
||||||
|
context["public_key"] = user.profile.public_key
|
||||||
|
context["encrypted_private_key"] = user.profile.encrypted_private_key
|
||||||
return Response(context, status=status.HTTP_201_CREATED)
|
return Response(context, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
# log in user and return pub/priv keys if existing
|
||||||
else:
|
else:
|
||||||
user = authenticate(request, username=nickname, password=token)
|
user = authenticate(request, username=nickname, password=token_sha256)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
login(request, user)
|
login(request, user)
|
||||||
# Sends the welcome back message, only if created +3 mins ago
|
# Sends the welcome back message, only if created +3 mins ago
|
||||||
if request.user.date_joined < (timezone.now() -
|
if request.user.date_joined < (timezone.now() -
|
||||||
timedelta(minutes=3)):
|
timedelta(minutes=3)):
|
||||||
context["found"] = "We found your Robot avatar. Welcome back!"
|
context["found"] = "We found your Robot avatar. Welcome back!"
|
||||||
|
context["public_key"] = user.profile.public_key
|
||||||
|
context["encrypted_private_key"] = user.profile.encrypted_private_key
|
||||||
return Response(context, status=status.HTTP_202_ACCEPTED)
|
return Response(context, status=status.HTTP_202_ACCEPTED)
|
||||||
else:
|
else:
|
||||||
# It is unlikely, but maybe the nickname is taken (1 in 20 Billion change)
|
# It is unlikely, but maybe the nickname is taken (1 in 20 Billion chance)
|
||||||
context["found"] = "Bad luck, this nickname is taken"
|
context["found"] = "Bad luck, this nickname is taken"
|
||||||
context["bad_request"] = "Enter a different token"
|
context["bad_request"] = "Enter a different token"
|
||||||
return Response(context, status.HTTP_403_FORBIDDEN)
|
return Response(context, status.HTTP_403_FORBIDDEN)
|
||||||
|
@ -2,6 +2,7 @@ from channels.generic.websocket import AsyncWebsocketConsumer
|
|||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
from api.models import Order
|
from api.models import Order
|
||||||
from chat.models import ChatRoom
|
from chat.models import ChatRoom
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -131,6 +132,7 @@ class ChatRoomConsumer(AsyncWebsocketConsumer):
|
|||||||
"message": message,
|
"message": message,
|
||||||
"user_nick": nick,
|
"user_nick": nick,
|
||||||
"peer_connected": peer_connected,
|
"peer_connected": peer_connected,
|
||||||
|
"time":str(timezone.now()),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
@ -34,6 +34,7 @@ services:
|
|||||||
- .:/usr/src/robosats
|
- .:/usr/src/robosats
|
||||||
- /mnt/development/lnd:/lnd
|
- /mnt/development/lnd:/lnd
|
||||||
network_mode: service:tor
|
network_mode: service:tor
|
||||||
|
command: python3 -u manage.py runserver 0.0.0.0:8000
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
|
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@ -9248,6 +9248,11 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"js-sha256": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA=="
|
||||||
|
},
|
||||||
"js-tokens": {
|
"js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
@ -52,6 +52,7 @@
|
|||||||
"i18next-browser-languagedetector": "^6.1.4",
|
"i18next-browser-languagedetector": "^6.1.4",
|
||||||
"i18next-http-backend": "^1.4.0",
|
"i18next-http-backend": "^1.4.0",
|
||||||
"i18next-xhr-backend": "^3.2.2",
|
"i18next-xhr-backend": "^3.2.2",
|
||||||
|
"js-sha256": "^0.9.0",
|
||||||
"material-ui-image": "^3.3.2",
|
"material-ui-image": "^3.3.2",
|
||||||
"openpgp": "^5.2.1",
|
"openpgp": "^5.2.1",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
@ -2,9 +2,11 @@ import React, { Component } from 'react';
|
|||||||
import { withTranslation, Trans} from "react-i18next";
|
import { withTranslation, Trans} from "react-i18next";
|
||||||
import {Button, Link, Badge, TextField, Grid, Container, Card, CardHeader, Paper, Avatar, FormHelperText, Typography} from "@mui/material";
|
import {Button, Link, Badge, TextField, Grid, Container, Card, CardHeader, Paper, Avatar, FormHelperText, Typography} from "@mui/material";
|
||||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||||
import * as openpgp from 'openpgp/lightweight';
|
|
||||||
|
|
||||||
class Chat extends Component {
|
class Chat extends Component {
|
||||||
|
// Deprecated chat component
|
||||||
|
// Will still be used for ~1 week, until users change to robots with PGP keys
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
}
|
}
|
||||||
|
175
frontend/src/components/Dialogs/AuditPGP.tsx
Normal file
175
frontend/src/components/Dialogs/AuditPGP.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Link,
|
||||||
|
} from "@mui/material"
|
||||||
|
|
||||||
|
import { saveAsJson } from "../../utils/saveFile";
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
import KeyIcon from '@mui/icons-material/Key';
|
||||||
|
import ContentCopy from "@mui/icons-material/ContentCopy";
|
||||||
|
import ForumIcon from '@mui/icons-material/Forum';
|
||||||
|
import { ExportIcon, NewTabIcon } from '../Icons';
|
||||||
|
|
||||||
|
function CredentialTextfield(props){
|
||||||
|
return(
|
||||||
|
<Grid item align="center" xs={12}>
|
||||||
|
<Tooltip placement="top" enterTouchDelay={200} enterDelay={200} title={props.tooltipTitle}>
|
||||||
|
<TextField
|
||||||
|
sx={{width:"100%", maxWidth:"550px"}}
|
||||||
|
disabled
|
||||||
|
label={<b>{props.label}</b>}
|
||||||
|
value={props.value}
|
||||||
|
variant='filled'
|
||||||
|
size='small'
|
||||||
|
InputProps={{
|
||||||
|
endAdornment:
|
||||||
|
<Tooltip disableHoverListener enterTouchDelay={0} title={props.copiedTitle}>
|
||||||
|
<IconButton onClick={()=> navigator.clipboard.writeText(props.value)}>
|
||||||
|
<ContentCopy/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
orderId: number;
|
||||||
|
messages: array;
|
||||||
|
own_pub_key: string;
|
||||||
|
own_enc_priv_key: string;
|
||||||
|
peer_pub_key: string;
|
||||||
|
passphrase: string;
|
||||||
|
onClickBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuditPGPDialog = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
orderId,
|
||||||
|
messages,
|
||||||
|
own_pub_key,
|
||||||
|
own_enc_priv_key,
|
||||||
|
peer_pub_key,
|
||||||
|
passphrase,
|
||||||
|
onClickBack,
|
||||||
|
}: Props): JSX.Element => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<DialogTitle >
|
||||||
|
{t("Don't trust, verify")}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{t("Your communication is end-to-end encrypted with OpenPGP. You can verify the privacy of this chat using any tool based on the OpenPGP standard.")}
|
||||||
|
</DialogContentText>
|
||||||
|
<Grid container spacing={1} align="center">
|
||||||
|
|
||||||
|
<Grid item align="center" xs={12}>
|
||||||
|
<Button component={Link} target="_blank" href="https://learn.robosats.com/docs/pgp-encryption">{t("Learn how to verify")} <NewTabIcon sx={{width:16,height:16}}/></Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<CredentialTextfield
|
||||||
|
tooltipTitle={t("Your PGP public key. Your peer uses it to encrypt messages only you can read.")}
|
||||||
|
label={t("Your public key")}
|
||||||
|
value={own_pub_key}
|
||||||
|
copiedTitle={t("Copied!")}/>
|
||||||
|
|
||||||
|
<CredentialTextfield
|
||||||
|
tooltipTitle={t("Your peer PGP public key. You use it to encrypt messages only he can read and to verify your peer signed the incoming messages.")}
|
||||||
|
label={t("Peer public key")}
|
||||||
|
value={peer_pub_key}
|
||||||
|
copiedTitle={t("Copied!")}/>
|
||||||
|
|
||||||
|
<CredentialTextfield
|
||||||
|
tooltipTitle={t("Your encrypted private key. You use it to decrypt the messages that your peer encrypted for you. You also use it to sign the messages you send.")}
|
||||||
|
label={t("Your encrypted private key")}
|
||||||
|
value={own_enc_priv_key}
|
||||||
|
copiedTitle={t("Copied!")}/>
|
||||||
|
|
||||||
|
<CredentialTextfield
|
||||||
|
tooltipTitle={t("The passphrase to decrypt your private key. Only you know it! Do not share. It is also your robot token.")}
|
||||||
|
label={t("Your private key passphrase (keep secure!)")}
|
||||||
|
value={passphrase}
|
||||||
|
copiedTitle={t("Copied!")}/>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Tooltip placement="top" enterTouchDelay={0} enterDelay={1000} enterNextDelay={2000} title={t("Save credentials as a JSON file")}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
onClick={()=>saveAsJson(
|
||||||
|
'keys_'+orderId+'.json',
|
||||||
|
{"own_public_key": own_pub_key,
|
||||||
|
"peer_public_key":peer_pub_key,
|
||||||
|
"encrypted_private_key":own_enc_priv_key,
|
||||||
|
"passphrase":passphrase
|
||||||
|
})}>
|
||||||
|
<div style={{width:26,height:18}}>
|
||||||
|
<ExportIcon sx={{width:18,height:18}}/>
|
||||||
|
</div>
|
||||||
|
{t("Keys")}
|
||||||
|
<div style={{width:26,height:20}}>
|
||||||
|
<KeyIcon sx={{width:20,height:20}}/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Tooltip placement="top" enterTouchDelay={0} enterDelay={1000} enterNextDelay={2000} title={t("Save messages as a JSON file")}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
onClick={()=>saveAsJson(
|
||||||
|
'messages_'+orderId+'.json',
|
||||||
|
messages)}>
|
||||||
|
<div style={{width:28,height:20}}>
|
||||||
|
<ExportIcon sx={{width:18,height:18}}/>
|
||||||
|
</div>
|
||||||
|
{t("Messages")}
|
||||||
|
<div style={{width:26,height:20}}>
|
||||||
|
<ForumIcon sx={{width:20,height:20}}/>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClickBack} autoFocus>{t("Go back")}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditPGPDialog;
|
@ -1,3 +1,4 @@
|
|||||||
|
export { default as AuditPGPDialog } from "./AuditPGP";
|
||||||
export { default as CommunityDialog } from "./Community";
|
export { default as CommunityDialog } from "./Community";
|
||||||
export { default as InfoDialog } from "./Info";
|
export { default as InfoDialog } from "./Info";
|
||||||
export { default as LearnDialog } from "./Learn";
|
export { default as LearnDialog } from "./Learn";
|
||||||
|
314
frontend/src/components/EncryptedChat.js
Normal file
314
frontend/src/components/EncryptedChat.js
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { withTranslation } from "react-i18next";
|
||||||
|
import {Button, IconButton, Badge, Tooltip, TextField, Grid, Container, Card, CardHeader, Paper, Avatar, Typography} from "@mui/material";
|
||||||
|
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||||
|
import { encryptMessage , decryptMessage} from "../utils/pgp";
|
||||||
|
import { getCookie } from "../utils/cookies";
|
||||||
|
import { saveAsJson } from "../utils/saveFile";
|
||||||
|
import { AuditPGPDialog } from "./Dialogs"
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import ContentCopy from "@mui/icons-material/ContentCopy";
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import KeyIcon from '@mui/icons-material/Key';
|
||||||
|
import { ExportIcon } from './Icons';
|
||||||
|
|
||||||
|
class Chat extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
own_pub_key: getCookie('pub_key').split('\\').join('\n'),
|
||||||
|
own_enc_priv_key: getCookie('enc_priv_key').split('\\').join('\n'),
|
||||||
|
peer_pub_key: null,
|
||||||
|
token: getCookie('robot_token'),
|
||||||
|
messages: [],
|
||||||
|
value:'',
|
||||||
|
connected: false,
|
||||||
|
peer_connected: false,
|
||||||
|
audit: false,
|
||||||
|
showPGP: new Array,
|
||||||
|
waitingEcho: false,
|
||||||
|
lastSent: '---BLANK---',
|
||||||
|
};
|
||||||
|
|
||||||
|
rws = new ReconnectingWebSocket('ws://' + window.location.host + '/ws/chat/' + this.props.orderId + '/');
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.rws.addEventListener('open', () => {
|
||||||
|
console.log('Connected!');
|
||||||
|
this.setState({connected: true});
|
||||||
|
if ( this.state.peer_pub_key == null){
|
||||||
|
this.rws.send(JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
message: "----PLEASE SEND YOUR PUBKEY----",
|
||||||
|
nick: this.props.ur_nick,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
this.rws.send(JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
message: this.state.own_pub_key,
|
||||||
|
nick: this.props.ur_nick,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rws.addEventListener('message', (message) => {
|
||||||
|
|
||||||
|
const dataFromServer = JSON.parse(message.data);
|
||||||
|
console.log('Got reply!', dataFromServer.type);
|
||||||
|
|
||||||
|
if (dataFromServer){
|
||||||
|
console.log(dataFromServer)
|
||||||
|
|
||||||
|
// If we receive our own key on a message
|
||||||
|
if (dataFromServer.message == this.state.own_pub_key){console.log("ECHO OF OWN PUB KEY RECEIVED!!")}
|
||||||
|
|
||||||
|
// If we receive a request to send our public key
|
||||||
|
if (dataFromServer.message == `----PLEASE SEND YOUR PUBKEY----`) {
|
||||||
|
this.rws.send(JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
message: this.state.own_pub_key,
|
||||||
|
nick: this.props.ur_nick,
|
||||||
|
}));
|
||||||
|
} else
|
||||||
|
|
||||||
|
// If we receive a public key other than ours (our peer key!)
|
||||||
|
if (dataFromServer.message.substring(0,36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` & dataFromServer.message != this.state.own_pub_key) {
|
||||||
|
if (dataFromServer.message == this.state.peer_pub_key){
|
||||||
|
console.log("PEER HAS RECONNECTED USING HIS PREVIOUSLY KNOWN PUBKEY")
|
||||||
|
} else if (dataFromServer.message != this.state.peer_pub_key & this.state.peer_pub_key != null){
|
||||||
|
console.log("PEER PUBKEY HAS CHANGED")
|
||||||
|
}
|
||||||
|
console.log("PEER KEY PUBKEY RECEIVED!!")
|
||||||
|
this.setState({peer_pub_key:dataFromServer.message})
|
||||||
|
} else
|
||||||
|
|
||||||
|
// If we receive an encrypted message
|
||||||
|
if (dataFromServer.message.substring(0,27) == `-----BEGIN PGP MESSAGE-----`){
|
||||||
|
decryptMessage(
|
||||||
|
dataFromServer.message.split('\\').join('\n'),
|
||||||
|
dataFromServer.user_nick == this.props.ur_nick ? this.state.own_pub_key : this.state.peer_pub_key,
|
||||||
|
this.state.own_enc_priv_key,
|
||||||
|
this.state.token)
|
||||||
|
.then((decryptedData) =>
|
||||||
|
this.setState((state) =>
|
||||||
|
({
|
||||||
|
waitingEcho: this.state.waitingEcho == true ? (decryptedData.decryptedMessage == this.state.lastSent ? false: true ) : false,
|
||||||
|
lastSent: decryptedData.decryptedMessage == this.state.lastSent ? '----BLANK----': this.state.lastSent,
|
||||||
|
messages: [...state.messages,
|
||||||
|
{
|
||||||
|
encryptedMessage: dataFromServer.message.split('\\').join('\n'),
|
||||||
|
plainTextMessage: decryptedData.decryptedMessage,
|
||||||
|
validSignature: decryptedData.validSignature,
|
||||||
|
userNick: dataFromServer.user_nick,
|
||||||
|
showPGP: false,
|
||||||
|
time: dataFromServer.time
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
this.setState({peer_connected: dataFromServer.peer_connected})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rws.addEventListener('close', () => {
|
||||||
|
console.log('Socket is closed. Reconnect will be attempted');
|
||||||
|
this.setState({connected: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rws.addEventListener('error', () => {
|
||||||
|
console.error('Socket encountered error: Closing socket');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom = () => {
|
||||||
|
this.messagesEnd.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
onButtonClicked = (e) => {
|
||||||
|
if(this.state.value!=''){
|
||||||
|
this.setState({waitingEcho:true, lastSent:this.state.value});
|
||||||
|
encryptMessage(this.state.value, this.state.own_pub_key, this.state.peer_pub_key, this.state.own_enc_priv_key, this.state.token)
|
||||||
|
.then((encryptedMessage) =>
|
||||||
|
console.log("Sending Encrypted MESSAGE "+encryptedMessage) &
|
||||||
|
this.rws.send(JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
message: encryptedMessage.split('\n').join('\\'),
|
||||||
|
nick: this.props.ur_nick,
|
||||||
|
})
|
||||||
|
) & this.setState({value: "", waitingEcho: false})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
createJsonFile = () => {
|
||||||
|
return ({
|
||||||
|
"credentials": {
|
||||||
|
"own_public_key": this.state.own_pub_key,
|
||||||
|
"peer_public_key":this.state.peer_pub_key,
|
||||||
|
"encrypted_private_key":this.state.own_enc_priv_key,
|
||||||
|
"passphrase":this.state.token},
|
||||||
|
"messages": this.state.messages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
messageCard = (props) => {
|
||||||
|
const { t } = this.props;
|
||||||
|
return(
|
||||||
|
<Card elevation={5} align="left" >
|
||||||
|
<CardHeader sx={{color: '#333333'}}
|
||||||
|
avatar={
|
||||||
|
<Badge variant="dot" overlap="circular" badgeContent="" color={props.userConnected ? "success" : "error"}>
|
||||||
|
<Avatar className="flippedSmallAvatar"
|
||||||
|
alt={props.message.userNick}
|
||||||
|
src={window.location.origin +'/static/assets/avatars/' + props.message.userNick + '.png'}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
style={{backgroundColor: props.cardColor}}
|
||||||
|
title={
|
||||||
|
<Tooltip placement="top" enterTouchDelay={0} enterDelay={500} enterNextDelay={2000} title={t(props.message.validSignature ? "Verified signature by {{nickname}}": "Invalid signature! Not sent by {{nickname}}",{"nickname": props.message.userNick})}>
|
||||||
|
<div style={{display:'flex',alignItems:'center', flexWrap:'wrap', position:'relative',left:-5, width:210}}>
|
||||||
|
<div style={{width:168,display:'flex',alignItems:'center', flexWrap:'wrap'}}>
|
||||||
|
{props.message.userNick}
|
||||||
|
{props.message.validSignature ?
|
||||||
|
<CheckIcon sx={{height:16}} color="success"/>
|
||||||
|
:
|
||||||
|
<CloseIcon sx={{height:16}} color="error"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style={{width:20}}>
|
||||||
|
<IconButton sx={{height:18,width:18}}
|
||||||
|
onClick={()=>
|
||||||
|
this.setState(prevState => {
|
||||||
|
const newShowPGP = [...prevState.showPGP];
|
||||||
|
newShowPGP[props.index] = !newShowPGP[props.index];
|
||||||
|
return {showPGP: newShowPGP};
|
||||||
|
})}>
|
||||||
|
<VisibilityIcon color={this.state.showPGP[props.index]? "primary":"inherit"} sx={{height:16,width:16,color:this.state.showPGP[props.index]? "primary":"#333333"}}/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div style={{width:20}}>
|
||||||
|
<Tooltip disableHoverListener enterTouchDelay={0} title={t("Copied!")}>
|
||||||
|
<IconButton sx={{height:18,width:18}}
|
||||||
|
onClick={()=> navigator.clipboard.writeText(this.state.showPGP[props.index] ? props.message.encryptedMessage : props.message.plainTextMessage)}>
|
||||||
|
<ContentCopy sx={{height:16,width:16,color:'#333333'}}/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
subheader={this.state.showPGP[props.index] ? props.message.encryptedMessage : props.message.plainTextMessage}
|
||||||
|
subheaderTypographyProps={{sx: {wordWrap: "break-word", width: '200px', color: '#444444', fontSize: this.state.showPGP[props.index]? 11 : null }}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { t } = this.props;
|
||||||
|
return (
|
||||||
|
<Container component="main" maxWidth="xs" >
|
||||||
|
<Grid container spacing={0.5}>
|
||||||
|
<Grid item xs={0.3}/>
|
||||||
|
<Grid item xs={5.5}>
|
||||||
|
<Paper elevation={1} style={this.state.connected ? {backgroundColor: '#e8ffe6'}: {backgroundColor: '#FFF1C5'}}>
|
||||||
|
<Typography variant='caption' sx={{color: '#333333'}}>
|
||||||
|
{t("You")+": "}{this.state.connected ? t("connected"): t("disconnected")}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={0.4}/>
|
||||||
|
<Grid item xs={5.5}>
|
||||||
|
<Paper elevation={1} style={this.state.peer_connected ? {backgroundColor: '#e8ffe6'}: {backgroundColor: '#FFF1C5'}}>
|
||||||
|
<Typography variant='caption' sx={{color: '#333333'}}>
|
||||||
|
{t("Peer")+": "}{this.state.peer_connected ? t("connected"): t("disconnected")}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={0.3}/>
|
||||||
|
</Grid>
|
||||||
|
<Paper elevation={1} style={{ height: '300px', maxHeight: '300px' , width: '280px' ,overflow: 'auto', backgroundColor: '#F7F7F7' }}>
|
||||||
|
{this.state.messages.map((message, index) =>
|
||||||
|
<li style={{listStyleType:"none"}} key={index}>
|
||||||
|
{message.userNick == this.props.ur_nick ?
|
||||||
|
<this.messageCard message={message} index={index} cardColor={'#eeeeee'} userConnected={this.state.connected}/>
|
||||||
|
:
|
||||||
|
<this.messageCard message={message} index={index} cardColor={'#fafafa'} userConnected={this.state.peer_connected}/>
|
||||||
|
}
|
||||||
|
</li>)}
|
||||||
|
<div style={{ float:"left", clear: "both" }} ref={(el) => { this.messagesEnd = el; }}></div>
|
||||||
|
</Paper>
|
||||||
|
<form noValidate onSubmit={this.onButtonClicked}>
|
||||||
|
<Grid alignItems="stretch" style={{ display: "flex" }}>
|
||||||
|
<Grid item alignItems="stretch" style={{ display: "flex"}}>
|
||||||
|
<TextField
|
||||||
|
label={t("Type a message")}
|
||||||
|
variant="standard"
|
||||||
|
size="small"
|
||||||
|
helperText={this.state.connected ? null : t("Connecting...")}
|
||||||
|
value={this.state.value}
|
||||||
|
onChange={e => {
|
||||||
|
this.setState({ value: e.target.value });
|
||||||
|
this.value = this.state.value;
|
||||||
|
}}
|
||||||
|
sx={{width: 214}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item alignItems="stretch" style={{ display: "flex" }}>
|
||||||
|
<Button sx={{'width':68}} disabled={!this.state.connected || this.state.waitingEcho} type="submit" variant="contained" color="primary">
|
||||||
|
{this.state.waitingEcho ?
|
||||||
|
<div style={{display:'flex',alignItems:'center', flexWrap:'wrap', minWidth:68, width:68, position:"relative",left:15}}>
|
||||||
|
<div style={{width:20}}><KeyIcon sx={{width:18}}/></div>
|
||||||
|
<div style={{width:18}}><CircularProgress size={16} thickness={5}/></div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
t("Send")
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</form>
|
||||||
|
<div style={{height:4}}/>
|
||||||
|
<Grid container spacing={0}>
|
||||||
|
<AuditPGPDialog
|
||||||
|
open={this.state.audit}
|
||||||
|
onClose={() => this.setState({audit:false})}
|
||||||
|
orderId={Number(this.props.orderId)}
|
||||||
|
messages={this.state.messages}
|
||||||
|
own_pub_key={this.state.own_pub_key}
|
||||||
|
own_enc_priv_key={this.state.own_enc_priv_key}
|
||||||
|
peer_pub_key={this.state.peer_pub_key ? this.state.peer_pub_key : "Not received yet"}
|
||||||
|
passphrase={this.state.token}
|
||||||
|
onClickBack={() => this.setState({audit:false})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Tooltip placement="bottom" enterTouchDelay={0} enterDelay={500} enterNextDelay={2000} title={t("Verify your privacy")}>
|
||||||
|
<Button size="small" color="primary" variant="outlined" onClick={()=>this.setState({audit:!this.state.audit})}><KeyIcon/>{t("Audit PGP")} </Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Tooltip placement="bottom" enterTouchDelay={0} enterDelay={500} enterNextDelay={2000} title={t("Save full log as a JSON file (messages and credentials)")}>
|
||||||
|
<Button size="small" color="primary" variant="outlined" onClick={()=>saveAsJson('chat_'+this.props.orderId+'.json', this.createJsonFile())}><div style={{width:28,height:20}}><ExportIcon sx={{width:20,height:20}}/></div> {t("Export")} </Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withTranslation()(Chat);
|
10
frontend/src/components/Icons/Export.tsx
Normal file
10
frontend/src/components/Icons/Export.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { SvgIcon } from "@mui/material"
|
||||||
|
|
||||||
|
export default function ExportIcon(props) {
|
||||||
|
return (
|
||||||
|
<SvgIcon sx={props.sx} color={props.color} viewBox="0 0 576 512">
|
||||||
|
<path d="M192 312C192 298.8 202.8 288 216 288H384V160H256c-17.67 0-32-14.33-32-32L224 0H48C21.49 0 0 21.49 0 48v416C0 490.5 21.49 512 48 512h288c26.51 0 48-21.49 48-48v-128H216C202.8 336 192 325.3 192 312zM256 0v128h128L256 0zM568.1 295l-80-80c-9.375-9.375-24.56-9.375-33.94 0s-9.375 24.56 0 33.94L494.1 288H384v48h110.1l-39.03 39.03C450.3 379.7 448 385.8 448 392s2.344 12.28 7.031 16.97c9.375 9.375 24.56 9.375 33.94 0l80-80C578.3 319.6 578.3 304.4 568.1 295z"/>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
@ -12,3 +12,5 @@ export { default as RoboSatsTextIcon } from "./RoboSatsText";
|
|||||||
export { default as SellSatsCheckedIcon } from "./SellSatsChecked";
|
export { default as SellSatsCheckedIcon } from "./SellSatsChecked";
|
||||||
export { default as SellSatsIcon } from "./SellSats";
|
export { default as SellSatsIcon } from "./SellSats";
|
||||||
export { default as SendReceiveIcon } from "./SendReceive";
|
export { default as SendReceiveIcon } from "./SendReceive";
|
||||||
|
export { default as ExportIcon } from "./Export";
|
||||||
|
|
||||||
|
@ -577,7 +577,7 @@ class MakerPage extends Component {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Accordion elevation={0} sx={{width:'280px', position:'relative', left:'-8px'}}>
|
<Accordion defaultExpanded={true} elevation={0} sx={{width:'280px', position:'relative', left:'-8px'}}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon color="primary"/>}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon color="primary"/>}>
|
||||||
<Typography sx={{flexGrow: 1, textAlign: "center"}} color="text.secondary">{t("Expiry Timers")}</Typography>
|
<Typography sx={{flexGrow: 1, textAlign: "center"}} color="text.secondary">{t("Expiry Timers")}</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
|
@ -3,7 +3,7 @@ import { withTranslation, Trans} from "react-i18next";
|
|||||||
import { IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
|
import { IconButton, Box, Link, Paper, Rating, Button, Tooltip, CircularProgress, Grid, Typography, TextField, List, ListItem, ListItemText, Divider, ListItemIcon, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material"
|
||||||
import QRCode from "react-qr-code";
|
import QRCode from "react-qr-code";
|
||||||
import Countdown, { zeroPad} from 'react-countdown';
|
import Countdown, { zeroPad} from 'react-countdown';
|
||||||
import Chat from "./Chat"
|
import Chat from "./EncryptedChat"
|
||||||
import MediaQuery from 'react-responsive'
|
import MediaQuery from 'react-responsive'
|
||||||
import QrReader from 'react-qr-reader'
|
import QrReader from 'react-qr-reader'
|
||||||
|
|
||||||
|
@ -11,8 +11,12 @@ import ContentCopy from "@mui/icons-material/ContentCopy";
|
|||||||
import BoltIcon from '@mui/icons-material/Bolt';
|
import BoltIcon from '@mui/icons-material/Bolt';
|
||||||
import { RoboSatsNoTextIcon } from "./Icons";
|
import { RoboSatsNoTextIcon } from "./Icons";
|
||||||
|
|
||||||
|
import { sha256 } from 'js-sha256';
|
||||||
|
import { genBase62Token, tokenStrength } from "../utils/token";
|
||||||
|
import { genKey } from "../utils/pgp";
|
||||||
import { getCookie, writeCookie } from "../utils/cookies";
|
import { getCookie, writeCookie } from "../utils/cookies";
|
||||||
|
|
||||||
|
|
||||||
class UserGenPage extends Component {
|
class UserGenPage extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@ -37,7 +41,7 @@ class UserGenPage extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
var newToken = this.genBase62Token(36)
|
var newToken = genBase62Token(36)
|
||||||
this.setState({
|
this.setState({
|
||||||
token: newToken
|
token: newToken
|
||||||
});
|
});
|
||||||
@ -45,46 +49,61 @@ class UserGenPage extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort of cryptographically strong function to generate Base62 token client-side
|
|
||||||
genBase62Token(length)
|
|
||||||
{
|
|
||||||
return window.btoa(Array.from(
|
|
||||||
window.crypto.getRandomValues(
|
|
||||||
new Uint8Array(length * 2)))
|
|
||||||
.map((b) => String.fromCharCode(b))
|
|
||||||
.join("")).replace(/[+/]/g, "")
|
|
||||||
.substring(0, length);
|
|
||||||
}
|
|
||||||
|
|
||||||
getGeneratedUser=(token)=>{
|
getGeneratedUser=(token)=>{
|
||||||
fetch('/api/user' + '?token=' + token + '&ref_code=' + this.refCode)
|
|
||||||
.then((response) => response.json())
|
const strength = tokenStrength(token);
|
||||||
.then((data) => {
|
const refCode = this.refCode
|
||||||
this.setState({
|
|
||||||
|
const requestOptions = genKey(token).then(function(key) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRFToken': getCookie('csrftoken')},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token_sha256: sha256(token),
|
||||||
|
public_key: key.publicKeyArmored,
|
||||||
|
encrypted_private_key: key.encryptedPrivateKeyArmored,
|
||||||
|
unique_values: strength.uniqueValues,
|
||||||
|
counts: strength.counts,
|
||||||
|
length: token.length,
|
||||||
|
ref_code: refCode,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(requestOptions)
|
||||||
|
|
||||||
|
requestOptions.then((options) =>
|
||||||
|
fetch("/api/user/",options)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => { console.log(data) &
|
||||||
|
this.setState({
|
||||||
|
nickname: data.nickname,
|
||||||
|
bit_entropy: data.token_bits_entropy,
|
||||||
|
avatar_url: '/static/assets/avatars/' + data.nickname + '.png',
|
||||||
|
shannon_entropy: data.token_shannon_entropy,
|
||||||
|
bad_request: data.bad_request,
|
||||||
|
found: data.found,
|
||||||
|
loadingRobot:false,
|
||||||
|
})
|
||||||
|
&
|
||||||
|
// Add nick and token to App state (token only if not a bad request)
|
||||||
|
(data.bad_request ? this.props.setAppState({
|
||||||
nickname: data.nickname,
|
nickname: data.nickname,
|
||||||
bit_entropy: data.token_bits_entropy,
|
avatarLoaded: false,
|
||||||
avatar_url: '/static/assets/avatars/' + data.nickname + '.png',
|
})
|
||||||
shannon_entropy: data.token_shannon_entropy,
|
:
|
||||||
bad_request: data.bad_request,
|
(this.props.setAppState({
|
||||||
found: data.found,
|
nickname: data.nickname,
|
||||||
loadingRobot:false,
|
token: token,
|
||||||
})
|
avatarLoaded: false,
|
||||||
&
|
})) & writeCookie("robot_token",token)
|
||||||
// Add nick and token to App state (token only if not a bad request)
|
& writeCookie("pub_key",data.public_key.split('\n').join('\\'))
|
||||||
(data.bad_request ? this.props.setAppState({
|
& writeCookie("enc_priv_key",data.encrypted_private_key.split('\n').join('\\')))
|
||||||
nickname: data.nickname,
|
&
|
||||||
avatarLoaded: false,
|
// If the robot has been found (recovered) we assume the token is backed up
|
||||||
})
|
(data.found ? this.props.setAppState({copiedToken:true}) : null)
|
||||||
:
|
})
|
||||||
(this.props.setAppState({
|
);
|
||||||
nickname: data.nickname,
|
|
||||||
token: token,
|
|
||||||
avatarLoaded: false,
|
|
||||||
})) & writeCookie("robot_token",token))
|
|
||||||
&
|
|
||||||
// If the robot has been found (recovered) we assume the token is backed up
|
|
||||||
(data.found ? this.props.setAppState({copiedToken:true}) : null)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delGeneratedUser() {
|
delGeneratedUser() {
|
||||||
@ -97,7 +116,7 @@ class UserGenPage extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleClickNewRandomToken=()=>{
|
handleClickNewRandomToken=()=>{
|
||||||
var token = this.genBase62Token(36);
|
var token = genBase62Token(36);
|
||||||
this.setState({
|
this.setState({
|
||||||
token: token,
|
token: token,
|
||||||
tokenHasChanged: true,
|
tokenHasChanged: true,
|
||||||
@ -164,7 +183,7 @@ class UserGenPage extends Component {
|
|||||||
{
|
{
|
||||||
this.state.found ?
|
this.state.found ?
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<Typography component="subtitle2" variant="subtitle2" color='primary'>
|
<Typography variant="subtitle2" color='primary'>
|
||||||
{this.state.found ? t("A robot avatar was found, welcome back!"):null}<br/>
|
{this.state.found ? t("A robot avatar was found, welcome back!"):null}<br/>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -174,7 +193,7 @@ class UserGenPage extends Component {
|
|||||||
<Grid container align="center">
|
<Grid container align="center">
|
||||||
<Grid item xs={12} align="center">
|
<Grid item xs={12} align="center">
|
||||||
<TextField sx={{maxWidth: 280}}
|
<TextField sx={{maxWidth: 280}}
|
||||||
error={this.state.bad_request}
|
error={this.state.bad_request ? true : false}
|
||||||
label={t("Store your token safely")}
|
label={t("Store your token safely")}
|
||||||
required={true}
|
required={true}
|
||||||
value={this.state.token}
|
value={this.state.token}
|
||||||
|
@ -259,9 +259,32 @@
|
|||||||
"Type a message":"Type a message",
|
"Type a message":"Type a message",
|
||||||
"Connecting...":"Connecting...",
|
"Connecting...":"Connecting...",
|
||||||
"Send":"Send",
|
"Send":"Send",
|
||||||
"The chat has no memory: if you leave, messages are lost.":"The chat has no memory: if you leave, messages are lost.",
|
"Verify your privacy":"Verify your privacy",
|
||||||
"Learn easy PGP encryption.":"Learn easy PGP encryption.",
|
"Audit PGP":"Audit PGP",
|
||||||
"PGP_guide_url":"https://learn.robosats.com/docs/pgp-encryption/",
|
"Save full log as a JSON file (messages and credentials)":"Save full log as a JSON file (messages and credentials)",
|
||||||
|
"Export":"Export",
|
||||||
|
"Don't trust, verify":"Don't trust, verify",
|
||||||
|
"Your communication is end-to-end encrypted with OpenPGP. You can verify the privacy of this chat using any tool based on the OpenPGP standard.":"Your communication is end-to-end encrypted with OpenPGP. You can verify the privacy of this chat using any tool based on the OpenPGP standard.",
|
||||||
|
"Learn how to verify":"Learn how to verify",
|
||||||
|
"Your PGP public key. Your peer uses it to encrypt messages only you can read.":"Your PGP public key. Your peer uses it to encrypt messages only you can read.",
|
||||||
|
"Your public key":"Your public key",
|
||||||
|
"Your peer PGP public key. You use it to encrypt messages only he can read and to verify your peer signed the incoming messages.":"Your peer PGP public key. You use it to encrypt messages only he can read.and to verify your peer signed the incoming messages.",
|
||||||
|
"Peer public key":"Peer public key",
|
||||||
|
"Your encrypted private key. You use it to decrypt the messages that your peer encrypted for you. You also use it to sign the messages you send.":"Your encrypted private key. You use it to decrypt the messages that your peer encrypted for you. You also use it to sign the messages you send.",
|
||||||
|
"Your encrypted private key":"Your encrypted private key",
|
||||||
|
"The passphrase to decrypt your private key. Only you know it! Do not share. It is also your robot token.":"The passphrase to decrypt your private key. Only you know it! Do not share. It is also your robot avatar user token.",
|
||||||
|
"Your private key passphrase (keep secure!)":"Your private key passphrase (keep secure!)",
|
||||||
|
"Save credentials as a JSON file":"Save credentials as a JSON file",
|
||||||
|
"Keys":"Keys",
|
||||||
|
"Save messages as a JSON file":"Save messages as a JSON file",
|
||||||
|
"Messages":"Messages",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline",
|
"CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline",
|
||||||
"Contract Box":"Contract Box",
|
"Contract Box":"Contract Box",
|
||||||
@ -381,7 +404,7 @@
|
|||||||
"You can also check the full guide in ":"You can also check the full guide in ",
|
"You can also check the full guide in ":"You can also check the full guide in ",
|
||||||
"How to use":"How to use",
|
"How to use":"How to use",
|
||||||
"What payment methods are accepted?":"What payment methods are accepted?",
|
"What payment methods are accepted?":"What payment methods are accepted?",
|
||||||
"All of them as long as they are fast. You can write down your preferred payment method(s). You will have to match with a peer who also accepts that method. The step to exchange fiat has a expiry time of 24 hours before a dispute is automatically open. We highly recommend using instant fiat payment rails.":"All of them as long as they are fast. You can write down your preferred payment method(s). You will have to match with a peer who also accepts that method. The step to exchange fiat has a expiry time of 24 hours before a dispute is automatically open. We highly recommend using instant fiat payment rails.",
|
"All of them as long as they are fast. You can write down your preferred payment method(s). You will have to match with a peer who also accepts that method. The step to exchange fiat has a expiry time of 24 hours before a dispute is automatically open. We highly recommend using instant fiat payment rails.":"All of them as long as they are fast. You can write down your preferred payment method(s). You will have to match with a peer who also accepts that method. The step to exchange fiat has an expiry time of 24 hours before a dispute is automatically open. We highly recommend using instant fiat payment rails.",
|
||||||
"Are there trade limits?":"Are there trade limits?",
|
"Are there trade limits?":"Are there trade limits?",
|
||||||
"Maximum single trade size is {{maxAmount}} Satoshis to minimize lightning routing failure. There is no limits to the number of trades per day. A robot can only have one order at a time. However, you can use multiple robots simultaneously in different browsers (remember to back up your robot tokens!).":"Maximum single trade size is {{maxAmount}} Satoshis to minimize lightning routing failure. There is no limits to the number of trades per day. A robot can only have one order at a time. However, you can use multiple robots simultaneously in different browsers (remember to back up your robot tokens!).",
|
"Maximum single trade size is {{maxAmount}} Satoshis to minimize lightning routing failure. There is no limits to the number of trades per day. A robot can only have one order at a time. However, you can use multiple robots simultaneously in different browsers (remember to back up your robot tokens!).":"Maximum single trade size is {{maxAmount}} Satoshis to minimize lightning routing failure. There is no limits to the number of trades per day. A robot can only have one order at a time. However, you can use multiple robots simultaneously in different browsers (remember to back up your robot tokens!).",
|
||||||
"Is RoboSats private?":"Is RoboSats private?",
|
"Is RoboSats private?":"Is RoboSats private?",
|
||||||
|
@ -258,9 +258,25 @@
|
|||||||
"Type a message": "Escribe un mensaje",
|
"Type a message": "Escribe un mensaje",
|
||||||
"Connecting...": "Conectando...",
|
"Connecting...": "Conectando...",
|
||||||
"Send": "Enviar",
|
"Send": "Enviar",
|
||||||
"The chat has no memory: if you leave, messages are lost.": "Chat sin memoria: si lo cierras, los mensajes se pierden.",
|
"Verify your privacy":"Verifica tu privacidad",
|
||||||
"Learn easy PGP encryption.": "Aprende encriptación PGP.",
|
"Audit PGP":"Auditar",
|
||||||
"PGP_guide_url":"https://learn.robosats.com/docs/pgp-encryption/es",
|
"Save full log as a JSON file (messages and credentials)":"Guardar el log completo como JSON (credenciales y mensajes)",
|
||||||
|
"Export":"Exporta",
|
||||||
|
"Don't trust, verify":"No confíes, verifica",
|
||||||
|
"Your communication is end-to-end encrypted with OpenPGP. You can verify the privacy of this chat using any tool based on the OpenPGP standard.":"Tu comunicación se encripta de punta-a-punta con OpenPGP. Puedes verificar la privacida de este chat con cualquier herramienta de tercero basada en el estandar PGP.",
|
||||||
|
"Learn how to verify":"Aprende a verificar",
|
||||||
|
"Your PGP public key. Your peer uses it to encrypt messages only you can read.":"Esta es tu llave pública PGP. Tu contraparte la usa para encriptar mensajes que sólo tú puedes leer.",
|
||||||
|
"Your public key":"Tu llave pública",
|
||||||
|
"Your peer PGP public key. You use it to encrypt messages only he can read and to verify your peer signed the incoming messages.":"La llave pública PGP de tu contraparte. La usas para encriptar mensajes que solo él puede leer y verificar que es él quíen firmó los mensajes que recibes.",
|
||||||
|
"Peer public key":"Llave pública de tu contraparte",
|
||||||
|
"Your encrypted private key. You use it to decrypt the messages that your peer encrypted for you. You also use it to sign the messages you send.":"Tu llave privada PGP encriptada. La usas para desencriptar los mensajes que tu contraparte te envia. También la usas para firmar los mensajes que le envias.",
|
||||||
|
"Your encrypted private key":"Tu llave privada encriptada",
|
||||||
|
"The passphrase to decrypt your private key. Only you know it! Do not share. It is also your robot token.":"La contraseña para desencriptar tu llave privada. ¡Solo tú la sabes! Mantenla en secreto. También es el token de tu robot.",
|
||||||
|
"Your private key passphrase (keep secure!)":"La contraseña de tu llave privada ¡Mantener segura!",
|
||||||
|
"Save credentials as a JSON file":"Guardar credenciales como achivo JSON",
|
||||||
|
"Keys":"Llaves",
|
||||||
|
"Save messages as a JSON file":"Guardar mensajes como archivo JSON",
|
||||||
|
"Messages":"Mensajes",
|
||||||
|
|
||||||
"CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline",
|
"CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline",
|
||||||
"Contract Box": "Contrato",
|
"Contract Box": "Contrato",
|
||||||
|
62
frontend/src/utils/pgp.js
Normal file
62
frontend/src/utils/pgp.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import * as openpgp from 'openpgp/lightweight';
|
||||||
|
|
||||||
|
// Generate KeyPair. Private Key is encrypted with the highEntropyToken
|
||||||
|
export async function genKey(highEntropyToken) {
|
||||||
|
|
||||||
|
const keyPair = await openpgp.generateKey({
|
||||||
|
type: 'ecc', // Type of the key, defaults to ECC
|
||||||
|
curve: 'curve25519', // ECC curve name, defaults to curve25519
|
||||||
|
userIDs: [{name: 'RoboSats Avatar ID'+ parseInt(Math.random() * 1000000)}], //Just for identification. Ideally it would be the avatar nickname, but the nickname is generated only after submission
|
||||||
|
passphrase: highEntropyToken,
|
||||||
|
format: 'armored'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {publicKeyArmored: keyPair.publicKey, encryptedPrivateKeyArmored: keyPair.privateKey}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encrypt and sign a message
|
||||||
|
export async function encryptMessage(plaintextMessage, ownPublicKeyArmored, peerPublicKeyArmored, privateKeyArmored, passphrase) {
|
||||||
|
|
||||||
|
const ownPublicKey = await openpgp.readKey({ armoredKey: ownPublicKeyArmored });
|
||||||
|
const peerPublicKey = await openpgp.readKey({ armoredKey: peerPublicKeyArmored });
|
||||||
|
const privateKey = await openpgp.decryptKey({
|
||||||
|
privateKey: await openpgp.readPrivateKey({ armoredKey: privateKeyArmored }),
|
||||||
|
passphrase
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedMessage = await openpgp.encrypt({
|
||||||
|
message: await openpgp.createMessage({ text: plaintextMessage }), // input as Message object, message must be string
|
||||||
|
encryptionKeys: [ ownPublicKey, peerPublicKey ],
|
||||||
|
signingKeys: privateKey // optional
|
||||||
|
});
|
||||||
|
|
||||||
|
return encryptedMessage; // '-----BEGIN PGP MESSAGE ... END PGP MESSAGE-----'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt and check signature of a message
|
||||||
|
export async function decryptMessage(encryptedMessage, publicKeyArmored, privateKeyArmored, passphrase) {
|
||||||
|
|
||||||
|
const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
|
||||||
|
const privateKey = await openpgp.decryptKey({
|
||||||
|
privateKey: await openpgp.readPrivateKey({ armoredKey: privateKeyArmored }),
|
||||||
|
passphrase
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = await openpgp.readMessage({
|
||||||
|
armoredMessage: encryptedMessage // parse armored message
|
||||||
|
});
|
||||||
|
const { data: decrypted, signatures } = await openpgp.decrypt({
|
||||||
|
message,
|
||||||
|
verificationKeys: publicKey, // optional
|
||||||
|
decryptionKeys: privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
// check signature validity (signed messages only)
|
||||||
|
try {
|
||||||
|
await signatures[0].verified; // throws on invalid signature
|
||||||
|
console.log('Signature is valid');
|
||||||
|
return {decryptedMessage: decrypted, validSignature: true}
|
||||||
|
} catch (e) {
|
||||||
|
return {decryptedMessage: decrypted, validSignature: false};
|
||||||
|
}
|
||||||
|
};
|
22
frontend/src/utils/saveFile.js
Normal file
22
frontend/src/utils/saveFile.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/* function to save DATA as text from browser
|
||||||
|
* @param {String} file -- file name to save to
|
||||||
|
* @param {filename} data -- object to save
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const saveAsJson = (filename, dataObjToWrite) => {
|
||||||
|
const blob = new Blob([JSON.stringify(dataObjToWrite, null, 2)], { type: "text/json" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
|
||||||
|
link.download = filename;
|
||||||
|
link.href = window.URL.createObjectURL(blob);
|
||||||
|
link.dataset.downloadurl = ["text/json", link.download, link.href].join(":");
|
||||||
|
|
||||||
|
const evt = new MouseEvent("click", {
|
||||||
|
view: window,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
link.dispatchEvent(evt);
|
||||||
|
link.remove()
|
||||||
|
};
|
17
frontend/src/utils/token.js
Normal file
17
frontend/src/utils/token.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// sort of cryptographically strong function to generate Base62 token client-side
|
||||||
|
export function genBase62Token(length){
|
||||||
|
return window.btoa(Array.from(
|
||||||
|
window.crypto.getRandomValues(
|
||||||
|
new Uint8Array(length * 2)))
|
||||||
|
.map((b) => String.fromCharCode(b))
|
||||||
|
.join("")).replace(/[+/]/g, "")
|
||||||
|
.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenStrength(token) {
|
||||||
|
const characters = token.split("").reduce(function(obj, s){
|
||||||
|
obj[s] = (obj[s] || 0) + 1;
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
return {uniqueValues:Object.keys(characters).length,counts:Object.values(characters)}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user