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)
},