diff --git a/api/serializers.py b/api/serializers.py index b9235e88..82e0c408 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -474,7 +474,11 @@ class MakeOrderSerializer(serializers.ModelSerializer): class UpdateOrderSerializer(serializers.Serializer): invoice = serializers.CharField( - max_length=2000, allow_null=True, allow_blank=True, default=None + max_length=15000, + allow_null=True, + allow_blank=True, + default=None, + help_text="Invoice used for payouts. Must be PGP signed with the robot's public key. The expected Armored PGP header is -----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n", ) routing_budget_ppm = serializers.IntegerField( default=0, @@ -485,7 +489,11 @@ class UpdateOrderSerializer(serializers.Serializer): help_text="Max budget to allocate for routing in PPM", ) address = serializers.CharField( - max_length=100, allow_null=True, allow_blank=True, default=None + max_length=15000, + allow_null=True, + allow_blank=True, + default=None, + help_text="Onchain address used for payouts. Must be PGP signed with the robot's public key. The expected Armored PGP header is -----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n", ) statement = serializers.CharField( max_length=500_000, allow_null=True, allow_blank=True, default=None diff --git a/api/utils.py b/api/utils.py index 949e776a..6a1cbb01 100644 --- a/api/utils.py +++ b/api/utils.py @@ -301,6 +301,29 @@ def validate_pgp_keys(pub_key, enc_priv_key): return True, None, pub_key, enc_priv_key +def verify_signed_message(pub_key, signed_message): + """ + Verifies a signed cleartext PGP message. Returns whether the signature + is valid (was made by the given pub_key) and the content of the message. + """ + gpg = gnupg.GPG() + + # import the public key + import_result = gpg.import_keys(pub_key) + + # verify the signed message + verified = gpg.verify(signed_message) + + if verified.fingerprint == import_result.fingerprints[0]: + header = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n" + footer = "-----BEGIN PGP SIGNATURE-----" + cleartext_message = signed_message.split(header)[1].split(footer)[0].strip() + + return True, cleartext_message + else: + return False, None + + def base91_to_hex(base91_str: str) -> str: bytes_data = decode(base91_str) return bytes_data.hex() diff --git a/api/views.py b/api/views.py index c472a3af..aeae5c90 100644 --- a/api/views.py +++ b/api/views.py @@ -57,6 +57,7 @@ from api.utils import ( get_lnd_version, get_robosats_commit, validate_pgp_keys, + verify_signed_message, ) from chat.models import Message from control.models import AccountingDay, BalanceLog @@ -500,9 +501,9 @@ class OrderView(viewsets.ViewSet): # action is either 1)'take', 2)'confirm', 2.b)'undo_confirm', 3)'cancel', 4)'dispute' , 5)'update_invoice' # 5.b)'update_address' 6)'submit_statement' (in dispute), 7)'rate_user' , 8)'rate_platform' action = serializer.data.get("action") - invoice = serializer.data.get("invoice") + pgp_invoice = serializer.data.get("invoice") routing_budget_ppm = serializer.data.get("routing_budget_ppm", 0) - address = serializer.data.get("address") + pgp_address = serializer.data.get("address") mining_fee_rate = serializer.data.get("mining_fee_rate") statement = serializer.data.get("statement") rating = serializer.data.get("rating") @@ -544,6 +545,22 @@ class OrderView(viewsets.ViewSet): # 2) If action is 'update invoice' elif action == "update_invoice": + # DEPRECATE post v0.5.1. + if "---" not in pgp_invoice: + valid_signature = True + invoice = pgp_invoice + else: + # END DEPRECATE. + valid_signature, invoice = verify_signed_message( + request.user.robot.public_key, pgp_invoice + ) + + if not valid_signature: + return Response( + {"bad_request": "The PGP signed cleartext message is not valid."}, + status.HTTP_400_BAD_REQUEST, + ) + valid, context = Logics.update_invoice( order, request.user, invoice, routing_budget_ppm ) @@ -552,6 +569,22 @@ class OrderView(viewsets.ViewSet): # 2.b) If action is 'update address' elif action == "update_address": + # DEPRECATE post v0.5.1. + if "---" not in pgp_address: + valid_signature = True + address = pgp_address + else: + # END DEPRECATE. + valid_signature, address = verify_signed_message( + request.user.robot.public_key, pgp_address + ) + + if not valid_signature: + return Response( + {"bad_request": "The PGP signed cleartext message is not valid."}, + status.HTTP_400_BAD_REQUEST, + ) + valid, context = Logics.update_address( order, request.user, address, mining_fee_rate ) @@ -994,7 +1027,23 @@ class RewardView(CreateAPIView): if not serializer.is_valid(): return Response(status=status.HTTP_400_BAD_REQUEST) - invoice = serializer.data.get("invoice") + pgp_invoice = serializer.data.get("invoice") + + # DEPRECATE post v0.5.1. + if "---" not in pgp_invoice: + valid_signature = True + invoice = pgp_invoice + else: + # END DEPRECATE. + valid_signature, invoice = verify_signed_message( + request.user.robot.public_key, pgp_invoice + ) + + if not valid_signature: + return Response( + {"bad_request": "The PGP signed cleartext message is not valid."}, + status.HTTP_400_BAD_REQUEST, + ) valid, context = Logics.withdraw_rewards(request.user, invoice) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index c411c044..fd6380cf 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -3,3 +3,4 @@ static/rest_framework/** static/admin/** static/frontend/** static/import_export/** +static/drf_spectacular_sidecar/** diff --git a/frontend/src/components/Dialogs/Profile.tsx b/frontend/src/components/Dialogs/Profile.tsx index f82053c1..db8dd56a 100644 --- a/frontend/src/components/Dialogs/Profile.tsx +++ b/frontend/src/components/Dialogs/Profile.tsx @@ -12,7 +12,6 @@ import { Divider, FormControlLabel, Grid, - IconButton, List, ListItemAvatar, ListItemButton, @@ -37,6 +36,7 @@ import { getWebln } from '../../utils'; import RobotAvatar from '../RobotAvatar'; import { apiClient } from '../../services/api'; import { type Robot } from '../../models'; +import { signCleartextMessage } from '../../pgp'; interface Props { open: boolean; @@ -90,23 +90,27 @@ const ProfileDialog = ({ open = false, baseUrl, onClose, robot, setRobot }: Prop const handleSubmitInvoiceClicked = (e: any, rewardInvoice: string) => { setBadInvoice(''); setShowRewardsSpinner(true); - - apiClient - .post( - baseUrl, - '/api/reward/', - { - invoice: rewardInvoice, - }, - { tokenSHA256: robot.tokenSHA256 }, - ) - .then((data: any) => { - setBadInvoice(data.bad_invoice ?? ''); - setShowRewardsSpinner(false); - setWithdrawn(data.successful_withdrawal); - setOpenClaimRewards(!data.successful_withdrawal); - setRobot({ ...robot, earnedRewards: data.successful_withdrawal ? 0 : robot.earnedRewards }); - }); + signCleartextMessage(rewardInvoice, robot.encPrivKey, robot.token).then((signedInvoice) => { + apiClient + .post( + baseUrl, + '/api/reward/', + { + invoice: signedInvoice, + }, + { tokenSHA256: robot.tokenSHA256 }, + ) + .then((data: any) => { + setBadInvoice(data.bad_invoice ?? ''); + setShowRewardsSpinner(false); + setWithdrawn(data.successful_withdrawal); + setOpenClaimRewards(!data.successful_withdrawal); + setRobot({ + ...robot, + earnedRewards: data.successful_withdrawal ? 0 : robot.earnedRewards, + }); + }); + }); e.preventDefault(); }; diff --git a/frontend/src/components/TradeBox/index.tsx b/frontend/src/components/TradeBox/index.tsx index 16d64e44..6b7be19e 100644 --- a/frontend/src/components/TradeBox/index.tsx +++ b/frontend/src/components/TradeBox/index.tsx @@ -50,6 +50,7 @@ import { type Order, type Robot, type Settings } from '../../models'; import { type EncryptedChatMessage } from './EncryptedChat'; import CollabCancelAlert from './CollabCancelAlert'; import { Bolt } from '@mui/icons-material'; +import { signCleartextMessage } from '../../pgp'; interface loadingButtonsProps { cancel: boolean; @@ -224,19 +225,23 @@ const TradeBox = ({ const updateInvoice = function (invoice: string) { setLoadingButtons({ ...noLoadingButtons, submitInvoice: true }); - submitAction({ - action: 'update_invoice', - invoice, - routing_budget_ppm: lightning.routingBudgetPPM, + signCleartextMessage(invoice, robot.encPrivKey, robot.token).then((signedInvoice) => { + submitAction({ + action: 'update_invoice', + invoice: signedInvoice, + routing_budget_ppm: lightning.routingBudgetPPM, + }); }); }; const updateAddress = function () { setLoadingButtons({ ...noLoadingButtons, submitAddress: true }); - submitAction({ - action: 'update_address', - address: onchain.address, - mining_fee_rate: onchain.miningFee, + signCleartextMessage(onchain.address, robot.encPrivKey, robot.token).then((signedAddress) => { + submitAction({ + action: 'update_address', + address: signedAddress, + mining_fee_rate: onchain.miningFee, + }); }); }; diff --git a/frontend/src/pgp/index.js b/frontend/src/pgp/index.js index 484a42ea..c1a8fd88 100644 --- a/frontend/src/pgp/index.js +++ b/frontend/src/pgp/index.js @@ -6,7 +6,9 @@ import { encrypt, decrypt, createMessage, + createCleartextMessage, readMessage, + sign, } from 'openpgp/lightweight'; import { sha256 } from 'js-sha256'; @@ -82,3 +84,19 @@ export async function decryptMessage( return { decryptedMessage: decrypted, validSignature: false }; } } + +// Sign a cleartext message +export async function signCleartextMessage(message, privateKeyArmored, passphrase) { + const privateKey = await decryptKey({ + privateKey: await readPrivateKey({ armoredKey: privateKeyArmored }), + passphrase, + }); + + const unsignedMessage = await createCleartextMessage({ text: message }); + const signedMessage = await sign({ + message: unsignedMessage, + signingKeys: privateKey, + }); + + return signedMessage; +}