diff --git a/lnbits/core/models.py b/lnbits/core/models.py index c65bdf93..4655c256 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -1,4 +1,7 @@ import json +import hmac +import hashlib +from ecdsa import SECP256k1, SigningKey # type: ignore from typing import List, NamedTuple, Optional, Dict from sqlite3 import Row @@ -33,6 +36,16 @@ class Wallet(NamedTuple): def balance(self) -> int: return self.balance_msat // 1000 + def lnurlauth_key(self, domain: str) -> SigningKey: + hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest() + linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256") + + return SigningKey.from_string( + linking_key, + 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 cda16d22..991e278b 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,8 +1,12 @@ import trio # type: ignore +import json import httpx +from io import BytesIO +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 lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore try: from typing import TypedDict # type: ignore @@ -155,6 +159,77 @@ async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo ) +async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]: + cb = urlparse(callback) + + k1 = unhexlify(parse_qs(cb.query)["k1"][0]) + key = g.wallet.lnurlauth_key(cb.netloc) + + def int_to_bytes_suitable_der(x: int) -> bytes: + """for strict DER we need to encode the integer with some quirks""" + b = x.to_bytes((x.bit_length() + 7) // 8, "big") + + if len(b) == 0: + # ensure there's at least one byte when the int is zero + return bytes([0]) + + if b[0] & 0x80 != 0: + # ensure it doesn't start with a 0x80 and so it isn't + # interpreted as a negative number + return bytes([0]) + b + + return b + + def encode_strict_der(r_int, s_int, order): + # if s > order/2 verification will fail sometimes + # so we must fix it here (see https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147) + if s_int > order // 2: + s_int = order - s_int + + # now we do the strict DER encoding copied from + # https://github.com/KiriKiri/bip66 (without any checks) + r = int_to_bytes_suitable_der(r_int) + s = int_to_bytes_suitable_der(s_int) + + r_len = len(r) + s_len = len(s) + sign_len = 6 + r_len + s_len + + signature = BytesIO() + signature.write(0x30 .to_bytes(1, "big", signed=False)) + signature.write((sign_len - 2).to_bytes(1, "big", signed=False)) + signature.write(0x02 .to_bytes(1, "big", signed=False)) + signature.write(r_len.to_bytes(1, "big", signed=False)) + signature.write(r) + signature.write(0x02 .to_bytes(1, "big", signed=False)) + signature.write(s_len.to_bytes(1, "big", signed=False)) + signature.write(s) + + return signature.getvalue() + + sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_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 LnurlErrorResponse(reason=resp["reason"]) + except (KeyError, json.decoder.JSONDecodeError): + return LnurlErrorResponse( + reason=r.text[:200] + "..." if len(r.text) > 200 else r.text, + ) + + 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 2cf64dfa..097a7819 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 @@ -342,7 +344,7 @@ new Vue({ .request( 'GET', '/api/v1/lnurlscan/' + this.parse.data.request, - this.g.user.wallets[0].adminkey + this.g.wallet.adminkey ) .catch(err => { LNbits.utils.notifyApiError(err) @@ -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,37 @@ 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() + if (err.response.data.reason) { + this.$q.notify({ + message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`, + caption: err.response.data.reason, + type: 'warning', + timeout: 5000 + }) + } else { + 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 8662d365..8ca21c11 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 }} from wallet {{ g.wallet.name }}? +

+ +

+ 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 0742220a..fd4a1159 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 @@ -300,51 +300,76 @@ async def api_lnurlscan(code: str): try: url = lnurl.Lnurl(code) except ValueError: - return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST + return jsonify({"message": "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 - 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 + lnurlauth_key = g.wallet.lnurlauth_key(domain) + params.update(pubkey=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, "message": "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, "message": 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", "message": "unsupported"}), HTTPStatus.BAD_REQUEST - params: Dict = data.dict() - if type(data) is lnurl.LnurlWithdrawResponse: - params.update(kind="withdraw") - params.update(fixed=data.min_withdrawable == data.max_withdrawable) + params.update(**data.dict()) - # 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.LnurlWithdrawResponse: + params.update(kind="withdraw") + params.update(fixed=data.min_withdrawable == data.max_withdrawable) - 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)) + # 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)) - 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(): + err = await perform_lnurlauth(g.data["callback"]) + if err: + return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE + return "", HTTPStatus.OK diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py index 821d0e0c..288fc04d 100644 --- a/lnbits/extensions/usermanager/views_api.py +++ b/lnbits/extensions/usermanager/views_api.py @@ -69,7 +69,7 @@ async def api_usermanager_users_delete(user_id): async def api_usermanager_activate_extension(): user = get_user(g.data["userid"]) if not user: - return jsonify({"error": "no such user"}), HTTPStatus.NO_CONTENT + return jsonify({"message": "no such user"}), HTTPStatus.NOT_FOUND update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"]) return jsonify({"extension": "updated"}), HTTPStatus.CREATED diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index f765c38b..94041e38 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) },