diff --git a/apps/machine/.env.example b/apps/machine/.env.example
index 66541dc..3230ecb 100644
--- a/apps/machine/.env.example
+++ b/apps/machine/.env.example
@@ -81,3 +81,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() {
+
+
+
@@ -340,16 +346,10 @@ function toggleLiveServices() {
-
+ class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
+ />
+/**
+ * Light/dark toggle — the single reusable control for switching color mode.
+ *
+ * Kiosk-sized by default (large touch target for a public display). colorMode
+ * is global + persisted (toggles `.dark` on , and dark mode pulls
+ * branding.json's dark palette + logo-dark.png), so this stays in sync
+ * wherever it's used. Position it via a fallthrough `class` on the consumer,
+ * e.g. ``.
+ */
+import { useTheme } from '@/composables/useTheme'
+import { Button } from '@/components/ui/button'
+import { Sun, Moon } from 'lucide-vue-next'
+
+const { colorMode } = useTheme()
+
+function toggle() {
+ colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
+}
+
+
+
+
+
diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts
new file mode 100644
index 0000000..0f175af
--- /dev/null
+++ b/apps/machine/src/services/access/__tests__/authorize.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest'
+import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
+import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
+
+const SALT = 'test-salt'
+const HEX_A = 'aa'.repeat(32)
+const HEX_B = 'bb'.repeat(32)
+const NPUB_A = npubEncode(HEX_A)
+const NPUB_B = npubEncode(HEX_B)
+
+describe('access authorize (ADR-003)', () => {
+ describe('open enrollment (prototype)', () => {
+ it('grants any valid npub as user', async () => {
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
+ salt: SALT,
+ openEnrollment: true,
+ })
+ expect(out.status).toBe('granted')
+ expect(out).toMatchObject({ status: 'granted', role: 'user' })
+ })
+
+ it('rejects a stray / non-npub QR', async () => {
+ const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
+ salt: SALT,
+ openEnrollment: true,
+ })
+ expect(out.status).toBe('denied')
+ expect(out).toMatchObject({ reason: 'not a valid npub' })
+ })
+
+ it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
+ const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
+ salt: SALT,
+ openEnrollment: true,
+ })
+ expect(out.status).toBe('granted')
+ })
+
+ it('accepts an nprofile and resolves to the same identity as its npub', async () => {
+ const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
+ const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
+ salt: SALT,
+ openEnrollment: true,
+ })
+ const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
+ salt: SALT,
+ openEnrollment: true,
+ })
+ expect(viaNprofile.status).toBe('granted')
+ // Same underlying pubkey → same credential hash.
+ expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
+ })
+ })
+
+ describe('allow-list (closed)', () => {
+ it('denies an unlisted npub when not open-enrollment', async () => {
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
+ expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
+ })
+
+ it('grants a listed npub with its role, no PIN', async () => {
+ const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
+ expect(out).toMatchObject({ status: 'granted', role: 'operator' })
+ })
+
+ it('does not match npub B against npub A entry', async () => {
+ const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
+ const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
+ expect(out.status).toBe('denied')
+ })
+ })
+
+ describe('PIN second factor', () => {
+ const makeEntry = async (): Promise => ({
+ idHash: await hashId(HEX_A, SALT),
+ role: 'user',
+ pinHash: await hashPin('1234', SALT),
+ })
+
+ it('asks for a PIN when one is configured and none supplied', async () => {
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], { salt: SALT })
+ expect(out.status).toBe('pin-required')
+ })
+
+ it('grants on correct PIN', async () => {
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
+ salt: SALT,
+ pin: '1234',
+ })
+ expect(out).toMatchObject({ status: 'granted', role: 'user' })
+ })
+
+ it('denies on wrong PIN', async () => {
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
+ salt: SALT,
+ pin: '9999',
+ })
+ expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
+ })
+ })
+
+ describe('challenge credential (v2 seam)', () => {
+ it('is not yet authorized', async () => {
+ const out = await authorize(
+ { kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' },
+ [],
+ { salt: SALT, openEnrollment: true }
+ )
+ expect(out.status).toBe('denied')
+ })
+ })
+})
diff --git a/apps/machine/src/services/access/authorize.ts b/apps/machine/src/services/access/authorize.ts
new file mode 100644
index 0000000..a6c99be
--- /dev/null
+++ b/apps/machine/src/services/access/authorize.ts
@@ -0,0 +1,141 @@
+/**
+ * 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).
+ *
+ * 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
+ */
+
+import { decode as nip19Decode } from 'nostr-tools/nip19'
+import type { AccessRole } from '@bitSpire/state-machine'
+import type { AccessScan } from './types'
+
+/** One authorized identity. `idHash` = hashId(, salt). */
+export interface AllowListEntry {
+ idHash: string
+ role: AccessRole
+ /** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
+ pinHash?: string
+ /** Optional operator-facing label (never a person's real identity). */
+ label?: string
+}
+
+export interface AuthorizeOptions {
+ /** Per-machine salt for all hashing. */
+ salt: string
+ /** Admit any valid credential when the allow-list has no match (prototype). */
+ openEnrollment?: boolean
+ /** PIN supplied on the follow-up call after a `pin-required` outcome. */
+ pin?: string
+}
+
+/**
+ * Three outcomes, so the caller can drive a two-step flow:
+ * - `granted` → send ACCESS_GRANTED
+ * - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
+ * - `denied` → send ACCESS_DENIED(reason)
+ */
+export type AuthorizeOutcome =
+ | { status: 'granted'; role: AccessRole; credentialIdHash: string }
+ | { status: 'pin-required'; credentialIdHash: string }
+ | { status: 'denied'; credentialIdHash: string; reason: string }
+
+/** Salted SHA-256, hex-encoded. */
+async function sha256Hex(input: string): Promise {
+ const data = new TextEncoder().encode(input)
+ const digest = await crypto.subtle.digest('SHA-256', data)
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
+}
+
+export const hashId = (id: string, salt: string): Promise => sha256Hex(`id:${salt}:${id}`)
+export const hashPin = (pin: string, salt: string): Promise => sha256Hex(`pin:${salt}:${pin}`)
+
+/**
+ * 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.
+ */
+function canonicalId(scan: AccessScan): string | null {
+ if (scan.kind === 'uid') return scan.uid || null
+ if (scan.kind === 'challenge') return null // v2 — handled separately
+ // Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
+ // prefix, and `nprofile1…` (npub + relay hints, what many clients export).
+ const raw = scan.npub.trim().replace(/^nostr:/i, '')
+ try {
+ const decoded = nip19Decode(raw)
+ if (decoded.type === 'npub' && typeof decoded.data === 'string') {
+ return decoded.data
+ }
+ if (
+ decoded.type === 'nprofile' &&
+ decoded.data &&
+ typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
+ ) {
+ return (decoded.data as { pubkey: string }).pubkey
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Decide whether a scanned credential is authorized.
+ * Always resolves (never throws) so the caller can uniformly react.
+ */
+export async function authorize(
+ scan: AccessScan,
+ allowList: AllowListEntry[],
+ opts: AuthorizeOptions
+): Promise {
+ if (scan.kind === 'challenge') {
+ return {
+ status: 'denied',
+ credentialIdHash: '',
+ reason: 'challenge-response credentials not yet supported',
+ }
+ }
+
+ const id = canonicalId(scan)
+ if (!id) {
+ return {
+ status: 'denied',
+ credentialIdHash: '',
+ reason: scan.kind === 'npub' ? 'not a valid npub' : 'invalid credential',
+ }
+ }
+
+ const credentialIdHash = await hashId(id, opts.salt)
+ const entry = allowList.find((e) => e.idHash === credentialIdHash)
+
+ if (!entry) {
+ if (opts.openEnrollment) {
+ return { status: 'granted', role: 'user', credentialIdHash }
+ }
+ return { status: 'denied', credentialIdHash, reason: 'not authorized' }
+ }
+
+ // No PIN configured → single-factor grant.
+ if (!entry.pinHash) {
+ return { status: 'granted', role: entry.role, credentialIdHash }
+ }
+
+ // PIN configured but not yet supplied → ask for it.
+ if (opts.pin === undefined) {
+ return { status: 'pin-required', credentialIdHash }
+ }
+
+ // PIN supplied → verify.
+ const pinHash = await hashPin(opts.pin, opts.salt)
+ if (pinHash !== entry.pinHash) {
+ return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
+ }
+ return { status: 'granted', role: entry.role, credentialIdHash }
+}
diff --git a/apps/machine/src/services/access/index.ts b/apps/machine/src/services/access/index.ts
new file mode 100644
index 0000000..fb1e26c
--- /dev/null
+++ b/apps/machine/src/services/access/index.ts
@@ -0,0 +1,42 @@
+/**
+ * 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.
+ */
+
+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 { authorize, hashId, hashPin } from './authorize'
+export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
+
+/** 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 {
+ const readers = allAccessReaders()
+ const flags = await Promise.all(readers.map((r) => r.isAvailable()))
+ return readers.filter((_, i) => flags[i])
+}
diff --git a/apps/machine/src/services/access/mock-reader.ts b/apps/machine/src/services/access/mock-reader.ts
new file mode 100644
index 0000000..c918b18
--- /dev/null
+++ b/apps/machine/src/services/access/mock-reader.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 {
+ return true
+ }
+
+ async start(opts: AccessReaderStartOptions): Promise {
+ 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
+ }
+ }
+}
diff --git a/apps/machine/src/services/access/qr-npub-reader.ts b/apps/machine/src/services/access/qr-npub-reader.ts
new file mode 100644
index 0000000..e95c507
--- /dev/null
+++ b/apps/machine/src/services/access/qr-npub-reader.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 {
+ return (
+ typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof navigator.mediaDevices.getUserMedia === 'function'
+ )
+ }
+
+ async start(opts: AccessReaderStartOptions): Promise {
+ const { onScan, onError, video } = opts
+ if (!video) throw new Error('QrNpubAccessReader requires a