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>
22 KiB
ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
Status: Accepted — amended 2026-09-20 (see Amendment below; the original text follows it unchanged) Date: 2026-07-29 Context: batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
Amendment (2026-09-20): what shipped
The gate landed in aiolabs/bitspire#86 as Bolt Card tap-to-enter, not the npub-QR → UID → serial-reader path planned below. The decisions still stand (opt-in locked state, operator-owned authorization, hashed identities, fail-closed, audited); the mechanism differs:
- Reader. The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by
pcscd+nfc-pcscin the main process (electron/nfc-service.ts, from #83), not the serial/dev/ttyNFCdevice or Web NFC. Taps reach the renderer over the existingnfc:card-tappedIPC and the store routes them by state (locked→ enter; the cash screens → pay / receive). The renderer-sideAccessReaderabstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed;services/access/now holds onlyauthorize, the Bolt Card parser and the credential types. - Credential. Identity is the card's boltcards
external_id, parsed locally from the tappedlnurlw(AccessScankindboltcard), not the NFC UID. OnlyhashId(external_id, salt)is compared or logged. Thenpubvariant (with its PIN second factor) stays inauthorize()and its tests for a future non-card credential;challengeremains the v2 seam; the planneduidvariant is gone. - Verified entry via
/session(superseding #86's soft entry). A tap yields a single-use SUNp/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 still not a security boundary.
/sessionproves the card is genuine to the server the card names: the session URL is derived from the tappedlnurlw's host, so withopenEnrollmenton a forged NDEF tag pointing at a server that answersauthenticated: truestill unlocks the terminal. Money is unaffected (cash-out never dispenses withoutPAYMENT_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 XStateaftercannot observe touches. Both sendEND_SESSION, which the machine accepts fromidleonly, 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_SALTfrom env, overridden by/var/lib/bitspire/access.json(pushed withdeploy/nixos/provision-access.sh, no rebuild).devUnlockdefaults off. - Audit is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90.
- Phased plan superseded. PR3 (Web NFC) and PR4 (serial HAL) will not happen — the pcscd reader covers real hardware and there is no laptop dev path beyond the debug paste-box on
LockedView. PR5's challenge-response idea survives as thechallengeseam.
Decision
-
Add a top-level
lockedstate to the ATM state machine, and make it the initial state. It sits below the existing initialization gates (unpaired/awaiting-fees/maintenance/signer-unreachable, which live inApp.vue). A healthy, paired machine boots intolockedand only revealsidle(Buy/Sell) after an access grant. -
Access control is opt-in via runtime config (
accessControl.enabled, defaultfalse). When disabled, the machine behaves exactly as today (boots straight toidle). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design. -
The reader lives behind an
AccessReaderabstraction that mirrors the existingPairingSourceseam. First implementation is aMockAccessReader(dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow. -
Credential model is a discriminated union with an explicit upgrade path. v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
-
Three-tier developer bypass, following existing conventions: a build flag (
VITE_SKIP_ACCESS_GATE), the config disable (accessControl.enabled=false), and a runtime operator/dev unlock gesture that dispatches a synthetic grant. -
Authorization is owned by the machine operator, not the SaaS operator — consistent with ADR-002. The card allow-list is authorized by the operator key (the #42 allow-list mechanism), local first, operator-synced later. When access control is enabled and the reader is absent/broken, the machine fails closed (with the operator/dev unlock as the escape hatch); when disabled, reader state is irrelevant.
-
Every grant/deny is audited to
state.db(hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
Context
What this is (and what it is not)
This is the end-user physical access plane: a person must badge in to use the machine. It is distinct from the three planes in ADR-002 — it is not the operator's SSH/NetBird recovery plane, and not the SaaS payment plane. It shares one idea with ADR-002: the machine operator owns who is authorized, expressed through the operator key / #42 allow-list.
Why the codebase is well-shaped for this
Three seams already exist; we extend them rather than invent:
- The idle→transaction transition is unguarded.
packages/state-machine/src/machine.tsstarts atinitial: 'idle'(~L431) andidlemoves tocashIn/cashOutvia plainSELECT_CASH_IN/SELECT_CASH_OUTtransitions with no guards (~L457-464). Inserting alockedpredecessor state is a localized change. PairingSourceis a reader abstraction designed to grow. Its doc (apps/machine/src/services/pairing/types.ts) explicitly anticipates "an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."AccessReadermirrors it:qr-source.ts/nfc-source.ts→mock-reader.ts/web-nfc-reader.ts/serial-reader.ts.- Dev-flag and config conventions are established.
import.meta.env.VITE_* === 'true'(e.g.VITE_MAINTENANCE_MODE,VITE_FORCE_MOCK), plus Electronget-configfields that the renderer reads (electron/main.tsL280–312:maintenanceMode,branding). A newaccessControlconfig field and aVITE_SKIP_ACCESS_GATEflag follow the same shape.
Hardware reality check (important)
The batm3 already exposes an NFC device, but it is serial: deploy/nixos/hardware/batm3.nix L154 maps udev serial A9ZF8ELY → /dev/ttyNFC. The existing pairing/nfc-source.ts uses Web NFC (NDEFReader), which drives a phone/laptop NFC radio, not a serial reader. So the real batm3 access reader needs a main-process serial driver (HAL-style, per ADR-001) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
Architecture
Boot / render layering
Electron get-config ─┐
▼
App.vue init gates (unchanged):
unpaired? → PairingWizard
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
else ▼
State machine (paired + healthy):
┌───────────────────────────────────────────────┐
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
│ ▲ │ SELECT_CASH_* │
│ │ re-lock (session end / ▼ │
│ │ inactivity / complete) cashIn / cashOut │
│ └──────────────────────────┘ │
└───────────────────────────────────────────────┘
(when accessControl.enabled === false,
`locked` immediately `always`-bypasses to `idle`)
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches locked.
1. State machine (packages/state-machine)
- New top-level state
locked,initial: 'locked'. - New events on the machine's event union:
ACCESS_GRANTED(carries an authorizedCardCredential+ resolved role),ACCESS_DENIED(carries a reason),DEV_UNLOCK. lockedtransitions:always: [{ guard: 'accessBypass', target: 'idle' }]— instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }.
- Re-lock: the existing
completeauto-return (currently 60s →idle) and the inactivity timeouts (INACTIVITY_TIMEOUT/TIMEOUT_MS, ~L422-429) targetlockedinstead ofidle. Becauselockedalways-bypasses when disabled, this is one code path for both modes. - Purity: the state-machine package must not read Vite env.
accessControl.enabledand the bypass boolean are passed in as actor input → context (context.accessControlEnabled,context.accessBypass); guardaccessBypassreads context only. New context fields:accessControlEnabled,accessBypass,session({ role, grantedAt, credentialIdHash } | null). - Guards:
accessBypass,devUnlockAllowed. Actions:startSession,startDevSession,recordAccessGrant,recordAccessDeny, andresetContextextended to clearsession.
2. Config plumbing
apps/machine/src/types/electron.d.ts— extendRuntimeConfig(L5) with:accessControl: { enabled: boolean // default false devUnlock: boolean // allow the runtime operator/dev unlock gesture // v2: allowListSource, challengeRequired, … }apps/machine/electron/main.ts— theget-confighandler (L280) returnsaccessControl, sourced from env for now (ACCESS_CONTROL_ENABLED === 'true',VITE_SKIP_ACCESS_GATE→ forcesenabled:false), later from a provisioned file under/var/lib/bitspire/alongside branding.apps/machine/.env.example— documentVITE_SKIP_ACCESS_GATE=true(browser/dev straight to idle) andACCESS_CONTROL_ENABLED.
3. Reader abstraction (apps/machine/src/services/access/)
Mirrors services/pairing/:
services/access/
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
authorize.ts # allow-list check + role resolution (hashed UID v1)
index.ts # availableAccessReaders(): AccessReader[]
__tests__/
export type AccessRole = 'user' | 'operator'
export type CardCredential =
| { kind: 'uid'; uidHash: string } // v1
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
export interface AccessReader {
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
readonly label: string
isAvailable(): Promise<boolean>
start(opts: {
onCard: (cred: CardCredential) => void
onError?: (e: unknown) => void
}): Promise<StopCapture>
}
4. Renderer wiring
apps/machine/src/views/LockedView.vue(new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (whenaccessControl.devUnlock) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).apps/machine/src/App.vue— add alockedrender branch mirroring thePairingWizardbranch (L179) and theinitErrorbranch (L183):<LockedView v-else-if="atmStore.isLocked" />, then the existing<router-view>only when unlocked. Keeps rendering state-driven and matches the current shape.apps/machine/src/stores/atm.ts—createActor(machine, { input: { accessControlEnabled, accessBypass } })at the existingcreateActor(machine)site (L452), seeded fromRuntimeConfig.isLockedcomputed off the snapshot (peer ofisIdle, ~L422).grantAccess(cred, role)/denyAccess(reason)/devUnlock()thatsend({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })(peers ofselectCashInat L1382, using the existingsendat L1378).- On init, when
accessControl.enabled, subscribe toavailableAccessReaders()[0]; ononCard, runauthorize()→grantAccess/denyAccess. When disabled, do nothing (machinealways-bypasses).
5. Audit
- Add
recordAccessEvent({ credentialIdHash, role, outcome, at })alongside the existing state.db handlers (state:record-transactionetc. inelectron/main.ts, exposed viapreload.ts). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
Session semantics (decided)
One badge = one transaction-scoped session. A grant unlocks idle, the user runs a single transaction (Buy or Sell), and the machine re-locks on complete, on inactivity, or on an explicit "Done". The session context field is deliberately shaped as a general access session ({ role, grantedAt, credentialIdHash }), not a transaction handle, because this terminal may later handle non-transaction functions — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
Step-up authorization (future seam, not in PR1). The badge tap grants terminal access; a specific sensitive action can independently request re-authorization — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a session rather than a boolean "unlocked": a later REQUIRE_STEPUP event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
Alternatives considered
- Gate between
idleand the transaction (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. Alockedpredecessor matches the mental model and gives a clean re-lock boundary. - Web NFC only (reuse
nfc-source.tsas-is). Rejected: the batm3 reader is serial (ttyNFC); Web NFC can't drive it. Web NFC stays a dev-only convenience. - Fail-open by default (no card → allow). Rejected for an access-control feature; but note the disabled default sidesteps this — access is simply off until an operator turns it on, at which point it fails closed.
- OS/kiosk-level lock (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
- UID allow-list as the permanent model. Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
Security considerations
- UID cloning — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the
authorize()seam for it now. - Data at rest — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
- Fail-closed when enabled — reader absent/broken +
enabled⇒ locked, escape hatch = operator/dev unlock. Reader problems on a disabled machine are inert. - Operator ownership — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
- Dev bypass blast radius —
VITE_SKIP_ACCESS_GATEis build-time and never set in a production image;devUnlockis gated byaccessControl.devUnlock(off in a locked-down deployment) and every dev unlock is audited with roleoperator/dev.
Resolved decisions (2026-07-29)
- Session model — ✅ one badge = one transaction-scoped session, with the
sessioncontext modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action step-up auth (tap phone / PIN) later. See Session semantics above. - v1 credential — ✅ UID allow-list first (hashed), behind a
CardCredentialunion so challenge-response is additive (PR5). - Allow-list home — ✅ local first (
state.db/ provisionedaccess.json, peer ofbranding/); operator-Nostr sync is a later PR.
Still open (cosmetic, decide during PR2):
- Dev unlock affordance — hidden long-press corner vs a labeled button on the existing debug bar.
Implementation plan (phased PRs)
Each PR is independently mergeable. PR1 changes nothing observable while accessControl.enabled=false (the default).
PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
Goal: the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
packages/state-machine: addlockedstate (initial),ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCKevents,accessBypass/devUnlockAllowedguards,startSession/recordAccess*actions, context fields + actorinput. Re-pointcomplete/inactivity re-lock targets tolocked.apps/machine/src/types/electron.d.ts:RuntimeConfig.accessControl.apps/machine/electron/main.ts:get-configreturnsaccessControl(env-sourced);.env.exampledocumentsVITE_SKIP_ACCESS_GATE+ACCESS_CONTROL_ENABLED.apps/machine/src/services/access/:types.ts,mock-reader.ts,authorize.ts(UID allow-list, hashed),index.ts.apps/machine/src/stores/atm.ts: actorinput,isLocked,grantAccess/denyAccess/devUnlock, reader subscription (only when enabled).apps/machine/src/views/LockedView.vue+App.vuelockedrender branch.- Audit stub:
recordAccessEventhandler + preload exposure (local write only). - Tests: state-machine
locked → idleon grant;always-bypass when disabled; re-lock fromcomplete;authorize()allow/deny;devUnlockgated by config. - Flag state on merge:
enabled=false. CI green = no behavior change.
PR2 — Locked UX polish
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
PR3 — Web NFC reader (dev)
web-nfc-reader.ts(NDEFReader), registered inavailableAccessReaders()behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
PR4 — Serial NFC HAL driver (real batm3 hardware)
- Main-process serial driver for
/dev/ttyNFC(HAL-style per ADR-001), IPC channelaccess:watch-card+preload.tsexposure;serial-reader.tsrenderer client. Requires the physical reader to validate. Document the reader's protocol/baud indocs/device-configuration.md.
PR5 — Challenge-response credential + operator allow-list sync
- Extend
CardCredentialwith thechallengevariant;authorize.tsverifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
Cross-cutting
- Docs: update
docs/machine-installation.md(enabling access control, enrolling cards) anddeploy/nixos/README.md(theaccessControlconfig +/dev/ttyNFC) as PR4/PR5 land. - Provisioning: a later change can add an
access.jsonunder/var/lib/bitspire/(peer ofbranding/) with the allow-list +enabled, plus aprovision-access.shmirroringprovision-branding.sh.