From eaec3480e6a2fc96d362bb5862cbca39136943cd Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 10 Nov 2020 00:25:46 -0300 Subject: [PATCH] lnurl-auth from lnbits wallets to services. --- lnbits/core/models.py | 10 +++ lnbits/core/services.py | 30 ++++++++ lnbits/core/static/js/wallet.js | 26 +++++++ lnbits/core/templates/core/wallet.html | 28 +++++++- lnbits/core/views/api.py | 95 ++++++++++++++++---------- lnbits/static/js/base.js | 5 ++ 6 files changed, 156 insertions(+), 38 deletions(-) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index c65bdf93c..23c111f3a 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -1,4 +1,6 @@ import json +import hashlib +from ecdsa import SECP256k1, SigningKey # type: ignore from typing import List, NamedTuple, Optional, Dict from sqlite3 import Row @@ -33,6 +35,14 @@ class Wallet(NamedTuple): def balance(self) -> int: return self.balance_msat // 1000 + @property + def lnurlauth_key(self) -> SigningKey: + return SigningKey.from_string( + hashlib.sha256(self.id.encode("utf-8")).digest(), + curve=SECP256k1, + hashfunc=hashlib.sha256, + ) + def get_payment(self, payment_hash: str) -> Optional["Payment"]: from .crud import get_wallet_payment diff --git a/lnbits/core/services.py b/lnbits/core/services.py index cda16d22a..00bfc84b8 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,8 +1,12 @@ import trio # type: ignore +import json import httpx +from binascii import unhexlify from typing import Optional, Tuple, Dict +from urllib.parse import urlparse, parse_qs from quart import g from lnurl import LnurlWithdrawResponse # type: ignore +from ecdsa.util import sigencode_der # type: ignore try: from typing import TypedDict # type: ignore @@ -155,6 +159,32 @@ async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo ) +async def perform_lnurlauth(callback: str): + k1 = unhexlify(parse_qs(urlparse(callback).query)["k1"][0]) + key = g.wallet.lnurlauth_key + sig = key.sign_digest_deterministic(k1, sigencode=sigencode_der) + + async with httpx.AsyncClient() as client: + r = await client.get( + callback, + params={ + "k1": k1.hex(), + "key": key.verifying_key.to_string("compressed").hex(), + "sig": sig.hex(), + }, + ) + try: + resp = json.loads(r.text) + if resp["status"] == "OK": + return None + + return resp["reason"] + except (KeyError, json.decoder.JSONDecodeError): + return r.text[:200] + "..." if len(r.text) > 200 else r.text + + return None + + def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus: payment = get_wallet_payment(wallet_id, payment_hash) if not payment: diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 2cf64dfac..9bba45ad2 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -128,6 +128,7 @@ new Vue({ show: false, invoice: null, lnurlpay: null, + lnurlauth: null, data: { request: '', amount: 0, @@ -237,6 +238,7 @@ new Vue({ this.parse.show = true this.parse.invoice = null this.parse.lnurlpay = null + this.parse.lnurlauth = null this.parse.data.request = '' this.parse.data.comment = '' this.parse.data.paymentChecker = null @@ -363,6 +365,8 @@ new Vue({ if (data.kind === 'pay') { this.parse.lnurlpay = Object.freeze(data) this.parse.data.amount = data.minSendable / 1000 + } else if (data.kind === 'auth') { + this.parse.lnurlauth = Object.freeze(data) } else if (data.kind === 'withdraw') { this.parse.show = false this.receive.show = true @@ -542,6 +546,28 @@ new Vue({ LNbits.utils.notifyApiError(err) }) }, + authLnurl: function () { + let dismissAuthMsg = this.$q.notify({ + timeout: 10, + message: 'Performing authentication...' + }) + + LNbits.api + .authLnurl(this.g.wallet, this.parse.lnurlauth.callback) + .then(response => { + dismissAuthMsg() + this.$q.notify({ + message: `Authentication successful.`, + type: 'positive', + timeout: 3500 + }) + this.parse.show = false + }) + .catch(err => { + dismissAuthMsg() + LNbits.utils.notifyApiError(err) + }) + }, deleteWallet: function (walletId, user) { LNbits.utils .confirmDialog('Are you sure you want to delete this wallet?') diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 8662d3659..88f28b68b 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -329,7 +329,7 @@ {% raw %}
{{ parse.invoice.fsat }} sat
-

+

Description: {{ parse.invoice.description }}
Expire date: {{ parse.invoice.expireDate }}
Hash: {{ parse.invoice.hash }} @@ -346,6 +346,32 @@ Cancel +

+ {% raw %} + +

+ Authenticate with {{ parse.lnurlauth.domain }}? +

+ +

+ For every website and for every LNbits wallet, a new keypair will be + deterministically generated so your identity can't be tied to your + LNbits wallet or linked across websites. No other data will be shared + with {{ parse.lnurlauth.domain }}. +

+

Your public key for {{ parse.lnurlauth.domain }} is:

+

+ {{ parse.lnurlauth.pubkey }} +

+
+ Login + Cancel +
+
+ {% endraw %} +
{% raw %} diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 0742220a4..ecef466b5 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -13,7 +13,7 @@ from lnbits import bolt11 from lnbits.decorators import api_check_wallet_key, api_validate_post_request from .. import core_app -from ..services import create_invoice, pay_invoice +from ..services import create_invoice, pay_invoice, perform_lnurlauth from ..crud import delete_expired_invoices from ..tasks import sse_listeners @@ -303,48 +303,69 @@ async def api_lnurlscan(code: str): return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST domain = urlparse(url.url).netloc + + # params is what will be returned to the client + params: Dict = {"domain": domain} + if url.is_login: - return jsonify({"domain": domain, "kind": "auth", "error": "unsupported"}), HTTPStatus.BAD_REQUEST + params.update(kind="auth") + params.update(callback=url.url) # with k1 already in it + params.update(pubkey=g.wallet.lnurlauth_key.verifying_key.to_string("compressed").hex()) + else: + async with httpx.AsyncClient() as client: + r = await client.get(url.url, timeout=40) + if r.is_error: + return jsonify({"domain": domain, "error": "failed to get parameters"}), HTTPStatus.SERVICE_UNAVAILABLE - async with httpx.AsyncClient() as client: - r = await client.get(url.url, timeout=40) - if r.is_error: - return jsonify({"domain": domain, "error": "failed to get parameters"}), HTTPStatus.SERVICE_UNAVAILABLE + try: + jdata = json.loads(r.text) + data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata) + except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException): + return ( + jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}), + HTTPStatus.SERVICE_UNAVAILABLE, + ) - try: - jdata = json.loads(r.text) - data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata) - except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException): - return ( - jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}), - HTTPStatus.SERVICE_UNAVAILABLE, - ) + if type(data) is lnurl.LnurlChannelResponse: + return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST - if type(data) is lnurl.LnurlChannelResponse: - return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST + params.update(**data.dict()) - params: Dict = data.dict() - if type(data) is lnurl.LnurlWithdrawResponse: - params.update(kind="withdraw") - params.update(fixed=data.min_withdrawable == data.max_withdrawable) + if type(data) is lnurl.LnurlWithdrawResponse: + params.update(kind="withdraw") + params.update(fixed=data.min_withdrawable == data.max_withdrawable) - # callback with k1 already in it - parsed_callback: ParseResult = urlparse(data.callback) - qs: Dict = parse_qs(parsed_callback.query) - qs["k1"] = data.k1 - parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True)) - params.update(callback=urlunparse(parsed_callback)) + # callback with k1 already in it + parsed_callback: ParseResult = urlparse(data.callback) + qs: Dict = parse_qs(parsed_callback.query) + qs["k1"] = data.k1 + parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True)) + params.update(callback=urlunparse(parsed_callback)) - if type(data) is lnurl.LnurlPayResponse: - params.update(kind="pay") - params.update(fixed=data.min_sendable == data.max_sendable) - params.update(description_hash=data.metadata.h) - params.update(description=data.metadata.text) - if data.metadata.images: - image = min(data.metadata.images, key=lambda image: len(image[1])) - data_uri = "data:" + image[0] + "," + image[1] - params.update(image=data_uri) - params.update(commentAllowed=jdata.get("commentAllowed", 0)) + if type(data) is lnurl.LnurlPayResponse: + params.update(kind="pay") + params.update(fixed=data.min_sendable == data.max_sendable) + params.update(description_hash=data.metadata.h) + params.update(description=data.metadata.text) + if data.metadata.images: + image = min(data.metadata.images, key=lambda image: len(image[1])) + data_uri = "data:" + image[0] + "," + image[1] + params.update(image=data_uri) + params.update(commentAllowed=jdata.get("commentAllowed", 0)) - params.update(domain=domain) return jsonify(params) + + +@core_app.route("/api/v1/lnurlauth", methods=["POST"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "callback": {"type": "string", "required": True}, + } +) +async def api_perform_lnurlauth(): + try: + await perform_lnurlauth(g.data["callback"]) + return "", HTTPStatus.OK + except Exception as exc: + return jsonify({"error": str(exc)}), HTTPStatus.SERVICE_UNAVAILABLE diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index f765c38b8..94041e386 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -44,6 +44,11 @@ window.LNbits = { description }) }, + authLnurl: function (wallet, callback) { + return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, { + callback + }) + }, getWallet: function (wallet) { return this.request('get', '/api/v1/wallet', wallet.inkey) },