From a653a5327be396d69fb92e0db798f7a0f4d0ea1b Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 14 Mar 2021 14:21:26 -0300 Subject: [PATCH] support totp confirmation method. --- lnbits/extensions/offlineshop/crud.py | 6 +- lnbits/extensions/offlineshop/helpers.py | 17 ++++ lnbits/extensions/offlineshop/lnurl.py | 6 +- lnbits/extensions/offlineshop/migrations.py | 1 + lnbits/extensions/offlineshop/models.py | 23 ++++- .../extensions/offlineshop/static/js/index.js | 21 +++-- .../templates/offlineshop/index.html | 83 +++++++++++++++++-- lnbits/extensions/offlineshop/views.py | 6 +- lnbits/extensions/offlineshop/views_api.py | 14 ++-- 9 files changed, 147 insertions(+), 30 deletions(-) diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py index 5da350a5e..365015a3e 100644 --- a/lnbits/extensions/offlineshop/crud.py +++ b/lnbits/extensions/offlineshop/crud.py @@ -32,10 +32,10 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: return Shop(**dict(row)) if row else None -async def set_wordlist(shop: int, wordlist: str) -> Optional[Shop]: +async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]: await db.execute( - "UPDATE shops SET wordlist = ? WHERE id = ?", - (wordlist, shop), + "UPDATE shops SET method = ?, wordlist = ? WHERE id = ?", + (method, wordlist, shop), ) return await get_shop(shop) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index 13e748574..2930809dc 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -1,5 +1,9 @@ import trio # type: ignore import httpx +import base64 +import struct +import hmac +import time async def get_fiat_rate(currency: str): @@ -46,3 +50,16 @@ async def get_usd_rate(): satoshi_prices = [x for x in satoshi_prices if x] return sum(satoshi_prices) / len(satoshi_prices) + + +def hotp(key, counter, digits=6, digest="sha1"): + key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) + counter = struct.pack(">Q", counter) + mac = hmac.new(key, counter, digest).digest() + offset = mac[-1] & 0x0F + binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF + return str(binary)[-digits:].zfill(digits) + + +def totp(key, time_step=30, digits=6, digest="sha1"): + return hotp(key, int(time.time() / time_step), digits, digest) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index e53a145cd..f58a59ebe 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -61,6 +61,10 @@ async def lnurl_callback(item_id): extra={"tag": "offlineshop", "item": item.id}, ) - resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(shop, payment_hash), routes=[]) + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=item.success_action(shop, payment_hash) if shop.method else None, + routes=[], + ) return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py index 72e9e501d..8e8a4877b 100644 --- a/lnbits/extensions/offlineshop/migrations.py +++ b/lnbits/extensions/offlineshop/migrations.py @@ -7,6 +7,7 @@ async def m001_initial(db): CREATE TABLE shops ( id INTEGER PRIMARY KEY AUTOINCREMENT, wallet TEXT NOT NULL, + method TEXT NOT NULL, wordlist TEXT ); """ diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 175b440b0..52cf95f28 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -1,4 +1,6 @@ import json +import base64 +import hashlib from collections import OrderedDict from quart import url_for from typing import NamedTuple, Optional, List, Dict @@ -6,6 +8,8 @@ from lnurl import encode as lnurl_encode # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +from .helpers import totp + shop_counters: Dict = {} @@ -53,11 +57,24 @@ class ShopCounter(object): class Shop(NamedTuple): id: int wallet: str + method: str wordlist: str - def get_word(self, payment_hash): - sc = ShopCounter.invoke(self) - return sc.get_word(payment_hash) + @property + def otp_key(self) -> str: + return base64.b32encode( + hashlib.sha256( + ("otpkey" + str(self.id) + self.wallet).encode("ascii"), + ).digest() + ).decode("ascii") + + def get_code(self, payment_hash: str) -> str: + if self.method == "wordlist": + sc = ShopCounter.invoke(self) + return sc.get_word(payment_hash) + elif self.method == "totp": + return totp(self.otp_key) + return "" class Item(NamedTuple): diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index 84b6f8650..f1d612f4e 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -14,7 +14,10 @@ new Vue({ data() { return { selectedWallet: null, + confirmationMethod: 'wordlist', + wordlistTainted: false, offlineshop: { + method: null, wordlist: [], items: [] }, @@ -80,28 +83,32 @@ new Vue({ ) .then(response => { this.offlineshop = response.data + this.confirmationMethod = response.data.method + this.wordlistTainted = false }) .catch(err => { LNbits.utils.notifyApiError(err) }) }, - async updateWordlist() { + async setMethod() { try { await LNbits.api.request( 'PUT', - '/offlineshop/api/v1/offlineshop/wordlist', + '/offlineshop/api/v1/offlineshop/method', this.selectedWallet.inkey, - {wordlist: this.offlineshop.wordlist} + {method: this.confirmationMethod, wordlist: this.offlineshop.wordlist} ) - this.$q.notify({ - message: `Wordlist updated. Counter reset.`, - timeout: 700 - }) } catch (err) { LNbits.utils.notifyApiError(err) return } + this.$q.notify({ + message: + `Method set to ${this.confirmationMethod}.` + + (this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''), + timeout: 700 + }) this.loadShop() }, async sendItem() { diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html index 8de5a8ede..6dfbc9933 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -120,17 +120,41 @@ - -
-
Wordlist
-
- + + + + + + + +
- +
-
- +
+ Update Wordlist
+ +
+
+
+ + + +
+
+ + Set TOTP + +
+
+
+ +
+

+ Setting this option disables the confirmation code message that + appears in the consumer wallet after a purchase is paid for. It's ok + if the consumer is to be trusted when they claim to have paid. +

+ + + Disable Confirmation Codes + +
diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index 86c9d9c0a..aa4ac3a13 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -51,9 +51,9 @@ async def confirmation_code(): return ( f""" -[{shop.get_word(payment_hash)}] -{item.name} -{item.price} {item.unit} +[{shop.get_code(payment_hash)}]
+{item.name}
+{item.price} {item.unit}
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} """ + style diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 211306e54..fd60014a4 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -7,7 +7,7 @@ from lnbits.decorators import api_check_wallet_key, api_validate_post_request from . import offlineshop_ext from .crud import ( get_or_create_shop_by_wallet, - set_wordlist, + set_method, add_item, update_item, get_items, @@ -28,6 +28,7 @@ async def api_shop_from_wallet(): { **shop._asdict(), **{ + "otp_key": shop.otp_key, "items": [item.values() for item in items], }, } @@ -86,14 +87,17 @@ async def api_delete_item(item_id): return "", HTTPStatus.NO_CONTENT -@offlineshop_ext.route("/api/v1/offlineshop/wordlist", methods=["PUT"]) +@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"]) @api_check_wallet_key("invoice") @api_validate_post_request( schema={ - "wordlist": {"type": "string", "empty": True, "nullable": True, "required": True}, + "method": {"type": "string", "required": True, "nullable": False}, + "wordlist": {"type": "string", "empty": True, "nullable": True, "required": False}, } ) -async def api_set_wordlist(): +async def api_set_method(): + method = g.data["method"] + wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None wordlist = [word.strip() for word in wordlist if word.strip()] @@ -101,7 +105,7 @@ async def api_set_wordlist(): if not shop: return "", HTTPStatus.NOT_FOUND - updated_shop = await set_wordlist(shop.id, "\n".join(wordlist)) + updated_shop = await set_method(shop.id, method, "\n".join(wordlist)) if not updated_shop: return "", HTTPStatus.NOT_FOUND