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(): def boltcards_renderer():
return template_renderer(["lnbits/extensions/boltcards/templates"]) return template_renderer(["lnbits/extensions/boltcards/templates"])
from .lnurl import * # noqa
from .views import * # noqa from .views import * # noqa
from .views_api 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, card_name TEXT NOT NULL,
uid TEXT NOT NULL, uid TEXT NOT NULL,
counter INT NOT NULL DEFAULT 0, 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', k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
@ -31,9 +32,24 @@ async def m001_initial(db):
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
card_id TEXT NOT NULL, card_id TEXT NOT NULL,
ip TEXT NOT NULL, ip TEXT NOT NULL,
spent BOOL NOT NULL DEFAULT True,
useragent TEXT, useragent TEXT,
old_ctr INT NOT NULL DEFAULT 0, old_ctr INT NOT NULL DEFAULT 0,
new_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 """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """

View file

@ -10,7 +10,8 @@ class Card(BaseModel):
card_name: str card_name: str
uid: str uid: str
counter: int counter: int
withdraw: str tx_limit: int
daily_limit: int
k0: str k0: str
k1: str k1: str
k2: str k2: str
@ -20,12 +21,24 @@ class Card(BaseModel):
otp: str otp: str
time: int 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): class CreateCardData(BaseModel):
card_name: str = Query(...) card_name: str = Query(...)
uid: str = Query(...) uid: str = Query(...)
counter: int = Query(0) counter: int = Query(0)
withdraw: str = Query(...) tx_limit: int = Query(0)
daily_limit: int = Query(0)
k0: str = Query(ZERO_KEY) k0: str = Query(ZERO_KEY)
k1: str = Query(ZERO_KEY) k1: str = Query(ZERO_KEY)
k2: str = Query(ZERO_KEY) k2: str = Query(ZERO_KEY)
@ -33,12 +46,18 @@ class CreateCardData(BaseModel):
prev_k1: str = Query(ZERO_KEY) prev_k1: str = Query(ZERO_KEY)
prev_k2: str = Query(ZERO_KEY) prev_k2: str = Query(ZERO_KEY)
class Hit(BaseModel): class Hit(BaseModel):
id: str id: str
card_id: str card_id: str
ip: str ip: str
spent: bool
useragent: str useragent: str
old_ctr: int old_ctr: int
new_ctr: int new_ctr: int
time: 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, toggleAdvanced: false,
cards: [], cards: [],
hits: [], hits: [],
withdrawsOptions: [],
cardDialog: { cardDialog: {
show: false, show: false,
data: {counter:1}, data: {
counter:1,
k0: '',
k1: '',
k2: '',
card_name:''},
temp: {} temp: {}
}, },
cardsTable: { cardsTable: {
@ -133,25 +137,6 @@ new Vue({
console.log(self.hits) 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) { openQrCodeDialog(cardId) {
var card = _.findWhere(this.cards, {id: cardId}) var card = _.findWhere(this.cards, {id: cardId})
@ -166,6 +151,7 @@ new Vue({
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
}, },
generateKeys: function () { generateKeys: function () {
this.cardDialog.show = true
const genRanHex = size => const genRanHex = size =>
[...Array(size)] [...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16)) .map(() => Math.floor(Math.random() * 16).toString(16))
@ -194,7 +180,6 @@ new Vue({
this.cardDialog.data = {} this.cardDialog.data = {}
}, },
sendFormData: function () { sendFormData: function () {
this.generateKeys()
let wallet = _.findWhere(this.g.user.wallets, { let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet 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"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <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 >Add Card</q-btn
> >
</q-card-section> </q-card-section>
@ -122,6 +122,45 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </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>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md"> <div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card> <q-card>
@ -148,15 +187,32 @@
label="Wallet *" label="Wallet *"
> >
</q-select> </q-select>
<q-select
<div class="row">
<div class="col">
<q-input
filled filled
dense dense
emit-value emit-value
v-model="cardDialog.data.withdraw" v-model.trim="cardDialog.data.trans_limit"
:options="withdrawsOptions" type="number"
label="Withdraw link *" label="Max transaction (sats)"
> class="q-pr-sm"
</q-select> ></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 <q-input
filled filled
dense dense
@ -174,6 +230,7 @@
><q-tooltip>From the NFC 424 ntag card that will be loaded</q-tooltip> ><q-tooltip>From the NFC 424 ntag card that will be loaded</q-tooltip>
</q-input> </q-input>
<q-toggle <q-toggle
@click="toggleKeys"
v-model="toggleAdvanced" v-model="toggleAdvanced"
label="Show advanced options" label="Show advanced options"
></q-toggle> ></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 import secrets
from http import HTTPStatus from http import HTTPStatus
@ -15,7 +7,6 @@ from starlette.requests import Request
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.extensions.withdraw import get_withdraw_link
from . import boltcards_ext from . import boltcards_ext
from .crud import ( from .crud import (
@ -128,89 +119,3 @@ async def api_hits(
cards_ids.append(card.id) cards_ids.append(card.id)
return [hit.dict() for hit in await get_hits(cards_ids)] 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