diff --git a/crud.py b/crud.py index 9efa945..60c4c6b 100644 --- a/crud.py +++ b/crud.py @@ -1,11 +1,20 @@ 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") @@ -13,6 +22,8 @@ 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 ( @@ -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 ) """, { @@ -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) @@ -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) @@ -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( diff --git a/docs/LNbits-pin-limit.jpg b/docs/LNbits-pin-limit.jpg new file mode 100644 index 0000000..f5122b4 Binary files /dev/null and b/docs/LNbits-pin-limit.jpg differ diff --git a/docs/ZapBox-Touch3.5-pin-screen.jpg b/docs/ZapBox-Touch3.5-pin-screen.jpg new file mode 100644 index 0000000..2dc9b81 Binary files /dev/null and b/docs/ZapBox-Touch3.5-pin-screen.jpg differ diff --git a/migrations.py b/migrations.py index 8d8464f..b6fd217 100644 --- a/migrations.py +++ b/migrations.py @@ -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( diff --git a/models.py b/models.py index 0856594..434c449 100644 --- a/models.py +++ b/models.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from typing import Optional from fastapi import Query, Request from lnurl import Lnurl @@ -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( @@ -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): @@ -64,6 +70,7 @@ class Hit(BaseModel): new_ctr: int amount: int time: datetime + pin_attempts: int = 0 class Refund(BaseModel): diff --git a/static/js/index.js b/static/js/index.js index b553347..d10c0be 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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 { @@ -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 diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html index 4e2d6e1..ec260ca 100644 --- a/templates/boltcards/index.html +++ b/templates/boltcards/index.html @@ -269,6 +269,32 @@