From c7e312a63f5f244fd5e0403b642257b7f0c684d5 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 22:37:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(access):=20Bolt=20Card=20tap-to-enter=20?= =?UTF-8?q?=E2=80=94=20load=20card=20into=20session,=20one-press=20Complet?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the access gate: a single Bolt Card tap at the locked screen both unlocks the terminal AND pre-loads the card, so buy/sell just need "Complete" — no second tap. Reuses the #83/#84 payment paths verbatim. Soft entry, verify-at-payment: the tap is parsed LOCALLY (external_id only) so the single-use SUN p/c stay valid; the cryptographic check happens at Complete when the stored lnurlw actually moves sats (withdraw for sell, lnurlp-pay for buy). Open-enrollment, card-only (no npub-QR, no PIN) per product decision. - services/access: `boltcard` credential (externalId + lnurlw) in the AccessScan union; canonicalId + open-enrollment/allow-list authorize; parseBoltcardLnurlw (local, no server). Only external_id is hashed — p/c never enter authorize. - store: loadedBoltCard (session-scoped, cleared on re-lock); handleBoltCardEntry (tap while locked → authorize → grant + load); completeWithCard (routes to the existing tap handlers); NFC listener routes locked→enter. - LockedView: card-only "Tap your Bolt Card" screen (dropped camera/npub-QR/PIN). - CashIn/CashOutView: "Complete Purchase/Sale" button + card chip when loaded. - tests: boltcard authorize + parseBoltcardLnurlw (17 access tests total). Enabling the gate is a provisioning step (access.json enabled+openEnrollment); other machines default off → unchanged. --- .../access/__tests__/authorize.test.ts | 65 +++- apps/machine/src/services/access/authorize.ts | 11 +- apps/machine/src/services/access/boltcard.ts | 27 ++ apps/machine/src/services/access/index.ts | 1 + apps/machine/src/services/access/types.ts | 6 + apps/machine/src/stores/atm.ts | 84 ++++- apps/machine/src/views/CashInView.vue | 18 + apps/machine/src/views/CashOutView.vue | 18 + apps/machine/src/views/LockedView.vue | 343 ++++-------------- 9 files changed, 286 insertions(+), 287 deletions(-) create mode 100644 apps/machine/src/services/access/boltcard.ts diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts index 0f175af..521cfcb 100644 --- a/apps/machine/src/services/access/__tests__/authorize.test.ts +++ b/apps/machine/src/services/access/__tests__/authorize.test.ts @@ -1,6 +1,7 @@ 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) @@ -79,7 +80,9 @@ describe('access authorize (ADR-003)', () => { }) 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 }) + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], { + salt: SALT, + }) expect(out.status).toBe('pin-required') }) @@ -102,12 +105,62 @@ describe('access authorize (ADR-003)', () => { 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 } - ) + 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 index a6c99be..86aac4d 100644 --- a/apps/machine/src/services/access/authorize.ts +++ b/apps/machine/src/services/access/authorize.ts @@ -55,7 +55,8 @@ async function sha256Hex(input: string): Promise { } export const hashId = (id: string, salt: string): Promise => sha256Hex(`id:${salt}:${id}`) -export const hashPin = (pin: string, salt: string): Promise => sha256Hex(`pin:${salt}:${pin}`) +export const hashPin = (pin: string, salt: string): Promise => + sha256Hex(`pin:${salt}:${pin}`) /** * Resolve a scan to a canonical identity string, or `null` if malformed. @@ -64,6 +65,7 @@ export const hashPin = (pin: string, salt: string): Promise => sha256Hex */ 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). @@ -108,7 +110,12 @@ export async function authorize( return { status: 'denied', credentialIdHash: '', - reason: scan.kind === 'npub' ? 'not a valid npub' : 'invalid credential', + reason: + scan.kind === 'npub' + ? 'not a valid npub' + : scan.kind === 'boltcard' + ? 'not a valid card' + : 'invalid credential', } } 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 index fb1e26c..a3ed586 100644 --- a/apps/machine/src/services/access/index.ts +++ b/apps/machine/src/services/access/index.ts @@ -23,6 +23,7 @@ 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[] { diff --git a/apps/machine/src/services/access/types.ts b/apps/machine/src/services/access/types.ts index b7719f7..d6ecec3 100644 --- a/apps/machine/src/services/access/types.ts +++ b/apps/machine/src/services/access/types.ts @@ -32,6 +32,12 @@ export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial' 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: 'challenge'; pubkey: string; nonce: string; sig: string } export interface AccessReaderStartOptions { diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 28eb75b..a65f900 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -27,6 +27,7 @@ import { } from '@bitSpire/clink' import type { TransactionRecord } from '@/types/state' import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast' +import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access' // Check if we're running in Electron const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined @@ -305,6 +306,11 @@ export const useAtmStore = defineStore('atm', () => { }) // Build/dev bypass — opens the gate even when enabled (browser dev / CI). const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true' + // Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held + // here for the whole session so buy/sell just need "Complete" — no second tap. + // Carries the full lnurlw (single-use SUN p/c intact; spent only at payment). + // Cleared when the session ends (machine re-locks). Never logged. + const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null) const fiatCode = ref('USD') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` envelope from satmachineadmin) @@ -494,6 +500,12 @@ export const useAtmStore = defineStore('atm', () => { lnurlCleanupFn() } + // Drop the tapped-at-entry Bolt Card when the session ends (machine + // re-locks) so the next customer starts fresh — never carry a card over. + if (state === 'locked' && loadedBoltCard.value) { + loadedBoltCard.value = null + } + // Detect network from first invoice we see if (newSnapshot.context.invoice) { detectNetworkFromInvoice(newSnapshot.context.invoice) @@ -671,21 +683,76 @@ export const useAtmStore = defineStore('atm', () => { } } + /** + * A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry: + * parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay + * valid), authorize (open-enrollment or allow-list), then hold the full lnurlw + * for the session. The cryptographic check happens later, at Complete, when the + * stored lnurlw actually moves sats. + */ + async function handleBoltCardEntry(lnurlw: string) { + if (!isLocked.value) return + if (boltCardProcessing.value) return + const parsed = parseBoltcardLnurlw(lnurlw) + if (!parsed) { + nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' } + denyAccess('not a Bolt Card') + return + } + boltCardProcessing.value = true + nfcStatus.value = { state: 'processing', message: 'Reading card…' } + try { + const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw } + const outcome = await authorize(scan, accessControl.value.allowList, { + salt: accessControl.value.salt, + openEnrollment: accessControl.value.openEnrollment, + }) + if (outcome.status === 'granted') { + loadedBoltCard.value = { externalId: parsed.externalId, lnurlw } + nfcStatus.value = { state: 'accepted', message: 'Card accepted' } + grantAccess(outcome.role, outcome.credentialIdHash) + } else { + // pin-required can't occur for card-only open-enrollment; treat as denied. + const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized' + nfcStatus.value = { state: 'declined', message: reason } + denyAccess(reason, outcome.credentialIdHash) + } + } finally { + boltCardProcessing.value = false + } + } + + /** + * Complete a buy/sell using the Bolt Card loaded at entry — no second tap. + * Cash-out pulls via the stored lnurlw; cash-in resolves it to the card + * wallet's lnurlp and pays. Reuses the tap handlers verbatim. + */ + function completeWithCard() { + const card = loadedBoltCard.value + if (!card) return + if (isCashOut.value) void handleBoltCardTap(card.lnurlw) + else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw) + } + /** Wire the main-process reader once (idempotent via preload removeAllListeners). */ function setupNfcListener() { if (!isElectron || !window.electronAPI?.onNfcCardTapped) return window.electronAPI.onNfcCardTapped((lnurlw) => { - // Route the same physical tap by flow: cash-out pulls, cash-in receives. - if (isCashOut.value && nestedState.value === 'displayingInvoice') { + // Route the tap by state: locked → enter + load the card; then cash-out + // pulls, cash-in receives (fallback if no card was loaded at entry). + if (isLocked.value) { + void handleBoltCardEntry(lnurlw) + } else if (isCashOut.value && nestedState.value === 'displayingInvoice') { void handleBoltCardTap(lnurlw) } else if (isCashIn.value && nestedState.value === 'displayingQR') { void handleBoltCardReceive(lnurlw) } }) window.electronAPI.onNfcStatus?.((status) => { - // Only surface reader status on a tap screen, and don't clobber an - // in-flight tap's message. + // Surface reader status on a tap screen (locked / invoice / QR); don't + // clobber an in-flight tap's message. const onTapScreen = + isLocked.value || (isCashOut.value && nestedState.value === 'displayingInvoice') || (isCashIn.value && nestedState.value === 'displayingQR') if (onTapScreen && !boltCardProcessing.value) { @@ -704,6 +771,11 @@ export const useAtmStore = defineStore('atm', () => { void handleBoltCardReceive(lnurlw) } + /** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */ + function simulateBoltCardEntry(lnurlw: string) { + void handleBoltCardEntry(lnurlw) + } + /** * Group an array of inserted bill denominations into { denomination, count } pairs. */ @@ -1716,6 +1788,10 @@ export const useAtmStore = defineStore('atm', () => { boltCardProcessing, simulateBoltCardTap, simulateBoltCardReceive, + // Tap-to-enter: card loaded at the locked screen, reused at Complete + loadedBoltCard, + completeWithCard, + simulateBoltCardEntry, // Access control (ADR-003) accessControl, diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index 915c113..b75a2a4 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -363,6 +363,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)

+ +
+

+ Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} +

+ +
+

diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 6257882..5a037c3 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -328,6 +328,24 @@ function formatFiat(cents: number): string {

+ +
+

+ Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} +

+ +
+

diff --git a/apps/machine/src/views/LockedView.vue b/apps/machine/src/views/LockedView.vue index 90c4ac0..86115e9 100644 --- a/apps/machine/src/views/LockedView.vue +++ b/apps/machine/src/views/LockedView.vue @@ -1,316 +1,109 @@