diff --git a/apps/machine/.env.example b/apps/machine/.env.example index 66541dc..8748c21 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -73,6 +73,18 @@ VITE_SPIRE_SEED= # Show "Under Service" screen and block all transactions # VITE_MAINTENANCE_MODE=true +# ============================================================================= +# Public Web Demo +# ============================================================================= + +# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on +# every real machine. When set it: +# - keeps the mouse cursor visible (kiosk builds hide it) +# - mints one extra, never-used LNbits wallet named with this exact string, +# so the throwaway accounts the demo creates (one per page load, each with +# its own ephemeral identity) can be swept by name instead of guessed at. +# VITE_DEMO_TAG=bitspire-web-demo + # ============================================================================= # Mock Fallback (Production Safety) # ============================================================================= @@ -81,3 +93,29 @@ VITE_SPIRE_SEED= # Set to 'true' for development/demo environments only # When false (production default), initialization failures show a maintenance screen # VITE_ALLOW_MOCK_FALLBACK=true + +# ============================================================================= +# 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. +# 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. +# ACCESS_OPEN_ENROLLMENT=true + +# Allow the on-screen runtime dev/operator unlock button (default: allowed when +# the gate is on). Set to 'false' to hide it on a locked-down deployment. +# ACCESS_DEV_UNLOCK=false + +# Per-machine salt for hashing credentials/PINs. Provision a real value in +# production (or in access.json); a fixed default is used if unset. +# ACCESS_SALT=change-me-per-machine + +# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI). +# Renderer-side (Vite) flag, never set in a production image. +# VITE_SKIP_ACCESS_GATE=true diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 6cfcb20..736d89d 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -164,6 +164,61 @@ function loadBranding(): BrandingConfig | null { return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl } } +// Access-control config loader (ADR-003). Env toggles the gate; an optional +// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF — +// a machine with neither env nor file behaves as if there is no access layer. +// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts); +// duplicated here to avoid a cross-project (electron↔renderer) import. +interface AccessAllowListEntry { + idHash: string + role: 'user' | 'operator' + pinHash?: string + label?: string +} +function loadAccessControl() { + // Env provides defaults; access.json (writable, operator-provisioned — same + // spirit as branding/) overrides them, so the gate can be toggled on a + // deployed machine by dropping a file + restarting the service, with no image + // rebuild. Defaults OFF. + let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true' + // Dev unlock allowed by default when the gate is on; opt out explicitly. + let devUnlock = process.env.ACCESS_DEV_UNLOCK !== 'false' + let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true' + let salt = process.env.ACCESS_SALT || '' + let allowList: AccessAllowListEntry[] = [] + + const jsonPath = path.join( + fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(), + 'access.json' + ) + if (fs.existsSync(jsonPath)) { + try { + const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) + if (typeof raw.enabled === 'boolean') enabled = raw.enabled + if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock + if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment + if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt + if (Array.isArray(raw.allowList)) { + allowList = (raw.allowList as unknown[]).filter( + (e): e is AccessAllowListEntry => + !!e && + typeof (e as AccessAllowListEntry).idHash === 'string' && + ((e as AccessAllowListEntry).role === 'user' || + (e as AccessAllowListEntry).role === 'operator') + ) + } + } catch (e) { + console.warn('[Electron] Failed to parse access.json:', e) + } + } + + // A gated machine needs a stable salt for deterministic hashing. Fall back to + // a fixed default (prototype); production should provision a real salt. + if (!salt) salt = 'bitspire-access-v1' + + return { enabled, devUnlock, openEnrollment, salt, allowList } +} + // Determine if we're in development const isDev = process.env.ELECTRON_FORCE_PROD !== '1' && @@ -311,6 +366,9 @@ ipcMain.handle('get-config', () => { // Operator branding (logo/title/theme) — null when no override branding: loadBranding(), + + // Access-control gate (ADR-003) — `enabled` defaults false (no gate). + accessControl: loadAccessControl(), } }) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index ca9ace1..e94ddaf 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -13,6 +13,8 @@ * QR path keeps working — cash-out never depends on this. */ +import { execFile } from 'node:child_process' + export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export interface NfcStatus { state: NfcState @@ -83,7 +85,11 @@ export async function readNdefLnurlw( const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) // Select the NDEF Tag Application (AID D2760000850101). - if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + if ( + !swOk( + await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]) + ) + ) { return null } @@ -107,6 +113,29 @@ export async function readNdefLnurlw( let stopFn: (() => void) | null = null +// ── Wedge auto-recovery ─────────────────────────────────────────────────── +// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they +// keep detecting a card but every APDU returns "card absent or mute", and ONLY +// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of +// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot +// that re-binds the reader's USB device = a software replug); nfc-pcsc then +// re-detects the reader on hotplug with no app restart. The trigger is gated by +// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g. +// ACR1252U) wedges far less; this is belt-and-suspenders for any reader. +const WEDGE_FAILURE_THRESHOLD = 3 +const RESET_COOLDOWN_MS = 30_000 +// Persist across reader re-enumerations (a reset spawns a fresh reader closure). +let lastReaderResetAt = 0 + +/** Trigger the privileged USB power-cycle of the reader. Best-effort. */ +function resetWedgedReader(): void { + // NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it + // to start this one unit. systemctl lives at a stable path on the device. + execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => { + /* best-effort — if it fails the reader stays wedged until a manual reset */ + }) +} + /** * Start listening for Bolt Card taps. Idempotent. Returns a stop function. * Never throws — failures surface via onStatus. @@ -159,6 +188,10 @@ export async function startNfcReader( // a present↔empty storm when hammered, so ignore re-detections for a beat // after a failure. Successful reads don't cool down. let cooldownUntil = 0 + // Consecutive failed reads → wedge detection (see resetWedgedReader above). + // A completed read (Bolt Card or not) proves the reader is healthy and + // clears the count; only a run of thrown transmits trips the reset. + let consecutiveFailures = 0 r.on('card', async () => { if (Date.now() < cooldownUntil) return onStatus({ state: 'reading', reader: name }) @@ -167,13 +200,30 @@ export async function startNfcReader( // user simply re-taps. try { const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) + consecutiveFailures = 0 if (lnurlw) { onCard(lnurlw) return } onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) } catch (e) { - onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' }) + consecutiveFailures++ + if ( + consecutiveFailures >= WEDGE_FAILURE_THRESHOLD && + Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS + ) { + // Reader looks wedged — auto power-cycle it (only fix that works). + lastReaderResetAt = Date.now() + consecutiveFailures = 0 + onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' }) + resetWedgedReader() + } else { + onStatus({ + state: 'error', + reader: name, + message: 'card read failed — hold steady & retap', + }) + } void e } cooldownUntil = Date.now() + 1500 @@ -182,7 +232,9 @@ export async function startNfcReader( r.on('error', (err: unknown) => onStatus({ state: 'error', reader: name, message: errMsg(err) }) ) - r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })) + r.on('end', () => + onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' }) + ) }) nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) })) diff --git a/apps/machine/index.html b/apps/machine/index.html index cc57859..7b3a983 100644 --- a/apps/machine/index.html +++ b/apps/machine/index.html @@ -2,7 +2,7 @@ - + + +