From a7b409b10912cb34317080d37e2700f285ae07df Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 22:16:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(access):=20access-control=20gate=20?= =?UTF-8?q?=E2=80=94=20npub-QR=20badge=20+=20PIN=20+=20dev=20bypass=20(ADR?= =?UTF-8?q?-003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a clean rebase onto dev. Adds a `locked` gate the terminal boots into until a credential is presented; opt-in and non-breaking (defaults off → boots straight to idle as before). - state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK events + accessBypass/devUnlockAllowed guards (packages/state-machine). - services/access: reader abstraction, npub+PIN authorize() (nostr-tools nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader. - LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied reason, dev-unlock; camera off-by-default + idle return. - store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring, access.json provisioning (no rebuild), get-config surface. - deploy: access.example.json + provision-access.sh; ADR-003. Credential union is npub today; UID (NFC tap) is the next step. --- apps/machine/.env.example | 26 ++ apps/machine/electron/main.ts | 58 ++++ apps/machine/src/App.vue | 20 +- .../src/components/ColorModeToggle.vue | 33 ++ .../access/__tests__/authorize.test.ts | 113 +++++++ apps/machine/src/services/access/authorize.ts | 141 ++++++++ apps/machine/src/services/access/index.ts | 42 +++ .../src/services/access/mock-reader.ts | 53 +++ .../src/services/access/qr-npub-reader.ts | 79 +++++ apps/machine/src/services/access/types.ts | 60 ++++ apps/machine/src/stores/atm.ts | 83 +++++ apps/machine/src/types/electron.d.ts | 22 ++ apps/machine/src/views/LockedView.vue | 316 ++++++++++++++++++ deploy/nixos/access.example.json | 5 + deploy/nixos/provision-access.sh | 69 ++++ docs/adr/003-nfc-access-control-layer.md | 201 +++++++++++ .../src/__tests__/access-control.test.ts | 83 +++++ packages/state-machine/src/index.ts | 2 + packages/state-machine/src/machine.ts | 107 +++++- packages/state-machine/src/types.ts | 49 +++ 20 files changed, 1534 insertions(+), 28 deletions(-) create mode 100644 apps/machine/src/components/ColorModeToggle.vue create mode 100644 apps/machine/src/services/access/__tests__/authorize.test.ts create mode 100644 apps/machine/src/services/access/authorize.ts create mode 100644 apps/machine/src/services/access/index.ts create mode 100644 apps/machine/src/services/access/mock-reader.ts create mode 100644 apps/machine/src/services/access/qr-npub-reader.ts create mode 100644 apps/machine/src/services/access/types.ts create mode 100644 apps/machine/src/views/LockedView.vue create mode 100644 deploy/nixos/access.example.json create mode 100755 deploy/nixos/provision-access.sh create mode 100644 docs/adr/003-nfc-access-control-layer.md create mode 100644 packages/state-machine/src/__tests__/access-control.test.ts diff --git a/apps/machine/.env.example b/apps/machine/.env.example index ec66f81..8748c21 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -93,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/src/App.vue b/apps/machine/src/App.vue index d9bce92..742c868 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -7,8 +7,9 @@ import { setBranding } from '@/composables/useBranding' import { classifyInitError } from '@/services/init-error' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Sun, Moon } from 'lucide-vue-next' import PairingWizard from '@/components/PairingWizard.vue' +import LockedView from '@/views/LockedView.vue' +import ColorModeToggle from '@/components/ColorModeToggle.vue' const atmStore = useAtmStore() const route = useRoute() @@ -289,6 +290,11 @@ function toggleLiveServices() { + + +