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/types.ts b/apps/machine/src/services/access/types.ts index d6ecec3..26ae8d5 100644 --- a/apps/machine/src/services/access/types.ts +++ b/apps/machine/src/services/access/types.ts @@ -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