From 6893f409be33182db7fb4a1550554be8eaa961fb Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 7 Aug 2026 21:52:31 +0200 Subject: [PATCH] feat(lnurl): access-control verify endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/verify/{external_id}?p=&c= — side-effect-light SUN check for access control (doors via the aiolabs extension): confirms a genuine, non-replayed tap and returns the card identity (external_id, card_name), WITHOUT /scan's spend semantics (no withdrawRequest, no daily-limit, no hit). It still advances the SUN counter, so a captured p/c can't be replayed. Named 'verify' because /auth is already the card-programming OTP endpoint. config.json → 1.1.1-aio.2. --- config.json | 2 +- views_lnurl.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index d82c719..34b358a 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "name": "Bolt Cards", "short_description": "Self custody Bolt Cards with one time LNURLw", "tile": "/boltcards/static/image/boltcard.png", - "version": "1.1.1-aio.1", + "version": "1.1.1-aio.2", "min_lnbits_version": "1.3.0", "contributors": [ { diff --git a/views_lnurl.py b/views_lnurl.py index bdf3534..54d5915 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -394,3 +394,41 @@ async def pay_callback( action = MessageAction(message=Max144Str("Topped up!")) invoice = parse_obj_as(LightningInvoice, payment.bolt11) return LnurlPayActionResponse(pr=invoice, successAction=action) + + +###############ACCESS-CONTROL VERIFY (doors)################# +# /boltcards/api/v1/verify/{external_id}?p=<32-hex>&c=<16-hex> +# Side-effect-light SUN check for access control (e.g. doors via the `access` +# extension): confirm the tap is a genuine, non-replayed card and return its +# identity — WITHOUT /scan's spend semantics (no withdrawRequest, no daily-limit +# check, no "hit" record). It DOES advance the SUN counter, exactly like /scan, +# so a captured p/c can't be replayed. Named `verify` because `/auth` is already +# the card-programming OTP endpoint. +@boltcards_lnurl_router.get("/api/v1/verify/{external_id}") +async def api_verify(p, c, external_id: str): + p = p.upper() + c = c.upper() + card = await get_card_by_external_id(external_id) + if not card: + return {"authenticated": False, "reason": "Card not found."} + if not card.enable: + return {"authenticated": False, "reason": "Card is disabled."} + try: + card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1)) + if card.uid.upper() != card_uid.hex().upper(): + return {"authenticated": False, "reason": "Card UID mis-match."} + if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): + return {"authenticated": False, "reason": "CMAC does not check."} + except Exception: + return {"authenticated": False, "reason": "Error decrypting card."} + + ctr_int = int.from_bytes(counter, "little") + if ctr_int <= card.counter: + return {"authenticated": False, "reason": "This link is already used."} + await update_card_counter(ctr_int, card.id) + + return { + "authenticated": True, + "external_id": card.external_id, + "card_name": card.card_name, + }