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:
Reckless_Satoshi 2022-05-24 22:35:51 +00:00 committed by GitHub
commit f1ed560f86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 906 additions and 79 deletions

View File

@ -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 . .

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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);
}

View 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;

View File

@ -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";

View 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);

View 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>
);
}

View File

@ -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";

View File

@ -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>

View File

@ -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'

View File

@ -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}

View File

@ -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?",

View File

@ -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
View 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};
}
};

View 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()
};

View 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)}
}