diff --git a/apps/machine/.env.example b/apps/machine/.env.example index 8a62105..153066b 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -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/ 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. diff --git a/apps/machine/src/services/access/authorize.ts b/apps/machine/src/services/access/authorize.ts index 86aac4d..5743f91 100644 --- a/apps/machine/src/services/access/authorize.ts +++ b/apps/machine/src/services/access/authorize.ts @@ -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 => /** * 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 diff --git a/apps/machine/src/services/access/index.ts b/apps/machine/src/services/access/index.ts index a3ed586..5a4737c 100644 --- a/apps/machine/src/services/access/index.ts +++ b/apps/machine/src/services/access/index.ts @@ -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 { - 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 deleted file mode 100644 index c918b18..0000000 --- a/apps/machine/src/services/access/mock-reader.ts +++ /dev/null @@ -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 { - 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 deleted file mode 100644 index e95c507..0000000 --- a/apps/machine/src/services/access/qr-npub-reader.ts +++ /dev/null @@ -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 { - 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