Skip to content
69 changes: 63 additions & 6 deletions crud.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import secrets
from datetime import datetime
from hashlib import pbkdf2_hmac

from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash

from .models import Card, CreateCardData, Hit, Refund


def hash_pin(pin: str, card_id: str) -> str:
return pbkdf2_hmac("sha256", pin.encode(), card_id.encode(), 100_000, 32).hex()


def verify_pin(pin: str, card_id: str, stored_hash: str) -> bool:
return hash_pin(pin, card_id) == stored_hash

db = Database("ext_boltcards")


async def create_card(data: CreateCardData, wallet_id: str) -> Card:
card_id = urlsafe_short_hash().upper()
extenal_id = urlsafe_short_hash().lower()

hashed_pin = hash_pin(data.pin, card_id) if data.pin else None

await db.execute(
"""
INSERT INTO boltcards.cards (
Expand All @@ -28,11 +39,14 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
k0,
k1,
k2,
otp
otp,
pin_limit,
pin
)
VALUES (
:id, :uid, :external_id, :wallet, :card_name, :counter,
:tx_limit, :daily_limit, :enable, :k0, :k1, :k2, :otp
:tx_limit, :daily_limit, :enable, :k0, :k1, :k2, :otp,
:pin_limit, :pin
)
""",
{
Expand All @@ -49,6 +63,8 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
"k1": data.k1,
"k2": data.k2,
"otp": secrets.token_hex(16),
"pin_limit": data.pin_limit,
"pin": hashed_pin,
},
)
card = await get_card(card_id)
Expand Down Expand Up @@ -126,10 +142,18 @@ async def update_card_counter(counter: int, card_id: str):


async def enable_disable_card(enable: bool, card_id: str) -> Card | None:
await db.execute(
"UPDATE boltcards.cards SET enable = :enable WHERE id = :id",
{"enable": enable, "id": card_id},
)
if enable:
# Reset attempt counter when card is re-enabled so first wrong PIN
# doesn't immediately re-block a card that was just unlocked by admin.
await db.execute(
"UPDATE boltcards.cards SET enable = :enable, pin_total_attempts = 0 WHERE id = :id",
{"enable": enable, "id": card_id},
)
else:
await db.execute(
"UPDATE boltcards.cards SET enable = :enable WHERE id = :id",
{"enable": enable, "id": card_id},
)
return await get_card(card_id)


Expand Down Expand Up @@ -213,6 +237,39 @@ async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
return hit


async def update_hit_pin_attempts(hit_id: str, attempts: int) -> None:
await db.execute(
"UPDATE boltcards.hits SET pin_attempts = :attempts WHERE id = :id",
{"attempts": attempts, "id": hit_id},
)


async def increment_card_pin_attempts(card_id: str) -> int:
await db.execute(
"UPDATE boltcards.cards SET pin_total_attempts = pin_total_attempts + 1 WHERE id = :id",
{"id": card_id},
)
row = await db.fetchone(
"SELECT pin_total_attempts FROM boltcards.cards WHERE id = :id",
{"id": card_id},
)
return row["pin_total_attempts"] if row else 0


async def reset_card_pin_attempts(card_id: str) -> None:
await db.execute(
"UPDATE boltcards.cards SET pin_total_attempts = 0 WHERE id = :id",
{"id": card_id},
)


async def invalidate_hit(hit_id: str) -> None:
await db.execute(
"UPDATE boltcards.hits SET spent = :spent WHERE id = :id",
{"spent": True, "id": hit_id},
)


async def create_refund(hit_id, refund_amount) -> Refund:
refund_id = urlsafe_short_hash()
await db.execute(
Expand Down
Binary file added docs/LNbits-pin-limit.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/ZapBox-Touch3.5-pin-screen.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ async def m001_initial(db):
)


async def m003_add_pin_limit(db):
await db.execute(
"ALTER TABLE boltcards.cards ADD COLUMN pin_limit INT DEFAULT NULL"
)
await db.execute(
"ALTER TABLE boltcards.cards ADD COLUMN pin TEXT DEFAULT NULL"
)
await db.execute(
"ALTER TABLE boltcards.hits ADD COLUMN pin_attempts INT NOT NULL DEFAULT 0"
)


async def m004_add_pin_blocked(db):
await db.execute(
"ALTER TABLE boltcards.cards ADD COLUMN pin_blocked BOOL NOT NULL DEFAULT False"
)


async def m005_add_card_pin_attempts(db):
await db.execute(
"ALTER TABLE boltcards.cards ADD COLUMN pin_total_attempts INT NOT NULL DEFAULT 0"
)


async def m002_correct_typing(db):
await db.execute("ALTER TABLE boltcards.cards RENAME TO cards_m001;")
await db.execute(
Expand Down
7 changes: 7 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from datetime import datetime
from typing import Optional

from fastapi import Query, Request
from lnurl import Lnurl
Expand Down Expand Up @@ -28,6 +29,9 @@ class Card(BaseModel):
prev_k2: str
otp: str
time: datetime
pin_limit: Optional[int] = None
pin: Optional[str] = None
pin_total_attempts: int = 0

def lnurl(self, req: Request) -> Lnurl:
url = str(
Expand All @@ -52,6 +56,8 @@ class CreateCardData(BaseModel):
prev_k0: str = Query(ZERO_KEY)
prev_k1: str = Query(ZERO_KEY)
prev_k2: str = Query(ZERO_KEY)
pin_limit: Optional[int] = Query(None)
pin: Optional[str] = Query(None)


class Hit(BaseModel):
Expand All @@ -64,6 +70,7 @@ class Hit(BaseModel):
new_ctr: int
amount: int
time: datetime
pin_attempts: int = 0


class Refund(BaseModel):
Expand Down
10 changes: 9 additions & 1 deletion static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,14 @@ window.app = Vue.createApp({
let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet
})
let data = this.cardDialog.data
let data = _.clone(this.cardDialog.data)
if (!data.pin) {
data.pin = null
} else if (!/^\d{4}$/.test(data.pin)) {
Quasar.Notify.create({type: 'negative', message: 'PIN must be exactly 4 digits.'})
return
}
if (!data.pin_limit) data.pin_limit = null
if (data.id) {
this.updateCard(wallet, data)
} else {
Expand All @@ -316,6 +323,7 @@ window.app = Vue.createApp({
updateCardDialog(formId) {
var card = _.findWhere(this.cards, {id: formId})
this.cardDialog.data = _.clone(card)
this.cardDialog.data.pin = ''

this.cardDialog.temp.k0 = this.cardDialog.data.k0
this.cardDialog.temp.k1 = this.cardDialog.data.k1
Expand Down
26 changes: 26 additions & 0 deletions templates/boltcards/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,32 @@ <h6 class="text-subtitle1 q-my-none">
></q-input>
</div>
</div>
<div class="row">
<div class="col q-pr-sm">
<q-input
filled
dense
v-model.number="cardDialog.data.pin_limit"
type="number"
label="PIN required above (sat, optional)"
hint="Leave empty to disable PIN protection"
clearable
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model="cardDialog.data.pin"
type="password"
maxlength="4"
label="4-digit PIN (optional)"
:hint="cardDialog.data.id ? 'Leave empty to keep existing PIN' : 'Leave empty for no PIN'"
:rules="[v => !v || (v.length === 4 && /^\d{4}$/.test(v)) || 'PIN must be exactly 4 digits']"
lazy-rules
></q-input>
</div>
</div>
<q-input
filled
dense
Expand Down
7 changes: 7 additions & 0 deletions views_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
get_cards,
get_hits,
get_refunds,
hash_pin,
update_card,
)
from .models import Card, CreateCardData, Hit, Refund
Expand Down Expand Up @@ -86,6 +87,12 @@ async def api_card_update(
)
for key, value in data.dict().items():
setattr(card, key, value)
if data.pin:
card.pin = hash_pin(data.pin, card.id)
card.pin_total_attempts = 0
elif data.pin is None:
card.pin = None
card.pin_total_attempts = 0
await update_card(card)
return card

Expand Down
49 changes: 37 additions & 12 deletions views_lnurl.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,21 @@

from .crud import (
create_hit,
enable_disable_card,
get_card,
get_card_by_external_id,
get_card_by_otp,
get_card_by_uid,
get_hit,
get_hits_today,
increment_card_pin_attempts,
invalidate_hit,
reset_card_pin_attempts,
spend_hit,
update_card_counter,
update_card_otp,
update_hit_pin_attempts,
verify_pin,
)
from .models import UIDPost
from .nxp424 import decrypt_sun, get_sun_mac
Expand All @@ -43,7 +49,7 @@
@boltcards_lnurl_router.get("/api/v1/scan/{external_id}")
async def api_scan(
p, c, request: Request, external_id: str
) -> LnurlWithdrawResponse | LnurlErrorResponse:
) -> dict | LnurlErrorResponse:
# some wallets send everything as lower case, no bueno
p = p.upper()
c = c.upper()
Expand Down Expand Up @@ -94,17 +100,19 @@ async def api_scan(
pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace(
"https://", "lnurlp://"
)
callback_url = parse_obj_as(
CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id))
)
return LnurlWithdrawResponse(
callback=callback_url,
k1=hit.id,
minWithdrawable=MilliSatoshi(1000),
maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000),
defaultDescription=f"Boltcard (refund address {pay_link})",
payLink=pay_link, # type: ignore
)
callback_url = str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id))
response: dict = {
"tag": "withdrawRequest",
"callback": callback_url,
"k1": hit.id,
"minWithdrawable": 1000,
"maxWithdrawable": int(card.tx_limit) * 1000,
"defaultDescription": f"Boltcard (refund address {pay_link})",
"payLink": pay_link,
}
if card.pin_limit is not None:
response["pinLimit"] = card.pin_limit * 1000
return response


@boltcards_lnurl_router.get(
Expand All @@ -116,6 +124,7 @@ async def lnurl_callback(
hit_id: str,
k1: str = Query(None),
pr: str = Query(None),
pin: str = Query(None),
) -> LnurlErrorResponse | LnurlSuccessResponse:
if not k1:
return LnurlErrorResponse(reason="Missing K1 token")
Expand All @@ -139,6 +148,22 @@ async def lnurl_callback(
card = await get_card(hit.card_id)
if not card:
return LnurlErrorResponse(reason="Card not found.")

if card.pin_limit is not None and invoice.amount_msat >= card.pin_limit * 1000:
if not pin:
return LnurlErrorResponse(reason="PIN required.")
if not card.pin or not verify_pin(pin, card.id, card.pin):
total = await increment_card_pin_attempts(card.id)
await update_hit_pin_attempts(hit.id, hit.pin_attempts + 1)
if total >= 3:
await invalidate_hit(hit.id)
await enable_disable_card(enable=False, card_id=card.id)
return LnurlErrorResponse(
reason="Card blocked: too many incorrect PIN attempts"
)
return LnurlErrorResponse(reason="Invalid PIN")

await reset_card_pin_attempts(card.id)
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000))
if not hit:
return LnurlErrorResponse(reason="Failed to update hit as spent.")
Expand Down