mirror of
https://github.com/lnbits/lnbits.git
synced 2025-09-27 12:26:19 +02:00
Merge pull request #122 from lnbits/lnurl-auth
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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?')
|
||||
|
@@ -329,7 +329,7 @@
|
||||
{% raw %}
|
||||
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p style="word-break: break-all">
|
||||
<p class="text-wrap">
|
||||
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
@@ -346,6 +346,32 @@
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
{% raw %}
|
||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||
<p class="q-my-none text-h6">
|
||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b> from wallet <em>{{ g.wallet.name }}</em>?
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p>
|
||||
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 }}.
|
||||
</p>
|
||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="deep-purple" type="submit">Login</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlpay">
|
||||
{% raw %}
|
||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
},
|
||||
|
Reference in New Issue
Block a user