feat(access): verified Bolt Card session at entry + hidden-by-default balance #93
3 changed files with 125 additions and 2 deletions
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>
commit
ec2b15c08f
|
|
@ -10,8 +10,8 @@ The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the np
|
||||||
|
|
||||||
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
|
- **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types.
|
||||||
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
|
- **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone.
|
||||||
- **Soft entry, verify-at-payment.** A tap yields a single-use SUN `p`/`c`. Verifying it at entry would spend the voucher we want to reuse at Complete, so entry makes **no server call**. The stored `lnurlw` is presented once, at Complete, through the #83 / #84 payment paths, and that is where the cryptographic check happens. Consequence: the loaded card is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps.
|
- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/<id>?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate).
|
||||||
- **Open enrollment is not a security boundary.** With `openEnrollment` on (the current posture on every gated machine), any NDEF tag whose URL contains `/scan/<id>` unlocks the terminal. The gate keeps casual users off the menu; money still only moves on a valid SUN. Closing this — a provisioned allow-list, or a verify-at-entry variant that spends one tap — is tracked in aiolabs/bitspire#91.
|
- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91.
|
||||||
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
|
- **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately.
|
||||||
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
|
- **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**.
|
||||||
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
|
- **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,12 @@ wallet …".
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
- **Tap-to-enter uses `/session` instead.** When the access gate is on, the
|
||||||
|
card was already verified at entry and the ATM holds the LUD-06 second step
|
||||||
|
from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback`
|
||||||
|
directly and never touches `/pay`. `/pay` remains the path for a card tapped
|
||||||
|
directly on the cash-in screen (gate off, or a second card).
|
||||||
|
|
||||||
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
||||||
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
||||||
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
||||||
|
|
|
||||||
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