diff --git a/.env-sample b/.env-sample index fb5c3952..34b6927a 100644 --- a/.env-sample +++ b/.env-sample @@ -1,3 +1,6 @@ +# Coordinator Alias (Same as longAlias) +COORDINATOR_ALIAS="Local Dev" + # LND directory to read TLS cert and macaroon LND_DIR='/lnd/' MACAROON_PATH='data/chain/bitcoin/testnet/admin.macaroon' diff --git a/api/logics.py b/api/logics.py index 62fef2ae..37ce90ed 100644 --- a/api/logics.py +++ b/api/logics.py @@ -2,7 +2,6 @@ import ast import math from datetime import timedelta -import gnupg from decouple import config from django.contrib.auth.models import User from django.db.models import Q, Sum @@ -88,56 +87,6 @@ class Logics: return True, None, None - def validate_pgp_keys(pub_key, enc_priv_key): - """Validates PGP valid keys. Formats them in a way understandable by the frontend""" - gpg = gnupg.GPG() - - # Standarize format with linux linebreaks '\n'. Windows users submitting their own keys have '\r\n' breaking communication. - enc_priv_key = enc_priv_key.replace("\r\n", "\n") - pub_key = pub_key.replace("\r\n", "\n") - - # Try to import the public key - import_pub_result = gpg.import_keys(pub_key) - if not import_pub_result.imported == 1: - # If a robot is deleted and it is rebuilt with the same pubKey, the key will not be imported again - # so we assert that the import error is "Not actually changed" - if "Not actually changed" not in import_pub_result.results[0]["text"]: - return ( - False, - { - "bad_request": "Your PGP public key does not seem valid.\n" - + f"Stderr: {str(import_pub_result.stderr)}\n" - + f"ReturnCode: {str(import_pub_result.returncode)}\n" - + f"Summary: {str(import_pub_result.summary)}\n" - + f"Results: {str(import_pub_result.results)}\n" - + f"Imported: {str(import_pub_result.imported)}\n" - }, - None, - None, - ) - # Exports the public key again for uniform formatting. - pub_key = gpg.export_keys(import_pub_result.fingerprints[0]) - - # Try to import the encrypted private key (without passphrase) - import_priv_result = gpg.import_keys(enc_priv_key) - if not import_priv_result.sec_imported == 1: - if "Not actually changed" not in import_priv_result.results[0]["text"]: - return ( - False, - { - "bad_request": "Your PGP encrypted private key does not seem valid.\n" - + f"Stderr: {str(import_priv_result.stderr)}\n" - + f"ReturnCode: {str(import_priv_result.returncode)}\n" - + f"Summary: {str(import_priv_result.summary)}\n" - + f"Results: {str(import_priv_result.results)}\n" - + f"Sec Imported: {str(import_priv_result.sec_imported)}\n" - }, - None, - None, - ) - - return True, None, pub_key, enc_priv_key - @classmethod def validate_order_size(cls, order): """Validates if order size in Sats is within limits at t0""" diff --git a/api/nick_generator/dicts/en/nouns.py b/api/nick_generator/dicts/en/nouns.py index c55e1819..2f16a17d 100755 --- a/api/nick_generator/dicts/en/nouns.py +++ b/api/nick_generator/dicts/en/nouns.py @@ -11014,7 +11014,7 @@ nouns = [ "Zostera", "Zosterops", "Zouave", - "Zu/is", + "Zuis", "Zubr", "Zucchini", "Zuche", diff --git a/api/oas_schemas.py b/api/oas_schemas.py index c382240d..4e8648b6 100644 --- a/api/oas_schemas.py +++ b/api/oas_schemas.py @@ -467,6 +467,23 @@ class UserViewSchema: "description": "Whether the user prefers stealth invoices", }, "found": {"type": "string", "description": "Welcome back message"}, + "tg_enabled": { + "type": "boolean", + "description": "The robot has telegram notifications enabled", + }, + "tg_token": { + "type": "string", + "description": "Token to enable telegram with /start ", + }, + "tg_bot_name": { + "type": "string", + "description": "Name of the coordinator's telegram bot", + }, + "last_login": { + "type": "string", + "format": "date-time", + "description": "Last time seen", + }, "active_order_id": { "type": "integer", "description": "Active order id if present", @@ -623,6 +640,114 @@ class BookViewSchema: } +class RobotViewSchema: + get = { + "summary": "Get robot info", + "description": textwrap.dedent( + """ + DEPRECATED: Use `/robot` GET. + + Get robot info 🤖 + + An authenticated request (has the token's sha256 hash encoded as base 91 in the Authorization header) will be + returned the information about the state of a robot. + + Make sure you generate your token using cryptographically secure methods. [Here's]() the function the Javascript + client uses to generate the tokens. Since the server only receives the hash of the + token, it is responsibility of the client to create a strong token. Check + [here](https://github.com/Reckless-Satoshi/robosats/blob/main/frontend/src/utils/token.js) + to see how the Javascript client creates a random strong token and how it validates entropy is optimal for tokens + created by the user at will. + + `public_key` - PGP key associated with the user (Armored ASCII format) + `encrypted_private_key` - Private PGP key. This is only stored on the backend for later fetching by + the frontend and the key can't really be used by the server since it's protected by the token + that only the client knows. Will be made an optional parameter in a future release. + On the Javascript client, It's passphrase is set to be the secret token generated. + + A gpg key can be created by: + + ```shell + gpg --full-gen-key + ``` + + it's public key can be exported in ascii armored format with: + + ```shell + gpg --export --armor + ``` + + and it's private key can be exported in ascii armored format with: + + ```shell + gpg --export-secret-keys --armor + ``` + + """ + ), + "responses": { + 200: { + "type": "object", + "properties": { + "encrypted_private_key": { + "type": "string", + "description": "Armored ASCII PGP private key block", + }, + "nickname": { + "type": "string", + "description": "Username generated (Robot name)", + }, + "public_key": { + "type": "string", + "description": "Armored ASCII PGP public key block", + }, + "wants_stealth": { + "type": "boolean", + "default": False, + "description": "Whether the user prefers stealth invoices", + }, + "found": { + "type": "boolean", + "description": "Robot had been created in the past. Only if the robot was created +5 mins ago.", + }, + "tg_enabled": { + "type": "boolean", + "description": "The robot has telegram notifications enabled", + }, + "tg_token": { + "type": "string", + "description": "Token to enable telegram with /start ", + }, + "tg_bot_name": { + "type": "string", + "description": "Name of the coordinator's telegram bot", + }, + "active_order_id": { + "type": "integer", + "description": "Active order id if present", + }, + "last_order_id": { + "type": "integer", + "description": "Last order id if present", + }, + }, + }, + }, + "examples": [ + OpenApiExample( + "Successfully retrieved robot", + value={ + "nickname": "SatoshiNakamoto21", + "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n......\n......", + "encrypted_private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n......\n......", + "wants_stealth": True, + }, + status_codes=[200], + ), + ], + } + + class InfoViewSchema: get = { "summary": "Get info", diff --git a/api/urls.py b/api/urls.py index 9e7f8d37..95e52861 100644 --- a/api/urls.py +++ b/api/urls.py @@ -12,6 +12,7 @@ from .views import ( OrderView, PriceView, RewardView, + RobotView, StealthView, TickView, UserView, @@ -26,6 +27,7 @@ urlpatterns = [ OrderView.as_view({"get": "get", "post": "take_update_confirm_dispute_cancel"}), ), path("user/", UserView.as_view()), + path("robot/", RobotView.as_view()), path("book/", BookView.as_view()), path("info/", InfoView.as_view()), path("price/", PriceView.as_view()), diff --git a/api/utils.py b/api/utils.py index b4e2cfd7..1fcbb257 100644 --- a/api/utils.py +++ b/api/utils.py @@ -2,9 +2,11 @@ import json import logging import os +import gnupg import numpy as np import requests import ring +from base91 import decode, encode from decouple import config from api.models import Order @@ -147,18 +149,6 @@ def get_robosats_commit(): return commit_hash -robosats_version_cache = {} - - -@ring.dict(robosats_commit_cache, expire=99999) -def get_robosats_version(): - - with open("version.json") as f: - version_dict = json.load(f) - - return version_dict - - premium_percentile = {} @@ -238,3 +228,75 @@ def compute_avg_premium(queryset): else: weighted_median_premium = 0.0 return weighted_median_premium, total_volume + + +def validate_pgp_keys(pub_key, enc_priv_key): + """Validates PGP valid keys. Formats them in a way understandable by the frontend""" + gpg = gnupg.GPG() + + # Standardize format with linux linebreaks '\n'. Windows users submitting their own keys have '\r\n' breaking communication. + enc_priv_key = enc_priv_key.replace("\r\n", "\n").replace("\\", "\n") + pub_key = pub_key.replace("\r\n", "\n").replace("\\", "\n") + + # Try to import the public key + import_pub_result = gpg.import_keys(pub_key) + if not import_pub_result.imported == 1: + # If a robot is deleted and it is rebuilt with the same pubKey, the key will not be imported again + # so we assert that the import error is "Not actually changed" + if "Not actually changed" not in import_pub_result.results[0]["text"]: + return ( + False, + { + "bad_request": "Your PGP public key does not seem valid.\n" + + f"Stderr: {str(import_pub_result.stderr)}\n" + + f"ReturnCode: {str(import_pub_result.returncode)}\n" + + f"Summary: {str(import_pub_result.summary)}\n" + + f"Results: {str(import_pub_result.results)}\n" + + f"Imported: {str(import_pub_result.imported)}\n" + }, + None, + None, + ) + # Exports the public key again for uniform formatting. + pub_key = gpg.export_keys(import_pub_result.fingerprints[0]) + + # Try to import the encrypted private key (without passphrase) + import_priv_result = gpg.import_keys(enc_priv_key) + if not import_priv_result.sec_imported == 1: + if "Not actually changed" not in import_priv_result.results[0]["text"]: + return ( + False, + { + "bad_request": "Your PGP encrypted private key does not seem valid.\n" + + f"Stderr: {str(import_priv_result.stderr)}\n" + + f"ReturnCode: {str(import_priv_result.returncode)}\n" + + f"Summary: {str(import_priv_result.summary)}\n" + + f"Results: {str(import_priv_result.results)}\n" + + f"Sec Imported: {str(import_priv_result.sec_imported)}\n" + }, + None, + None, + ) + + return True, None, pub_key, enc_priv_key + + +def base91_to_hex(base91_str: str) -> str: + bytes_data = decode(base91_str) + return bytes_data.hex() + + +def hex_to_base91(hex_str: str) -> str: + hex_bytes = bytes.fromhex(hex_str) + base91_str = encode(hex_bytes) + return base91_str + + +def is_valid_token(token: str) -> bool: + num_chars = len(token) + + if not 38 < num_chars < 41: + return False + + charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,./:;<=>?@[]^_`{|}~"' + return all(c in charset for c in token) diff --git a/api/views.py b/api/views.py index c9885896..839ac69e 100644 --- a/api/views.py +++ b/api/views.py @@ -12,7 +12,12 @@ from django.db.models import Q, Sum from django.utils import timezone from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets +from rest_framework.authentication import ( + SessionAuthentication, # DEPRECATE session authentication +) +from rest_framework.authentication import TokenAuthentication from rest_framework.generics import CreateAPIView, ListAPIView, UpdateAPIView +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from robohash import Robohash @@ -30,6 +35,7 @@ from api.oas_schemas import ( OrderViewSchema, PriceViewSchema, RewardViewSchema, + RobotViewSchema, StealthViewSchema, TickViewSchema, UserViewSchema, @@ -51,7 +57,7 @@ from api.utils import ( compute_premium_percentile, get_lnd_version, get_robosats_commit, - get_robosats_version, + validate_pgp_keys, ) from chat.models import Message from control.models import AccountingDay, BalanceLog @@ -72,9 +78,12 @@ avatar_path.mkdir(parents=True, exist_ok=True) class MakerView(CreateAPIView): serializer_class = MakeOrderSerializer + authentication_classes = [TokenAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] @extend_schema(**MakerViewSchema.post) def post(self, request): + serializer = self.serializer_class(data=request.data) if not request.user.is_authenticated: @@ -178,6 +187,8 @@ class MakerView(CreateAPIView): class OrderView(viewsets.ViewSet): + authentication_classes = [TokenAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] serializer_class = UpdateOrderSerializer lookup_url_kwarg = "order_id" @@ -617,13 +628,60 @@ class OrderView(viewsets.ViewSet): return self.get(request) +class RobotView(APIView): + authentication_classes = [TokenAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + + @extend_schema(**RobotViewSchema.get) + def get(self, request, format=None): + """ + Respond with Nickname, pubKey, privKey. + """ + user = request.user + context = {} + context["nickname"] = user.username + context["public_key"] = user.robot.public_key + context["encrypted_private_key"] = user.robot.encrypted_private_key + context["earned_rewards"] = user.robot.earned_rewards + context["wants_stealth"] = user.robot.wants_stealth + context["last_login"] = user.last_login + + # Adds/generate telegram token and whether it is enabled + context = {**context, **Telegram.get_context(user)} + + # return active order or last made order if any + has_no_active_order, _, order = Logics.validate_already_maker_or_taker( + request.user + ) + if not has_no_active_order: + context["active_order_id"] = order.id + else: + last_order = Order.objects.filter( + Q(maker=request.user) | Q(taker=request.user) + ).last() + if last_order: + context["last_order_id"] = last_order.id + + # Robot was found, only if created +5 mins ago + if user.date_joined < (timezone.now() - timedelta(minutes=5)): + context["found"] = True + + return Response(context, status=status.HTTP_200_OK) + + class UserView(APIView): + """ + Deprecated. UserView will be completely replaced by the smaller RobotView in + combination with the RobotTokenSHA256 middleware (on-the-fly robot generation) + """ + NickGen = NickGenerator( lang="English", use_adv=False, use_adj=True, use_noun=True, max_num=999 ) serializer_class = UserGenSerializer + @extend_schema(**UserViewSchema.post) def post(self, request, format=None): """ Get a new user derived from a high entropy token @@ -715,7 +773,7 @@ class UserView(APIView): bad_keys_context, public_key, encrypted_private_key, - ) = Logics.validate_pgp_keys(public_key, encrypted_private_key) + ) = validate_pgp_keys(public_key, encrypted_private_key) if not valid: return Response(bad_keys_context, status.HTTP_400_BAD_REQUEST) @@ -922,7 +980,7 @@ class InfoView(ListAPIView): context["lifetime_volume"] = round(lifetime_volume, 8) context["lnd_version"] = get_lnd_version() context["robosats_running_commit_hash"] = get_robosats_commit() - context["version"] = get_robosats_version() + context["version"] = settings.VERSION context["alternative_site"] = config("ALTERNATIVE_SITE") context["alternative_name"] = config("ALTERNATIVE_NAME") context["node_alias"] = config("NODE_ALIAS") @@ -945,18 +1003,15 @@ class InfoView(ListAPIView): class RewardView(CreateAPIView): + authentication_classes = [TokenAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = ClaimRewardSerializer @extend_schema(**RewardViewSchema.post) def post(self, request): serializer = self.serializer_class(data=request.data) - if not request.user.is_authenticated: - return Response( - {"bad_request": "Woops! It seems you do not have a robot avatar"}, - status.HTTP_400_BAD_REQUEST, - ) - if not serializer.is_valid(): return Response(status=status.HTTP_400_BAD_REQUEST) @@ -1052,6 +1107,8 @@ class HistoricalView(ListAPIView): class StealthView(UpdateAPIView): + authentication_classes = [TokenAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] serializer_class = StealthSerializer @@ -1059,12 +1116,6 @@ class StealthView(UpdateAPIView): def put(self, request): serializer = self.serializer_class(data=request.data) - if not request.user.is_authenticated: - return Response( - {"bad_request": "Woops! It seems you do not have a robot avatar"}, - status.HTTP_400_BAD_REQUEST, - ) - if not serializer.is_valid(): return Response(status=status.HTTP_400_BAD_REQUEST) diff --git a/chat/views.py b/chat/views.py index 1186911a..1debfc7f 100644 --- a/chat/views.py +++ b/chat/views.py @@ -5,6 +5,11 @@ from channels.layers import get_channel_layer from django.contrib.auth.models import User from django.utils import timezone from rest_framework import status, viewsets +from rest_framework.authentication import ( + SessionAuthentication, # DEPRECATE session Authentication +) +from rest_framework.authentication import TokenAuthentication +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from api.models import Order @@ -15,6 +20,9 @@ from chat.serializers import ChatSerializer, PostMessageSerializer class ChatView(viewsets.ViewSet): serializer_class = PostMessageSerializer + authentication_classes = [TokenAuthentication, SessionAuthentication] + permission_classes = [IsAuthenticated] + lookup_url_kwarg = ["order_id", "offset"] queryset = Message.objects.filter( diff --git a/frontend/models.py b/frontend/models.py deleted file mode 100644 index 0b4331b3..00000000 --- a/frontend/models.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.db import models - -# Create your models here. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d8d74de..8ae73714 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@mui/x-date-pickers": "^6.0.4", "@nivo/core": "^0.80.0", "@nivo/line": "^0.80.0", + "base-ex": "^0.7.5", "country-flag-icons": "^1.4.25", "date-fns": "^2.28.0", "file-replace-loader": "^1.4.0", @@ -5165,6 +5166,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base-ex": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/base-ex/-/base-ex-0.7.5.tgz", + "integrity": "sha512-tJhPMqiXc6GTrMZusgZIJvYV9wFZ5lReDUsmm4AIIznAFm2ZD8i1/bAlgTkkR2elxjdtHAGFoKaALXDaJBv4yA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -18920,6 +18926,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base-ex": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/base-ex/-/base-ex-0.7.5.tgz", + "integrity": "sha512-tJhPMqiXc6GTrMZusgZIJvYV9wFZ5lReDUsmm4AIIznAFm2ZD8i1/bAlgTkkR2elxjdtHAGFoKaALXDaJBv4yA==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5203307b..1a3c318a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,6 +59,7 @@ "@mui/x-date-pickers": "^6.0.4", "@nivo/core": "^0.80.0", "@nivo/line": "^0.80.0", + "base-ex": "^0.7.5", "country-flag-icons": "^1.4.25", "date-fns": "^2.28.0", "file-replace-loader": "^1.4.0", diff --git a/frontend/src/basic/BookPage/index.tsx b/frontend/src/basic/BookPage/index.tsx index e8c5993d..50f753e1 100644 --- a/frontend/src/basic/BookPage/index.tsx +++ b/frontend/src/basic/BookPage/index.tsx @@ -80,7 +80,6 @@ const BookPage = (): JSX.Element => { setOpenMaker(false)}> { navigate('/order/' + id); }} diff --git a/frontend/src/basic/Main.tsx b/frontend/src/basic/Main.tsx index 5318bcff..5a75ea4f 100644 --- a/frontend/src/basic/Main.tsx +++ b/frontend/src/basic/Main.tsx @@ -77,7 +77,7 @@ const Main: React.FC = () => { - {['/robot/:refCode?', '/', ''].map((path, index) => { + {['/robot/:token?', '/', ''].map((path, index) => { return ( { onOrderCreated={(id) => { navigate('/order/' + id); }} - hasRobot={robot.avatarLoaded} disableRequest={matches.length > 0 && !showMatches} collapseAll={showMatches} onSubmit={() => setShowMatches(matches.length > 0)} diff --git a/frontend/src/basic/OrderPage/index.tsx b/frontend/src/basic/OrderPage/index.tsx index 31bddc73..23d515d2 100644 --- a/frontend/src/basic/OrderPage/index.tsx +++ b/frontend/src/basic/OrderPage/index.tsx @@ -58,7 +58,7 @@ const OrderPage = (): JSX.Element => { escrow_duration: order.escrow_duration, bond_size: order.bond_size, }; - apiClient.post(baseUrl, '/api/make/', body).then((data: any) => { + apiClient.post(baseUrl, '/api/make/', body, robot.tokenSHA256).then((data: any) => { if (data.bad_request) { setBadOrder(data.bad_request); } else if (data.id) { diff --git a/frontend/src/basic/RobotPage/Onboarding.tsx b/frontend/src/basic/RobotPage/Onboarding.tsx index 055e58ec..70db1289 100644 --- a/frontend/src/basic/RobotPage/Onboarding.tsx +++ b/frontend/src/basic/RobotPage/Onboarding.tsx @@ -11,12 +11,10 @@ import { LinearProgress, Link, Typography, - useTheme, Accordion, AccordionSummary, AccordionDetails, } from '@mui/material'; -import { Page } from '../NavBar'; import { Robot } from '../../models'; import { Casino, Bolt, Check, Storefront, AddBox, School } from '@mui/icons-material'; import RobotAvatar from '../../components/RobotAvatar'; @@ -31,7 +29,7 @@ interface OnboardingProps { inputToken: string; setInputToken: (state: string) => void; getGenerateRobot: (token: string) => void; - badRequest: string | undefined; + badToken: string; baseUrl: string; } @@ -41,7 +39,7 @@ const Onboarding = ({ inputToken, setInputToken, setRobot, - badRequest, + badToken, getGenerateRobot, baseUrl, }: OnboardingProps): JSX.Element => { @@ -102,7 +100,7 @@ const Onboarding = ({ inputToken={inputToken} setInputToken={setInputToken} setRobot={setRobot} - badRequest={badRequest} + badToken={badToken} robot={robot} onPressEnter={() => null} /> diff --git a/frontend/src/basic/RobotPage/Recovery.tsx b/frontend/src/basic/RobotPage/Recovery.tsx index ee8fd914..436a3d80 100644 --- a/frontend/src/basic/RobotPage/Recovery.tsx +++ b/frontend/src/basic/RobotPage/Recovery.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Grid, Typography, useTheme } from '@mui/material'; +import { Button, Grid, Typography } from '@mui/material'; import { Robot } from '../../models'; import TokenInput from './TokenInput'; import Key from '@mui/icons-material/Key'; @@ -10,6 +10,7 @@ interface RecoveryProps { setRobot: (state: Robot) => void; setView: (state: 'welcome' | 'onboarding' | 'recovery' | 'profile') => void; inputToken: string; + badToken: string; setInputToken: (state: string) => void; getGenerateRobot: (token: string) => void; } @@ -18,21 +19,16 @@ const Recovery = ({ robot, setRobot, inputToken, + badToken, setView, setInputToken, getGenerateRobot, }: RecoveryProps): JSX.Element => { const { t } = useTranslation(); - const recoveryDisabled = () => { - return !(inputToken.length > 20); - }; const onClickRecover = () => { - if (recoveryDisabled()) { - } else { - getGenerateRobot(inputToken); - setView('profile'); - } + getGenerateRobot(inputToken); + setView('profile'); }; return ( @@ -56,16 +52,11 @@ const Recovery = ({ label={t('Paste token here')} robot={robot} onPressEnter={onClickRecover} - badRequest={''} + badToken={badToken} /> - diff --git a/frontend/src/basic/RobotPage/RobotProfile.tsx b/frontend/src/basic/RobotPage/RobotProfile.tsx index d47fcc0d..c73d1b89 100644 --- a/frontend/src/basic/RobotPage/RobotProfile.tsx +++ b/frontend/src/basic/RobotPage/RobotProfile.tsx @@ -27,12 +27,9 @@ interface RobotProfileProps { setView: (state: 'welcome' | 'onboarding' | 'recovery' | 'profile') => void; getGenerateRobot: (token: string, slot?: number) => void; inputToken: string; - setCurrentOrder: (state: number) => void; logoutRobot: () => void; - inputToken: string; setInputToken: (state: string) => void; baseUrl: string; - badRequest: string; width: number; } @@ -42,10 +39,8 @@ const RobotProfile = ({ inputToken, getGenerateRobot, setInputToken, - setCurrentOrder, logoutRobot, setView, - badRequest, baseUrl, width, }: RobotProfileProps): JSX.Element => { @@ -227,7 +222,6 @@ const RobotProfile = ({ label={t('Store your token safely')} setInputToken={setInputToken} setRobot={setRobot} - badRequest={badRequest} robot={robot} onPressEnter={() => null} /> diff --git a/frontend/src/basic/RobotPage/TokenInput.tsx b/frontend/src/basic/RobotPage/TokenInput.tsx index c443e849..296008af 100644 --- a/frontend/src/basic/RobotPage/TokenInput.tsx +++ b/frontend/src/basic/RobotPage/TokenInput.tsx @@ -14,7 +14,7 @@ interface TokenInputProps { inputToken: string; autoFocusTarget?: 'textfield' | 'copyButton' | 'none'; onPressEnter: () => void; - badRequest: string | undefined; + badToken?: string; setInputToken: (state: string) => void; showCopy?: boolean; label?: string; @@ -30,7 +30,7 @@ const TokenInput = ({ onPressEnter, autoFocusTarget = 'textfield', inputToken, - badRequest, + badToken = '', loading = false, setInputToken, }: TokenInputProps): JSX.Element => { @@ -46,7 +46,7 @@ const TokenInput = ({ } else { return ( 20 ? !!badToken : false} disabled={!editable} required={true} label={label || undefined} @@ -55,7 +55,7 @@ const TokenInput = ({ fullWidth={fullWidth} sx={{ borderColor: 'primary' }} variant={editable ? 'outlined' : 'filled'} - helperText={badRequest} + helperText={badToken} size='medium' onChange={(e) => setInputToken(e.target.value)} onKeyPress={(e) => { diff --git a/frontend/src/basic/RobotPage/index.tsx b/frontend/src/basic/RobotPage/index.tsx index 3d3bf652..87a93508 100644 --- a/frontend/src/basic/RobotPage/index.tsx +++ b/frontend/src/basic/RobotPage/index.tsx @@ -20,18 +20,19 @@ import Recovery from './Recovery'; import { TorIcon } from '../../components/Icons'; import { genKey } from '../../pgp'; import { AppContext, UseAppStoreType } from '../../contexts/AppContext'; +import { validateTokenEntropy } from '../../utils'; const RobotPage = (): JSX.Element => { - const { robot, setRobot, setCurrentOrder, fetchRobot, torStatus, windowSize, baseUrl } = + const { robot, setRobot, fetchRobot, torStatus, windowSize, baseUrl } = useContext(AppContext); const { t } = useTranslation(); const params = useParams(); - const refCode = params.refCode; + const url_token = params.token; const width = Math.min(windowSize.width * 0.8, 28); const maxHeight = windowSize.height * 0.85 - 3; const theme = useTheme(); - const [badRequest, setBadRequest] = useState(undefined); + const [badToken, setBadToken] = useState(''); const [inputToken, setInputToken] = useState(''); const [view, setView] = useState<'welcome' | 'onboarding' | 'recovery' | 'profile'>( robot.token ? 'profile' : 'welcome', @@ -41,26 +42,35 @@ const RobotPage = (): JSX.Element => { if (robot.token) { setInputToken(robot.token); } - if (robot.nickname == null && robot.token) { + const token = url_token ?? robot.token; + if (robot.nickname == null && token) { if (window.NativeRobosats === undefined || torStatus == '"Done"') { - fetchRobot({ action: 'generate', setBadRequest }); + getGenerateRobot(token); + setView('profile'); } } }, [torStatus]); + useEffect(() => { + if (inputToken.length < 20) { + setBadToken(t('The token is too short')); + } else if (!validateTokenEntropy(inputToken).hasEnoughEntropy) { + setBadToken(t('Not enough entropy, make it more complex')); + } else { + setBadToken(''); + } + }, [inputToken]); + const getGenerateRobot = (token: string, slot?: number) => { setInputToken(token); genKey(token).then(function (key) { fetchRobot({ - action: 'generate', newKeys: { pubKey: key.publicKeyArmored, encPrivKey: key.encryptedPrivateKeyArmored, }, newToken: token, slot, - refCode, - setBadRequest, }); }); }; @@ -138,7 +148,7 @@ const RobotPage = (): JSX.Element => { setView={setView} robot={robot} setRobot={setRobot} - badRequest={badRequest} + badToken={badToken} inputToken={inputToken} setInputToken={setInputToken} getGenerateRobot={getGenerateRobot} @@ -151,9 +161,6 @@ const RobotPage = (): JSX.Element => { setView={setView} robot={robot} setRobot={setRobot} - setCurrentOrder={setCurrentOrder} - badRequest={badRequest} - getGenerateRobot={getGenerateRobot} logoutRobot={logoutRobot} width={width} inputToken={inputToken} @@ -168,11 +175,10 @@ const RobotPage = (): JSX.Element => { setView={setView} robot={robot} setRobot={setRobot} - badRequest={badRequest} + badToken={badToken} inputToken={inputToken} setInputToken={setInputToken} getGenerateRobot={getGenerateRobot} - baseUrl={baseUrl} /> ) : null} diff --git a/frontend/src/components/Dialogs/Profile.tsx b/frontend/src/components/Dialogs/Profile.tsx index 6188dee5..fd8c8835 100644 --- a/frontend/src/components/Dialogs/Profile.tsx +++ b/frontend/src/components/Dialogs/Profile.tsx @@ -73,10 +73,6 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop setWeblnEnabled(webln !== undefined); }, []); - const copyReferralCodeHandler = () => { - systemClient.copyToClipboard(`http://${host}/robot/${robot.referralCode}`); - }; - const handleWeblnInvoiceClicked = async (e: any) => { e.preventDefault(); if (robot.earnedRewards) { @@ -94,9 +90,14 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop setShowRewardsSpinner(true); apiClient - .post(baseUrl, '/api/reward/', { - invoice: rewardInvoice, - }) + .post( + baseUrl, + '/api/reward/', + { + invoice: rewardInvoice, + }, + robot.tokenSHA256, + ) .then((data: any) => { setBadInvoice(data.bad_invoice ?? ''); setShowRewardsSpinner(false); @@ -109,7 +110,7 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop const setStealthInvoice = (wantsStealth: boolean) => { apiClient - .put(baseUrl, '/api/stealth/', { wantsStealth }) + .put(baseUrl, '/api/stealth/', { wantsStealth }, robot.tokenSHA256) .then((data) => setRobot({ ...robot, stealthInvoices: data?.wantsStealth })); }; @@ -268,29 +269,6 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop - - - - - - - - - - - - ), - }} - /> - - - diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 2d8b123c..33035da2 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,3 @@ -import { Paper } from '@mui/material'; import React, { Component } from 'react'; interface ErrorBoundaryProps { @@ -28,13 +27,13 @@ export default class ErrorBoundary extends Component { window.location.reload(); - }, 10000); + }, 30000); } render() { if (this.state.hasError) { return (
-

Something is borked! Restarting app in 10 seconds...

+

Something is borked! Restarting app in 30 seconds...

Error: {this.state.error.name}

diff --git a/frontend/src/components/MakerForm/MakerForm.tsx b/frontend/src/components/MakerForm/MakerForm.tsx index f92c3bb5..e182468d 100644 --- a/frontend/src/components/MakerForm/MakerForm.tsx +++ b/frontend/src/components/MakerForm/MakerForm.tsx @@ -50,7 +50,6 @@ interface MakerFormProps { onReset?: () => void; submitButtonLabel?: string; onOrderCreated?: (id: number) => void; - hasRobot?: boolean; } const MakerForm = ({ @@ -61,9 +60,8 @@ const MakerForm = ({ onReset = () => {}, submitButtonLabel = 'Create Order', onOrderCreated = () => null, - hasRobot = true, }: MakerFormProps): JSX.Element => { - const { fav, setFav, limits, fetchLimits, info, maker, setMaker, baseUrl } = + const { fav, setFav, limits, fetchLimits, info, maker, setMaker, baseUrl, robot } = useContext(AppContext); const { t } = useTranslation(); @@ -251,7 +249,7 @@ const MakerForm = ({ escrow_duration: maker.escrowDuration, bond_size: maker.bondSize, }; - apiClient.post(baseUrl, '/api/make/', body).then((data: object) => { + apiClient.post(baseUrl, '/api/make/', body, robot.tokenSHA256).then((data: object) => { setBadRequest(data.bad_request); if (data.id) { onOrderCreated(data.id); @@ -466,7 +464,7 @@ const MakerForm = ({ open={openDialogs} onClose={() => setOpenDialogs(false)} onClickDone={handleCreateOrder} - hasRobot={hasRobot} + hasRobot={robot.avatarLoaded} />
diff --git a/frontend/src/components/OrderDetails/TakeButton.tsx b/frontend/src/components/OrderDetails/TakeButton.tsx index 56ed6bcf..a69b99fe 100644 --- a/frontend/src/components/OrderDetails/TakeButton.tsx +++ b/frontend/src/components/OrderDetails/TakeButton.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, @@ -21,16 +21,16 @@ import Countdown from 'react-countdown'; import currencies from '../../../static/assets/currencies.json'; import { apiClient } from '../../services/api'; -import { Order } from '../../models'; +import { Order, Info } from '../../models'; import { ConfirmationDialog } from '../Dialogs'; import { LoadingButton } from '@mui/lab'; -import { computeSats, pn } from '../../utils'; +import { computeSats } from '../../utils'; +import { AppContext, UseAppStoreType } from '../../contexts/AppContext'; interface TakeButtonProps { order: Order; setOrder: (state: Order) => void; baseUrl: string; - hasRobot: boolean; info: Info; } @@ -40,9 +40,10 @@ interface OpenDialogsProps { } const closeAll = { inactiveMaker: false, confirmation: false }; -const TakeButton = ({ order, setOrder, baseUrl, hasRobot, info }: TakeButtonProps): JSX.Element => { +const TakeButton = ({ order, setOrder, baseUrl, info }: TakeButtonProps): JSX.Element => { const { t } = useTranslation(); const theme = useTheme(); + const { robot } = useContext(AppContext); const [takeAmount, setTakeAmount] = useState(''); const [badRequest, setBadRequest] = useState(''); @@ -277,10 +278,15 @@ const TakeButton = ({ order, setOrder, baseUrl, hasRobot, info }: TakeButtonProp const takeOrder = function () { setLoadingTake(true); apiClient - .post(baseUrl, '/api/order/?order_id=' + order.id, { - action: 'take', - amount: order.currency == 1000 ? takeAmount / 100000000 : takeAmount, - }) + .post( + baseUrl, + '/api/order/?order_id=' + order.id, + { + action: 'take', + amount: order.currency == 1000 ? takeAmount / 100000000 : takeAmount, + }, + robot.tokenSHA256, + ) .then((data) => { setLoadingTake(false); if (data.bad_request) { @@ -313,7 +319,7 @@ const TakeButton = ({ order, setOrder, baseUrl, hasRobot, info }: TakeButtonProp setLoadingTake(true); setOpen(closeAll); }} - hasRobot={hasRobot} + hasRobot={robot.avatarLoaded} /> diff --git a/frontend/src/components/PaymentMethods/StringAsIcons.tsx b/frontend/src/components/PaymentMethods/StringAsIcons.tsx index 8d04823c..975d8133 100644 --- a/frontend/src/components/PaymentMethods/StringAsIcons.tsx +++ b/frontend/src/components/PaymentMethods/StringAsIcons.tsx @@ -14,7 +14,7 @@ interface Props { text: string; } -const StringAsIcons: React.FC = ({ othersText, verbose, size, text }: Props) => { +const StringAsIcons: React.FC = ({ othersText, verbose, size, text = '' }: Props) => { const { t } = useTranslation(); const parsedText = useMemo(() => { diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx index 551f6c12..35e6008d 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx @@ -14,6 +14,7 @@ import MessageCard from '../MessageCard'; import ChatHeader from '../ChatHeader'; import { EncryptedChatMessage, ServerMessage } from '..'; import ChatBottom from '../ChatBottom'; +import { sha256 } from 'js-sha256'; interface Props { orderId: number; @@ -92,19 +93,24 @@ const EncryptedSocketChat: React.FC = ({ }, [serverMessages]); const connectWebsocket = () => { - websocketClient.open(`ws://${window.location.host}/ws/chat/${orderId}/`).then((connection) => { - setConnection(connection); - setConnected(true); + websocketClient + .open( + `ws://${window.location.host}/ws/chat/${orderId}/?token_sha256_hex=${sha256(robot.token)}`, + ) + .then((connection) => { + setConnection(connection); + setConnected(true); - connection.send({ - message: robot.pubKey, - nick: userNick, + connection.send({ + message: robot.pubKey, + nick: userNick, + authorization: `Token ${robot.tokenSHA256}`, + }); + + connection.onMessage((message) => setServerMessages((prev) => [...prev, message])); + connection.onClose(() => setConnected(false)); + connection.onError(() => setConnected(false)); }); - - connection.onMessage((message) => setServerMessages((prev) => [...prev, message])); - connection.onClose(() => setConnected(false)); - connection.onError(() => setConnected(false)); - }); }; const createJsonFile: () => object = () => { @@ -135,6 +141,7 @@ const EncryptedSocketChat: React.FC = ({ connection.send({ message: `-----SERVE HISTORY-----`, nick: userNick, + authorization: `Token ${robot.tokenSHA256}`, }); } // If we receive an encrypted message @@ -206,6 +213,7 @@ const EncryptedSocketChat: React.FC = ({ connection.send({ message: value, nick: userNick, + authorization: `Token ${robot.tokenSHA256}`, }); setValue(''); } @@ -221,6 +229,7 @@ const EncryptedSocketChat: React.FC = ({ connection.send({ message: encryptedMessage.toString().split('\n').join('\\'), nick: userNick, + authorization: `Token ${robot.tokenSHA256}`, }); } }) diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx index 9478d30d..6b780aeb 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx @@ -76,7 +76,7 @@ const EncryptedTurtleChat: React.FC = ({ const loadMessages: () => void = () => { apiClient - .get(baseUrl, `/api/chat/?order_id=${orderId}&offset=${lastIndex}`) + .get(baseUrl, `/api/chat/?order_id=${orderId}&offset=${lastIndex}`, robot.tokenSHA256) .then((results: any) => { if (results) { setPeerConnected(results.peer_connected); @@ -167,11 +167,16 @@ const EncryptedTurtleChat: React.FC = ({ // If input string contains '#' send unencrypted and unlogged message else if (value.substring(0, 1) == '#') { apiClient - .post(baseUrl, `/api/chat/`, { - PGP_message: value, - order_id: orderId, - offset: lastIndex, - }) + .post( + baseUrl, + `/api/chat/`, + { + PGP_message: value, + order_id: orderId, + offset: lastIndex, + }, + robot.tokenSHA256, + ) .then((response) => { if (response != null) { if (response.messages) { @@ -192,11 +197,16 @@ const EncryptedTurtleChat: React.FC = ({ encryptMessage(value, robot.pubKey, peerPubKey, robot.encPrivKey, robot.token) .then((encryptedMessage) => { apiClient - .post(baseUrl, `/api/chat/`, { - PGP_message: encryptedMessage.toString().split('\n').join('\\'), - order_id: orderId, - offset: lastIndex, - }) + .post( + baseUrl, + `/api/chat/`, + { + PGP_message: encryptedMessage.toString().split('\n').join('\\'), + order_id: orderId, + offset: lastIndex, + }, + robot.tokenSHA256, + ) .then((response) => { if (response != null) { setPeerConnected(response.peer_connected); diff --git a/frontend/src/components/TradeBox/index.tsx b/frontend/src/components/TradeBox/index.tsx index 676d4106..a41c828b 100644 --- a/frontend/src/components/TradeBox/index.tsx +++ b/frontend/src/components/TradeBox/index.tsx @@ -161,15 +161,20 @@ const TradeBox = ({ rating, }: SubmitActionProps) { apiClient - .post(baseUrl, '/api/order/?order_id=' + order.id, { - action, - invoice, - routing_budget_ppm, - address, - mining_fee_rate, - statement, - rating, - }) + .post( + baseUrl, + '/api/order/?order_id=' + order.id, + { + action, + invoice, + routing_budget_ppm, + address, + mining_fee_rate, + statement, + rating, + }, + robot.tokenSHA256, + ) .catch(() => { setOpen(closeAll); setLoadingButtons({ ...noLoadingButtons }); diff --git a/frontend/src/contexts/AppContext.ts b/frontend/src/contexts/AppContext.ts index b1edb0d5..e7f27e81 100644 --- a/frontend/src/contexts/AppContext.ts +++ b/frontend/src/contexts/AppContext.ts @@ -18,13 +18,13 @@ import { } from '../models'; import { apiClient } from '../services/api'; -import { systemClient } from '../services/System'; -import { checkVer, getHost, tokenStrength } from '../utils'; +import { checkVer, getHost, hexToBase91, validateTokenEntropy } from '../utils'; import { sha256 } from 'js-sha256'; import defaultCoordinators from '../../static/federation.json'; import { createTheme, Theme } from '@mui/material/styles'; import i18n from '../i18n/Web'; +import { systemClient } from '../services/System'; const getWindowSize = function (fontSize: number) { // returns window size in EM units @@ -63,12 +63,10 @@ export interface SlideDirection { } export interface fetchRobotProps { - action?: 'login' | 'generate' | 'refresh'; - newKeys?: { encPrivKey: string; pubKey: string } | null; - newToken?: string | null; - refCode?: string | null; - slot?: number | null; - setBadRequest?: (state: string) => void; + newKeys?: { encPrivKey: string; pubKey: string }; + newToken?: string; + slot?: number; + isRefresh?: boolean; } export type TorStatus = 'NOTINIT' | 'STARTING' | '"Done"' | 'DONE'; @@ -297,7 +295,9 @@ export const useAppStore = () => { const fetchOrder = function () { if (currentOrder != undefined) { - apiClient.get(baseUrl, '/api/order/?order_id=' + currentOrder).then(orderReceived); + apiClient + .get(baseUrl, '/api/order/?order_id=' + currentOrder, robot.tokenSHA256) + .then(orderReceived); } }; @@ -307,97 +307,87 @@ export const useAppStore = () => { }; const fetchRobot = function ({ - action = 'login', - newKeys = null, - newToken = null, - refCode = null, - slot = null, - setBadRequest = () => {}, - }: fetchRobotProps) { - const oldRobot = robot; + newToken, + newKeys, + slot, + isRefresh = false, + }: fetchRobotProps): void { + const token = newToken ?? robot.token ?? ''; + + const { hasEnoughEntropy, bitsEntropy, shannonEntropy } = validateTokenEntropy(token); + + if (!hasEnoughEntropy) { + return; + } + + const tokenSHA256 = hexToBase91(sha256(token)); const targetSlot = slot ?? currentSlot; - const token = newToken ?? oldRobot.token; - if (action != 'refresh') { - setRobot(new Robot()); - } - setBadRequest(''); + const encPrivKey = newKeys?.encPrivKey ?? robot.encPrivKey ?? ''; + const pubKey = newKeys?.pubKey ?? robot.pubKey ?? ''; - const requestBody = {}; - if (action == 'login' || action == 'refresh') { - requestBody.token_sha256 = sha256(token); - } else if (action == 'generate' && token != null) { - const strength = tokenStrength(token); - requestBody.token_sha256 = sha256(token); - requestBody.unique_values = strength.uniqueValues; - requestBody.counts = strength.counts; - requestBody.length = token.length; - requestBody.ref_code = refCode; - requestBody.public_key = newKeys?.pubKey ?? oldRobot.pubKey; - requestBody.encrypted_private_key = newKeys?.encPrivKey ?? oldRobot.encPrivKey; - } + // On first authenticated request, pubkey and privkey must be in header cookies + systemClient.setCookie('public_key', pubKey.split('\n').join('\\')); + systemClient.setCookie('encrypted_private_key', encPrivKey.split('\n').join('\\')); - apiClient.post(baseUrl, '/api/user/', requestBody).then((data: any) => { - let newRobot = robot; - if (currentOrder === undefined) { - setCurrentOrder( - data.active_order_id - ? data.active_order_id - : data.last_order_id - ? data.last_order_id - : null, - ); - } - if (data.bad_request) { - setBadRequest(data.bad_request); - newRobot = { - ...oldRobot, - loading: false, - nickname: data.nickname ?? oldRobot.nickname, - activeOrderId: data.active_order_id ?? null, - referralCode: data.referral_code ?? oldRobot.referralCode, - earnedRewards: data.earned_rewards ?? oldRobot.earnedRewards, - lastOrderId: data.last_order_id ?? oldRobot.lastOrderId, - stealthInvoices: data.wants_stealth ?? robot.stealthInvoices, - tgEnabled: data.tg_enabled, - tgBotName: data.tg_bot_name, - tgToken: data.tg_token, - found: false, + if (!isRefresh) { + setRobot((robot) => { + return { + ...robot, + loading: true, + avatarLoaded: false, }; - } else { - newRobot = { - ...oldRobot, + }); + } + + apiClient + .get(baseUrl, '/api/robot/', tokenSHA256) + .then((data: any) => { + const newRobot = { + avatarLoaded: isRefresh ? robot.avatarLoaded : false, nickname: data.nickname, token, + tokenSHA256, loading: false, activeOrderId: data.active_order_id ?? null, lastOrderId: data.last_order_id ?? null, - referralCode: data.referral_code, earnedRewards: data.earned_rewards ?? 0, stealthInvoices: data.wants_stealth, tgEnabled: data.tg_enabled, tgBotName: data.tg_bot_name, tgToken: data.tg_token, found: data?.found, - bitsEntropy: data.token_bits_entropy, - shannonEntropy: data.token_shannon_entropy, + last_login: data.last_login, + bitsEntropy, + shannonEntropy, pubKey: data.public_key, encPrivKey: data.encrypted_private_key, copiedToken: !!data.found, }; + if (currentOrder === undefined) { + setCurrentOrder( + data.active_order_id + ? data.active_order_id + : data.last_order_id + ? data.last_order_id + : null, + ); + } setRobot(newRobot); garage.updateRobot(newRobot, targetSlot); setCurrentSlot(targetSlot); - systemClient.setItem('robot_token', token); - } - }); + }) + .finally(() => { + systemClient.deleteCookie('public_key'); + systemClient.deleteCookie('encrypted_private_key'); + }); }; useEffect(() => { if (baseUrl != '' && page != 'robot') { if (open.profile && robot.avatarLoaded) { - fetchRobot({ action: 'refresh' }); // refresh/update existing robot + fetchRobot({ isRefresh: true }); // refresh/update existing robot } else if (!robot.avatarLoaded && robot.token && robot.encPrivKey && robot.pubKey) { - fetchRobot({ action: 'generate' }); // create new robot with existing token and keys (on network and coordinator change) + fetchRobot({}); // create new robot with existing token and keys (on network and coordinator change) } } }, [open.profile, baseUrl]); diff --git a/frontend/src/models/Robot.model.ts b/frontend/src/models/Robot.model.ts index f5b9e2c3..a91a87cb 100644 --- a/frontend/src/models/Robot.model.ts +++ b/frontend/src/models/Robot.model.ts @@ -2,6 +2,7 @@ class Robot { constructor(garageRobot?: Robot) { if (garageRobot != null) { this.token = garageRobot?.token ?? undefined; + this.tokenSHA256 = garageRobot?.tokenSHA256 ?? undefined; this.pubKey = garageRobot?.pubKey ?? undefined; this.encPrivKey = garageRobot?.encPrivKey ?? undefined; } @@ -9,20 +10,21 @@ class Robot { public nickname?: string; public token?: string; - public pubKey?: string; - public encPrivKey?: string; public bitsEntropy?: number; public shannonEntropy?: number; + public tokenSHA256?: string; + public pubKey?: string; + public encPrivKey?: string; public stealthInvoices: boolean = true; public activeOrderId?: number; public lastOrderId?: number; public earnedRewards: number = 0; - public referralCode: string = ''; public tgEnabled: boolean = false; public tgBotName: string = 'unknown'; public tgToken: string = 'unknown'; public loading: boolean = false; public found: boolean = false; + public last_login: string = ''; public avatarLoaded: boolean = false; public copiedToken: boolean = false; } diff --git a/frontend/src/services/System/SystemWebClient/index.ts b/frontend/src/services/System/SystemWebClient/index.ts index 60c0c84b..aed9ecdd 100644 --- a/frontend/src/services/System/SystemWebClient/index.ts +++ b/frontend/src/services/System/SystemWebClient/index.ts @@ -50,7 +50,7 @@ class SystemWebClient implements SystemClient { }; public deleteCookie: (key: string) => void = (key) => { - document.cookie = `${name}= ; expires = Thu, 01 Jan 1970 00:00:00 GMT`; + document.cookie = `${key}= ;path=/; expires = Thu, 01 Jan 1970 00:00:00 GMT`; }; // Local storage diff --git a/frontend/src/services/api/ApiNativeClient/index.ts b/frontend/src/services/api/ApiNativeClient/index.ts index 30841b2f..adb8ccb6 100644 --- a/frontend/src/services/api/ApiNativeClient/index.ts +++ b/frontend/src/services/api/ApiNativeClient/index.ts @@ -5,21 +5,27 @@ class ApiNativeClient implements ApiClient { private assetsCache: { [path: string]: string } = {}; private assetsPromises: { [path: string]: Promise } = {}; - private readonly getHeaders: () => HeadersInit = () => { + private readonly getHeaders: (tokenSHA256?: string) => HeadersInit = (tokenSHA256) => { let headers = { 'Content-Type': 'application/json', }; - const robotToken = systemClient.getItem('robot_token'); - if (robotToken) { - const sessionid = systemClient.getCookie('sessionid'); - // const csrftoken = systemClient.getCookie('csrftoken'); - + if (tokenSHA256) { headers = { ...headers, ...{ - // 'X-CSRFToken': csrftoken, - Cookie: `sessionid=${sessionid}`, // ;csrftoken=${csrftoken} + Authorization: `Token ${tokenSHA256.substring(0, 40)}`, + }, + }; + } + const encrypted_private_key = systemClient.getCookie('encrypted_private_key'); + const public_key = systemClient.getCookie('public_key'); + + if (encrypted_private_key && public_key) { + headers = { + ...headers, + ...{ + Cookie: `public_key=${public_key};encrypted_private_key=${encrypted_private_key}`, }, }; } @@ -45,44 +51,47 @@ class ApiNativeClient implements ApiClient { return await new Promise((res, _rej) => res({})); }; - public delete: (baseUrl: string, path: string) => Promise = async ( - baseUrl, - path, - ) => { + public delete: ( + baseUrl: string, + path: string, + tokenSHA256?: string, + ) => Promise = async (baseUrl, path, tokenSHA256) => { return await window.NativeRobosats?.postMessage({ category: 'http', type: 'delete', baseUrl, path, - headers: this.getHeaders(), + headers: this.getHeaders(tokenSHA256), }).then(this.parseResponse); }; - public post: (baseUrl: string, path: string, body: object) => Promise = - async (baseUrl, path, body) => { - return await window.NativeRobosats?.postMessage({ - category: 'http', - type: 'post', - baseUrl, - path, - body, - headers: this.getHeaders(), - }).then(this.parseResponse); - }; - - public get: (baseUrl: string, path: string) => Promise = async ( - baseUrl, - path, - ) => { + public post: ( + baseUrl: string, + path: string, + body: object, + tokenSHA256?: string, + ) => Promise = async (baseUrl, path, body, tokenSHA256) => { return await window.NativeRobosats?.postMessage({ category: 'http', - type: 'get', + type: 'post', baseUrl, path, - headers: this.getHeaders(), + body, + headers: this.getHeaders(tokenSHA256), }).then(this.parseResponse); }; + public get: (baseUrl: string, path: string, tokenSHA256?: string) => Promise = + async (baseUrl, path, tokenSHA256) => { + return await window.NativeRobosats?.postMessage({ + category: 'http', + type: 'get', + baseUrl, + path, + headers: this.getHeaders(tokenSHA256), + }).then(this.parseResponse); + }; + public fileImageUrl: (baseUrl: string, path: string) => Promise = async ( baseUrl, path, diff --git a/frontend/src/services/api/ApiWebClient/index.ts b/frontend/src/services/api/ApiWebClient/index.ts index a25187b2..cc6055c8 100644 --- a/frontend/src/services/api/ApiWebClient/index.ts +++ b/frontend/src/services/api/ApiWebClient/index.ts @@ -1,22 +1,32 @@ import { ApiClient } from '..'; -import { systemClient } from '../../System'; class ApiWebClient implements ApiClient { - private readonly getHeaders: () => HeadersInit = () => { - return { + private readonly getHeaders: (tokenSHA256?: string) => HeadersInit = (tokenSHA256) => { + let headers = { 'Content-Type': 'application/json', - // 'X-CSRFToken': systemClient.getCookie('csrftoken') || '', }; + + if (tokenSHA256) { + headers = { + ...headers, + ...{ + Authorization: `Token ${tokenSHA256.substring(0, 40)}`, + }, + }; + } + + return headers; }; - public post: (baseUrl: string, path: string, body: object) => Promise = async ( - baseUrl, - path, - body, - ) => { + public post: ( + baseUrl: string, + path: string, + body: object, + tokenSHA256?: string, + ) => Promise = async (baseUrl, path, body, tokenSHA256) => { const requestOptions = { method: 'POST', - headers: this.getHeaders(), + headers: this.getHeaders(tokenSHA256), body: JSON.stringify(body), }; @@ -25,14 +35,15 @@ class ApiWebClient implements ApiClient { ); }; - public put: (baseUrl: string, path: string, body: object) => Promise = async ( - baseUrl, - path, - body, - ) => { + public put: ( + baseUrl: string, + path: string, + body: object, + tokenSHA256?: string, + ) => Promise = async (baseUrl, path, body, tokenSHA256) => { const requestOptions = { method: 'PUT', - headers: this.getHeaders(), + headers: this.getHeaders(tokenSHA256), body: JSON.stringify(body), }; return await fetch(baseUrl + path, requestOptions).then( @@ -40,18 +51,28 @@ class ApiWebClient implements ApiClient { ); }; - public delete: (baseUrl: string, path: string) => Promise = async (baseUrl, path) => { + public delete: (baseUrl: string, path: string, tokenSHA256?: string) => Promise = async ( + baseUrl, + path, + tokenSHA256, + ) => { const requestOptions = { method: 'DELETE', - headers: this.getHeaders(), + headers: this.getHeaders(tokenSHA256), }; return await fetch(baseUrl + path, requestOptions).then( async (response) => await response.json(), ); }; - public get: (baseUrl: string, path: string) => Promise = async (baseUrl, path) => { - return await fetch(baseUrl + path).then(async (response) => await response.json()); + public get: (baseUrl: string, path: string, tokenSHA256?: string) => Promise = async ( + baseUrl, + path, + tokenSHA256, + ) => { + return await fetch(baseUrl + path, { headers: this.getHeaders(tokenSHA256) }).then( + async (response) => await response.json(), + ); }; } diff --git a/frontend/src/services/api/index.ts b/frontend/src/services/api/index.ts index bb031d6a..0864f2e7 100644 --- a/frontend/src/services/api/index.ts +++ b/frontend/src/services/api/index.ts @@ -2,10 +2,20 @@ import ApiWebClient from './ApiWebClient'; import ApiNativeClient from './ApiNativeClient'; export interface ApiClient { - post: (baseUrl: string, path: string, body: object) => Promise; - put: (baseUrl: string, path: string, body: object) => Promise; - get: (baseUrl: string, path: string) => Promise; - delete: (baseUrl: string, path: string) => Promise; + post: ( + baseUrl: string, + path: string, + body: object, + tokenSHA256?: string, + ) => Promise; + put: ( + baseUrl: string, + path: string, + body: object, + tokenSHA256?: string, + ) => Promise; + get: (baseUrl: string, path: string, tokenSHA256?: string) => Promise; + delete: (baseUrl: string, path: string, tokenSHA256?: string) => Promise; fileImageUrl?: (baseUrl: string, path: string) => Promise; } diff --git a/frontend/src/utils/hexToBase91.ts b/frontend/src/utils/hexToBase91.ts new file mode 100644 index 00000000..edfe5d24 --- /dev/null +++ b/frontend/src/utils/hexToBase91.ts @@ -0,0 +1,10 @@ +import { Base91 } from 'base-ex'; + +export default function hexToBase85(hex: string): string { + const hexes = hex.match(/.{1,2}/g); + if (!hexes) return ''; + const byteArray = hexes.map((byte) => parseInt(byte, 16)); + const b91 = new Base91(); + const base91string = b91.encode(new Uint8Array(byteArray)); + return base91string; +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 951b808e..28a4db72 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -2,11 +2,12 @@ export { default as checkVer } from './checkVer'; export { default as filterOrders } from './filterOrders'; export { default as getHost } from './getHost'; export { default as hexToRgb } from './hexToRgb'; +export { default as hexToBase91 } from './hexToBase91'; export { default as matchMedian } from './match'; export { default as pn } from './prettyNumbers'; export { amountToString } from './prettyNumbers'; export { default as saveAsJson } from './saveFile'; export { default as statusBadgeColor } from './statusBadgeColor'; -export { genBase62Token, tokenStrength } from './token'; +export { genBase62Token, validateTokenEntropy } from './token'; export { default as getWebln } from './webln'; export { default as computeSats } from './computeSats'; diff --git a/frontend/src/utils/token.js b/frontend/src/utils/token.js deleted file mode 100644 index 8f4c0a4e..00000000 --- a/frontend/src/utils/token.js +++ /dev/null @@ -1,19 +0,0 @@ -// 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) }; -} diff --git a/frontend/src/utils/token.ts b/frontend/src/utils/token.ts new file mode 100644 index 00000000..69a79e16 --- /dev/null +++ b/frontend/src/utils/token.ts @@ -0,0 +1,45 @@ +// sort of cryptographically strong function to generate Base62 token client-side +export function genBase62Token(length: number): string { + return window + .btoa( + Array.from(window.crypto.getRandomValues(new Uint8Array(length * 2))) + .map((b) => String.fromCharCode(b)) + .join(''), + ) + .replace(/[+/]/g, '') + .substring(0, length); +} + +interface TokenEntropy { + hasEnoughEntropy: boolean; + bitsEntropy: number; + shannonEntropy: number; +} + +export function validateTokenEntropy(token: string): TokenEntropy { + const charCounts: Record = {}; + const len = token.length; + let shannonEntropy = 0; + + // Count number of occurrences of each character + for (let i = 0; i < len; i++) { + const char = token.charAt(i); + if (charCounts[char]) { + charCounts[char]++; + } else { + charCounts[char] = 1; + } + } + // Calculate the entropy + Object.keys(charCounts).forEach((char) => { + const probability = charCounts[char] / len; + shannonEntropy -= probability * Math.log2(probability); + }); + + const uniqueChars = Object.keys(charCounts).length; + const bitsEntropy = Math.log2(Math.pow(uniqueChars, len)); + + const hasEnoughEntropy = bitsEntropy > 128 && shannonEntropy > 4; + + return { hasEnoughEntropy, bitsEntropy, shannonEntropy }; +} diff --git a/frontend/static/locales/ca.json b/frontend/static/locales/ca.json index d4f54c3b..20c71200 100644 --- a/frontend/static/locales/ca.json +++ b/frontend/static/locales/ca.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Aprèn RoboSats", "See profile": "Veure perfil", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connectant a TOR", "Connection encrypted and anonymized using TOR.": "Connexió xifrada i anònima mitjançant TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "Això garanteix la màxima privadesa, però és possible que sentis que l'aplicació es comporta lenta. Si es perd la connexió, reinicia l'aplicació.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram activat", "Enable Telegram Notifications": "Notificar en Telegram", "Use stealth invoices": "Factures ofuscades", - "Share to earn 100 Sats per trade": "Comparteix per a guanyar 100 Sats por intercanvi", - "Your referral link": "El teu enllaç de referits", "Your earned rewards": "Les teves recompenses guanyades", "Claim": "Retirar", "Invoice for {{amountSats}} Sats": "Factura per {{amountSats}} Sats", diff --git a/frontend/static/locales/cs.json b/frontend/static/locales/cs.json index ba3a8882..6918190c 100644 --- a/frontend/static/locales/cs.json +++ b/frontend/static/locales/cs.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Více o RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram povolen", "Enable Telegram Notifications": "Povolit Telegram notifikace", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Sdílej a získej za každý obchod 100 Satů", - "Your referral link": "Tvůj referral odkaz", "Your earned rewards": "Tvé odměny", "Claim": "Vybrat", "Invoice for {{amountSats}} Sats": "Invoice pro {{amountSats}} Satů", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 3ebffca3..9f77201a 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Lerne RoboSats kennen", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram aktiviert", "Enable Telegram Notifications": "Telegram-Benachrichtigungen aktivieren", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Teilen, um 100 Sats pro Handel zu verdienen", - "Your referral link": "Dein Empfehlungslink", "Your earned rewards": "Deine verdienten Belohnungen", "Claim": "Erhalten", "Invoice for {{amountSats}} Sats": "Invoice für {{amountSats}} Sats", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 36b44c93..7a1e4390 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Learn RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram enabled", "Enable Telegram Notifications": "Enable Telegram Notifications", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Share to earn 100 Sats per trade", - "Your referral link": "Your referral link", "Your earned rewards": "Your earned rewards", "Claim": "Claim", "Invoice for {{amountSats}} Sats": "Invoice for {{amountSats}} Sats", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index 9a2cd9d6..141c6ea4 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Aprende RoboSats", "See profile": "Ver perfil", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Conectando con TOR", "Connection encrypted and anonymized using TOR.": "Conexión encriptada y anonimizada usando TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "Esto asegura máxima privacidad, aunque quizá observe algo de lentitud. Si se corta la conexión, reinicie la aplicación.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram activado", "Enable Telegram Notifications": "Notificar en Telegram", "Use stealth invoices": "Facturas sigilosas", - "Share to earn 100 Sats per trade": "Comparte para ganar 100 Sats por intercambio", - "Your referral link": "Tu enlace de referido", "Your earned rewards": "Tus recompensas ganadas", "Claim": "Reclamar", "Invoice for {{amountSats}} Sats": "Factura de {{amountSats}} Sats", diff --git a/frontend/static/locales/eu.json b/frontend/static/locales/eu.json index 5d3198a6..dc75d0af 100644 --- a/frontend/static/locales/eu.json +++ b/frontend/static/locales/eu.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Ikasi RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram baimendua", "Enable Telegram Notifications": "Baimendu Telegram Jakinarazpenak", "Use stealth invoices": "Erabili ezkutuko fakturak", - "Share to earn 100 Sats per trade": "Partekatu 100 Sat trukeko irabazteko", - "Your referral link": "Zure erreferentziako esteka", "Your earned rewards": "Irabazitako sariak", "Claim": "Eskatu", "Invoice for {{amountSats}} Sats": "{{amountSats}} Sateko fakura", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index 71566231..7d179e1f 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Learn RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram activé", "Enable Telegram Notifications": "Activer les notifications Telegram", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Partagez pour gagner 100 Sats par transaction", - "Your referral link": "Votre lien de parrainage", "Your earned rewards": "Vos récompenses gagnées", "Claim": "Réclamer", "Invoice for {{amountSats}} Sats": "Facture pour {{amountSats}} Sats", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 12d5b42b..f6993500 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Impara RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram attivato", "Enable Telegram Notifications": "Attiva notifiche Telegram", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Condividi e guadagna 100 Sats con ogni transazione", - "Your referral link": "Il tuo link di riferimento", "Your earned rewards": "La tua ricompensa", "Claim": "Riscatta", "Invoice for {{amountSats}} Sats": "Ricevuta per {{amountSats}} Sats", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index 21cdafdc..d9015dba 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Robosatsを学ぶ", "See profile": "プロフィール", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Torネットワークに接続中", "Connection encrypted and anonymized using TOR.": "接続はTORを使って暗号化され、匿名化されています。", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "これにより最大限のプライバシーが確保されますが、アプリの動作が遅いと感じることがあります。接続が切断された場合は、アプリを再起動してください。", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegramが有効になりました", "Enable Telegram Notifications": "Telegram通知を有効にする", "Use stealth invoices": "ステルス・インボイスを使用する", - "Share to earn 100 Sats per trade": "共有して取引ごとに100 Satsを稼ぐ", - "Your referral link": "あなたの紹介リンク", "Your earned rewards": "あなたの獲得報酬", "Claim": "請求する", "Invoice for {{amountSats}} Sats": " {{amountSats}} Satsのインボイス", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index 03762db2..9948d8a1 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Learn RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram włączony", "Enable Telegram Notifications": "Włącz powiadomienia telegramu", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Udostępnij, aby zarobić 100 Sats na transakcję", - "Your referral link": "Twój link referencyjny", "Your earned rewards": "Twoje zarobione nagrody", "Claim": "Prawo", "Invoice for {{amountSats}} Sats": "Faktura za {{amountSats}} Sats", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 092544ae..15d39df3 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Aprender sobre o RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram ativado", "Enable Telegram Notifications": "Habilitar notificações do Telegram", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Compartilhe para ganhar 100 Sats por negociação", - "Your referral link": "Seu link de referência", "Your earned rewards": "Suas recompensas ganhas", "Claim": "Reinvindicar", "Invoice for {{amountSats}} Sats": "Invoice para {{amountSats}} Sats", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 4be33068..fad7553d 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Изучить RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram включен", "Enable Telegram Notifications": "Включить уведомления Telegram", "Use stealth invoices": "Использовать стелс инвойсы", - "Share to earn 100 Sats per trade": "Поделись, чтобы заработать 100 Сатоши за сделку", - "Your referral link": "Ваша реферальная ссылка", "Your earned rewards": "Ваши заработанные награды", "Claim": "Запросить", "Invoice for {{amountSats}} Sats": "Инвойс на {{amountSats}} Сатоши", diff --git a/frontend/static/locales/sv.json b/frontend/static/locales/sv.json index 176db68e..f665ccf2 100644 --- a/frontend/static/locales/sv.json +++ b/frontend/static/locales/sv.json @@ -40,6 +40,8 @@ "Learn RoboSats": "Learn RoboSats", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram aktiverat", "Enable Telegram Notifications": "Aktivera Telegram-notiser", "Use stealth invoices": "Use stealth invoices", - "Share to earn 100 Sats per trade": "Dela för att tjäna 100 sats per trade", - "Your referral link": "Din referrallänk", "Your earned rewards": "Du fick belöningar", "Claim": "Claim", "Invoice for {{amountSats}} Sats": "Faktura för {{amountSats}} sats", diff --git a/frontend/static/locales/th.json b/frontend/static/locales/th.json index d2ea3ea9..291c9e67 100644 --- a/frontend/static/locales/th.json +++ b/frontend/static/locales/th.json @@ -40,6 +40,8 @@ "Learn RoboSats": "เรียนรู้การใช้งาน", "See profile": "See profile", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "Connecting to TOR", "Connection encrypted and anonymized using TOR.": "Connection encrypted and anonymized using TOR.", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.", @@ -252,8 +254,6 @@ "Telegram enabled": "เปิดใช้ Telegram แล้ว", "Enable Telegram Notifications": "เปิดใช้การแจ้งเตือนผ่านทาง Telegram", "Use stealth invoices": "ใช้งาน stealth invoices", - "Share to earn 100 Sats per trade": "Share เพื่อรับ 100 Sats ต่อการซื้อขาย", - "Your referral link": "ลิ้งค์ referral ของคุณ", "Your earned rewards": "รางวัลที่ตุณได้รับ", "Claim": "รับรางวัล", "Invoice for {{amountSats}} Sats": "Invoice สำหรับ {{amountSats}} Sats", diff --git a/frontend/static/locales/zh-SI.json b/frontend/static/locales/zh-SI.json index 938a6b43..0ca495ea 100644 --- a/frontend/static/locales/zh-SI.json +++ b/frontend/static/locales/zh-SI.json @@ -40,6 +40,8 @@ "Learn RoboSats": "学习 RoboSats", "See profile": "查看个人资料", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "正在连线 TOR", "Connection encrypted and anonymized using TOR.": "连接已用 TOR 加密和匿名化。", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "这确保最高的隐秘程度,但你可能会觉得应用程序运作缓慢。如果丢失连接,请重启应用。", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram 已开启", "Enable Telegram Notifications": "开启 Telegram 通知", "Use stealth invoices": "使用隐形发票", - "Share to earn 100 Sats per trade": "分享并于每笔交易获得100聪的奖励", - "Your referral link": "你的推荐链接", "Your earned rewards": "你获得的奖励", "Claim": "领取", "Invoice for {{amountSats}} Sats": "{{amountSats}} 聪的发票", diff --git a/frontend/static/locales/zh-TR.json b/frontend/static/locales/zh-TR.json index 099ff4ec..b86276ea 100644 --- a/frontend/static/locales/zh-TR.json +++ b/frontend/static/locales/zh-TR.json @@ -40,6 +40,8 @@ "Learn RoboSats": "學習 RoboSats", "See profile": "查看個人資料", "#6": "Phrases in basic/RobotPage/index.tsx", + "The token is too short": "The token is too short", + "Not enough entropy, make it more complex": "Not enough entropy, make it more complex", "Connecting to TOR": "正在連線 TOR", "Connection encrypted and anonymized using TOR.": "連接已用 TOR 加密和匿名化。", "This ensures maximum privacy, however you might feel the app behaves slow. If connection is lost, restart the app.": "這確保最高的隱密程度,但你可能會覺得應用程序運作緩慢。如果丟失連接,請重啟應用。", @@ -252,8 +254,6 @@ "Telegram enabled": "Telegram 已開啟", "Enable Telegram Notifications": "開啟 Telegram 通知", "Use stealth invoices": "使用隱形發票", - "Share to earn 100 Sats per trade": "分享並於每筆交易獲得100聰的獎勵", - "Your referral link": "你的推薦鏈接", "Your earned rewards": "你獲得的獎勵", "Claim": "領取", "Invoice for {{amountSats}} Sats": "{{amountSats}} 聰的發票", diff --git a/frontend/urls.py b/frontend/urls.py index b970ab50..a45fd59b 100644 --- a/frontend/urls.py +++ b/frontend/urls.py @@ -6,7 +6,7 @@ urlpatterns = [ path("", basic), path("create/", basic), path("robot/", basic), - path("robot/", basic), + path("robot/", basic), path("offers/", basic), path("order/", basic), path("settings/", basic), diff --git a/requirements.txt b/requirements.txt index 1b933c88..a6a3e8de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,3 +33,4 @@ isort==5.12.0 flake8==6.0.0 pyflakes==3.0.1 django-cors-headers==3.14.0 +base91==1.0.1 diff --git a/robosats/middleware.py b/robosats/middleware.py index fc76b11c..249e3021 100644 --- a/robosats/middleware.py +++ b/robosats/middleware.py @@ -1,3 +1,26 @@ +import hashlib +from pathlib import Path + +from channels.db import database_sync_to_async +from channels.middleware import BaseMiddleware +from django.conf import settings +from django.contrib.auth.models import AnonymousUser, User +from django.db import IntegrityError +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import AuthenticationFailed +from robohash import Robohash + +from api.nick_generator.nick_generator import NickGenerator +from api.utils import base91_to_hex, hex_to_base91, is_valid_token, validate_pgp_keys + +NickGen = NickGenerator( + lang="English", use_adv=False, use_adj=True, use_noun=True, max_num=999 +) + +avatar_path = Path(settings.AVATAR_ROOT) +avatar_path.mkdir(parents=True, exist_ok=True) + + class DisableCSRFMiddleware(object): def __init__(self, get_response): self.get_response = get_response @@ -6,3 +29,140 @@ class DisableCSRFMiddleware(object): setattr(request, "_dont_enforce_csrf_checks", True) response = self.get_response(request) return response + + +class RobotTokenSHA256AuthenticationMiddleWare: + """ + Builds on django-rest-framework Token Authentication. + + The robot token SHA256 is taken from the header. The token SHA256 must + be encoded as Base91 of 39 or 40 characters in length. This is the max length of + django DRF token keys. + + If the token exists, the requests passes through. If the token is valid and new, + a new user/robot is created (PGP keys are required in the request body). + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + token_sha256_b91 = request.META.get("HTTP_AUTHORIZATION", "").replace( + "Token ", "" + ) + + if not token_sha256_b91: + # Unauthenticated request + response = self.get_response(request) + return response + + if not is_valid_token(token_sha256_b91): + raise AuthenticationFailed( + "Robot token SHA256 was provided in the header. However it is not a valid 39 or 40 characters Base91 string." + ) + + # Check if it is an existing robot. + try: + Token.objects.get(key=token_sha256_b91) + + except Token.DoesNotExist: + # If we get here the user does not have a robot on this coordinator + # Let's create a new user & robot on-the-fly. + + # The first ever request to a coordinator must include cookies for the public key (and encrypted priv key as of now). + public_key = request.COOKIES.get("public_key") + encrypted_private_key = request.COOKIES.get("encrypted_private_key", "") + + if not public_key or not encrypted_private_key: + raise AuthenticationFailed( + "On the first request to a RoboSats coordinator, you must provide as well a valid public and encrypted private PGP keys" + ) + + ( + valid, + bad_keys_context, + public_key, + encrypted_private_key, + ) = validate_pgp_keys(public_key, encrypted_private_key) + if not valid: + raise AuthenticationFailed(bad_keys_context) + + # Hash the token_sha256, only 1 iteration. + # This is the second SHA256 of the user token, aka RoboSats ID + token_sha256 = base91_to_hex(token_sha256_b91) + hash = hashlib.sha256(token_sha256.encode("utf-8")).hexdigest() + + # Generate nickname deterministically + nickname = NickGen.short_from_SHA256(hash, max_length=18)[0] + + # DEPRECATE. Using Try and Except only as a temporary measure. + # This will allow existing robots to be added upgraded with a token.key + # After v0.5.0, only the following should remain + # `user = User.objects.create_user(username=nickname, password=None)` + try: + user = User.objects.create_user(username=nickname, password=None) + except IntegrityError: + # UNIQUE constrain failed, user exist. Get it. + user = User.objects.get(username=nickname) + + # Django rest_framework authtokens are limited to 40 characters. + # We use base91 so we can store the full entropy in the field. + Token.objects.create(key=token_sha256_b91, user=user) + + # Add PGP keys to the new user + if not user.robot.public_key: + user.robot.public_key = public_key + if not user.robot.encrypted_private_key: + user.robot.encrypted_private_key = encrypted_private_key + + # Generate avatar. Does not replace if existing. + image_path = avatar_path.joinpath(nickname + ".webp") + if not image_path.exists(): + + rh = Robohash(hash) + rh.assemble(roboset="set1", bgset="any") # for backgrounds ON + with open(image_path, "wb") as f: + rh.img.save(f, format="WEBP", quality=80) + + image_small_path = avatar_path.joinpath(nickname + ".small.webp") + with open(image_small_path, "wb") as f: + resized_img = rh.img.resize((80, 80)) + resized_img.save(f, format="WEBP", quality=80) + + user.robot.avatar = "static/assets/avatars/" + nickname + ".webp" + user.save() + + response = self.get_response(request) + return response + + +# Authenticate WebSockets connections using DRF tokens + + +@database_sync_to_async +def get_user(token_key): + try: + token = Token.objects.get(key=token_key) + return token.user + except Token.DoesNotExist: + return AnonymousUser() + + +class TokenAuthMiddleware(BaseMiddleware): + def __init__(self, inner): + super().__init__(inner) + + async def __call__(self, scope, receive, send): + try: + token_key = ( + dict((x.split("=") for x in scope["query_string"].decode().split("&"))) + ).get("token_sha256_hex", None) + token_key = hex_to_base91(token_key) + print(token_key) + except ValueError: + token_key = None + scope["user"] = ( + AnonymousUser() if token_key is None else await get_user(token_key) + ) + return await super().__call__(scope, receive, send) diff --git a/robosats/routing.py b/robosats/routing.py index d56ba5c6..0210e6ce 100644 --- a/robosats/routing.py +++ b/robosats/routing.py @@ -6,6 +6,7 @@ from decouple import config from django.core.asgi import get_asgi_application import chat.routing +from robosats.middleware import TokenAuthMiddleware os.environ.setdefault("DJANGO_SETTINGS_MODULE", "robosats.settings") # Initialize Django ASGI application early to ensure the AppRegistry @@ -14,9 +15,11 @@ django_asgi_app = get_asgi_application() protocols = {} protocols["websocket"] = AuthMiddlewareStack( - URLRouter( - chat.routing.websocket_urlpatterns, - # add api.routing.websocket_urlpatterns when Order page works with websocket + TokenAuthMiddleware( + URLRouter( + chat.routing.websocket_urlpatterns, + # add api.routing.websocket_urlpatterns when Order page works with websocket + ) ) ) diff --git a/robosats/settings.py b/robosats/settings.py index 7ea68382..64a41616 100644 --- a/robosats/settings.py +++ b/robosats/settings.py @@ -11,6 +11,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.0/ref/settings/ """ +import json import os import textwrap from pathlib import Path @@ -34,6 +35,9 @@ DEBUG = False STATIC_URL = "static/" STATIC_ROOT = "/usr/src/static/" +# RoboSats version +with open("version.json") as f: + VERSION = json.load(f) # SECURITY WARNING: don't run with debug turned on in production! if config("DEVELOPMENT", default=False): @@ -92,6 +96,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "corsheaders", "rest_framework", + "rest_framework.authtoken", "django_celery_beat", "django_celery_results", "import_export", @@ -105,10 +110,13 @@ INSTALLED_APPS = [ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.TokenAuthentication", + ], } SPECTACULAR_SETTINGS = { - "TITLE": "RoboSats REST API v0", + "TITLE": "RoboSats REST API", "DESCRIPTION": textwrap.dedent( """ REST API Documentation for [RoboSats](https://learn.robosats.com) - A Simple and Private LN P2P Exchange @@ -123,7 +131,7 @@ SPECTACULAR_SETTINGS = { """ ), - "VERSION": "0.1.0", + "VERSION": f"{VERSION['major']}.{VERSION['minor']}.{VERSION['patch']}", "SERVE_INCLUDE_SCHEMA": False, "SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead "SWAGGER_UI_FAVICON_HREF": "SIDECAR", @@ -145,9 +153,9 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", - # "django.middleware.csrf.CsrfViewMiddleware", "robosats.middleware.DisableCSRFMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "robosats.middleware.RobotTokenSHA256AuthenticationMiddleWare", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "corsheaders.middleware.CorsMiddleware", diff --git a/robosats/urls.py b/robosats/urls.py index 8fa84d02..d8a6d7d1 100644 --- a/robosats/urls.py +++ b/robosats/urls.py @@ -13,12 +13,21 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + +from decouple import config +from django.conf import settings from django.contrib import admin from django.urls import include, path +VERSION = settings.VERSION + urlpatterns = [ - path("admin/", admin.site.urls), + path("coordinator/", admin.site.urls), path("api/", include("api.urls")), # path('chat/', include('chat.urls')), path("", include("frontend.urls")), ] + +admin.site.site_header = f"RoboSats Coordinator: {config('COORDINATOR_ALIAS', cast=str, default='NoAlias')} (v{VERSION['major']}.{VERSION['minor']}.{VERSION['patch']})" +admin.site.index_title = "Coordinator administration" +admin.site.site_title = "RoboSats Coordinator Admin"