diff --git a/Dockerfile b/Dockerfile index 52c08374..fe04d2a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 . . diff --git a/api/models.py b/api/models.py index 7115ec9b..53b8b8f7 100644 --- a/api/models.py +++ b/api/models.py @@ -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) diff --git a/api/serializers.py b/api/serializers.py index 01c4b6a7..196d1986 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -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, diff --git a/api/views.py b/api/views.py index 7b0a05cc..e6420193 100644 --- a/api/views.py +++ b/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) diff --git a/chat/consumers.py b/chat/consumers.py index c641814b..0e462d3b 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index bcb3e10f..d15aa09e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 951b8a70..9c7253ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0e41091f..040d304a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/Chat.js b/frontend/src/components/Chat.js index 7f99c31b..170a71b3 100644 --- a/frontend/src/components/Chat.js +++ b/frontend/src/components/Chat.js @@ -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); } diff --git a/frontend/src/components/Dialogs/AuditPGP.tsx b/frontend/src/components/Dialogs/AuditPGP.tsx new file mode 100644 index 00000000..3c5cf31c --- /dev/null +++ b/frontend/src/components/Dialogs/AuditPGP.tsx @@ -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( + + + {props.label}} + value={props.value} + variant='filled' + size='small' + InputProps={{ + endAdornment: + + navigator.clipboard.writeText(props.value)}> + + + , + }} + /> + + + ) +} + +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 ( + + + {t("Don't trust, verify")} + + + + + {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.")} + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+
+ + + + + +
+ ) +} + +export default AuditPGPDialog; diff --git a/frontend/src/components/Dialogs/index.ts b/frontend/src/components/Dialogs/index.ts index b7177726..f95a5189 100644 --- a/frontend/src/components/Dialogs/index.ts +++ b/frontend/src/components/Dialogs/index.ts @@ -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"; diff --git a/frontend/src/components/EncryptedChat.js b/frontend/src/components/EncryptedChat.js new file mode 100644 index 00000000..c272a37b --- /dev/null +++ b/frontend/src/components/EncryptedChat.js @@ -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( + + + + + } + style={{backgroundColor: props.cardColor}} + title={ + +
+
+ {props.message.userNick} + {props.message.validSignature ? + + : + + } +
+
+ + this.setState(prevState => { + const newShowPGP = [...prevState.showPGP]; + newShowPGP[props.index] = !newShowPGP[props.index]; + return {showPGP: newShowPGP}; + })}> + + +
+
+ + navigator.clipboard.writeText(this.state.showPGP[props.index] ? props.message.encryptedMessage : props.message.plainTextMessage)}> + + + +
+
+
+ } + 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 }}} + /> +
+ ) + } + + render() { + const { t } = this.props; + return ( + + + + + + + {t("You")+": "}{this.state.connected ? t("connected"): t("disconnected")} + + + + + + + + {t("Peer")+": "}{this.state.peer_connected ? t("connected"): t("disconnected")} + + + + + + + {this.state.messages.map((message, index) => +
  • + {message.userNick == this.props.ur_nick ? + + : + + } +
  • )} +
    { this.messagesEnd = el; }}>
    +
    +
    + + + { + this.setState({ value: e.target.value }); + this.value = this.state.value; + }} + sx={{width: 214}} + /> + + + + + +
    +
    + + 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})} + /> + + + + + + + + + + + + + + + + ) + } +} + +export default withTranslation()(Chat); diff --git a/frontend/src/components/Icons/Export.tsx b/frontend/src/components/Icons/Export.tsx new file mode 100644 index 00000000..038ee27f --- /dev/null +++ b/frontend/src/components/Icons/Export.tsx @@ -0,0 +1,10 @@ +import React, { Component } from "react"; +import { SvgIcon } from "@mui/material" + +export default function ExportIcon(props) { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Icons/index.ts b/frontend/src/components/Icons/index.ts index e63c964e..dce24d8f 100644 --- a/frontend/src/components/Icons/index.ts +++ b/frontend/src/components/Icons/index.ts @@ -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"; + diff --git a/frontend/src/components/MakerPage.js b/frontend/src/components/MakerPage.js index d1c4c64a..8adb9a2f 100644 --- a/frontend/src/components/MakerPage.js +++ b/frontend/src/components/MakerPage.js @@ -577,7 +577,7 @@ class MakerPage extends Component { - + }> {t("Expiry Timers")} diff --git a/frontend/src/components/TradeBox.js b/frontend/src/components/TradeBox.js index 0ecc4a36..127d9cdc 100644 --- a/frontend/src/components/TradeBox.js +++ b/frontend/src/components/TradeBox.js @@ -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' diff --git a/frontend/src/components/UserGenPage.js b/frontend/src/components/UserGenPage.js index 4acfdd0b..18dc2bda 100644 --- a/frontend/src/components/UserGenPage.js +++ b/frontend/src/components/UserGenPage.js @@ -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 ? - + {this.state.found ? t("A robot avatar was found, welcome back!"):null}
    @@ -174,7 +193,7 @@ class UserGenPage extends Component { { + 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() +}; \ No newline at end of file diff --git a/frontend/src/utils/token.js b/frontend/src/utils/token.js new file mode 100644 index 00000000..181f99c5 --- /dev/null +++ b/frontend/src/utils/token.js @@ -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)} +} \ No newline at end of file