From aa13533791391cfe6b45257ce95ca76b63163729 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Tue, 14 Feb 2023 11:07:51 +0000 Subject: [PATCH] Add files via upload --- README.md | 88 ++++++ __init__.py | 37 +++ config.json | 6 + crud.py | 278 +++++++++++++++++ lnurl.py | 222 ++++++++++++++ migrations.py | 57 ++++ models.py | 83 +++++ nxp424.py | 36 +++ static/image/boltcard.png | Bin 0 -> 33935 bytes static/js/index.js | 456 +++++++++++++++++++++++++++ tasks.py | 47 +++ templates/boltcards/_api_docs.html | 22 ++ templates/boltcards/index.html | 474 +++++++++++++++++++++++++++++ views.py | 17 ++ views_api.py | 165 ++++++++++ 15 files changed, 1988 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 lnurl.py create mode 100644 migrations.py create mode 100644 models.py create mode 100644 nxp424.py create mode 100644 static/image/boltcard.png create mode 100644 static/js/index.js create mode 100644 tasks.py create mode 100644 templates/boltcards/_api_docs.html create mode 100644 templates/boltcards/index.html create mode 100644 views.py create mode 100644 views_api.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9345706 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Bolt cards (NXP NTAG) Extension + +This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow. + +Tutorial + +**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!*** + + +***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp). + +## About the keys + +Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set, but for the security reasons all five keys should be changed from default (empty) state. The keys directly needed by this extension are: + +- One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1. + +- One for calculating CMAC (c parameter), let's called it file key, key #02 or K2. + +The key #00, K0 (also know as auth key) is used as authentification key. It is not directly needed by this extension, but should be filled in order to write the keys in cooperation with Boltcard NFC Card Creator. In this case also K3 is set to same value as K1 and K4 as K2, so all keys are changed from default values. Keep that in your mind in case you ever need to reset the keys manually. + +***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!*** + + +## Setting the card - Boltcard NFC Card Creator (easy way) +Updated for v0.1.3 + +- Add new card in the extension. + - Set a max sats per transaction. Any transaction greater than this amount will be rejected. + - Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected. + - Set a card name. This is just for your reference inside LNbits. + - Set the card UID. This is the unique identifier on your NFC card and is 7 bytes. + - If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field. + - Otherwise read it with the Android app (Advanced -> Read NFC) and paste it to the field. + - Advanced Options + - Card Keys (k0, k1, k2) will be automatically generated if not explicitly set. + - Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure). + - GENERATE KEY button fill the keys randomly. + - Click CREATE CARD button +- Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager. + - Now you can scan the QR code with the Android app (Create Bolt Card -> SCAN QR CODE). + - Or you can Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL). +- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY! + +## Erasing the card - Boltcard NFC Card Creator +Updated for v0.1.3 + +Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys but also disable the SUN function and do the complete erase so the card can be use again as a static tag (or set as a new Bolt Card, ofc). + +- Click the QR code button next to a card to view its details and select WIPE +- OR click the red cross icon on the right side to reach the same +- In the android app (Advanced -> Reset Keys) + - Click SCAN QR CODE to scan the QR + - Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON) +- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY! +- Now if there is all success the card can be safely delete from LNbits (but keep the keys backuped anyway; batter safe than brick). + +If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4). + +## Setting the card - computer (hard way) + +Follow the guide. + +The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000` + +Then fill up the card parameters in the extension. Card Auth key (K0) can be filled in the extension just for the record. Initical counter can be 0. + +## Setting the card - android NXP app (hard way) +- If you don't know the card ID, use NXP TagInfo app to find it out. +- In the TagWriter app tap Write tags +- New Data Set > Link +- Set URI type to Custom URL +- URL should look like lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000 +- click Configure mirroring options +- Select Card Type NTAG 424 DNA +- Check Enable SDM Mirroring +- Select SDM Meta Read Access Right to 01 +- Check Enable UID Mirroring +- Check Enable Counter Mirroring +- Set SDM Counter Retrieval Key to 0E +- Set PICC Data Offset to immediately after e= +- Set Derivation Key for CMAC Calculation to 00 +- Set SDM MAC Input Offset to immediately after c= +- Set SDM MAC Offset to immediately after c= +- Save & Write +- Scan with compatible Wallet + +This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5c6b87a --- /dev/null +++ b/__init__.py @@ -0,0 +1,37 @@ +import asyncio + +from fastapi import APIRouter +from starlette.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_boltcards") + +boltcards_static_files = [ + { + "path": "/boltcards/static", + "app": StaticFiles(packages=[("lnbits", "extensions/boltcards/static")]), + "name": "boltcards_static", + } +] + +boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"]) + + +def boltcards_renderer(): + return template_renderer(["lnbits/extensions/boltcards/templates"]) + + +from .lnurl import * # noqa: F401,F403 +from .tasks import * # noqa: F401,F403 + + +def boltcards_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) # noqa: F405 + + +from .views import * # noqa: F401,F403 +from .views_api import * # noqa: F401,F403 diff --git a/config.json b/config.json new file mode 100644 index 0000000..0551f18 --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "name": "Bolt Cards", + "short_description": "Self custody Bolt Cards with one time LNURLw", + "tile": "/boltcards/static/image/boltcard.png", + "contributors": ["iwarpbtc", "arcbtc", "leesalminen"] +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..0a678e7 --- /dev/null +++ b/crud.py @@ -0,0 +1,278 @@ +import secrets +from datetime import datetime +from typing import List, Optional + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Card, CreateCardData, Hit, Refund + + +async def create_card(data: CreateCardData, wallet_id: str) -> Card: + card_id = urlsafe_short_hash().upper() + extenal_id = urlsafe_short_hash().lower() + + await db.execute( + """ + INSERT INTO boltcards.cards ( + id, + uid, + external_id, + wallet, + card_name, + counter, + tx_limit, + daily_limit, + enable, + k0, + k1, + k2, + otp + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + card_id, + data.uid.upper(), + extenal_id, + wallet_id, + data.card_name, + data.counter, + data.tx_limit, + data.daily_limit, + True, + data.k0, + data.k1, + data.k2, + secrets.token_hex(16), + ), + ) + card = await get_card(card_id) + assert card, "Newly created card couldn't be retrieved" + return card + + +async def update_card(card_id: str, **kwargs) -> Optional[Card]: + if "is_unique" in kwargs: + kwargs["is_unique"] = int(kwargs["is_unique"]) + if "uid" in kwargs: + kwargs["uid"] = kwargs["uid"].upper() + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE boltcards.cards SET {q} WHERE id = ?", + (*kwargs.values(), card_id), + ) + row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) + return Card(**row) if row else None + + +async def get_cards(wallet_ids: List[str]) -> List[Card]: + if len(wallet_ids) == 0: + return [] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Card(**row) for row in rows] + + +async def get_card(card_id: str) -> Optional[Card]: + row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def get_card_by_uid(card_uid: str) -> Optional[Card]: + row = await db.fetchone( + "SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),) + ) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def get_card_by_external_id(external_id: str) -> Optional[Card]: + row = await db.fetchone( + "SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),) + ) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def get_card_by_otp(otp: str) -> Optional[Card]: + row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,)) + if not row: + return None + + card = dict(**row) + + return Card.parse_obj(card) + + +async def delete_card(card_id: str) -> None: + # Delete cards + await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,)) + # Delete hits + hits = await get_hits([card_id]) + for hit in hits: + await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,)) + # Delete refunds + refunds = await get_refunds([hit.id]) + for refund in refunds: + await db.execute( + "DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,) + ) + + +async def update_card_counter(counter: int, id: str): + await db.execute( + "UPDATE boltcards.cards SET counter = ? WHERE id = ?", + (counter, id), + ) + + +async def enable_disable_card(enable: bool, id: str) -> Optional[Card]: + await db.execute( + "UPDATE boltcards.cards SET enable = ? WHERE id = ?", + (enable, id), + ) + return await get_card(id) + + +async def update_card_otp(otp: str, id: str): + await db.execute( + "UPDATE boltcards.cards SET otp = ? WHERE id = ?", + (otp, id), + ) + + +async def get_hit(hit_id: str) -> Optional[Hit]: + row = await db.fetchone("SELECT * FROM boltcards.hits WHERE id = ?", (hit_id,)) + if not row: + return None + + hit = dict(**row) + + return Hit.parse_obj(hit) + + +async def get_hits(cards_ids: List[str]) -> List[Hit]: + if len(cards_ids) == 0: + return [] + + q = ",".join(["?"] * len(cards_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,) + ) + + return [Hit(**row) for row in rows] + + +async def get_hits_today(card_id: str) -> List[Hit]: + rows = await db.fetchall( + "SELECT * FROM boltcards.hits WHERE card_id = ?", + (card_id,), + ) + updatedrow = [] + for row in rows: + if datetime.now().date() == datetime.fromtimestamp(row.time).date(): + updatedrow.append(row) + + return [Hit(**row) for row in updatedrow] + + +async def spend_hit(id: str, amount: int): + await db.execute( + "UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?", + (True, amount, id), + ) + return await get_hit(id) + + +async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: + hit_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO boltcards.hits ( + id, + card_id, + ip, + spent, + useragent, + old_ctr, + new_ctr, + amount + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + hit_id, + card_id, + ip, + False, + useragent, + old_ctr, + new_ctr, + 0, + ), + ) + hit = await get_hit(hit_id) + assert hit, "Newly recorded hit couldn't be retrieved" + return hit + + +async def create_refund(hit_id, refund_amount) -> Refund: + refund_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO boltcards.refunds ( + id, + hit_id, + refund_amount + ) + VALUES (?, ?, ?) + """, + ( + refund_id, + hit_id, + refund_amount, + ), + ) + refund = await get_refund(refund_id) + assert refund, "Newly recorded hit couldn't be retrieved" + return refund + + +async def get_refund(refund_id: str) -> Optional[Refund]: + row = await db.fetchone( + "SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id,) + ) + if not row: + return None + refund = dict(**row) + return Refund.parse_obj(refund) + + +async def get_refunds(hits_ids: List[str]) -> List[Refund]: + if len(hits_ids) == 0: + return [] + + q = ",".join(["?"] * len(hits_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,) + ) + + return [Refund(**row) for row in rows] diff --git a/lnurl.py b/lnurl.py new file mode 100644 index 0000000..3b99fdf --- /dev/null +++ b/lnurl.py @@ -0,0 +1,222 @@ +import json +import secrets +from http import HTTPStatus +from urllib.parse import urlparse + +from fastapi import HTTPException, Query, Request +from lnurl import encode as lnurl_encode +from lnurl.types import LnurlPayMetadata +from starlette.responses import HTMLResponse + +from lnbits import bolt11 +from lnbits.core.services import create_invoice +from lnbits.core.views.api import pay_invoice + +from . import boltcards_ext +from .crud import ( + create_hit, + get_card, + get_card_by_external_id, + get_card_by_otp, + get_hit, + get_hits_today, + spend_hit, + update_card_counter, + update_card_otp, +) +from .nxp424 import decryptSUN, getSunMAC + +###############LNURLWITHDRAW################# + + +# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 +@boltcards_ext.get("/api/v1/scan/{external_id}") +async def api_scan(p, c, request: Request, external_id: str = Query(None)): + # some wallets send everything as lower case, no bueno + p = p.upper() + c = c.upper() + card = None + counter = b"" + card = await get_card_by_external_id(external_id) + if not card: + return {"status": "ERROR", "reason": "No card."} + if not card.enable: + return {"status": "ERROR", "reason": "Card is disabled."} + try: + 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."} + if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): + return {"status": "ERROR", "reason": "CMAC does not check."} + except: + return {"status": "ERROR", "reason": "Error decrypting card."} + + 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 + assert request.client + 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) + + hits_amount = 0 + for hit in todays_hits: + hits_amount = hits_amount + hit.amount + if hits_amount > card.daily_limit: + return {"status": "ERROR", "reason": "Max daily limit spent."} + hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) + lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) + return { + "tag": "withdrawRequest", + "callback": request.url_for("boltcards.lnurl_callback", hitid=hit.id), + "k1": hit.id, + "minWithdrawable": 1 * 1000, + "maxWithdrawable": card.tx_limit * 1000, + "defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay})", + } + + +@boltcards_ext.get( + "/api/v1/lnurl/cb/{hitid}", + status_code=HTTPStatus.OK, + name="boltcards.lnurl_callback", +) +async def lnurl_callback( + pr: str = Query(None), + k1: str = Query(None), +): + if not k1: + return {"status": "ERROR", "reason": "Missing K1 token"} + + hit = await get_hit(k1) + + if not hit: + return { + "status": "ERROR", + "reason": "Record not found for this charge (bad k1)", + } + if hit.spent: + return {"status": "ERROR", "reason": "Payment already claimed"} + if not pr: + return {"status": "ERROR", "reason": "Missing payment request"} + + try: + invoice = bolt11.decode(pr) + except: + return {"status": "ERROR", "reason": "Failed to decode payment request"} + + card = await get_card(hit.card_id) + assert card + hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000)) + assert hit + try: + await pay_invoice( + wallet_id=card.wallet, + payment_request=pr, + max_sat=card.tx_limit, + extra={"tag": "boltcard", "hit": hit.id}, + ) + return {"status": "OK"} + except Exception as exc: + return {"status": "ERROR", "reason": f"Payment failed - {exc}"} + + +# /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) + await update_card_otp(new_otp, card.id) + + lnurlw_base = ( + f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}" + ) + + response = { + "card_name": card.card_name, + "id": str(1), + "k0": card.k0, + "k1": card.k1, + "k2": card.k2, + "k3": card.k1, + "k4": card.k2, + "lnurlw_base": "lnurlw://" + lnurlw_base, + "protocol_name": "new_bolt_card_response", + "protocol_version": str(1), + } + + return response + + +###############LNURLPAY REFUNDS################# + + +@boltcards_ext.get( + "/api/v1/lnurlp/{hit_id}", + response_class=HTMLResponse, + name="boltcards.lnurlp_response", +) +async def lnurlp_response(req: Request, hit_id: str = Query(None)): + hit = await get_hit(hit_id) + assert hit + card = await get_card(hit.card_id) + assert card + if not hit: + return {"status": "ERROR", "reason": "LNURL-pay record not found."} + if not card.enable: + return {"status": "ERROR", "reason": "Card is disabled."} + payResponse = { + "tag": "payRequest", + "callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id), + "metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), + "minSendable": 1 * 1000, + "maxSendable": card.tx_limit * 1000, + } + return json.dumps(payResponse) + + +@boltcards_ext.get( + "/api/v1/lnurlp/cb/{hit_id}", + response_class=HTMLResponse, + name="boltcards.lnurlp_callback", +) +async def lnurlp_callback(hit_id: str = Query(None), amount: str = Query(None)): + hit = await get_hit(hit_id) + assert hit + card = await get_card(hit.card_id) + assert card + if not hit: + return {"status": "ERROR", "reason": "LNURL-pay record not found."} + + _, payment_request = await create_invoice( + wallet_id=card.wallet, + amount=int(int(amount) / 1000), + memo=f"Refund {hit_id}", + unhashed_description=LnurlPayMetadata( + json.dumps([["text/plain", "Refund"]]) + ).encode(), + extra={"refund": hit_id}, + ) + + payResponse = {"pr": payment_request, "routes": []} + + return json.dumps(payResponse) diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..43d5bb0 --- /dev/null +++ b/migrations.py @@ -0,0 +1,57 @@ +async def m001_initial(db): + await db.execute( + """ + CREATE TABLE boltcards.cards ( + id TEXT PRIMARY KEY UNIQUE, + wallet TEXT NOT NULL, + card_name TEXT NOT NULL, + uid TEXT NOT NULL UNIQUE, + external_id TEXT NOT NULL UNIQUE, + counter INT NOT NULL DEFAULT 0, + tx_limit TEXT NOT NULL, + daily_limit TEXT NOT NULL, + enable BOOL NOT NULL, + k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000', + otp TEXT NOT NULL DEFAULT '', + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE boltcards.hits ( + id TEXT PRIMARY KEY UNIQUE, + 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 {db.big_int} NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE boltcards.refunds ( + id TEXT PRIMARY KEY UNIQUE, + hit_id TEXT NOT NULL, + refund_amount {db.big_int} NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..5ea4be1 --- /dev/null +++ b/models.py @@ -0,0 +1,83 @@ +import json +from sqlite3 import Row + +from fastapi import Query, Request +from lnurl import Lnurl +from lnurl import encode as lnurl_encode +from lnurl.types import LnurlPayMetadata +from pydantic import BaseModel + +ZERO_KEY = "00000000000000000000000000000000" + + +class Card(BaseModel): + id: str + wallet: str + card_name: str + uid: str + external_id: str + counter: int + tx_limit: int + daily_limit: int + enable: bool + k0: str + k1: str + k2: str + prev_k0: str + prev_k1: str + prev_k2: str + otp: str + time: int + + @classmethod + 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.card_name]])) + + +class CreateCardData(BaseModel): + card_name: str = Query(...) + uid: str = Query(...) + counter: int = Query(0) + tx_limit: int = Query(0) + daily_limit: int = Query(0) + enable: bool = Query(True) + k0: str = Query(ZERO_KEY) + k1: str = Query(ZERO_KEY) + k2: str = Query(ZERO_KEY) + prev_k0: str = Query(ZERO_KEY) + 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 + amount: int + time: int + + @classmethod + def from_row(cls, row: Row) -> "Hit": + return cls(**dict(row)) + + +class Refund(BaseModel): + id: str + hit_id: str + refund_amount: int + time: int + + @classmethod + def from_row(cls, row: Row) -> "Refund": + return cls(**dict(row)) diff --git a/nxp424.py b/nxp424.py new file mode 100644 index 0000000..83f4e50 --- /dev/null +++ b/nxp424.py @@ -0,0 +1,36 @@ +# https://www.nxp.com/docs/en/application-note/AN12196.pdf +from typing import Tuple + +from Cryptodome.Cipher import AES +from Cryptodome.Hash import CMAC + +SV2 = "3CC300010080" + + +def myCMAC(key: bytes, msg: bytes = b"") -> bytes: + cobj = CMAC.new(key, ciphermod=AES) + if msg != b"": + cobj.update(msg) + return cobj.digest() + + +def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]: + IVbytes = b"\x00" * 16 + + cipher = AES.new(key, AES.MODE_CBC, IVbytes) + sun_plain = cipher.decrypt(sun) + + UID = sun_plain[1:8] + counter = sun_plain[8:11] + + return UID, counter + + +def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes: + sv2prefix = bytes.fromhex(SV2) + sv2bytes = sv2prefix + UID + counter + + mac1 = myCMAC(key, sv2bytes) + mac2 = myCMAC(mac1) + + return mac2[1::2] diff --git a/static/image/boltcard.png b/static/image/boltcard.png new file mode 100644 index 0000000000000000000000000000000000000000..ce79906eb4fb52d29f26d6941dd2338197a5740f GIT binary patch literal 33935 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 literal 0 HcmV?d00001 diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..880a555 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,456 @@ +Vue.component(VueQrcode.name, VueQrcode) + +const mapCards = obj => { + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + toggleAdvanced: false, + nfcTagReading: false, + lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`, + cards: [], + hits: [], + refunds: [], + cardDialog: { + show: false, + data: { + counter: 1, + k0: '', + k1: '', + k2: '', + uid: '', + card_name: '' + }, + temp: {} + }, + cardsTable: { + columns: [ + { + name: 'card_name', + align: 'left', + label: 'Card name', + field: 'card_name' + }, + { + name: 'counter', + align: 'left', + label: 'Counter', + field: 'counter' + }, + { + name: 'wallet', + align: 'left', + label: 'Wallet', + field: 'wallet' + }, + { + name: 'tx_limit', + align: 'left', + label: 'Max tx', + field: 'tx_limit' + }, + { + name: 'daily_limit', + align: 'left', + label: 'Daily tx limit', + field: 'daily_limit' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + refundsTable: { + columns: [ + { + name: 'hit_id', + align: 'left', + label: 'Hit ID', + field: 'hit_id' + }, + { + name: 'refund_amount', + align: 'left', + label: 'Refund Amount', + field: 'refund_amount' + }, + { + name: 'date', + align: 'left', + label: 'Time', + field: 'date' + } + ], + pagination: { + rowsPerPage: 10, + sortBy: 'date', + descending: true + } + }, + hitsTable: { + columns: [ + { + name: 'card_name', + align: 'left', + label: 'Card name', + field: 'card_name' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount' + }, + { + name: 'old_ctr', + align: 'left', + label: 'Old counter', + field: 'old_ctr' + }, + { + name: 'new_ctr', + align: 'left', + label: 'New counter', + field: 'new_ctr' + }, + { + name: 'date', + align: 'left', + label: 'Time', + field: 'date' + }, + { + name: 'ip', + align: 'left', + label: 'IP', + field: 'ip' + }, + { + name: 'useragent', + align: 'left', + label: 'User agent', + field: 'useragent' + } + ], + pagination: { + rowsPerPage: 10, + sortBy: 'date', + descending: true + } + }, + qrCodeDialog: { + show: false, + wipe: false, + data: null + } + } + }, + methods: { + readNfcTag: function () { + try { + const self = this + + if (typeof NDEFReader == 'undefined') { + throw { + toString: function () { + return 'NFC not supported on this device or browser.' + } + } + } + + const ndef = new NDEFReader() + + const readerAbortController = new AbortController() + readerAbortController.signal.onabort = event => { + console.log('All NFC Read operations have been aborted.') + } + + this.nfcTagReading = true + this.$q.notify({ + message: 'Tap your NFC tag to copy its UID here.' + }) + + return ndef.scan({signal: readerAbortController.signal}).then(() => { + ndef.onreadingerror = () => { + self.nfcTagReading = false + + this.$q.notify({ + type: 'negative', + message: 'There was an error reading this NFC tag.' + }) + + readerAbortController.abort() + } + + ndef.onreading = ({message, serialNumber}) => { + //Decode NDEF data from tag + var self = this + self.cardDialog.data.uid = serialNumber + .toUpperCase() + .replaceAll(':', '') + this.$q.notify({ + type: 'positive', + message: 'NFC tag read successfully.' + }) + } + }) + } catch (error) { + this.nfcTagReading = false + this.$q.notify({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } + }, + getCards: function () { + var self = this + + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/cards?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.cards = response.data.map(function (obj) { + return mapCards(obj) + }) + }) + .then(function () { + self.getHits() + }) + }, + getHits: function () { + var self = this + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/hits?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.hits = response.data.map(function (obj) { + obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name + return mapCards(obj) + }) + }) + }, + getRefunds: function () { + var self = this + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/refunds?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.refunds = response.data.map(function (obj) { + return mapCards(obj) + }) + }) + }, + openQrCodeDialog(cardId, wipe) { + var card = _.findWhere(this.cards, {id: cardId}) + this.qrCodeDialog.data = { + id: card.id, + link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, + name: card.card_name, + uid: card.uid, + external_id: card.external_id, + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2 + } + this.qrCodeDialog.data_wipe = JSON.stringify({ + action: 'wipe', + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2, + uid: card.uid, + version: 1 + }) + this.qrCodeDialog.wipe = wipe + this.qrCodeDialog.show = true + }, + addCardOpen: function () { + this.cardDialog.show = true + this.generateKeys() + }, + generateKeys: function () { + var self = this + const genRanHex = size => + [...Array(size)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join('') + + debugcard = + typeof this.cardDialog.data.card_name === 'string' && + this.cardDialog.data.card_name.search('debug') > -1 + + self.cardDialog.data.k0 = debugcard + ? '11111111111111111111111111111111' + : genRanHex(32) + + self.cardDialog.data.k1 = debugcard + ? '22222222222222222222222222222222' + : genRanHex(32) + + self.cardDialog.data.k2 = debugcard + ? '33333333333333333333333333333333' + : genRanHex(32) + }, + closeFormDialog: function () { + this.cardDialog.data = {} + }, + sendFormData: function () { + let wallet = _.findWhere(this.g.user.wallets, { + id: this.cardDialog.data.wallet + }) + let data = this.cardDialog.data + if (data.id) { + this.updateCard(wallet, data) + } else { + this.createCard(wallet, data) + } + }, + createCard: function (wallet, data) { + var self = this + + LNbits.api + .request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data) + .then(function (response) { + self.cards.push(mapCards(response.data)) + self.cardDialog.show = false + self.cardDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + updateCardDialog: function (formId) { + var card = _.findWhere(this.cards, {id: formId}) + this.cardDialog.data = _.clone(card) + + this.cardDialog.temp.k0 = this.cardDialog.data.k0 + this.cardDialog.temp.k1 = this.cardDialog.data.k1 + this.cardDialog.temp.k2 = this.cardDialog.data.k2 + + this.cardDialog.show = true + }, + updateCard: function (wallet, data) { + var self = this + + if ( + this.cardDialog.temp.k0 != data.k0 || + this.cardDialog.temp.k1 != data.k1 || + this.cardDialog.temp.k2 != data.k2 + ) { + data.prev_k0 = this.cardDialog.temp.k0 + data.prev_k1 = this.cardDialog.temp.k1 + data.prev_k2 = this.cardDialog.temp.k2 + } + + LNbits.api + .request( + 'PUT', + '/boltcards/api/v1/cards/' + data.id, + wallet.adminkey, + data + ) + .then(function (response) { + self.cards = _.reject(self.cards, function (obj) { + return obj.id == data.id + }) + self.cards.push(mapCards(response.data)) + self.cardDialog.show = false + self.cardDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + enableCard: function (wallet, card_id, enable) { + var self = this + let fullWallet = _.findWhere(self.g.user.wallets, { + id: wallet + }) + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/cards/enable/' + card_id + '/' + enable, + fullWallet.adminkey + ) + .then(function (response) { + console.log(response.data) + self.cards = _.reject(self.cards, function (obj) { + return obj.id == response.data.id + }) + self.cards.push(mapCards(response.data)) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteCard: function (cardId) { + let self = this + let cards = _.findWhere(this.cards, {id: cardId}) + + Quasar.utils.exportFile( + cards.card_name + '.json', + this.qrCodeDialog.data_wipe, + 'application/json' + ) + + LNbits.utils + .confirmDialog( + "Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!" + ) + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/boltcards/api/v1/cards/' + cardId, + _.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey + ) + .then(function (response) { + self.cards = _.reject(self.cards, function (obj) { + return obj.id == cardId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + }, + exportCardsCSV: function () { + LNbits.utils.exportCSV(this.cardsTable.columns, this.cards) + }, + exportHitsCSV: function () { + LNbits.utils.exportCSV(this.hitsTable.columns, this.hits) + }, + exportRefundsCSV: function () { + LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds) + } + }, + created: function () { + if (this.g.user.wallets.length) { + this.getCards() + this.getRefunds() + } + } +}) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..375e700 --- /dev/null +++ b/tasks.py @@ -0,0 +1,47 @@ +import asyncio +import json + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + +from .crud import create_refund, get_hit + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue, get_current_extension_name()) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + + if not payment.extra.get("refund"): + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + + hit = await get_hit(str(payment.extra.get("refund"))) + + if hit: + await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000)) + await mark_webhook_sent(payment, 1) + + +async def mark_webhook_sent(payment: Payment, status: int) -> None: + + payment.extra["wh_status"] = status + + await core_db.execute( + """ + UPDATE apipayments SET extra = ? + WHERE hash = ? + """, + (json.dumps(payment.extra), payment.payment_hash), + ) diff --git a/templates/boltcards/_api_docs.html b/templates/boltcards/_api_docs.html new file mode 100644 index 0000000..ec5ed57 --- /dev/null +++ b/templates/boltcards/_api_docs.html @@ -0,0 +1,22 @@ + + + +
Be your own card association
+

+ Manage your Bolt Cards self custodian way
+ + More details +
+

+
+
+
diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html new file mode 100644 index 0000000..2091718 --- /dev/null +++ b/templates/boltcards/index.html @@ -0,0 +1,474 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + +
+
+
+
+
Cards
+
+
+ + Add card + +
+
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + +
+
+
Hits
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + +
+
+
Refunds
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Bolt Cards extension +
+
+ + + {% include "boltcards/_api_docs.html" %} + +
+
+ + + + + +
+
+ +
+
+ +
+
+ + +
+
+ + Get from the card you'll use, using an NFC app + +
+
+ + Tap card to scan UID + +
+
+ + +
+ + + + + + + Zero if you don't know. + + Generate keys +
+
+ Update Card + Create Card + + Cancel +
+
+
+
+ + + + {% raw %} +
+ + + +

+ (QR for create the card in + Boltcard NFC Card Creator) +

+ + + +

+ (QR for wipe the card in + Boltcard NFC Card Creator) +

+
+
+ +
+

+ Name: {{ qrCodeDialog.data.name }}
+ UID: {{ qrCodeDialog.data.uid }}
+ External ID: {{ qrCodeDialog.data.external_id }}
+ Lock key (K0): {{ qrCodeDialog.data.k0 }}
+ Meta key (K1 & K3): {{ qrCodeDialog.data.k1 }}
+ File key (K2 & K4): {{ qrCodeDialog.data.k2 }}
+

+

+ Always backup all keys that you're trying to write on the card. Without + them you may not be able to change them in the future! +

+ + Click to copy, then paste to NFC Card Creator + + + Click to copy, then paste to NFC Card Creator + + + Backup the keys, or wipe the card first! + + {% endraw %} +
+ Close +
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..273cfcb --- /dev/null +++ b/views.py @@ -0,0 +1,17 @@ +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import boltcards_ext, boltcards_renderer + +templates = Jinja2Templates(directory="templates") + + +@boltcards_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return boltcards_renderer().TemplateResponse( + "boltcards/index.html", {"request": request, "user": user.dict()} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..d242783 --- /dev/null +++ b/views_api.py @@ -0,0 +1,165 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Query + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import boltcards_ext +from .crud import ( + create_card, + delete_card, + enable_disable_card, + get_card, + get_card_by_uid, + get_cards, + get_hits, + get_refunds, + update_card, +) +from .models import CreateCardData + + +@boltcards_ext.get("/api/v1/cards") +async def api_cards( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + return [card.dict() for card in await get_cards(wallet_ids)] + + +@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED) +@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK) +async def api_card_create_or_update( + data: CreateCardData, + card_id: str = Query(None), + wallet: WalletTypeInfo = Depends(require_admin_key), +): + try: + if len(bytes.fromhex(data.uid)) != 7: + raise HTTPException( + detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k0)) != 16: + raise HTTPException( + detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k1)) != 16: + raise HTTPException( + detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k2)) != 16: + raise HTTPException( + detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST + ) + except: + raise HTTPException( + detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST + ) + if card_id: + card = await get_card(card_id) + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + if card.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your card.", status_code=HTTPStatus.FORBIDDEN + ) + checkUid = await get_card_by_uid(data.uid) + if checkUid and checkUid.id != card_id: + raise HTTPException( + detail="UID already registered. Delete registered card and try again.", + status_code=HTTPStatus.BAD_REQUEST, + ) + card = await update_card(card_id, **data.dict()) + else: + checkUid = await get_card_by_uid(data.uid) + if checkUid: + raise HTTPException( + detail="UID already registered. Delete registered card and try again.", + status_code=HTTPStatus.BAD_REQUEST, + ) + card = await create_card(wallet_id=wallet.wallet.id, data=data) + assert card + return card.dict() + + +@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK) +async def enable_card( + card_id, + enable, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + card = await get_card(card_id) + if not card: + raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) + if card.wallet != wallet.wallet.id: + raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) + card = await enable_disable_card(enable=enable, id=card_id) + assert card + return card.dict() + + +@boltcards_ext.delete("/api/v1/cards/{card_id}") +async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)): + card = await get_card(card_id) + + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if card.wallet != wallet.wallet.id: + raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) + + await delete_card(card_id) + return "", HTTPStatus.NO_CONTENT + + +@boltcards_ext.get("/api/v1/hits") +async def api_hits( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + cards = await get_cards(wallet_ids) + cards_ids = [] + for card in cards: + cards_ids.append(card.id) + + return [hit.dict() for hit in await get_hits(cards_ids)] + + +@boltcards_ext.get("/api/v1/refunds") +async def api_refunds( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + cards = await get_cards(wallet_ids) + cards_ids = [] + for card in cards: + cards_ids.append(card.id) + hits = await get_hits(cards_ids) + hits_ids = [] + for hit in hits: + hits_ids.append(hit.id) + + return [refund.dict() for refund in await get_refunds(hits_ids)]