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..07cb912 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -1,18 +1,39 @@ + + diff --git a/apps/machine/src/composables/useSessionSecurity.ts b/apps/machine/src/composables/useSessionSecurity.ts new file mode 100644 index 0000000..6317474 --- /dev/null +++ b/apps/machine/src/composables/useSessionSecurity.ts @@ -0,0 +1,95 @@ +import { watch } from 'vue' +import { useEventListener, useIntervalFn } from '@vueuse/core' +import { useAtmStore } from '@/stores/atm' + +/** + * Session security timeouts for the ADR-003 access gate. + * + * Enforced at the DOM layer on purpose: the state machine can't observe raw + * pointer events, so an XState `after` delay can only count from state entry — + * it never resets on a screen touch and therefore can't measure *inactivity*. + * Two independent, fail-closed limits, both of which re-lock via the machine's + * root-level END_SESSION transition: + * + * - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user + * input while on the idle menu. Resets on every genuine pointer/touch/key + * event. Scoped to `idle` so it never interrupts an in-flight cash-in/out + * (those carry their own, longer machine timeouts). + * - HARD cap (HARD_CAP_MS): re-lock this long after the session began, + * regardless of activity. Anchored to unlock time and never reset — an + * absolute ceiling a forgotten or relayed card can't hold open. + * + * Security properties: + * - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted + * events in the renderer can't keep a session alive. + * - Limits are wall-clock deadline comparisons, not chained setTimeouts: a + * suspended/resumed renderer re-locks on the very next tick instead of + * silently extending the session past its deadline. + * - Both limits only ever *lock*. The machine's accessGateActive guard makes + * END_SESSION a no-op when the gate is off, so this is inert on a + * gate-disabled machine. + * - One-shot per session: after firing, it disarms until the next unlock, so + * a lock that (under dev bypass) doesn't take can't spin. + * + * Call once from the always-mounted App shell. + */ +const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu +const HARD_CAP_MS = 600_000 // 10min absolute session ceiling + +export function useSessionSecurity() { + const atm = useAtmStore() + + // Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session). + let sessionStartedAt: number | null = null + let lastActivityAt = 0 + + function arm() { + const now = Date.now() + sessionStartedAt = now + lastActivityAt = now + } + function disarm() { + sessionStartedAt = null + } + + // Arm on each locked → unlocked edge; disarm on lock. Anchored to the + // isLocked transition so the hard cap starts at unlock and does NOT restart + // when moving idle → cashIn → idle within a single session. + watch( + () => atm.isLocked, + (locked, wasLocked) => { + if (wasLocked && !locked && atm.accessControl.enabled) arm() + else if (locked) disarm() + } + ) + + // Only genuine hardware input counts as activity. Passive + capture so it + // observes every touch without interfering with handling. useEventListener + // auto-detaches on unmount. + const onActivity = (e: Event) => { + if (e.isTrusted) lastActivityAt = Date.now() + } + for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) { + useEventListener(document, type, onActivity, { passive: true, capture: true }) + } + + // Single 1s evaluator — cheap, and coarse enough that timer drift/suspend + // can only ever make it fire late-then-immediately, never early. + useIntervalFn(() => { + if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return + const now = Date.now() + + // Hard cap first — absolute, activity-independent. + if (now - sessionStartedAt >= HARD_CAP_MS) { + disarm() // one-shot; re-arms on next unlock + atm.endSession('session-cap') + return + } + + // Soft inactivity — idle menu only, resets on trusted input. + if (atm.currentState === 'idle' && now - lastActivityAt >= SOFT_IDLE_MS) { + disarm() + atm.endSession('inactivity') + } + }, 1000) +} 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..521cfcb --- /dev/null +++ b/apps/machine/src/services/access/__tests__/authorize.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from 'vitest' +import { npubEncode, nprofileEncode } from 'nostr-tools/nip19' +import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize' +import { parseBoltcardLnurlw } from '../boltcard' + +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') + }) + }) + + describe('boltcard credential (tap-to-enter)', () => { + it('open-enrollment grants any card as user', async () => { + const out = await authorize( + { kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' }, + [], + { salt: SALT, openEnrollment: true } + ) + expect(out).toMatchObject({ status: 'granted', role: 'user' }) + }) + + it('rejects a card with no external_id', async () => { + const out = await authorize({ kind: 'boltcard', externalId: '', lnurlw: '' }, [], { + salt: SALT, + openEnrollment: true, + }) + expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' }) + }) + + it('allow-list matches by external_id hash', async () => { + const idHash = await hashId('abc123', SALT) + const list: AllowListEntry[] = [{ idHash, role: 'operator' }] + const out = await authorize( + { kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' }, + list, + { salt: SALT, openEnrollment: false } + ) + expect(out).toMatchObject({ status: 'granted', role: 'operator' }) + }) + }) +}) + +describe('parseBoltcardLnurlw', () => { + it('extracts external_id from a tapped lnurlw', () => { + expect( + parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF') + ).toEqual({ externalId: 'abc123' }) + }) + it('strips a lightning: prefix and accepts https', () => { + expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({ + externalId: 'xyz', + }) + expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({ + externalId: 'xyz', + }) + }) + it('returns null for non-card / malformed input', () => { + expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull() + expect(parseBoltcardLnurlw('not a url')).toBeNull() + expect(parseBoltcardLnurlw('')).toBeNull() + }) +}) diff --git a/apps/machine/src/services/access/authorize.ts b/apps/machine/src/services/access/authorize.ts new file mode 100644 index 0000000..86aac4d --- /dev/null +++ b/apps/machine/src/services/access/authorize.ts @@ -0,0 +1,148 @@ +/** + * 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 === '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 + // 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' + : scan.kind === 'boltcard' + ? 'not a valid card' + : '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/boltcard.ts b/apps/machine/src/services/access/boltcard.ts new file mode 100644 index 0000000..1160f14 --- /dev/null +++ b/apps/machine/src/services/access/boltcard.ts @@ -0,0 +1,27 @@ +/** + * Bolt Card lnurlw parsing for the access gate (ADR-003). + * + * The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/?p=&c=` + * voucher and needs the `external_id` for the session identity — WITHOUT hitting + * the server (that would burn the single-use SUN p/c we want to reuse at + * Complete). So this is a purely local parse: extract the id from the URL path; + * the p/c ride along in the stored lnurlw and are only spent at payment time. + */ + +/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */ +export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null { + let s = lnurlw.trim() + if (!s) return null + if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length) + const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://') + if (!/^https:\/\//i.test(https)) return null + try { + const u = new URL(https) + // …/boltcards/api/v1/scan/ + const m = u.pathname.match(/\/scan\/([^/?#]+)/) + if (!m || !m[1]) return null + return { externalId: decodeURIComponent(m[1]) } + } catch { + return null + } +} diff --git a/apps/machine/src/services/access/index.ts b/apps/machine/src/services/access/index.ts new file mode 100644 index 0000000..a3ed586 --- /dev/null +++ b/apps/machine/src/services/access/index.ts @@ -0,0 +1,43 @@ +/** + * 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' +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 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