Compare commits

..

1 commit

Author SHA1 Message Date
Patrick Mulligan
6893f409be feat(lnurl): access-control verify endpoint
Some checks failed
lint.yml / feat(lnurl): access-control verify endpoint (push) Failing after 0s
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.
2026-08-07 21:52:31 +02:00
2 changed files with 39 additions and 1 deletions

View file

@ -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": [
{

View file

@ -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,
}