fix(access): single-shot loaded card + ADR-003 amendment #92

Merged
padreug merged 2 commits from fix/access-gate-followups into dev 2026-09-20 14:41:45 +00:00
6 changed files with 62 additions and 107 deletions
Showing only changes of commit f11aced450 - Show all commits

chore(access): prune unwired readers, amend ADR-003 for what shipped

ADR-003, .env.example, the access module's headers and the provisioning
schema still described the planned npub-QR → UID → serial-reader path.
What shipped (#86) is Bolt Card tap-to-enter over the main-process
pcscd reader with external_id as the identity, soft entry and
verify-at-payment. Nothing ever called availableAccessReaders(): the
camera npub-QR reader, the mock reader and the AccessReader seam were
dead, so they go; services/access now holds authorize, the card parser
and the credential types. The unused 'uid' scan variant goes with them;
'npub' (+PIN) and the 'challenge' seam stay.

The ADR gets an amendment section recording the differences, including
that open enrollment is not a security boundary and that the audit is
still a stub (both tracked as issues).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Padreug 2026-09-20 14:39:19 +02:00

View file

@ -98,14 +98,16 @@ VITE_SPIRE_SEED=
# Access Control (ADR-003) # Access Control (ADR-003)
# ============================================================================= # =============================================================================
# Badge-to-enter gate. When disabled (default), the machine boots straight to # Tap-to-enter gate. When disabled (default), the machine boots straight to
# idle exactly as before. When enabled, it boots into a locked screen and # idle exactly as before. When enabled, it boots into a locked screen and a
# requires a credential (prototype: an npub QR scanned by the camera, with an # Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it
# optional PIN) before transactions are reachable. # and loads the card for the session, so buy/sell finish with one Complete.
# ACCESS_CONTROL_ENABLED=true # ACCESS_CONTROL_ENABLED=true
# Prototype posture: admit ANY valid npub when the allow-list has no match. # Admit ANY Bolt Card when the allow-list has no match. With this on the gate
# Turn OFF once a real allow-list (/var/lib/bitspire/access.json) is provisioned. # only keeps casual users off the menu — any NDEF tag with a /scan/<id> URL
# unlocks it; money still moves only on a valid SUN at Complete. Turn OFF once
# a real allow-list (/var/lib/bitspire/access.json) is provisioned.
# ACCESS_OPEN_ENROLLMENT=true # ACCESS_OPEN_ENROLLMENT=true
# Show the on-screen runtime dev/operator unlock button on the locked screen. # Show the on-screen runtime dev/operator unlock button on the locked screen.

View file

@ -1,16 +1,19 @@
/** /**
* Credential authorization (ADR-003). * Credential authorization (ADR-003).
* *
* PROTOTYPE (this PR): a QR "badge" carrying an npub grants terminal access, * Decides whether a presented credential may unlock the terminal. Matching is
* with an OPTIONAL PIN as a second factor. Matching is against a local * against a local allow-list of salted identity hashes, optionally behind a
* allow-list of hashed identities; `openEnrollment` admits any valid npub * PIN second factor; `openEnrollment` admits any well-formed credential when
* (no allow-list) for early prototyping. Only salted hashes are compared or * the allow-list has no match (the current posture — see the ADR amendment:
* stored — never the raw npub/UID (KYC-free). * with it on, the gate is a convenience, not a security boundary). Only
* salted hashes are compared, stored or logged — never the raw id (KYC-free).
* *
* Identity id per scan kind: * Identity id per scan kind:
* - npub → hex pubkey (decoded, canonical), then hashed * - boltcard → the card's boltcards `external_id` (parsed locally from the
* - uid → raw UID hashed (NFC, PR4) * lnurlw; the SUN p/c are NOT verified here — that happens at
* - challenge → v2 seam (PR5), not yet authorized * payment time, where the voucher is actually spent)
* - npub → hex pubkey (decoded, canonical)
* - challenge → v2 seam, not yet authorized
*/ */
import { decode as nip19Decode } from 'nostr-tools/nip19' import { decode as nip19Decode } from 'nostr-tools/nip19'
@ -61,10 +64,9 @@ export const hashPin = (pin: string, salt: string): Promise<string> =>
/** /**
* Resolve a scan to a canonical identity string, or `null` if malformed. * Resolve a scan to a canonical identity string, or `null` if malformed.
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a * npub is decoded to its hex pubkey so npub/hex forms compare equal and a
* stray (non-npub) QR is rejected. * stray (non-npub) string is rejected.
*/ */
function canonicalId(scan: AccessScan): string | null { function canonicalId(scan: AccessScan): string | null {
if (scan.kind === 'uid') return scan.uid || null
if (scan.kind === 'boltcard') return scan.externalId || null if (scan.kind === 'boltcard') return scan.externalId || null
if (scan.kind === 'challenge') return null // v2 — handled separately if (scan.kind === 'challenge') return null // v2 — handled separately
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI // Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI

View file

@ -1,43 +1,13 @@
/** /**
* Access-control module surface (ADR-003). * Access-control module surface (ADR-003).
* *
* `availableAccessReaders()` returns the readers this device can run, in * Credential capture is NOT here: the reader is the main-process NFC service
* preference order: the camera npub-QR badge first (prototype, works on the * (`electron/nfc-service.ts`, over the `nfc:card-tapped` IPC), and the store
* batm3 today), the mock reader as a keyboard/console fallback. PR3 adds a * turns a tapped lnurlw into a `boltcard` scan. This module only decides —
* Web-NFC reader and PR4 the serial `/dev/ttyNFC` reader ahead of these. * parse the card, hash the identity, match the allow-list.
*/ */
import { QrNpubAccessReader } from './qr-npub-reader' export type { AccessScan, AccessRole } from './types'
import { MockAccessReader } from './mock-reader'
import type { AccessReader } from './types'
export type {
AccessReader,
AccessReaderKind,
AccessReaderStartOptions,
AccessScan,
AccessRole,
StopCapture,
} from './types'
export { QrNpubAccessReader } from './qr-npub-reader'
export { MockAccessReader, MOCK_NPUB } from './mock-reader'
export { authorize, hashId, hashPin } from './authorize' export { authorize, hashId, hashPin } from './authorize'
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize' export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
export { parseBoltcardLnurlw } from './boltcard' export { parseBoltcardLnurlw } from './boltcard'
/** All readers in preference order, regardless of availability. */
export function allAccessReaders(): AccessReader[] {
// PR3: WebNfcAccessReader, PR4: SerialNfcAccessReader — inserted ahead of the
// camera once real NFC hardware is present.
return [new QrNpubAccessReader(), new MockAccessReader()]
}
/**
* Only the readers this device can run, in preference order. The mock reader
* is always available, so it lands last as a guaranteed fallback.
*/
export async function availableAccessReaders(): Promise<AccessReader[]> {
const readers = allAccessReaders()
const flags = await Promise.all(readers.map((r) => r.isAvailable()))
return readers.filter((_, i) => flags[i])
}

View file

@ -1,66 +1,32 @@
/** /**
* Access-control reader abstraction (ADR-003). * Access-control credential types (ADR-003).
* *
* Mirrors the `services/pairing` `PairingSource` seam: an `AccessReader` * A credential is captured elsewhere — for Bolt Cards by the main-process NFC
* captures a credential from whatever hardware the machine has and hands an * reader (`electron/nfc-service.ts`), which hands the tapped lnurlw to the
* `AccessScan` to the store, which authorizes it and grants/denies terminal * store over IPC — and arrives here as a RAW `AccessScan`. Hashing and
* access. Implementations live next to this file: * authorization (and the optional PIN second factor) happen in `authorize.ts`,
* - `qr-npub-reader.ts` — camera scans an npub QR "badge" (PROTOTYPE, works * so raw ids never leave this layer (KYC-free).
* on batm3 today — same camera the pairing wizard uses)
* - `mock-reader.ts` — dev, no hardware (keyboard / console trigger)
* - `web-nfc-reader.ts` — Web NFC / NDEFReader, laptop/phone dev (PR3)
* - `serial-reader.ts` — /dev/ttyNFC via main-process HAL + IPC (PR4)
*
* The reader emits a RAW credential; hashing/authorization (and the optional
* PIN second factor) is the store's job (see `authorize.ts`), so raw ids never
* leave this layer (KYC-free).
*/ */
import type { AccessRole } from '@bitSpire/state-machine' import type { AccessRole } from '@bitSpire/state-machine'
export type { AccessRole } export type { AccessRole }
export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
/** /**
* A raw credential captured by a reader. Discriminated union so new factors * A raw credential. Discriminated union so new factors are additive:
* are additive: * - `boltcard` — what ships: a tapped Bolt Card. `externalId` is the
* - `npub` — prototype QR badge (this PR) * identity (from the lnurlw path); `lnurlw` is the full
* - `uid` — NFC card UID (PR4) * voucher (single-use SUN p/c intact) the session presents
* - `challenge` — card-signed nonce, challenge-response (PR5) * once, at Complete, to move sats. Only `externalId` is
* ever hashed/authorized — the p/c never enter the
* authorize layer.
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
* No reader emits it today; kept, with the PIN second
* factor, for a future non-card credential.
* - `challenge` — card-signed nonce, challenge-response. v2 seam; not yet
* authorized.
*/ */
export type AccessScan = export type AccessScan =
| { kind: 'npub'; npub: string }
| { kind: 'uid'; uid: string }
// A tapped Bolt Card: `externalId` is the identity (from the lnurlw path);
// `lnurlw` is the full voucher (p/c intact) the session reuses at Complete to
// move sats. Only `externalId` is ever hashed/authorized — the p/c never enter
// the authorize layer (KYC-free; they're single-use secrets held transiently
// by the store for the one transaction).
| { kind: 'boltcard'; externalId: string; lnurlw: string } | { kind: 'boltcard'; externalId: string; lnurlw: string }
| { kind: 'npub'; npub: string }
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } | { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
export interface AccessReaderStartOptions {
/** Called with each captured credential. */
onScan: (scan: AccessScan) => void
/** Non-fatal capture error (e.g. a frame decode glitch). */
onError?: (error: unknown) => void
/**
* The <video> element a camera reader renders into. Required by camera
* readers (qr-npub); ignored by readers with no viewfinder (mock, NFC).
*/
video?: HTMLVideoElement
}
/** Releases the reader (camera stream, event listeners, serial handle). Idempotent. */
export type StopCapture = () => void
export interface AccessReader {
readonly kind: AccessReaderKind
/** Short human label for status/debug UI. */
readonly label: string
/** Whether this reader can run in the current environment. */
isAvailable(): Promise<boolean>
/** Begin capturing; resolves once the reader is live. */
start(opts: AccessReaderStartOptions): Promise<StopCapture>
}

View file

@ -12,11 +12,11 @@
# access.json schema (all keys optional; omitted keys fall back to env/defaults): # access.json schema (all keys optional; omitted keys fall back to env/defaults):
# { # {
# "enabled": true, // master switch for the gate # "enabled": true, // master switch for the gate
# "openEnrollment": true, // prototype: admit any valid npub # "openEnrollment": true, // admit any Bolt Card (gate is not a security boundary)
# "devUnlock": false, // on-screen dev/operator unlock (bypasses the gate; default off) # "devUnlock": false, // on-screen dev/operator unlock (bypasses the gate; default off)
# "salt": "per-machine", // hashing salt (provision a real one for prod) # "salt": "per-machine", // hashing salt (provision a real one for prod)
# "allowList": [ // authorized identities (hashed); empty in open mode # "allowList": [ // authorized identities (hashed); empty in open mode
# { "idHash": "<hashId(hexpubkey,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" } # { "idHash": "<hashId(external_id,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
# ] # ]
# } # }
# #

View file

@ -1,9 +1,24 @@
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass # ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
**Status:** Accepted **Status:** Accepted — amended 2026-09-20 (see [Amendment](#amendment-2026-09-20-what-shipped) below; the original text follows it unchanged)
**Date:** 2026-07-29 **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. **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.
- **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/<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.
- **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 ## 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. 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.