feat(access): Bolt Card tap-to-enter access gate (ADR-003) #86

Merged
padreug merged 11 commits from feat/access-control-skeleton into dev 2026-09-20 13:16:22 +00:00
Owner

Adds an opt-in access gate: the terminal boots into a locked state and a single Bolt Card tap unlocks it and pre-loads the card, so buy/sell only need a Complete press — no second tap. Rebased onto current dev; validated live on the batm3.

The flow

locked ── tap card ──▶ authorize(external_id) [open-enrollment] ──▶ idle, session card loaded
buy:  insert cash → Complete Purchase → resolveCardInvoice(stored) pays the card wallet
sell: pick amount → Complete Sale     → executeLnurlWithdraw(stored) pulls from the card
… idle unattended 60s → auto re-locks (card cleared)

Key design: soft entry, verify-at-payment

A Bolt Card tap yields single-use SUN p/c, so we can't both verify at entry and reuse for payment. Resolution:

  • Entry parses the lnurlw locally (external_id only) — no server call, so the p/c stay valid. Only the external_id is ever hashed; the p/c never touch the authorize layer (KYC-free).
  • Complete is the cryptographic check — the stored lnurlw moves sats via the existing #83/#84 paths (executeLnurlWithdraw / resolveCardInvoice). The hard check lands where money moves.

Non-breaking: accessControl.enabled defaults off → the locked state's always bypass settles straight to idle, identical to today. Enabled per-machine via /var/lib/bitspire/access.json (provisioning, no rebuild).

Commits

  1. Gate skeleton — locked state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK + accessBypass/devUnlockAllowed guards; services/access (authorize, allow-list, salted hashes); config loader; ADR-003.
  2. Tap-to-enter — boltcard credential (external_id + lnurlw) + local parser; loadedBoltCard session state; NFC routing (locked→enter); completeWithCard; card-only LockedView; Complete buttons on the cash views.
  3. Re-lock router fix — reset the router to / on re-lock so the reopened gate lands on IdleView, not the stale transaction view (fixes a stuck "collect" screen found in testing).
  4. Idle auto-lock — IDLE_LOCK_TIMEOUT (60s) on idle → locked (guarded to gate-active) so an unattended unlocked session with a loaded card returns to the lock screen.
  5. END_SESSION scoped to idle — the hard session cap (and End Session) no longer lock mid-transaction; a root-level END_SESSION bypassed confirmAbandon / an in-flight dispense and could strand a customer's bills. Every transaction already returns to locked on its own, so the timers just defer until the machine is back at the menu.
  6. Dev unlock defaults OFF — ACCESS_DEV_UNLOCK is now opt-in and access.example.json ships it off, so a gated machine never shows a bypass button unless asked.

Product decisions (this PR)

Soft entry · open enrollment (any card) · card-only (npub-QR/PIN dropped from the entry UI, but the union + allow-list + PIN code paths remain for future use).

Tests / validation

  • 38 state-machine tests (incl. gate, END_SESSION ignored mid-transaction), 17 access-service tests (npub + boltcard + parser), full machine build (typecheck + vite + electron) — all green.
  • Live on batm3: tap → enter (card chip) → sell → Complete → dispense → re-lock → tap → clean idle; idle-unattended → auto re-lock. Paired to …/nostrrelay/sapien.

Notes

  • Depends on the l484 boltcards 1.1.1-aio.1 /pay endpoint for the buy path (already forked/tagged).
  • Combined with fix/nfc-reader-auto-recovery (#85) only on the throwaway deploy/access-plus-nfc-recovery branch used for the on-device build; that fix is its own PR.

🤖 Generated with Claude Code

Adds an opt-in **access gate**: the terminal boots into a `locked` state and a single **Bolt Card tap** unlocks it *and* pre-loads the card, so buy/sell only need a **Complete** press — no second tap. Rebased onto current `dev`; validated live on the batm3. ## The flow ``` locked ── tap card ──▶ authorize(external_id) [open-enrollment] ──▶ idle, session card loaded buy: insert cash → Complete Purchase → resolveCardInvoice(stored) pays the card wallet sell: pick amount → Complete Sale → executeLnurlWithdraw(stored) pulls from the card … idle unattended 60s → auto re-locks (card cleared) ``` ## Key design: soft entry, verify-at-payment A Bolt Card tap yields **single-use** SUN `p`/`c`, so we can't both verify at entry *and* reuse for payment. Resolution: - **Entry** parses the `lnurlw` **locally** (`external_id` only) — no server call, so the p/c stay valid. Only the `external_id` is ever hashed; the p/c never touch the authorize layer (**KYC-free**). - **Complete** is the cryptographic check — the stored `lnurlw` moves sats via the existing #83/#84 paths (`executeLnurlWithdraw` / `resolveCardInvoice`). The hard check lands where money moves. Non-breaking: `accessControl.enabled` defaults **off** → the `locked` state's `always` bypass settles straight to `idle`, identical to today. Enabled per-machine via `/var/lib/bitspire/access.json` (provisioning, no rebuild). ## Commits 1. **Gate skeleton** — `locked` state + `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` + `accessBypass`/`devUnlockAllowed` guards; `services/access` (authorize, allow-list, salted hashes); config loader; ADR-003. 2. **Tap-to-enter** — `boltcard` credential (external_id + lnurlw) + local parser; `loadedBoltCard` session state; NFC routing (locked→enter); `completeWithCard`; card-only `LockedView`; **Complete** buttons on the cash views. 3. **Re-lock router fix** — reset the router to `/` on re-lock so the reopened gate lands on `IdleView`, not the stale transaction view (fixes a stuck "collect" screen found in testing). 4. **Idle auto-lock** — `IDLE_LOCK_TIMEOUT` (60s) on `idle → locked` (guarded to gate-active) so an unattended unlocked session with a loaded card returns to the lock screen. 5. **END_SESSION scoped to idle** — the hard session cap (and End Session) no longer lock mid-transaction; a root-level END_SESSION bypassed `confirmAbandon` / an in-flight dispense and could strand a customer's bills. Every transaction already returns to `locked` on its own, so the timers just defer until the machine is back at the menu. 6. **Dev unlock defaults OFF** — `ACCESS_DEV_UNLOCK` is now opt-in and `access.example.json` ships it off, so a gated machine never shows a bypass button unless asked. ## Product decisions (this PR) Soft entry · **open enrollment** (any card) · **card-only** (npub-QR/PIN dropped from the entry UI, but the union + allow-list + PIN code paths remain for future use). ## Tests / validation - **38** state-machine tests (incl. gate, END_SESSION ignored mid-transaction), **17** access-service tests (npub + boltcard + parser), full machine build (typecheck + vite + electron) — all green. - **Live on batm3**: tap → enter (card chip) → sell → Complete → dispense → re-lock → tap → clean idle; idle-unattended → auto re-lock. Paired to `…/nostrrelay/sapien`. ## Notes - Depends on the l484 boltcards `1.1.1-aio.1` `/pay` endpoint for the buy path (already forked/tagged). - Combined with `fix/nfc-reader-auto-recovery` (#85) only on the throwaway `deploy/access-plus-nfc-recovery` branch used for the on-device build; that fix is its own PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a
clean rebase onto dev. Adds a `locked` gate the terminal boots into until a
credential is presented; opt-in and non-breaking (defaults off → boots
straight to idle as before).

- state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK
  events + accessBypass/devUnlockAllowed guards (packages/state-machine).
- services/access: reader abstraction, npub+PIN authorize() (nostr-tools
  nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader.
- LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied
  reason, dev-unlock; camera off-by-default + idle return.
- store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring,
  access.json provisioning (no rebuild), get-config surface.
- deploy: access.example.json + provision-access.sh; ADR-003.

Credential union is npub today; UID (NFC tap) is the next step.
Builds on the access gate: a single Bolt Card tap at the locked screen both
unlocks the terminal AND pre-loads the card, so buy/sell just need "Complete"
— no second tap. Reuses the #83/#84 payment paths verbatim.

Soft entry, verify-at-payment: the tap is parsed LOCALLY (external_id only) so
the single-use SUN p/c stay valid; the cryptographic check happens at Complete
when the stored lnurlw actually moves sats (withdraw for sell, lnurlp-pay for
buy). Open-enrollment, card-only (no npub-QR, no PIN) per product decision.

- services/access: `boltcard` credential (externalId + lnurlw) in the AccessScan
  union; canonicalId + open-enrollment/allow-list authorize; parseBoltcardLnurlw
  (local, no server). Only external_id is hashed — p/c never enter authorize.
- store: loadedBoltCard (session-scoped, cleared on re-lock); handleBoltCardEntry
  (tap while locked → authorize → grant + load); completeWithCard (routes to the
  existing tap handlers); NFC listener routes locked→enter.
- LockedView: card-only "Tap your Bolt Card" screen (dropped camera/npub-QR/PIN).
- CashIn/CashOutView: "Complete Purchase/Sale" button + card chip when loaded.
- tests: boltcard authorize + parseBoltcardLnurlw (17 access tests total).

Enabling the gate is a provisioning step (access.json enabled+openEnrollment);
other machines default off → unchanged.
After a transaction with the gate enabled, the machine re-locks (cashOut/
cashIn → locked, not idle), so the view's isIdle watch never fires and the
router stays on /cash-out|/cash-in. When the next tap reopens the gate to
idle, the stale transaction view showed (e.g. a completed sell's collect
screen, stuck). Reset the route to / while locked (router-view hidden under
LockedView) so idle renders IdleView.
An unlocked session left unattended (card tapped in, no transaction) stayed
at idle indefinitely, so anyone could then transact on the loaded card. Add
an IDLE_LOCK_TIMEOUT (60s) after-transition on idle → locked, guarded by
accessGateActive so a gate-disabled machine (which rests at idle) never
re-locks. Selecting cash-in/out leaves idle and cancels the timer; the store
clears the loaded Bolt Card on re-lock. Transaction flows already re-lock on
their own inactivity timeouts.
The access gate (ADR-003, #86) only wired services.pcscd + the pcsc
polkit rule into batm3.nix, so the tap-to-enter reader was invisible on
upboard machines. Port the same device-agnostic wiring to upboard.nix
(HID Global OMNIKEY 5022, 076b:5022) so the gate works on the sintra dev
unit — and on tejo — when #86 lands on dev and the nightly upgrade pulls
it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
A Bolt Card tap loads the holder's card for the whole session, so an
unattended idle menu is transactable by the next person until the 60s
IDLE_LOCK_TIMEOUT fires. Give the holder an explicit re-lock:

- END_SESSION event on `idle`, guarded to the active gate, targets
  `locked` (whose entry already clears the access session + loaded card).
  No-op on a gate-disabled machine that rests at idle.
- endSession() store action; IdleView shows a destructive-styled
  "End Session" button top-right only while accessControl.enabled.
- Tests: END_SESSION re-locks when the gate is active; no-op when off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
The top-right "End Session" button overlapped the centered balance /
commission chips, which wrap into the top-right corner on narrower
screens (sintra). Relocate it to a minimal red ✕ icon button grouped
next to the "?" help button in the top-left, clear of the chips. Same
endSession() behavior; shown only while the gate is active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
Per on-device review: order the top-left group ✕ then ?, and use the
`destructive` button variant so the exit swatch is a solid, theme-aware
red (--destructive is scoped per colorscheme) rather than a subtle
outline that didn't read as an exit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
The idle re-lock was an XState `after` on `idle`, which is anchored to
state ENTRY and never reset on screen touches — so it fired a fixed 60s
countdown regardless of interaction (reported: touching the screen
didn't extend the session). The machine can't observe raw pointer
events, so inactivity can't be measured there.

Move session timeouts to the DOM layer (useSessionSecurity, mounted in
the always-on App shell), enforcing two fail-closed limits that both
re-lock via a new root-level END_SESSION transition:

- SOFT idle (60s): re-lock after no *trusted* pointer/touch/key input
  while on the idle menu; resets on every genuine interaction. Scoped to
  idle so it never interrupts an in-flight cash-in/out.
- HARD cap (10min): absolute ceiling from unlock time, never reset — a
  forgotten/relayed card can't hold a session open. Lives at the machine
  root so it can lock mid-transaction, not just from idle.

Security posture: only event.isTrusted resets the soft timer (synthetic
events can't keep a session alive); wall-clock deadline checks re-lock
immediately after a suspend/resume rather than silently extending;
one-shot disarm-on-fire prevents spin; END_SESSION is guarded to the
active gate so it's inert when the gate is off.

Machine no longer owns the idle timer; tests updated (END_SESSION
re-locks from idle and from an in-flight cash-out; no-op when disabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
padreug force-pushed feat/access-control-skeleton from 984ae9d71e to 82fbf12950 2026-09-19 08:35:51 +00:00 Compare
The root-level END_SESSION let the 10-minute hard cap (and the End Session
button) jump to `locked` from any state, bypassing the money-path guards
the machine already has: confirmAbandon with bills stacked, an in-flight
dispense, an outbound cash-in payment. Cap fires at minute 10 while a
customer's bills sit in the stacker → locked → next unlock resetContext
wipes them unpaid; during dispensingCash the done-event is dropped and
no transaction record is written.

Nothing is lost by scoping it: every transaction terminal state already
targets #atm.locked on this branch, so the machine re-locks on its own
when the transaction ends. END_SESSION now lives on idle.on only, and
useSessionSecurity defers both deadlines until currentState is idle —
an expired session re-locks on the first tick back at the menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ACCESS_DEV_UNLOCK was opt-out (anything but 'false' enabled it) and
access.example.json shipped it on, so a gated production machine would
render a visible gate-bypass button on the lock screen by default. Flip
to opt-in (=== 'true'), update the example file and the provisioning
schema comment to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
padreug deleted branch feat/access-control-skeleton 2026-09-20 13:16:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!86
No description provided.