fix(access): single-shot loaded card + ADR-003 amendment #92
9 changed files with 107 additions and 254 deletions
|
|
@ -98,14 +98,16 @@ VITE_SPIRE_SEED=
|
|||
# Access Control (ADR-003)
|
||||
# =============================================================================
|
||||
|
||||
# Badge-to-enter gate. When disabled (default), the machine boots straight to
|
||||
# idle exactly as before. When enabled, it boots into a locked screen and
|
||||
# requires a credential (prototype: an npub QR scanned by the camera, with an
|
||||
# optional PIN) before transactions are reachable.
|
||||
# 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 a
|
||||
# Bolt Card tap (read by the main-process NFC service over pcscd) unlocks it
|
||||
# and loads the card for the session, so buy/sell finish with one Complete.
|
||||
# ACCESS_CONTROL_ENABLED=true
|
||||
|
||||
# Prototype posture: admit ANY valid npub when the allow-list has no match.
|
||||
# Turn OFF once a real allow-list (/var/lib/bitspire/access.json) is provisioned.
|
||||
# Admit ANY Bolt Card when the allow-list has no match. With this on the gate
|
||||
# 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
|
||||
|
||||
# Show the on-screen runtime dev/operator unlock button on the locked screen.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
/**
|
||||
* Credential authorization (ADR-003).
|
||||
*
|
||||
* PROTOTYPE (this PR): a QR "badge" carrying an npub grants terminal access,
|
||||
* with an OPTIONAL PIN as a second factor. Matching is against a local
|
||||
* allow-list of hashed identities; `openEnrollment` admits any valid npub
|
||||
* (no allow-list) for early prototyping. Only salted hashes are compared or
|
||||
* stored — never the raw npub/UID (KYC-free).
|
||||
* Decides whether a presented credential may unlock the terminal. Matching is
|
||||
* against a local allow-list of salted identity hashes, optionally behind a
|
||||
* PIN second factor; `openEnrollment` admits any well-formed credential when
|
||||
* the allow-list has no match (the current posture — see the ADR amendment:
|
||||
* 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:
|
||||
* - npub → hex pubkey (decoded, canonical), then hashed
|
||||
* - uid → raw UID hashed (NFC, PR4)
|
||||
* - challenge → v2 seam (PR5), not yet authorized
|
||||
* - boltcard → the card's boltcards `external_id` (parsed locally from the
|
||||
* lnurlw; the SUN p/c are NOT verified here — that happens at
|
||||
* 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'
|
||||
|
|
@ -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.
|
||||
* 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 {
|
||||
if (scan.kind === 'uid') return scan.uid || null
|
||||
if (scan.kind === 'boltcard') return scan.externalId || null
|
||||
if (scan.kind === 'challenge') return null // v2 — handled separately
|
||||
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
||||
|
|
|
|||
|
|
@ -1,43 +1,13 @@
|
|||
/**
|
||||
* Access-control module surface (ADR-003).
|
||||
*
|
||||
* `availableAccessReaders()` returns the readers this device can run, in
|
||||
* preference order: the camera npub-QR badge first (prototype, works on the
|
||||
* batm3 today), the mock reader as a keyboard/console fallback. PR3 adds a
|
||||
* Web-NFC reader and PR4 the serial `/dev/ttyNFC` reader ahead of these.
|
||||
* Credential capture is NOT here: the reader is the main-process NFC service
|
||||
* (`electron/nfc-service.ts`, over the `nfc:card-tapped` IPC), and the store
|
||||
* turns a tapped lnurlw into a `boltcard` scan. This module only decides —
|
||||
* parse the card, hash the identity, match the allow-list.
|
||||
*/
|
||||
|
||||
import { QrNpubAccessReader } from './qr-npub-reader'
|
||||
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 type { AccessScan, AccessRole } from './types'
|
||||
export { authorize, hashId, hashPin } from './authorize'
|
||||
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
||||
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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* Mock access reader (ADR-003) — SCAFFOLD, no hardware.
|
||||
*
|
||||
* A keyboard/console fallback for when no camera or NFC reader is present
|
||||
* (headless dev, CI, a batm3 with a dead camera). Emits an npub scan on
|
||||
* demand via two triggers:
|
||||
* - `window.__bitspireMockCard(npub?)` — from the LockedView dev button or
|
||||
* the devtools console.
|
||||
* - the `F9` key — a quick tap on the physical machine.
|
||||
*
|
||||
* Registered only when it is the sole available reader (see `index.ts`), so it
|
||||
* never shadows the real camera/NFC path.
|
||||
*/
|
||||
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
|
||||
|
||||
/**
|
||||
* Default npub the mock emits when none is supplied — derived from a fixed
|
||||
* (all-ones) hex pubkey so it carries a valid bech32 checksum and survives
|
||||
* `authorize()`'s nip19 decode. Not a real key; dev-only.
|
||||
*/
|
||||
export const MOCK_NPUB = npubEncode('11'.repeat(32))
|
||||
|
||||
interface MockCardGlobal {
|
||||
__bitspireMockCard?: (npub?: string) => void
|
||||
}
|
||||
|
||||
export class MockAccessReader implements AccessReader {
|
||||
readonly kind = 'mock' as const
|
||||
readonly label = 'Mock reader (dev)'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
|
||||
const emit = (npub: string = MOCK_NPUB) => opts.onScan({ kind: 'npub', npub })
|
||||
|
||||
const g = globalThis as unknown as MockCardGlobal
|
||||
g.__bitspireMockCard = emit
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F9') emit()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (g.__bitspireMockCard === emit) delete g.__bitspireMockCard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* QR-npub access reader (ADR-003, PROTOTYPE).
|
||||
*
|
||||
* Until the NFC reader hardware exists, the batm3's camera — the same one the
|
||||
* pairing wizard uses — reads a QR "badge" that encodes the user's npub. The
|
||||
* decoded npub is handed to `authorize()`, which admits it (optionally behind
|
||||
* a PIN). This is a thin adapter onto the same `qr/dom.js` decode loop as
|
||||
* `pairing/qr-source.ts`; see that file for the capture-resolution rationale.
|
||||
*
|
||||
* It emits the raw decoded string as an `npub` scan and lets `authorize()`
|
||||
* validate it — a stray, non-npub QR is rejected there, not here.
|
||||
*/
|
||||
|
||||
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
|
||||
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
|
||||
|
||||
export class QrNpubAccessReader implements AccessReader {
|
||||
readonly kind = 'qr-npub' as const
|
||||
readonly label = 'Camera (npub QR)'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
|
||||
const { onScan, onError, video } = opts
|
||||
if (!video) throw new Error('QrNpubAccessReader requires a <video> element')
|
||||
|
||||
const camera = await frontalCamera(video)
|
||||
|
||||
// Match the pairing source's deliberate capture resolution (see
|
||||
// pairing/qr-source.ts) — 1280x960 balances px/module against decode speed
|
||||
// on this fixed-focus panel. Soft `ideal` so a camera that can't honor it
|
||||
// degrades instead of throwing.
|
||||
try {
|
||||
const stream = video.srcObject
|
||||
if (stream instanceof MediaStream) {
|
||||
await stream.getVideoTracks()[0]?.applyConstraints({
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 960 },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
|
||||
const canvas = new QRCanvas() // decode-only
|
||||
|
||||
let stopped = false
|
||||
let cancel: (() => void) | null = null
|
||||
const stop: StopCapture = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
cancel?.()
|
||||
camera.stop()
|
||||
}
|
||||
|
||||
cancel = frameLoop(() => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const result = camera.readFrame(canvas, true)
|
||||
if (result) {
|
||||
// Stop on first decode so one badge isn't ingested repeatedly; the
|
||||
// store restarts the reader if authorization fails.
|
||||
stop()
|
||||
onScan({ kind: 'npub', npub: result.trim() })
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
})
|
||||
|
||||
return stop
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +1,32 @@
|
|||
/**
|
||||
* Access-control reader abstraction (ADR-003).
|
||||
* Access-control credential types (ADR-003).
|
||||
*
|
||||
* Mirrors the `services/pairing` `PairingSource` seam: an `AccessReader`
|
||||
* captures a credential from whatever hardware the machine has and hands an
|
||||
* `AccessScan` to the store, which authorizes it and grants/denies terminal
|
||||
* access. Implementations live next to this file:
|
||||
* - `qr-npub-reader.ts` — camera scans an npub QR "badge" (PROTOTYPE, works
|
||||
* 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).
|
||||
* A credential is captured elsewhere — for Bolt Cards by the main-process NFC
|
||||
* reader (`electron/nfc-service.ts`), which hands the tapped lnurlw to the
|
||||
* store over IPC — and arrives here as a RAW `AccessScan`. Hashing and
|
||||
* authorization (and the optional PIN second factor) happen in `authorize.ts`,
|
||||
* so raw ids never leave this layer (KYC-free).
|
||||
*/
|
||||
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
|
||||
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
|
||||
* are additive:
|
||||
* - `npub` — prototype QR badge (this PR)
|
||||
* - `uid` — NFC card UID (PR4)
|
||||
* - `challenge` — card-signed nonce, challenge-response (PR5)
|
||||
* A raw credential. Discriminated union so new factors are additive:
|
||||
* - `boltcard` — what ships: a tapped Bolt Card. `externalId` is the
|
||||
* identity (from the lnurlw path); `lnurlw` is the full
|
||||
* voucher (single-use SUN p/c intact) the session presents
|
||||
* 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 =
|
||||
| { 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: 'npub'; npub: 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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,6 +294,14 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||
const boltCardProcessing = ref(false)
|
||||
// What a tap handler did with the voucher it was given:
|
||||
// 'skipped' — a guard bounced it before any server call; the SUN p/c are
|
||||
// untouched and the lnurlw can still be presented later.
|
||||
// 'accepted' — the card's server took the voucher (payment in flight).
|
||||
// 'declined' — presented and refused, or failed after presentation. The
|
||||
// boltcards server bumps the SUN counter on the first GET, so
|
||||
// treat the voucher as spent even when the failure was ours.
|
||||
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined'
|
||||
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||
|
|
@ -623,11 +631,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||
* ok here only means the card accepted the pull.
|
||||
*/
|
||||
async function handleBoltCardTap(lnurlw: string) {
|
||||
if (nestedState.value !== 'displayingInvoice') return
|
||||
async function handleBoltCardTap(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
||||
const invoice = context.value?.invoice
|
||||
if (!invoice) return
|
||||
if (boltCardProcessing.value) return // one pull at a time
|
||||
if (!invoice) return 'skipped'
|
||||
if (boltCardProcessing.value) return 'skipped' // one pull at a time
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
|
|
@ -635,14 +643,16 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
||||
if (res.ok) {
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||
} else {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||
return 'accepted'
|
||||
}
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||
return 'declined'
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||
return 'declined'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -653,11 +663,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
||||
* path. Settlement + completion reuse the tested cash-in flow.
|
||||
*/
|
||||
async function handleBoltCardReceive(lnurlw: string) {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
|
||||
async function handleBoltCardReceive(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
||||
const amountSats = context.value?.satsAmount ?? 0
|
||||
if (amountSats <= 0) return
|
||||
if (boltCardProcessing.value) return // one at a time
|
||||
if (amountSats <= 0) return 'skipped'
|
||||
if (boltCardProcessing.value) return 'skipped' // one at a time
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
|
|
@ -666,20 +676,23 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
if (!res.ok || !res.bolt11) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||
return
|
||||
return 'declined'
|
||||
}
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
|
||||
const paid = await payInvoice(res.bolt11)
|
||||
if (!paid) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
|
||||
return 'declined'
|
||||
}
|
||||
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
||||
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
||||
return 'accepted'
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card receive failed:', e)
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||
return 'declined'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -726,12 +739,29 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
|
||||
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
||||
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
|
||||
*
|
||||
* The loaded card is SINGLE-SHOT: its lnurlw carries one SUN p/c pair and the
|
||||
* boltcards server consumes it on the first GET, so once the voucher has been
|
||||
* presented — accepted or declined — it can never succeed again. Drop it after
|
||||
* the first real attempt and tell the customer to re-tap; a fresh tap on the
|
||||
* cash screen goes straight through the normal tap-to-pay/receive path.
|
||||
* A 'skipped' outcome (guard bounced it, no server call) keeps the card.
|
||||
*/
|
||||
function completeWithCard() {
|
||||
async function completeWithCard() {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return
|
||||
if (isCashOut.value) void handleBoltCardTap(card.lnurlw)
|
||||
else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw)
|
||||
let outcome: BoltCardOutcome = 'skipped'
|
||||
if (isCashOut.value) outcome = await handleBoltCardTap(card.lnurlw)
|
||||
else if (isCashIn.value) outcome = await handleBoltCardReceive(card.lnurlw)
|
||||
if (outcome === 'skipped') return
|
||||
loadedBoltCard.value = null
|
||||
if (outcome === 'declined') {
|
||||
const reason = nfcStatus.value?.message ?? 'Card declined'
|
||||
nfcStatus.value = {
|
||||
state: nfcStatus.value?.state ?? 'declined',
|
||||
message: `${reason} — tap your card to try again`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
|
||||
# {
|
||||
# "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)
|
||||
# "salt": "per-machine", // hashing salt (provision a real one for prod)
|
||||
# "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)>" }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
# 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
|
||||
**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
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue