LNURLs handled internally

Has bugs
This commit is contained in:
ben 2022-08-22 22:33:20 +01:00
parent 4e68c114fd
commit 56c9234aff
7 changed files with 312 additions and 133 deletions

View file

@ -20,6 +20,6 @@ boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"])
def boltcards_renderer():
return template_renderer(["lnbits/extensions/boltcards/templates"])
from .lnurl import * # noqa
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,197 @@
import base64
import hashlib
import hmac
from http import HTTPStatus
from io import BytesIO
from typing import Optional
from embit import bech32, compact
from fastapi import Request
from fastapi.param_functions import Query
from starlette.exceptions import HTTPException
from lnbits.core.services import create_invoice
from lnbits.core.views.api import pay_invoice
from lnurl import Lnurl, LnurlWithdrawResponse
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from . import boltcards_ext
from .crud import (
create_hit,
get_card,
get_card_by_otp,
get_card_by_uid,
get_hit,
update_card,
update_card_counter,
update_card_otp,
)
from .models import CreateCardData
from .nxp424 import decryptSUN, getSunMAC
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_ext.get("/api/v1/scan/{card_uid}")
async def api_scan(p, c, request: Request, card_uid: str = None):
# some wallets send everything as lower case, no bueno
p = p.upper()
c = c.upper()
card = None
counter = b""
try:
card = await get_card_by_uid(card_uid)
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
if card.uid.upper() != card_uid.hex().upper():
return {"status": "ERROR", "reason": "Card UID mis-match."}
except:
return {"status": "ERROR", "reason": "Error decrypting card."}
if card == None:
return {"status": "ERROR", "reason": "Unknown card."}
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return {"status": "ERROR", "reason": "CMAC does not check."}
ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter:
return {"status": "ERROR", "reason": "This link is already used."}
await update_card_counter(ctr_int, card.id)
# gathering some info for hit record
ip = request.client.host
if "x-real-ip" in request.headers:
ip = request.headers["x-real-ip"]
elif "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
todays_hits = await get_hits_today(card.id)
int hits_amount = 0
for hit in todays_hits:
hits_amount = hits_amount + hit.amount
if (hits_amount + card.tx_limit) > card.daily_limit:
return {"status": "ERROR", "reason": "Max daily liit spent."}
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
# link = await get_withdraw_link(card.withdraw, 0)
return link.lnurl_response(request)
return {
"tag": "withdrawRequest",
"callback": request.url_for(
"boltcards.lnurl_callback"
),
"k1": hit.id,
"minWithdrawable": 1 * 1000,
"maxWithdrawable": card.tx_limit * 1000,
"defaultDescription": f"Boltcard (Refunds address {lnurl_encode(req.url_for("boltcards.lnurlp_response", hit_id=hit.id))})",
}
@boltcards_ext.get(
"/api/v1/lnurl/cb/{hitid}",
status_code=HTTPStatus.OK,
name="boltcards.lnurl_callback",
)
async def lnurl_callback(
request: Request,
pr: str = Query(None),
k1: str = Query(None),
):
hit = await get_hit(k1)
card = await get_card(hit.id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
if pr:
if hit.id != k1:
return {"status": "ERROR", "reason": "Bad K1"}
if hit.spent:
return {"status": "ERROR", "reason": f"Payment already claimed"}
hit = await spend_hit(hit.id)
if not hit:
return {"status": "ERROR", "reason": f"Payment failed"}
await pay_invoice(
wallet_id=card.wallet,
payment_request=pr,
max_sat=card.tx_limit / 1000,
extra={"tag": "boltcard"},
)
return {"status": "OK"}
else:
return {"status": "ERROR", "reason": f"Payment failed"}
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_ext.get("/api/v1/auth")
async def api_auth(a, request: Request):
if a == "00000000000000000000000000000000":
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
return response
card = await get_card_by_otp(a)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
new_otp = secrets.token_hex(16)
print(card.otp)
print(new_otp)
await update_card_otp(new_otp, card.id)
response = {"k0": card.k0, "k1": card.k1, "k2": card.k2}
return response
###############LNURLPAY REFUNDS#################
@satsdice_ext.get(
"/api/v1/lnurlp/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_response",
)
async def api_lnurlp_response(req: Request, hit_id: str = Query(None)):
hit = await get_hit(hit_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
payResponse = {
"tag": "payRequest",
"callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
"minSendable": math.ceil(link.min_bet * 1) * 1000,
"maxSendable": round(link.max_bet * 1) * 1000,
}
return json.dumps(payResponse)
@satsdice_ext.get(
"/api/v1/lnurlp/cb/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_callback",
)
async def api_lnurlp_callback(
req: Request, hit_id: str = Query(None), amount: str = Query(None)
):
hit = await get_hit(hit_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet,
amount=int(amount / 1000),
memo=f"Refund {hit_id}",
unhashed_description=LnurlPayMetadata(json.dumps([["text/plain", hit_id]])).encode("utf-8"),
extra={"refund": hit_id},
)
payResponse = {"pr": payment_request, "successAction": success_action, "routes": []}
return json.dumps(payResponse)

View file

@ -10,7 +10,8 @@ async def m001_initial(db):
card_name TEXT NOT NULL,
uid TEXT NOT NULL,
counter INT NOT NULL DEFAULT 0,
withdraw TEXT NOT NULL,
tx_limit TEXT NOT NULL,
daily_limit TEXT NOT NULL,
k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
@ -31,9 +32,24 @@ async def m001_initial(db):
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
ip TEXT NOT NULL,
spent BOOL NOT NULL DEFAULT True,
useragent TEXT,
old_ctr INT NOT NULL DEFAULT 0,
new_ctr INT NOT NULL DEFAULT 0,
amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltcards.refunds (
id TEXT PRIMARY KEY,
hit_id TEXT NOT NULL,
refund_amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """

View file

@ -10,7 +10,8 @@ class Card(BaseModel):
card_name: str
uid: str
counter: int
withdraw: str
tx_limit: int
daily_limit: int
k0: str
k1: str
k2: str
@ -20,12 +21,24 @@ class Card(BaseModel):
otp: str
time: int
def from_row(cls, row: Row) -> "Card":
return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl:
url = req.url_for(
"boltcard.lnurl_response", device_id=self.id, _external=True
)
return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
class CreateCardData(BaseModel):
card_name: str = Query(...)
uid: str = Query(...)
counter: int = Query(0)
withdraw: str = Query(...)
tx_limit: int = Query(0)
daily_limit: int = Query(0)
k0: str = Query(ZERO_KEY)
k1: str = Query(ZERO_KEY)
k2: str = Query(ZERO_KEY)
@ -33,12 +46,18 @@ class CreateCardData(BaseModel):
prev_k1: str = Query(ZERO_KEY)
prev_k2: str = Query(ZERO_KEY)
class Hit(BaseModel):
id: str
card_id: str
ip: str
spent: bool
useragent: str
old_ctr: int
new_ctr: int
time: int
class Refund(BaseModel):
id: str
hit_id: str
refund_amount: int
time: int

View file

@ -17,10 +17,14 @@ new Vue({
toggleAdvanced: false,
cards: [],
hits: [],
withdrawsOptions: [],
cardDialog: {
show: false,
data: {counter:1},
data: {
counter:1,
k0: '',
k1: '',
k2: '',
card_name:''},
temp: {}
},
cardsTable: {
@ -133,25 +137,6 @@ new Vue({
console.log(self.hits)
})
},
getWithdraws: function () {
var self = this
LNbits.api
.request(
'GET',
'/withdraw/api/v1/links?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.withdrawsOptions = response.data.map(function (obj) {
return {
label: [obj.title, ' - ', obj.id].join(''),
value: obj.id
}
})
console.log(self.withdraws)
})
},
openQrCodeDialog(cardId) {
var card = _.findWhere(this.cards, {id: cardId})
@ -166,6 +151,7 @@ new Vue({
this.qrCodeDialog.show = true
},
generateKeys: function () {
this.cardDialog.show = true
const genRanHex = size =>
[...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16))
@ -194,7 +180,6 @@ new Vue({
this.cardDialog.data = {}
},
sendFormData: function () {
this.generateKeys()
let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet
})

View file

@ -5,7 +5,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="cardDialog.show = true"
<q-btn unelevated color="primary" v-on:click="generateKeys"
>Add Card</q-btn
>
</q-card-section>
@ -122,6 +122,45 @@
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Refunds</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportRefundsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="refunds"
row-key="id"
:columns="refundsTable.columns"
:pagination.sync="refundsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
@ -148,15 +187,32 @@
label="Wallet *"
>
</q-select>
<q-select
<div class="row">
<div class="col">
<q-input
filled
dense
emit-value
v-model="cardDialog.data.withdraw"
:options="withdrawsOptions"
label="Withdraw link *"
>
</q-select>
v-model.trim="cardDialog.data.trans_limit"
type="number"
label="Max transaction (sats)"
class="q-pr-sm"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.daily_limit"
type="number"
label="Daily limit (sats)"
></q-input>
</div>
</div>
<q-input
filled
dense
@ -174,6 +230,7 @@
><q-tooltip>From the NFC 424 ntag card that will be loaded</q-tooltip>
</q-input>
<q-toggle
@click="toggleKeys"
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>

View file

@ -1,11 +1,3 @@
# views_api.py is for you API endpoints that could be hit by another service
# add your dependencies here
# import httpx
# (use httpx just like requests, except instead of response.ok there's only the
# response.is_error that is its inverse)
import secrets
from http import HTTPStatus
@ -15,7 +7,6 @@ from starlette.requests import Request
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.extensions.withdraw import get_withdraw_link
from . import boltcards_ext
from .crud import (
@ -128,89 +119,3 @@ async def api_hits(
cards_ids.append(card.id)
return [hit.dict() for hit in await get_hits(cards_ids)]
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_ext.get("/api/v1/scan")
@boltcards_ext.get("/api/v1/scan/{card_uid}")
async def api_scan(p, c, request: Request, card_uid: str = None):
# some wallets send everything as lower case, no bueno
p = p.upper()
c = c.upper()
card = None
counter = b""
if not card_uid:
# since this route is common to all cards I don't know whitch 'meta key' to use
# so I try one by one until decrypted uid matches
for cand in await get_all_cards():
if cand.k1:
try:
card_uid, counter = decryptSUN(
bytes.fromhex(p), bytes.fromhex(cand.k1)
)
if card_uid.hex().upper() == cand.uid.upper():
card = cand
break
except:
continue
else:
try:
card = await get_card_by_uid(card_uid)
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
if card.uid.upper() != card_uid.hex().upper():
return {"status": "ERROR", "reason": "Card UID mis-match."}
except:
return {"status": "ERROR", "reason": "Error decrypting card."}
if card == None:
return {"status": "ERROR", "reason": "Unknown card."}
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return {"status": "ERROR", "reason": "CMAC does not check."}
ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter:
return {"status": "ERROR", "reason": "This link is already used."}
await update_card_counter(ctr_int, card.id)
# gathering some info for hit record
ip = request.client.host
if "x-real-ip" in request.headers:
ip = request.headers["x-real-ip"]
elif "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
await create_hit(card.id, ip, agent, card.counter, ctr_int)
link = await get_withdraw_link(card.withdraw, 0)
return link.lnurl_response(request)
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_ext.get("/api/v1/auth")
async def api_auth(a, request: Request):
if a == "00000000000000000000000000000000":
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
return response
card = await get_card_by_otp(a)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
new_otp = secrets.token_hex(16)
print(card.otp)
print(new_otp)
await update_card_otp(new_otp, card.id)
response = {"k0": card.k0, "k1": card.k1, "k2": card.k2}
return response