docs: Bolt Card session contract, ADR-003 amendment for verified entry
docs/boltcard-session.md is the /session wire contract (sibling of boltcard-receive-resolver.md), including the trust boundary: the session URL is derived from the card's own host, so open enrollment is still not a security boundary (#91). ADR-003's amendment now records verified entry via /session and the hidden-by-default balance display. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
6779d8ef55
commit
ec2b15c08f
3 changed files with 125 additions and 2 deletions
117
docs/boltcard-session.md
Normal file
117
docs/boltcard-session.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Bolt Card session — one verified tap for a terminal visit
|
||||
|
||||
Wire contract for the `/session` endpoint the ATM's access gate (ADR-003
|
||||
tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the
|
||||
aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by
|
||||
`apps/machine/electron/boltcard-session.ts`.
|
||||
|
||||
## Why a session
|
||||
|
||||
A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it
|
||||
and advances the card's read counter, so any endpoint that checks it — `/scan`,
|
||||
`/pay`, `/verify` — spends it. The gate wants two things from one tap:
|
||||
|
||||
1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and
|
||||
we can show the holder their balance.
|
||||
2. **Complete without a second tap** — the buy or sell later in the visit
|
||||
moves sats with the same card.
|
||||
|
||||
`/session` does the verification once and hands back the _second steps_ of
|
||||
both LNURL flows, keyed by a single-use server-side `hit` — the same bearer
|
||||
`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c`
|
||||
afterwards.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/session/{external_id}?p={p}&c={c}
|
||||
```
|
||||
|
||||
Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM
|
||||
derives it by string substitution on the tapped `lnurlw`
|
||||
(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared
|
||||
helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed
|
||||
counter all reject with `/scan`'s reasons. On success the counter advances and
|
||||
one `hit` is recorded.
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"external_id": "abc123",
|
||||
"card_name": "Alice",
|
||||
"balance_msat": 123456000,
|
||||
"currency": "USD",
|
||||
"fiat": 98.76,
|
||||
"withdraw": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/<hit>",
|
||||
"k1": "<hit>",
|
||||
"minWithdrawable": 1000,
|
||||
"maxWithdrawable": 50000000
|
||||
},
|
||||
"withdraw_blocked_reason": null,
|
||||
"pay": {
|
||||
"callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/<hit>",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 50000000,
|
||||
"metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `balance_msat` — the card wallet's balance. Display only.
|
||||
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
|
||||
it: the wallet's own currency (per-wallet setting) first, else the instance's
|
||||
default accounting currency, at the server's rate. `null` when the server has
|
||||
no currency or the rate lookup failed; the ATM then prices the sats itself in
|
||||
its own fiat at its display rate. A rate failure never fails the session.
|
||||
- `withdraw` — the LUD-03 second step. The ATM calls
|
||||
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
||||
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
||||
spent); cash-in stays possible.
|
||||
- `pay` — the LUD-06 second step. The ATM calls `callback?amount=<msat>` at
|
||||
cash-in Complete and pays the returned BOLT11 over its own nostr transport.
|
||||
- Limits are the card's `tx_limit`, as on `/scan` and `/pay`.
|
||||
|
||||
Rejection:
|
||||
|
||||
```json
|
||||
{ "authenticated": false, "reason": "This link is already used." }
|
||||
```
|
||||
|
||||
`reason` is surfaced verbatim on the locked screen — terse, non-sensitive.
|
||||
|
||||
## Semantics of the hit
|
||||
|
||||
- The first withdraw that uses the hit spends it (`spent = true`), exactly as
|
||||
after a `/scan`; a second withdraw is refused with "Payment already claimed."
|
||||
- A top-up does not mark the hit spent (as `/pay` today).
|
||||
- The ATM treats the whole session as single-shot regardless: after the first
|
||||
Complete attempt, accepted or declined, it drops the session and asks for a
|
||||
re-tap (`completeWithCard` in `stores/atm.ts`).
|
||||
- Hits do not expire server-side. The ATM's session security (60 s idle,
|
||||
10 min cap, End Session) bounds how long one is held.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
locked ── tap ─▶ GET /session/<id>?p=&c= (spends the SUN)
|
||||
├─ authenticated:false → stay locked, show reason
|
||||
└─ authenticated:true → authorize(external_id) → idle, session held
|
||||
CardChip: "Alice · ••c123 •••••• [eye]"
|
||||
sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense
|
||||
buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete
|
||||
… re-lock (End Session / idle / complete) drops the session
|
||||
```
|
||||
|
||||
## Trust boundary (read this)
|
||||
|
||||
The ATM derives the session URL from the **card's own `lnurlw` host**. With
|
||||
`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that
|
||||
answers `{"authenticated": true, …}` still unlocks the terminal. Money is not
|
||||
at risk — a fake server can only make the ATM pay an invoice the holder chose
|
||||
(cash-in) or accept a pull it never honours (cash-out never dispenses without
|
||||
`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was
|
||||
told to ask. Closing that means pinning the card-server host(s) the gate
|
||||
accepts; tracked in aiolabs/bitspire#91.
|
||||
Loading…
Add table
Add a link
Reference in a new issue