diff --git a/CLAUDE.md b/CLAUDE.md index 4f86331..a1a5691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,12 +84,32 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): |---|---|---| | `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. See aiolabs/bitspire#52. | +| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. | | `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface. +## Pairing (on-machine QR wizard) + +A machine with no seed **and** no stored binding boots `unpaired` and, under +Electron, renders an interactive wizard (`src/components/PairingWizard.vue`) +instead of a dead-end fault screen. The operator displays the `spire-seed` +QR (minted by spirekeeper's `/pair`) to the machine's camera; the wizard: + +1. captures + decodes via a `PairingSource` (`src/services/pairing/`) — camera + today (decode through `qr`, paulmillr's zero-dep lib), NFC scaffolded; +2. validates the scan parses as a spire-seed (`ingestScannedSeed`), rejecting + a stray QR; +3. persists it as `VITE_SPIRE_SEED` via the `state:save-spire-seed` IPC and + relaunches (`app:relaunch`). + +Pairing itself is **not** done in the wizard — relaunch lets the normal boot +path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so +there's one tested pairing path. A revoked/expired binding lands on the same +wizard (re-pair = scan a fresh seed). Provisioning `VITE_SPIRE_SEED` up front +still works and skips the wizard. + ## Commands ```bash diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index c35382d..516f32a 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -353,6 +353,50 @@ ipcMain.handle('state:reset-bootstrap-gate', (): void => { resetBootstrapGate() }) +// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a +// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the +// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed) +// exactly as if it had been provisioned. We deliberately do NOT pair here — +// persisting + relaunching reuses the single, tested pairing path rather than +// duplicating it in the renderer. +function runtimeEnvPath(): string { + const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd() + return path.join(base, '.env') +} + +ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => { + const trimmed = (seed || '').trim() + if (!trimmed) throw new Error('save-spire-seed: empty seed') + const envPath = runtimeEnvPath() + const line = `VITE_SPIRE_SEED=${trimmed}` + let lines: string[] = [] + if (fs.existsSync(envPath)) { + lines = fs.readFileSync(envPath, 'utf8').split('\n') + } + const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED=')) + if (idx >= 0) { + lines[idx] = line + } else { + // Drop a trailing empty element so we don't accumulate blank lines. + if (lines.length && lines[lines.length - 1] === '') lines.pop() + lines.push(line) + } + fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 }) + // Keep this process's view in sync so get-atm-secrets reflects the new seed + // even before relaunch (belt-and-suspenders; relaunch re-reads from disk). + process.env.VITE_SPIRE_SEED = trimmed + console.log('[Pairing] Spire seed persisted to', envPath) +}) + +// Relaunch the kiosk so the new seed is picked up by a clean boot. Under +// systemd (bitspire.service) the exit triggers an automatic restart; in dev +// Electron's relaunch re-spawns the process. +ipcMain.handle('app:relaunch', (): void => { + console.log('[Pairing] Relaunching to apply new pairing') + app.relaunch() + app.exit(0) +}) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index f439de9..f1320fb 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', { clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, + // then relaunch so the normal boot flow pairs it. + saveSpireSeed: (seed: string): Promise => ipcRenderer.invoke('state:save-spire-seed', seed), + relaunchApp: (): Promise => ipcRenderer.invoke('app:relaunch'), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -234,6 +239,8 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + saveSpireSeed: (seed: string) => Promise + relaunchApp: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/package.json b/apps/machine/package.json index 844211c..de432cb 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -37,6 +37,7 @@ "marked": "^17.0.5", "nostr-tools": "^2.10.0", "pinia": "^2.2.0", + "qr": "^0.6.0", "qrcode.vue": "^3.6.0", "reka-ui": "^2.7.0", "tailwind-merge": "^3.4.0", diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 48cfc8a..f28b639 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -8,6 +8,7 @@ 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' const atmStore = useAtmStore() const route = useRoute() @@ -61,6 +62,14 @@ const isKnownMaintenanceScreen = computed( () => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS ) +/** + * `unpaired` is interactive, not a dead-end: render the QR-pairing wizard so + * the operator can scan a spire-seed on-machine (aiolabs/bitspire#52). The + * wizard only works under Electron (needs the seed-persist + relaunch bridge); + * in browser dev it falls back to the static card. + */ +const showPairingWizard = computed(() => atmStore.initError === 'unpaired' && isElectron) + const formattedBtcPrice = computed(() => { if (atmStore.btcPrice === null) return null const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}` @@ -160,9 +169,12 @@ function toggleLiveServices() {
+ + +
+/** + * QR-pairing wizard (aiolabs/bitspire#52). + * + * Shown in place of the "Pairing Required" maintenance screen when the machine + * is unpaired. The operator displays the spire-seed QR (minted by spirekeeper) + * to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and + * relaunch so the normal boot path performs the bunker pairing. + * + * Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be + * offered later without changing this view. + */ +import { onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { + availablePairingSources, + ingestScannedSeed, + type PairingSource, + type StopCapture, +} from '@/services/pairing' + +type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' + +const phase = ref('probing') +const errorMessage = ref('') +const videoEl = ref(null) + +const sources = shallowRef([]) +const activeSource = shallowRef(null) +let stopCapture: StopCapture | null = null + +async function startWith(source: PairingSource) { + await teardown() + activeSource.value = source + errorMessage.value = '' + phase.value = 'scanning' + try { + stopCapture = await source.start({ + video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined, + onScan: handleScan, + onError: (e) => console.warn('[Pairing] capture glitch:', e), + }) + } catch (e) { + phase.value = 'error' + errorMessage.value = + e instanceof Error ? e.message : 'Could not start the camera. Check permissions.' + } +} + +let handling = false +async function handleScan(raw: string) { + if (handling) return + handling = true + const result = await ingestScannedSeed(raw) + if (result.ok) { + // saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen. + phase.value = 'pairing' + return + } + // Reject non-seed scans (a stray QR) and resume scanning. + console.warn('[Pairing] rejected scan:', result.reason, result.message) + errorMessage.value = + result.reason === 'invalid-seed' + ? 'That code is not a pairing code. Show the operator pairing QR.' + : result.message + handling = false + if (activeSource.value) await startWith(activeSource.value) +} + +async function teardown() { + if (stopCapture) { + try { + stopCapture() + } catch { + /* idempotent */ + } + stopCapture = null + } +} + +onMounted(async () => { + const available = await availablePairingSources() + sources.value = available + const first = available[0] + if (!first) { + phase.value = 'no-source' + return + } + await startWith(first) +}) + +onUnmounted(teardown) + + +
+

Pair This Machine

+ + +
+ + +
+
+ +

+ Hold the operator's pairing QR up to the camera. +

+ +

+ Starting camera… +

+ +
+

Pairing accepted — restarting…

+
+ +

+ No camera or NFC reader is available on this machine. Pair by provisioning + VITE_SPIRE_SEED instead. +

+ +

+ {{ errorMessage }} +

+ + +

+ {{ errorMessage }} +

+ + +
+ +
+
+ diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts index f9b0c84..bbeb117 100644 --- a/apps/machine/src/services/init-error.ts +++ b/apps/machine/src/services/init-error.ts @@ -3,14 +3,17 @@ * (see App.vue's MAINTENANCE_SCREENS). * * Bunker failures (aiolabs/bitspire#52) get dedicated screens: + * - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the + * interactive QR-pairing wizard so the operator can scan a spire-seed. * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) → - * `unpaired` — the operator must re-pair the machine. + * `unpaired` too — re-pairing is the same scan-a-fresh-seed flow. * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`, * a transient condition. * Everything else surfaces its raw message (or the caller's fallback). */ export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { const name = (error as { name?: string } | null)?.name + if (name === 'NoPairingError') return 'unpaired' if (name === 'BunkerRejectedError') return 'unpaired' if (name === 'BunkerTimeoutError') return 'signer-unreachable' return error instanceof Error ? error.message : fallback diff --git a/apps/machine/src/services/pairing/__tests__/ingest.test.ts b/apps/machine/src/services/pairing/__tests__/ingest.test.ts new file mode 100644 index 0000000..7392de3 --- /dev/null +++ b/apps/machine/src/services/pairing/__tests__/ingest.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ingestScannedSeed } from '../ingest' +import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client' + +/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ +function makeSeed(json: unknown): string { + const b64 = Buffer.from(JSON.stringify(json), 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + return SPIRE_SEED_SCHEME + b64 +} + +const SPIRE_PUBKEY = 'a'.repeat(64) +const VALID_SEED = makeSeed({ + v: 1, + spire_npub: 'npub1example', + spire_pubkey: SPIRE_PUBKEY, + bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`, + relays: ['wss://events.relay/'], +}) + +describe('ingestScannedSeed', () => { + const originalWindow = globalThis.window + + afterEach(() => { + globalThis.window = originalWindow + vi.restoreAllMocks() + }) + + it('rejects a non-seed scan without touching the bridge', async () => { + const saveSpireSeed = vi.fn() + globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis + + const result = await ingestScannedSeed('https://example.com/not-a-seed') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('invalid-seed') + expect(saveSpireSeed).not.toHaveBeenCalled() + }) + + it('reports no-bridge when Electron is absent', async () => { + globalThis.window = {} as unknown as Window & typeof globalThis + const result = await ingestScannedSeed(VALID_SEED) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('no-bridge') + }) + + it('persists the seed and relaunches on a valid scan', async () => { + const saveSpireSeed = vi.fn().mockResolvedValue(undefined) + const relaunchApp = vi.fn().mockResolvedValue(undefined) + globalThis.window = { + electronAPI: { saveSpireSeed, relaunchApp }, + } as unknown as Window & typeof globalThis + + const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace + expect(result.ok).toBe(true) + if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY) + expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED) + expect(relaunchApp).toHaveBeenCalledOnce() + }) + + it('surfaces persist-failed when saveSpireSeed throws', async () => { + const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES')) + globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis + + const result = await ingestScannedSeed(VALID_SEED) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('persist-failed') + }) +}) + +describe('ingest does not pair in-renderer', () => { + it('never imports connect logic — persistence + relaunch only', () => { + // Guard: the design intentionally reuses the boot-time pairing path. + // If someone wires connectNewSeed here, this comment + the ingest source + // should be revisited together. + expect(ingestScannedSeed).toBeTypeOf('function') + }) +}) diff --git a/apps/machine/src/services/pairing/index.ts b/apps/machine/src/services/pairing/index.ts new file mode 100644 index 0000000..67e3ade --- /dev/null +++ b/apps/machine/src/services/pairing/index.ts @@ -0,0 +1,30 @@ +/** + * Pairing module surface (aiolabs/bitspire#52). + * + * `availablePairingSources()` probes each known source and returns those the + * current device can actually run, in preference order (camera first, NFC if + * present). The wizard renders the first available source and offers the rest + * as alternates. + */ + +import { QrPairingSource } from './qr-source' +import { NfcPairingSource } from './nfc-source' +import type { PairingSource } from './types' + +export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types' +export { QrPairingSource } from './qr-source' +export { NfcPairingSource } from './nfc-source' +export { ingestScannedSeed } from './ingest' +export type { IngestResult } from './ingest' + +/** All sources in preference order, regardless of availability. */ +export function allPairingSources(): PairingSource[] { + return [new QrPairingSource(), new NfcPairingSource()] +} + +/** Only the sources this device can run, in preference order. */ +export async function availablePairingSources(): Promise { + const sources = allPairingSources() + const flags = await Promise.all(sources.map((s) => s.isAvailable())) + return sources.filter((_, i) => flags[i]) +} diff --git a/apps/machine/src/services/pairing/ingest.ts b/apps/machine/src/services/pairing/ingest.ts new file mode 100644 index 0000000..a758f1e --- /dev/null +++ b/apps/machine/src/services/pairing/ingest.ts @@ -0,0 +1,63 @@ +/** + * Seed ingest pipeline (aiolabs/bitspire#52). + * + * Turns a raw scanned payload into a paired machine. The wizard captures a + * string off some PairingSource and hands it here; we: + * 1. validate it parses as a spire-seed (reject anything else — a QR on the + * counter, a URL, a different protocol), + * 2. persist it as VITE_SPIRE_SEED via the Electron bridge, + * 3. relaunch so the normal boot path (signer-resolver → connectNewSeed) + * performs the actual bunker pairing. + * + * We do NOT pair in-renderer here: persisting + relaunching reuses the single, + * hardware-tested pairing path rather than duplicating connect/redeem logic in + * the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a + * one-time provisioning step. + */ + +import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client' + +export type IngestResult = + | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } + | { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string } + +export async function ingestScannedSeed(raw: string): Promise { + const trimmed = (raw || '').trim() + + let spirePubkey: string + let relays: string[] + try { + const seed = parseSpireSeed(trimmed) + spirePubkey = seed.spirePubkey + relays = seed.relays + } catch (e) { + return { + ok: false, + reason: 'invalid-seed', + message: e instanceof Error ? e.message : 'Not a valid pairing code', + } + } + + if (typeof window === 'undefined' || !window.electronAPI) { + return { + ok: false, + reason: 'no-bridge', + message: 'Pairing must run on the machine (no kiosk bridge available).', + } + } + + try { + await window.electronAPI.saveSpireSeed(trimmed) + } catch (e) { + return { + ok: false, + reason: 'persist-failed', + message: e instanceof Error ? e.message : 'Could not save the pairing.', + } + } + + // Fire-and-forget: the relaunch tears this process down. + void window.electronAPI.relaunchApp() + + return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays } +} diff --git a/apps/machine/src/services/pairing/nfc-source.ts b/apps/machine/src/services/pairing/nfc-source.ts new file mode 100644 index 0000000..5291b3c --- /dev/null +++ b/apps/machine/src/services/pairing/nfc-source.ts @@ -0,0 +1,67 @@ +/** + * NFC pairing source — SCAFFOLD (aiolabs/bitspire#52). + * + * The user flagged NFC as a plausible future pairing method (tap a tag/phone + * carrying the spire-seed). This wires the seam against the Web NFC API + * (`NDEFReader`) so a future build can light it up without reworking the + * wizard. It is NOT active on current hardware: Web NFC ships only on Chrome + * for Android, so `isAvailable()` returns false on the Sintra's Linux Electron + * and the wizard simply won't offer it. + * + * When real NFC hardware lands (likely a HAL peripheral rather than Web NFC), + * replace the body of `start()` with that driver — the PairingSource contract + * stays the same. + */ + +import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types' + +// Minimal structural type for the Web NFC API (not in lib.dom for Electron). +interface NDEFReaderLike { + scan(): Promise + addEventListener( + type: 'reading', + listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void + ): void + addEventListener(type: 'readingerror', listener: (event: unknown) => void): void +} + +function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null { + const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader + return ctor ?? null +} + +export class NfcPairingSource implements PairingSource { + readonly kind = 'nfc' as const + readonly label = 'NFC tap' + + async isAvailable(): Promise { + return getNDEFReaderCtor() !== null + } + + async start(opts: PairingSourceStartOptions): Promise { + const Ctor = getNDEFReaderCtor() + if (!Ctor) throw new Error('Web NFC unavailable on this device') + + const reader = new Ctor() + const decoder = new TextDecoder() + let stopped = false + + reader.addEventListener('reading', (event) => { + if (stopped) return + for (const record of event.message.records) { + if (record.recordType === 'text' && record.data) { + const raw = decoder.decode(record.data).trim() + if (raw) opts.onScan(raw) + } + } + }) + reader.addEventListener('readingerror', (e) => opts.onError?.(e)) + + await reader.scan() + // Web NFC has no explicit stop; the AbortController form would, but the + // scaffold just flips a guard so late events are ignored after teardown. + return () => { + stopped = true + } + } +} diff --git a/apps/machine/src/services/pairing/qr-source.ts b/apps/machine/src/services/pairing/qr-source.ts new file mode 100644 index 0000000..1628a4b --- /dev/null +++ b/apps/machine/src/services/pairing/qr-source.ts @@ -0,0 +1,90 @@ +/** + * Camera-based QR pairing source (aiolabs/bitspire#52). + * + * Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual + * MIT/Apache library from the same author as the `@noble`/`@scure` crypto our + * nostr stack already trusts (chosen over the dormant `jsqr` for that ethos + + * active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and + * the per-frame decode loop, so this source is a thin adapter onto the + * PairingSource contract. + * + * The first successful decode wins; the loop then stops itself so a single + * seed isn't ingested repeatedly. + */ + +import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js' +import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types' + +export class QrPairingSource implements PairingSource { + readonly kind = 'qr' as const + readonly label = 'Camera' + + async isAvailable(): Promise { + return ( + typeof navigator !== 'undefined' && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' + ) + } + + async start(opts: PairingSourceStartOptions): Promise { + const { onScan, onError, video } = opts + if (!video) throw new Error('QrPairingSource requires a