chore(machine): fund-atm resumes from binding; VITE_SPIRE_SEED docs/env

fund-atm resolves its signer by resuming the bunker binding from state.db
(the connect token is already spent by the main app, so it can't re-pair);
falls back to a dev nsec via VITE_ATM_PRIVATE_KEY. better-sqlite3 marked
external in the esbuild bundle. .env.example + CLAUDE.md document
VITE_SPIRE_SEED as the prod identity, VITE_ATM_PRIVATE_KEY as dev-only.

(fund-atm is slated for deprecation in favour of the operator funding the
wallet directly via the LNbits UI — kept working for now.)

Part of Phase C, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-19 00:15:59 +02:00
commit 0391dbaeb0
4 changed files with 47 additions and 13 deletions

View file

@ -84,7 +84,8 @@ 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_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_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) | | `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) 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_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 | | `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. 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.
@ -188,7 +189,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l
## Security priorities ## Security priorities
1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`. 1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback.
2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter. 2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter.
3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort. 3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort.
4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden. 4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden.

View file

@ -36,16 +36,23 @@ VITE_LNBITS_SERVER_PUBKEY=
# aiolabs/withdraw#1 / commit e9d911e.) # aiolabs/withdraw#1 / commit e9d911e.)
# ============================================================================= # =============================================================================
# ATM Identity # ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52)
# ============================================================================= # =============================================================================
# The spire pairing seed produced by the operator dashboard (spirekeeper):
# spire-seed:v1:<base64url>
# It carries a one-shot NIP-46 connect token + the spire's signing pubkey +
# the bunker URL. On first boot the ATM redeems the token, generates its own
# transport key, and persists the binding to state.db; thereafter it resumes
# from the binding (the seed can stay set — it's matched by fingerprint).
# A changed seed re-pairs (and re-publishes the cassette-state hello).
VITE_SPIRE_SEED=
# pragma: allowlist secret # pragma: allowlist secret
# ATM's Nostr private key (hex format, 64 characters). This signing # DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running
# key IS the credential — LNbits derives the account from it on first # without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists.
# contact (issue aiolabs/lnbits#9 alignment).
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
# If not set, generates ephemeral identity on each restart (dev only). # VITE_ATM_PRIVATE_KEY=
VITE_ATM_PRIVATE_KEY=
# ============================================================================= # =============================================================================
# Operator Identity # Operator Identity

View file

@ -13,8 +13,15 @@
*/ */
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client' import {
NostrClient,
LocalSigner,
loadIdentityFromHex,
resumeFromBinding,
type Signer,
} from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits' import { LnbitsClient } from '@bitSpire/lnbits'
import { initDatabase, getBunkerBinding } from './state-store.js'
// @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed
import QRCode from 'qrcode' import QRCode from 'qrcode'
@ -56,15 +63,34 @@ async function main() {
const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY'] const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY']
const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY'] const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY']
if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) { if (!relayUrl || !lnbitsServerPubkey) {
console.error('Missing required config in', envPath) console.error('Missing required config in', envPath)
console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY') console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY')
process.exit(1) process.exit(1)
} }
console.error(`Generating invoice for ${amountSats} sats...`) console.error(`Generating invoice for ${amountSats} sats...`)
const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) // Resolve the signer. Prod: resume the bunker binding from state.db (the
// ATM's transport key — the connect token was already redeemed by the main
// app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY.
let signer: Signer
if (atmPrivateKey) {
signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey))
} else {
initDatabase()
const binding = getBunkerBinding()
if (!binding) {
console.error('ATM is not paired (no bunker binding in state.db) and no')
console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.')
process.exit(1)
}
signer = await resumeFromBinding({
clientSecretHex: binding.clientSecretHex,
spirePubkey: binding.spirePubkey,
bunkerUrl: binding.bunkerUrl,
})
}
const nostrClient = new NostrClient({ const nostrClient = new NostrClient({
relays: [{ url: relayUrl }], relays: [{ url: relayUrl }],

View file

@ -14,7 +14,7 @@
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"", "dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
"dev:vite": "vite", "dev:vite": "vite",
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs", "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
"build:electron": "pnpm build && electron-builder", "build:electron": "pnpm build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",