mirror of
https://github.com/RoboSats/robosats.git
synced 2024-12-13 10:56:24 +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 ./
|
||||
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 . .
|
||||
|
@ -425,6 +425,20 @@ def delete_lnpayment_at_order_deletion(sender, instance, **kwargs):
|
||||
class Profile(models.Model):
|
||||
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_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)
|
||||
|
||||
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):
|
||||
invoice = serializers.CharField(max_length=2000,
|
||||
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 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 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 control.models import AccountingDay
|
||||
from api.logics import Logics
|
||||
@ -520,7 +522,6 @@ class OrderView(viewsets.ViewSet):
|
||||
|
||||
return self.get(request)
|
||||
|
||||
|
||||
class UserView(APIView):
|
||||
NickGen = NickGenerator(lang="English",
|
||||
use_adv=False,
|
||||
@ -528,9 +529,15 @@ class UserView(APIView):
|
||||
use_noun=True,
|
||||
max_num=999)
|
||||
|
||||
# Probably should be turned into a post method
|
||||
serializer_class = UserGenSerializer
|
||||
|
||||
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
|
||||
|
||||
- Request has a high-entropy token,
|
||||
@ -538,6 +545,77 @@ class UserView(APIView):
|
||||
- Creates login credentials (new User object)
|
||||
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 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"
|
||||
return Response(context, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
token = request.GET.get("token")
|
||||
ref_code = request.GET.get("ref_code")
|
||||
# The new way. The token is never sent. Only its SHA256
|
||||
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
|
||||
value, counts = np.unique(list(token), return_counts=True)
|
||||
shannon_entropy = entropy(counts, base=62)
|
||||
bits_entropy = log2(len(value)**len(token))
|
||||
# Payload
|
||||
context = {
|
||||
"token_shannon_entropy": shannon_entropy,
|
||||
"token_bits_entropy": bits_entropy,
|
||||
}
|
||||
# Now the server only receives a hash of the token. So server trusts the client
|
||||
# with computing length, counts and unique_values to confirm the high entropy of the token
|
||||
# In any case, it is up to the client if they want to create a bad high entropy token.
|
||||
|
||||
# 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)
|
||||
# Submitting the three params needed to compute token entropy is not mandatory
|
||||
# If not submitted, avatars can be created with garbage entropy token. Frontend will always submit them.
|
||||
try:
|
||||
unique_values = serializer.data.get("unique_values")
|
||||
counts = serializer.data.get("counts")
|
||||
length = serializer.data.get("length")
|
||||
|
||||
# Hash the token, only 1 iteration.
|
||||
hash = hashlib.sha256(str.encode(token)).hexdigest()
|
||||
shannon_entropy = entropy(counts, base=62)
|
||||
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
|
||||
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:
|
||||
rh.img.save(f, format="png")
|
||||
|
||||
|
||||
|
||||
# Create new credentials and login if nickname is new
|
||||
if len(User.objects.filter(username=nickname)) == 0:
|
||||
User.objects.create_user(username=nickname,
|
||||
password=token,
|
||||
password=token_sha256,
|
||||
is_staff=False)
|
||||
user = authenticate(request, username=nickname, password=token)
|
||||
user = authenticate(request, username=nickname, password=token_sha256)
|
||||
login(request, user)
|
||||
|
||||
context['referral_code'] = token_urlsafe(8)
|
||||
user.profile.referral_code = context['referral_code']
|
||||
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.
|
||||
queryset = Profile.objects.filter(referral_code=ref_code)
|
||||
if len(queryset) == 1:
|
||||
user.profile.is_referred = True
|
||||
user.profile.referred_by = queryset[0]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# log in user and return pub/priv keys if existing
|
||||
else:
|
||||
user = authenticate(request, username=nickname, password=token)
|
||||
user = authenticate(request, username=nickname, password=token_sha256)
|
||||
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!"
|
||||
context["public_key"] = user.profile.public_key
|
||||
context["encrypted_private_key"] = user.profile.encrypted_private_key
|
||||
return Response(context, status=status.HTTP_202_ACCEPTED)
|
||||
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["bad_request"] = "Enter a different token"
|
||||
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 api.models import Order
|
||||
from chat.models import ChatRoom
|
||||
from django.utils import timezone
|
||||
|
||||
import json
|
||||
|
||||
@ -131,6 +132,7 @@ class ChatRoomConsumer(AsyncWebsocketConsumer):
|
||||
"message": message,
|
||||
"user_nick": nick,
|
||||
"peer_connected": peer_connected,
|
||||
"time":str(timezone.now()),
|
||||
}))
|
||||
|
||||
pass
|
||||
|
@ -34,6 +34,7 @@ services:
|
||||
- .:/usr/src/robosats
|
||||
- /mnt/development/lnd:/lnd
|
||||
network_mode: service:tor
|
||||
command: python3 -u manage.py runserver 0.0.0.0:8000
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
|
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@ -9248,6 +9248,11 @@
|
||||
"@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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
@ -52,6 +52,7 @@
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"i18next-http-backend": "^1.4.0",
|
||||
"i18next-xhr-backend": "^3.2.2",
|
||||
"js-sha256": "^0.9.0",
|
||||
"material-ui-image": "^3.3.2",
|
||||
"openpgp": "^5.2.1",
|
||||
"react": "^18.0.0",
|
||||
|
@ -2,9 +2,11 @@ import React, { Component } from 'react';
|
||||
import { withTranslation, Trans} from "react-i18next";
|
||||
import {Button, Link, Badge, TextField, Grid, Container, Card, CardHeader, Paper, Avatar, FormHelperText, Typography} from "@mui/material";
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||
import * as openpgp from 'openpgp/lightweight';
|
||||
|
||||
class Chat extends Component {
|
||||
// Deprecated chat component
|
||||
// Will still be used for ~1 week, until users change to robots with PGP keys
|
||||
|
||||
constructor(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 InfoDialog } from "./Info";
|
||||
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 SellSatsIcon } from "./SellSats";
|
||||
export { default as SendReceiveIcon } from "./SendReceive";
|
||||
export { default as ExportIcon } from "./Export";
|
||||
|
||||
|
@ -577,7 +577,7 @@ class MakerPage extends Component {
|
||||
</Grid>
|
||||
|
||||
<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"/>}>
|
||||
<Typography sx={{flexGrow: 1, textAlign: "center"}} color="text.secondary">{t("Expiry Timers")}</Typography>
|
||||
</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 QRCode from "react-qr-code";
|
||||
import Countdown, { zeroPad} from 'react-countdown';
|
||||
import Chat from "./Chat"
|
||||
import Chat from "./EncryptedChat"
|
||||
import MediaQuery from 'react-responsive'
|
||||
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 { 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";
|
||||
|
||||
|
||||
class UserGenPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -37,7 +41,7 @@ class UserGenPage extends Component {
|
||||
});
|
||||
}
|
||||
else{
|
||||
var newToken = this.genBase62Token(36)
|
||||
var newToken = genBase62Token(36)
|
||||
this.setState({
|
||||
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)=>{
|
||||
fetch('/api/user' + '?token=' + token + '&ref_code=' + this.refCode)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
this.setState({
|
||||
|
||||
const strength = tokenStrength(token);
|
||||
const refCode = this.refCode
|
||||
|
||||
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,
|
||||
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,
|
||||
avatarLoaded: false,
|
||||
})
|
||||
:
|
||||
(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)
|
||||
});
|
||||
avatarLoaded: false,
|
||||
})
|
||||
:
|
||||
(this.props.setAppState({
|
||||
nickname: data.nickname,
|
||||
token: token,
|
||||
avatarLoaded: false,
|
||||
})) & writeCookie("robot_token",token)
|
||||
& writeCookie("pub_key",data.public_key.split('\n').join('\\'))
|
||||
& writeCookie("enc_priv_key",data.encrypted_private_key.split('\n').join('\\')))
|
||||
&
|
||||
// If the robot has been found (recovered) we assume the token is backed up
|
||||
(data.found ? this.props.setAppState({copiedToken:true}) : null)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
delGeneratedUser() {
|
||||
@ -97,7 +116,7 @@ class UserGenPage extends Component {
|
||||
}
|
||||
|
||||
handleClickNewRandomToken=()=>{
|
||||
var token = this.genBase62Token(36);
|
||||
var token = genBase62Token(36);
|
||||
this.setState({
|
||||
token: token,
|
||||
tokenHasChanged: true,
|
||||
@ -164,7 +183,7 @@ class UserGenPage extends Component {
|
||||
{
|
||||
this.state.found ?
|
||||
<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/>
|
||||
</Typography>
|
||||
</Grid>
|
||||
@ -174,7 +193,7 @@ class UserGenPage extends Component {
|
||||
<Grid container align="center">
|
||||
<Grid item xs={12} align="center">
|
||||
<TextField sx={{maxWidth: 280}}
|
||||
error={this.state.bad_request}
|
||||
error={this.state.bad_request ? true : false}
|
||||
label={t("Store your token safely")}
|
||||
required={true}
|
||||
value={this.state.token}
|
||||
|
@ -259,9 +259,32 @@
|
||||
"Type a message":"Type a message",
|
||||
"Connecting...":"Connecting...",
|
||||
"Send":"Send",
|
||||
"The chat has no memory: if you leave, messages are lost.":"The chat has no memory: if you leave, messages are lost.",
|
||||
"Learn easy PGP encryption.":"Learn easy PGP encryption.",
|
||||
"PGP_guide_url":"https://learn.robosats.com/docs/pgp-encryption/",
|
||||
"Verify your privacy":"Verify your privacy",
|
||||
"Audit PGP":"Audit PGP",
|
||||
"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":"Contract Box",
|
||||
@ -381,7 +404,7 @@
|
||||
"You can also check the full guide in ":"You can also check the full guide in ",
|
||||
"How to use":"How to use",
|
||||
"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?",
|
||||
"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?",
|
||||
|
@ -258,9 +258,25 @@
|
||||
"Type a message": "Escribe un mensaje",
|
||||
"Connecting...": "Conectando...",
|
||||
"Send": "Enviar",
|
||||
"The chat has no memory: if you leave, messages are lost.": "Chat sin memoria: si lo cierras, los mensajes se pierden.",
|
||||
"Learn easy PGP encryption.": "Aprende encriptación PGP.",
|
||||
"PGP_guide_url":"https://learn.robosats.com/docs/pgp-encryption/es",
|
||||
"Verify your privacy":"Verifica tu privacidad",
|
||||
"Audit PGP":"Auditar",
|
||||
"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": "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