bitspire/docs/adr/003-nfc-access-control-layer.md
Padreug ec2b15c08f 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>
2026-09-20 17:07:17 +02:00

22 KiB
Raw Permalink Blame History

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-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.
  • 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 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.
  • 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 the challenge seam.

Decision

  1. Add a top-level locked state 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 in App.vue). A healthy, paired machine boots into locked and only reveals idle (Buy/Sell) after an access grant.

  2. Access control is opt-in via runtime config (accessControl.enabled, default false). When disabled, the machine behaves exactly as today (boots straight to idle). 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.

  3. The reader lives behind an AccessReader abstraction that mirrors the existing PairingSource seam. First implementation is a MockAccessReader (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.

  4. 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.

  5. 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.

  6. 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.

  7. 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.ts starts at initial: 'idle' (~L431) and idle moves to cashIn/cashOut via plain SELECT_CASH_IN / SELECT_CASH_OUT transitions with no guards (~L457-464). Inserting a locked predecessor state is a localized change.
  • PairingSource is 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." AccessReader mirrors 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 Electron get-config fields that the renderer reads (electron/main.ts L280–312: maintenanceMode, branding). A new accessControl config field and a VITE_SKIP_ACCESS_GATE flag 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 authorized CardCredential + resolved role), ACCESS_DENIED (carries a reason), DEV_UNLOCK.
  • locked transitions:
    • 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 complete auto-return (currently 60s → idle) and the inactivity timeouts (INACTIVITY_TIMEOUT/TIMEOUT_MS, ~L422-429) target locked instead of idle. Because locked always-bypasses when disabled, this is one code path for both modes.
  • Purity: the state-machine package must not read Vite env. accessControl.enabled and the bypass boolean are passed in as actor input → context (context.accessControlEnabled, context.accessBypass); guard accessBypass reads context only. New context fields: accessControlEnabled, accessBypass, session ({ role, grantedAt, credentialIdHash } | null).
  • Guards: accessBypass, devUnlockAllowed. Actions: startSession, startDevSession, recordAccessGrant, recordAccessDeny, and resetContext extended to clear session.

2. Config plumbing

  • apps/machine/src/types/electron.d.ts — extend RuntimeConfig (L5) with:
    accessControl: {
      enabled: boolean            // default false
      devUnlock: boolean          // allow the runtime operator/dev unlock gesture
      // v2: allowListSource, challengeRequired, …
    }
    
  • apps/machine/electron/main.ts — the get-config handler (L280) returns accessControl, sourced from env for now (ACCESS_CONTROL_ENABLED === 'true', VITE_SKIP_ACCESS_GATE → forces enabled:false), later from a provisioned file under /var/lib/bitspire/ alongside branding.
  • apps/machine/.env.example — document VITE_SKIP_ACCESS_GATE=true (browser/dev straight to idle) and ACCESS_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 (when accessControl.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 a locked render branch mirroring the PairingWizard branch (L179) and the initError branch (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 existing createActor(machine) site (L452), seeded from RuntimeConfig.
    • isLocked computed off the snapshot (peer of isIdle, ~L422).
    • grantAccess(cred, role) / denyAccess(reason) / devUnlock() that send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … }) (peers of selectCashIn at L1382, using the existing send at L1378).
    • On init, when accessControl.enabled, subscribe to availableAccessReaders()[0]; on onCard, run authorize() → grantAccess/denyAccess. When disabled, do nothing (machine always-bypasses).

5. Audit

  • Add recordAccessEvent({ credentialIdHash, role, outcome, at }) alongside the existing state.db handlers (state:record-transaction etc. in electron/main.ts, exposed via preload.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 idle and 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. A locked predecessor matches the mental model and gives a clean re-lock boundary.
  • Web NFC only (reuse nfc-source.ts as-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_GATE is build-time and never set in a production image; devUnlock is gated by accessControl.devUnlock (off in a locked-down deployment) and every dev unlock is audited with role operator/dev.

Resolved decisions (2026-07-29)

  1. Session model — ✅ one badge = one transaction-scoped session, with the session context 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.
  2. v1 credential — ✅ UID allow-list first (hashed), behind a CardCredential union so challenge-response is additive (PR5).
  3. Allow-list home — ✅ local first (state.db / provisioned access.json, peer of branding/); operator-Nostr sync is a later PR.

Still open (cosmetic, decide during PR2):

  1. 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: add locked state (initial), ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK events, accessBypass/devUnlockAllowed guards, startSession/recordAccess* actions, context fields + actor input. Re-point complete/inactivity re-lock targets to locked.
  • apps/machine/src/types/electron.d.ts: RuntimeConfig.accessControl.
  • apps/machine/electron/main.ts: get-config returns accessControl (env-sourced); .env.example documents VITE_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: actor input, isLocked, grantAccess/denyAccess/devUnlock, reader subscription (only when enabled).
  • apps/machine/src/views/LockedView.vue + App.vue locked render branch.
  • Audit stub: recordAccessEvent handler + preload exposure (local write only).
  • Tests: state-machine locked → idle on grant; always-bypass when disabled; re-lock from complete; authorize() allow/deny; devUnlock gated 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 in availableAccessReaders() 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 channel access:watch-card + preload.ts exposure; serial-reader.ts renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in docs/device-configuration.md.

PR5 — Challenge-response credential + operator allow-list sync

  • Extend CardCredential with the challenge variant; authorize.ts verifies 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) and deploy/nixos/README.md (the accessControl config + /dev/ttyNFC) as PR4/PR5 land.
  • Provisioning: a later change can add an access.json under /var/lib/bitspire/ (peer of branding/) with the allow-list + enabled, plus a provision-access.sh mirroring provision-branding.sh.