lnurl-auth from lnbits wallets to services.

This commit is contained in:
fiatjaf 2020-11-10 00:25:46 -03:00
parent 8f1ae1646e
commit eaec3480e6
6 changed files with 156 additions and 38 deletions

View file

@ -1,4 +1,6 @@
import json import json
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 +35,14 @@ class Wallet(NamedTuple):
def balance(self) -> int: def balance(self) -> int:
return self.balance_msat // 1000 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"]: 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 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 LnurlWithdrawResponse # type: ignore
from ecdsa.util import sigencode_der # type: ignore
try: try:
from typing import TypedDict # type: ignore 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: 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
@ -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,28 @@ 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()
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>?
</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
@ -303,48 +303,69 @@ async def api_lnurlscan(code: str):
return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
domain = urlparse(url.url).netloc domain = urlparse(url.url).netloc
# params is what will be returned to the client
params: Dict = {"domain": domain}
if url.is_login: 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: try:
r = await client.get(url.url, timeout=40) jdata = json.loads(r.text)
if r.is_error: data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
return jsonify({"domain": domain, "error": "failed to get parameters"}), HTTPStatus.SERVICE_UNAVAILABLE except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
return (
jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"}),
HTTPStatus.SERVICE_UNAVAILABLE,
)
try: if type(data) is lnurl.LnurlChannelResponse:
jdata = json.loads(r.text) return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
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: params.update(**data.dict())
return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"}), HTTPStatus.BAD_REQUEST
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)
# callback with k1 already in it # callback with k1 already in it
parsed_callback: ParseResult = urlparse(data.callback) parsed_callback: ParseResult = urlparse(data.callback)
qs: Dict = parse_qs(parsed_callback.query) qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data.k1 qs["k1"] = data.k1
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True)) parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
params.update(callback=urlunparse(parsed_callback)) params.update(callback=urlunparse(parsed_callback))
if type(data) is lnurl.LnurlPayResponse: if type(data) is lnurl.LnurlPayResponse:
params.update(kind="pay") params.update(kind="pay")
params.update(fixed=data.min_sendable == data.max_sendable) params.update(fixed=data.min_sendable == data.max_sendable)
params.update(description_hash=data.metadata.h) params.update(description_hash=data.metadata.h)
params.update(description=data.metadata.text) params.update(description=data.metadata.text)
if data.metadata.images: if data.metadata.images:
image = min(data.metadata.images, key=lambda image: len(image[1])) image = min(data.metadata.images, key=lambda image: len(image[1]))
data_uri = "data:" + image[0] + "," + image[1] data_uri = "data:" + image[0] + "," + image[1]
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():
try:
await perform_lnurlauth(g.data["callback"])
return "", HTTPStatus.OK
except Exception as exc:
return jsonify({"error": str(exc)}), HTTPStatus.SERVICE_UNAVAILABLE

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