feat(lnurl): /session — one verified tap for a terminal visit
Some checks failed
lint.yml / feat(lnurl): /session — one verified tap for a terminal visit (pull_request) Failing after 0s
Some checks failed
lint.yml / feat(lnurl): /session — one verified tap for a terminal visit (pull_request) Failing after 0s
GET /api/v1/session/{external_id}?p=&c= for bitSpire tap-to-enter. A tap
yields a single-use SUN, so a terminal that verified it at entry could not
reuse the p/c to move sats later. This verifies once (advancing the
counter like /scan), records one hit, and returns what the rest of the
visit needs: the card wallet's balance + fiat equivalent (wallet currency,
then the instance default; display only), the LUD-03 withdraw step
(callback, k1 = hit) and the LUD-06 top-up step (callback), both keyed by
the hit — the same single-use bearer /scan and /pay already hand out.
Withdraw is withheld with a reason once the daily limit is spent, as
/scan would refuse; top-up stays available.
Tests drive the real decrypt/CMAC path with a SUN encrypted under the
card's keys, and pin /verify + /pay behaviour across the helper refactor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
3e614428c0
commit
4e9cd78b59
2 changed files with 332 additions and 0 deletions
250
tests/test_session.py
Normal file
250
tests/test_session.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""
|
||||
/session, and the shared tap-authentication helper it introduced, exercised
|
||||
with a genuine SUN: the test encrypts the PICC data with the card's k1 and
|
||||
CMACs it with k2 exactly as an NTAG424 does, so the decrypt/verify path in
|
||||
`_authenticate_tap` runs for real. Storage and LNbits core are stubbed at the
|
||||
module boundary.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from Cryptodome.Cipher import AES
|
||||
|
||||
from .. import views_lnurl as v
|
||||
from ..models import Card, Hit
|
||||
from ..nxp424 import get_sun_mac
|
||||
|
||||
K1 = bytes.fromhex("0F1E2D3C4B5A69788796A5B4C3D2E1F0")
|
||||
K2 = bytes.fromhex("F0E1D2C3B4A5968778695A4B3C2D1E0F")
|
||||
UID = bytes.fromhex("04A1B2C3D4E5F6") # 7-byte NTAG424 UID
|
||||
|
||||
|
||||
def make_sun(counter: int) -> tuple[str, str]:
|
||||
"""(p, c) as the card would emit them for this read counter."""
|
||||
ctr = counter.to_bytes(3, "little")
|
||||
plain = b"\xc7" + UID + ctr + b"\x00" * 5 # 16 bytes: tag, uid, ctr, pad
|
||||
p = AES.new(K1, AES.MODE_CBC, b"\x00" * 16).encrypt(plain).hex().upper()
|
||||
c = get_sun_mac(UID, ctr, K2).hex().upper()
|
||||
return p, c
|
||||
|
||||
|
||||
def make_card(
|
||||
counter: int = 5, enable: bool = True, daily_limit: int = 100_000
|
||||
) -> Card:
|
||||
return Card(
|
||||
id="card1",
|
||||
wallet="wallet1",
|
||||
card_name="Alice",
|
||||
uid=UID.hex().upper(),
|
||||
external_id="ext123",
|
||||
counter=counter,
|
||||
tx_limit=50_000,
|
||||
daily_limit=daily_limit,
|
||||
enable=enable,
|
||||
k0="00" * 16,
|
||||
k1=K1.hex(),
|
||||
k2=K2.hex(),
|
||||
prev_k0="",
|
||||
prev_k1="",
|
||||
prev_k2="",
|
||||
otp="",
|
||||
time=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
def make_hit(amount: int = 0, hit_id: str = "hit1") -> Hit:
|
||||
return Hit(
|
||||
id=hit_id,
|
||||
card_id="card1",
|
||||
ip="1.2.3.4",
|
||||
spent=False,
|
||||
useragent="test",
|
||||
old_ctr=5,
|
||||
new_ctr=6,
|
||||
amount=amount,
|
||||
time=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self) -> None:
|
||||
self.client = SimpleNamespace(host="1.2.3.4")
|
||||
self.headers = {"user-agent": "bitspire-test"}
|
||||
|
||||
def url_for(self, name: str, **params: str) -> str:
|
||||
return f"https://lnbits.test/boltcards/{name}/{params['hit_id']}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stubs(monkeypatch):
|
||||
"""Stub storage + LNbits core; returns a dict the tests can tweak/inspect."""
|
||||
state = {
|
||||
"card": make_card(),
|
||||
"hits_today": [],
|
||||
"updated_counter": None,
|
||||
"created_hits": [],
|
||||
"wallet": SimpleNamespace(balance_msat=123_456_000, currency="USD"),
|
||||
"default_currency": None,
|
||||
}
|
||||
|
||||
async def get_card_by_external_id(external_id):
|
||||
return state["card"] if external_id == state["card"].external_id else None
|
||||
|
||||
async def update_card_counter(ctr, card_id):
|
||||
state["updated_counter"] = ctr
|
||||
|
||||
async def get_hits_today(card_id):
|
||||
return state["hits_today"]
|
||||
|
||||
async def create_hit(card_id, ip, agent, old_ctr, new_ctr):
|
||||
hit = make_hit(hit_id=f"hit{len(state['created_hits']) + 1}")
|
||||
state["created_hits"].append((card_id, ip, agent, old_ctr, new_ctr))
|
||||
return hit
|
||||
|
||||
async def get_wallet(wallet_id):
|
||||
return state["wallet"]
|
||||
|
||||
async def satoshis_amount_as_fiat(amount, currency):
|
||||
assert currency == "USD"
|
||||
return amount * 0.0008 # 123456 sats → 98.7648
|
||||
|
||||
monkeypatch.setattr(v, "get_card_by_external_id", get_card_by_external_id)
|
||||
monkeypatch.setattr(v, "update_card_counter", update_card_counter)
|
||||
monkeypatch.setattr(v, "get_hits_today", get_hits_today)
|
||||
monkeypatch.setattr(v, "create_hit", create_hit)
|
||||
monkeypatch.setattr(v, "get_wallet", get_wallet)
|
||||
monkeypatch.setattr(v, "satoshis_amount_as_fiat", satoshis_amount_as_fiat)
|
||||
monkeypatch.setattr(
|
||||
v.settings, "lnbits_default_accounting_currency", state["default_currency"]
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_grants_balance_and_both_callbacks(stubs):
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
|
||||
assert out["authenticated"] is True
|
||||
assert out["external_id"] == "ext123"
|
||||
assert out["card_name"] == "Alice"
|
||||
assert out["balance_msat"] == 123_456_000
|
||||
assert out["currency"] == "USD"
|
||||
assert out["fiat"] == pytest.approx(98.7648)
|
||||
# One hit, and both second steps are keyed by it.
|
||||
assert len(stubs["created_hits"]) == 1
|
||||
assert out["withdraw"] == {
|
||||
"callback": "https://lnbits.test/boltcards/boltcards.lnurl_callback/hit1",
|
||||
"k1": "hit1",
|
||||
"minWithdrawable": 1000,
|
||||
"maxWithdrawable": 50_000_000,
|
||||
}
|
||||
assert out["withdraw_blocked_reason"] is None
|
||||
assert (
|
||||
out["pay"]["callback"]
|
||||
== "https://lnbits.test/boltcards/boltcards.pay_callback/hit1"
|
||||
)
|
||||
assert out["pay"]["maxSendable"] == 50_000_000
|
||||
assert out["pay"]["metadata"] == v._TOPUP_METADATA
|
||||
# The SUN counter advanced, so the same p/c can't be replayed.
|
||||
assert stubs["updated_counter"] == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_accepts_lowercase_p_and_c(stubs):
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_session(p.lower(), c.lower(), FakeRequest(), "ext123")
|
||||
assert out["authenticated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_rejects_replayed_counter(stubs):
|
||||
p, c = make_sun(counter=5) # card.counter is already 5
|
||||
out = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
assert out == {"authenticated": False, "reason": "This link is already used."}
|
||||
assert stubs["updated_counter"] is None
|
||||
assert stubs["created_hits"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_rejects_bad_cmac_and_unknown_card(stubs):
|
||||
p, c = make_sun(counter=6)
|
||||
bad = await v.api_session(p, "00" * 8, FakeRequest(), "ext123")
|
||||
assert bad == {"authenticated": False, "reason": "CMAC does not check."}
|
||||
missing = await v.api_session(p, c, FakeRequest(), "nope")
|
||||
assert missing == {"authenticated": False, "reason": "Card not found."}
|
||||
stubs["card"] = make_card(enable=False)
|
||||
disabled = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
assert disabled == {"authenticated": False, "reason": "Card is disabled."}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_withholds_withdraw_over_daily_limit_but_keeps_pay(stubs):
|
||||
stubs["card"] = make_card(daily_limit=1_000)
|
||||
stubs["hits_today"] = [make_hit(amount=900), make_hit(amount=200)]
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
assert out["authenticated"] is True
|
||||
assert out["withdraw"] is None
|
||||
assert out["withdraw_blocked_reason"] == "Max daily limit spent."
|
||||
assert out["pay"]["callback"].endswith("/hit1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_without_currency_has_no_fiat(stubs):
|
||||
stubs["wallet"] = SimpleNamespace(balance_msat=5_000, currency=None)
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
assert out["balance_msat"] == 5_000
|
||||
assert out["currency"] is None
|
||||
assert out["fiat"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_falls_back_to_instance_default_currency(stubs, monkeypatch):
|
||||
stubs["wallet"] = SimpleNamespace(balance_msat=5_000, currency=None)
|
||||
monkeypatch.setattr(v.settings, "lnbits_default_accounting_currency", "USD")
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
assert out["currency"] == "USD"
|
||||
assert out["fiat"] == pytest.approx(5 * 0.0008)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_survives_rate_failure(stubs, monkeypatch):
|
||||
async def boom(amount, currency):
|
||||
raise ValueError("no rate")
|
||||
|
||||
monkeypatch.setattr(v, "satoshis_amount_as_fiat", boom)
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_session(p, c, FakeRequest(), "ext123")
|
||||
assert out["authenticated"] is True
|
||||
assert out["fiat"] is None
|
||||
|
||||
|
||||
# The refactor moved /verify and /pay onto the shared helper — pin their
|
||||
# behaviour so the extraction can't have changed it.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_still_authenticates_via_shared_helper(stubs):
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_verify(p, c, "ext123")
|
||||
assert out == {"authenticated": True, "external_id": "ext123", "card_name": "Alice"}
|
||||
assert stubs["updated_counter"] == 6
|
||||
# The counter stub doesn't persist, so replay against a card already at 6.
|
||||
stubs["card"] = make_card(counter=6)
|
||||
replay = await v.api_verify(p, c, "ext123")
|
||||
assert replay == {"authenticated": False, "reason": "This link is already used."}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pay_still_returns_pay_request_via_shared_helper(stubs):
|
||||
p, c = make_sun(counter=6)
|
||||
out = await v.api_pay(p, c, FakeRequest(), "ext123")
|
||||
assert str(out.callback).endswith("/boltcards.pay_callback/hit1")
|
||||
assert int(out.maxSendable) == 50_000_000
|
||||
bad = await v.api_pay(p, "00" * 8, FakeRequest(), "ext123")
|
||||
assert bad.reason == "CMAC does not check."
|
||||
Loading…
Add table
Add a link
Reference in a new issue