chore(access): prune unwired readers, amend ADR-003 for what shipped
ADR-003, .env.example, the access module's headers and the provisioning schema still described the planned npub-QR → UID → serial-reader path. What shipped (#86) is Bolt Card tap-to-enter over the main-process pcscd reader with external_id as the identity, soft entry and verify-at-payment. Nothing ever called availableAccessReaders(): the camera npub-QR reader, the mock reader and the AccessReader seam were dead, so they go; services/access now holds authorize, the card parser and the credential types. The unused 'uid' scan variant goes with them; 'npub' (+PIN) and the 'challenge' seam stay. The ADR gets an amendment section recording the differences, including that open enrollment is not a security boundary and that the audit is still a stub (both tracked as issues). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
a652089441
commit
f11aced450
6 changed files with 62 additions and 107 deletions
|
|
@ -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<string> =>
|
|||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -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<AccessReader[]> {
|
||||
const readers = allAccessReaders()
|
||||
const flags = await Promise.all(readers.map((r) => r.isAvailable()))
|
||||
return readers.filter((_, i) => flags[i])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <video> element a camera reader renders into. Required by camera
|
||||
* readers (qr-npub); ignored by readers with no viewfinder (mock, NFC).
|
||||
*/
|
||||
video?: HTMLVideoElement
|
||||
}
|
||||
|
||||
/** Releases the reader (camera stream, event listeners, serial handle). Idempotent. */
|
||||
export type StopCapture = () => void
|
||||
|
||||
export interface AccessReader {
|
||||
readonly kind: AccessReaderKind
|
||||
/** Short human label for status/debug UI. */
|
||||
readonly label: string
|
||||
/** Whether this reader can run in the current environment. */
|
||||
isAvailable(): Promise<boolean>
|
||||
/** Begin capturing; resolves once the reader is live. */
|
||||
start(opts: AccessReaderStartOptions): Promise<StopCapture>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue