From ec2b15c08fc4edafafdaae414336dc8cbf3e172c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 17:07:17 +0200 Subject: [PATCH] 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 --- docs/adr/003-nfc-access-control-layer.md | 4 +- docs/boltcard-receive-resolver.md | 6 ++ docs/boltcard-session.md | 117 +++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 docs/boltcard-session.md diff --git a/docs/adr/003-nfc-access-control-layer.md b/docs/adr/003-nfc-access-control-layer.md index 6079fd1..b5ebcdb 100644 --- a/docs/adr/003-nfc-access-control-layer.md +++ b/docs/adr/003-nfc-access-control-layer.md @@ -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. - **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. -- **Open enrollment is not a security boundary.** With `openEnrollment` on (the current posture on every gated machine), any NDEF tag whose URL contains `/scan/` 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. +- **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/?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 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. - **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. diff --git a/docs/boltcard-receive-resolver.md b/docs/boltcard-receive-resolver.md index d505d3c..02cfbc0 100644 --- a/docs/boltcard-receive-resolver.md +++ b/docs/boltcard-receive-resolver.md @@ -99,6 +99,12 @@ wallet …". ## 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 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 diff --git a/docs/boltcard-session.md b/docs/boltcard-session.md new file mode 100644 index 0000000..2daa775 --- /dev/null +++ b/docs/boltcard-session.md @@ -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/", + "k1": "", + "minWithdrawable": 1000, + "maxWithdrawable": 50000000 + }, + "withdraw_blocked_reason": null, + "pay": { + "callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/", + "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=&pr=` 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=` 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/?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.