Merge pull request #122 from lnbits/lnurl-auth

This commit is contained in:
fiatjaf
2020-11-12 10:02:55 -03:00
committed by GitHub
7 changed files with 221 additions and 42 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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?')

View File

@@ -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">

View File

@@ -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

View File

@@ -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

View File

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