Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
13 changes: 13 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 @@ -84,8 +85,20 @@ async def api_card_update(
detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST,
)
original_pin = card.pin
original_pin_total_attempts = card.pin_total_attempts
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
else:
# The UI blanks the PIN field when opening the edit dialog
# ("Leave empty to keep existing PIN"), so an empty value must not
# overwrite the stored hash - otherwise every edit silently disables
# PIN verification.
card.pin = original_pin
card.pin_total_attempts = original_pin_total_attempts
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