mirror of
https://github.com/lnbits/lnbits.git
synced 2025-10-10 20:42:32 +02:00
Merge pull request #122 from lnbits/lnurl-auth
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||||
from typing import List, NamedTuple, Optional, Dict
|
from typing import List, NamedTuple, Optional, Dict
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
|
|
||||||
@@ -33,6 +36,16 @@ class Wallet(NamedTuple):
|
|||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
return self.balance_msat // 1000
|
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"]:
|
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||||
from .crud import get_wallet_payment
|
from .crud import get_wallet_payment
|
||||||
|
|
||||||
|
@@ -1,8 +1,12 @@
|
|||||||
import trio # type: ignore
|
import trio # type: ignore
|
||||||
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
|
from io import BytesIO
|
||||||
|
from binascii import unhexlify
|
||||||
from typing import Optional, Tuple, Dict
|
from typing import Optional, Tuple, Dict
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
from quart import g
|
from quart import g
|
||||||
from lnurl import LnurlWithdrawResponse # type: ignore
|
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from typing import TypedDict # type: ignore
|
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:
|
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
|
||||||
payment = get_wallet_payment(wallet_id, payment_hash)
|
payment = get_wallet_payment(wallet_id, payment_hash)
|
||||||
if not payment:
|
if not payment:
|
||||||
|
@@ -128,6 +128,7 @@ new Vue({
|
|||||||
show: false,
|
show: false,
|
||||||
invoice: null,
|
invoice: null,
|
||||||
lnurlpay: null,
|
lnurlpay: null,
|
||||||
|
lnurlauth: null,
|
||||||
data: {
|
data: {
|
||||||
request: '',
|
request: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -237,6 +238,7 @@ new Vue({
|
|||||||
this.parse.show = true
|
this.parse.show = true
|
||||||
this.parse.invoice = null
|
this.parse.invoice = null
|
||||||
this.parse.lnurlpay = null
|
this.parse.lnurlpay = null
|
||||||
|
this.parse.lnurlauth = null
|
||||||
this.parse.data.request = ''
|
this.parse.data.request = ''
|
||||||
this.parse.data.comment = ''
|
this.parse.data.comment = ''
|
||||||
this.parse.data.paymentChecker = null
|
this.parse.data.paymentChecker = null
|
||||||
@@ -342,7 +344,7 @@ new Vue({
|
|||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
'/api/v1/lnurlscan/' + this.parse.data.request,
|
||||||
this.g.user.wallets[0].adminkey
|
this.g.wallet.adminkey
|
||||||
)
|
)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
@@ -363,6 +365,8 @@ new Vue({
|
|||||||
if (data.kind === 'pay') {
|
if (data.kind === 'pay') {
|
||||||
this.parse.lnurlpay = Object.freeze(data)
|
this.parse.lnurlpay = Object.freeze(data)
|
||||||
this.parse.data.amount = data.minSendable / 1000
|
this.parse.data.amount = data.minSendable / 1000
|
||||||
|
} else if (data.kind === 'auth') {
|
||||||
|
this.parse.lnurlauth = Object.freeze(data)
|
||||||
} else if (data.kind === 'withdraw') {
|
} else if (data.kind === 'withdraw') {
|
||||||
this.parse.show = false
|
this.parse.show = false
|
||||||
this.receive.show = true
|
this.receive.show = true
|
||||||
@@ -542,6 +546,37 @@ new Vue({
|
|||||||
LNbits.utils.notifyApiError(err)
|
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) {
|
deleteWallet: function (walletId, user) {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||||
|
@@ -329,7 +329,7 @@
|
|||||||
{% raw %}
|
{% raw %}
|
||||||
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<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>Description:</strong> {{ parse.invoice.description }}<br />
|
||||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
<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>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-else-if="parse.lnurlpay">
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
<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 lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
|
|
||||||
from .. import core_app
|
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 ..crud import delete_expired_invoices
|
||||||
from ..tasks import sse_listeners
|
from ..tasks import sse_listeners
|
||||||
|
|
||||||
@@ -300,30 +300,42 @@ async def api_lnurlscan(code: str):
|
|||||||
try:
|
try:
|
||||||
url = lnurl.Lnurl(code)
|
url = lnurl.Lnurl(code)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
domain = urlparse(url.url).netloc
|
domain = urlparse(url.url).netloc
|
||||||
if url.is_login:
|
|
||||||
return jsonify({"domain": domain, "kind": "auth", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
|
|
||||||
|
|
||||||
|
# params is what will be returned to the client
|
||||||
|
params: Dict = {"domain": domain}
|
||||||
|
|
||||||
|
if url.is_login:
|
||||||
|
params.update(kind="auth")
|
||||||
|
params.update(callback=url.url) # with k1 already in it
|
||||||
|
|
||||||
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.get(url.url, timeout=40)
|
r = await client.get(url.url, timeout=40)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
return jsonify({"domain": domain, "error": "failed to get parameters"}), HTTPStatus.SERVICE_UNAVAILABLE
|
return (
|
||||||
|
jsonify({"domain": domain, "message": "failed to get parameters"}),
|
||||||
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jdata = json.loads(r.text)
|
jdata = json.loads(r.text)
|
||||||
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
|
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
|
||||||
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
|
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
|
||||||
return (
|
return (
|
||||||
jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}),
|
jsonify({"domain": domain, "message": f"got invalid response '{r.text[:200]}'"}),
|
||||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if type(data) is lnurl.LnurlChannelResponse:
|
if type(data) is lnurl.LnurlChannelResponse:
|
||||||
return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
|
return jsonify({"domain": domain, "kind": "channel", "message": "unsupported"}), HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
params.update(**data.dict())
|
||||||
|
|
||||||
params: Dict = data.dict()
|
|
||||||
if type(data) is lnurl.LnurlWithdrawResponse:
|
if type(data) is lnurl.LnurlWithdrawResponse:
|
||||||
params.update(kind="withdraw")
|
params.update(kind="withdraw")
|
||||||
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
|
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
|
||||||
@@ -346,5 +358,18 @@ async def api_lnurlscan(code: str):
|
|||||||
params.update(image=data_uri)
|
params.update(image=data_uri)
|
||||||
params.update(commentAllowed=jdata.get("commentAllowed", 0))
|
params.update(commentAllowed=jdata.get("commentAllowed", 0))
|
||||||
|
|
||||||
params.update(domain=domain)
|
|
||||||
return jsonify(params)
|
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():
|
async def api_usermanager_activate_extension():
|
||||||
user = get_user(g.data["userid"])
|
user = get_user(g.data["userid"])
|
||||||
if not user:
|
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"])
|
update_user_extension(user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"])
|
||||||
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
|
return jsonify({"extension": "updated"}), HTTPStatus.CREATED
|
||||||
|
|
||||||
|
@@ -44,6 +44,11 @@ window.LNbits = {
|
|||||||
description
|
description
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
authLnurl: function (wallet, callback) {
|
||||||
|
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
|
||||||
|
callback
|
||||||
|
})
|
||||||
|
},
|
||||||
getWallet: function (wallet) {
|
getWallet: function (wallet) {
|
||||||
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user