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 %}
+
Description: {{ parse.invoice.description }}
Expire date: {{ parse.invoice.expireDate }}
Hash: {{ parse.invoice.hash }}
@@ -346,6 +346,32 @@
+ 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 }}
+