spirekeeper/docs/security-pathway-v1.md
Padreug 439c47ceae docs: repoint migrated issue refs to spirekeeper numbers
Follow-up to the satmachineadmin->spirekeeper issue migration. The 20
open issues were recreated on aiolabs/spirekeeper with reassigned
numbers; this repoints in-repo references to the migrated issues at
their new spirekeeper numbers (#3->#1, #4->#2, #8->#4, #9->#5, #10->#6,
#17->#11, #21->#12, #28->#16, #44->#20). References to closed/non-
migrated satmachineadmin issues (#20/#22/#26/#29/#32/#37/#38/#39) stay
pointing at the original repo where they were resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:42:56 +02:00

403 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# bitSpire ↔ LNbits Security Pathway — State of the Union & Design Proposal
**Audience:** an operator, a junior dev, an auditor, the customer who walks up to the ATM.
**Goal:** explain — without handwaving — how money moves between a bitSpire ATM and the operator's LNbits wallet, what guarantees today's code provides, where the gaps are, and a concrete multilayered fix that capitalises on Nostr instead of bolting on TLSstyle fingerprints.
---
## 0 · Why this document exists
Today the satoshimachine code lives at `~/dev/shared/extensions/spirekeeper` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostrnative one: bitSpire publishes invoices over kind21000 NIP44 v2 events, LNbits pays them, and our extension hooks the resulting `Payment` object.
The hard truth: the *settlement* itself uses Lightning (so it can't be forged once a preimage lands), but everything *around* the settlement — who the ATM is, what operator it belongs to, what the principal/commission split was, and what fiat was dispensed — currently rides on **mutable, unauthenticated metadata** (`Payment.extra`) plus a **stopgap that has the ATM hold the operator's own Nostr private key**. The latter means physical possession of the ATM = total compromise of the operator's LNbits account.
Two realworld incidents during dev surfaced this:
1. A stale `sintra` machine with placeholder npub `npub1111…` was created under a `test` user. A real cashin landed on it because routing is *purely by `wallet_id`*, not by signed identity. We deleted the stale row, but the lesson is structural: there is no endtoend identity proof.
2. The provisioning script (`/home/padreug/dev/shocknet/lamassu-next/deploy/nixos/provision-atm.sh`) writes `VITE_ATM_PRIVATE_KEY` straight into `/var/lib/bitspire/.env`. Today we set that to the operator's own privkey ("Option 1 stopgap"). Anyone with physical/root access to the ATM can sign as the operator on any relay.
Lamassu's old answer here was TLS cert pinning. We have a richer toolbox — Nostr — and have so far used roughly one knob (NIP44 encryption) of it.
---
## 1 · Glossary (juniordev friendly)
| Term | Plain English |
|---|---|
| **bitSpire ATM** | The cash machine. Cousin of the old Lamassu hardware. Identifies itself with a Nostr keypair (`npub`/`nsec`). |
| **LNbits** | The Lightning wallet server we selfhost. The ATM is a "client" of LNbits over Nostr. |
| **Operator** | The human/business that owns one or more ATMs. Has an LNbits user account. |
| **Super** | The LNbits instance admin. Takes a platform fee from each operator. |
| **LP (Liquidity Provider)** | A customer who deposits fiat into the ATM business; receives BTC prorata via DCA. |
| **npub / nsec** | Nostr public / private key, bech32encoded. `npub` is shareable; `nsec` is the secret. |
| **Relay** | A Nostr pub/sub server. Carries encrypted RPC events between ATM and LNbits. |
| **NIPXX** | Nostr Implementation Possibility — a numbered protocol extension spec at `~/dev/nostr-protocol/nips/`. |
| **kind21000** | The event kind bitSpire/LNbits use for encrypted RPC (set by lamassunext's nostrtransport). |
| **NIP44 v2** | Authenticated encryption for Nostr DMs/RPC (ChaCha20 + HMACSHA256, MAC verified before signature). |
| **Payment.extra** | A freeform JSON dict LNbits stores alongside a payment. **Mutable. Unsigned.** |
| **Preimage** | The 32byte secret revealed when a Lightning invoice is paid. Unforgeable proof of payment. |
| **Settlement** | One bitSpire cashin or cashout, landed as a `dca_settlements` row in our DB. |
---
## 2 · Today's pathway — what the bytes actually do
### 2.1 Cashout, end to end (the only flow currently wired)
```
┌────────────────────┐ kind-21000 NIP-44 v2 RPC over relay ┌──────────────────────┐
│ bitSpire ATM │ ───────────────────────────────────────────▶ │ LNbits │
│ signs with │ │ nostr-transport │
│ VITE_ATM_PRIVATE │ {method: "create_invoice", amount, memo} │ handler │
│ _KEY (currently │ │ (auto-creates an │
│ the OPERATOR's │ ◀─────────────────────────────────────────── │ Account from npub) │
│ nsec — stopgap) │ {payment_request: "lnbc...", payment_hash} │ │
└──────────┬─────────┘ └──────────┬───────────┘
│ │
│ Customer scans QR, pays with their wallet on the Lightning network │
│ │
▼ ▼
Customer wallet ──── BOLT11 invoice settles ──────────────────▶ LNbits Payment row
is_in=True, success=True
wallet_id=auto-created
Payment.extra={source:"bitspire",
net_sats, fee_sats,
machine_npub, ...}
register_invoice_listener fires
spirekeeper/tasks.py:_handle_payment
┌─────────────────────────────────┴────────────────────────────┐
▼ ▼
get_active_machine_by_wallet_id(payment.wallet_id) parse_settlement(Payment.extra)
── routing decision lives HERE ── ── trust boundary lives HERE ──
(machine ↔ wallet is 1:1 in DB) (we trust Payment.extra wholesale)
│ │
└──────────────────┬──────────────────────────────────────────┘
create_settlement_idempotent
(UNIQUE on payment_hash)
asyncio.create_task(process_settlement)
┌───────────────────────────────────────┼───────────────────────────────────────┐
▼ ▼ ▼
_pay_super_fee _pay_operator_splits _pay_dca_distributions
(platform_fee_sats → (operator_fee_sats → (net_sats → LPs pro-rata,
super_fee_wallet_id) N legs per ruleset) capped at remaining_fiat * rate)
```
### 2.2 What signs *what* today
| Hop | Signed? | By whom? | Verified? |
|---|---|---|---|
| ATM → relay (kind21000 event) | Yes (NIP01 Schnorr sig) | ATM's keypair (= operator's keypair today) | Yes — relays drop unsigned events |
| RPC payload | Yes (NIP44 v2 MAC + outer sig) | Same key | Yes — handler verifies MAC before decrypt |
| LNbits payment ↔ ATM identity | **No** | — | **No** — the link is the autocreated Account's wallet_id, set at first contact |
| Payment.extra contents | **No** | — | **No** — anyone with the wallet admin key can mutate |
| Settlement row in our DB | No (DB row, not an event) | — | n/a — operator trusts their own DB |
| Lightning settlement | Yes (cryptographically, via preimage) | The HTLC chain | Yes — preimage hashes to `payment_hash` |
The Lightning settlement (the actual money) **is** cryptographically sound. Everything *attributing* that settlement to a particular machine, operator, fiat amount, and commission rate is not.
### 2.3 Routing decision today (the loadbearing line)
```python
# tasks.py:59
machine = await get_active_machine_by_wallet_id(payment.wallet_id)
```
That's it. One DB lookup. The `wallet_id` was minted by LNbits' nostrtransport when it autocreated an Account from the ATM's npub *on first contact*. From that moment on, "which machine?" is purely a join on `dca_machines.wallet_id → wallets.id`. If you can land a payment on that wallet — by any means — it counts as that machine's settlement.
### 2.4 The Option 1 stopgap (what's in `provision-atm.sh` today)
```bash
VITE_ATM_PRIVATE_KEY=$(openssl rand -hex 32)
# or, in practice: VITE_ATM_PRIVATE_KEY=<operator's own nsec>
```
The operator's Nostr private key — the one tied to their LNbits Account — is *physically present on the ATM filesystem* (`/var/lib/bitspire/.env`). Threat: cleaner steals the ATM, dumps the disk, signs `kind:1`/`kind:4`/`kind:21000` events impersonating the operator on every relay, draining their wallets via crafted RPC. There is no second factor, no scoping, no revocation.
---
## 3 · Threat model
Who might try to break this, and how:
| # | Adversary | Capability | What they want | Today's defence |
|---|---|---|---|---|
| T1 | Random Lightning user | Pay any LNbits invoice they have a bolt11 for | Free fiat / cashout without authorising | Bolt11 is singleuse; preimage settles only once |
| T2 | Curious LP | Has wallet admin key for their own LP wallet | See other LPs' balances | Operatorscoped CRUD; `_machine_owned_by` checks |
| T3 | Rogue operator | Owns their LNbits user; controls their own machines | Forge settlements to inflate volume / dodge super fee | **None** — operator can mutate Payment.extra |
| T4 | Compromised relay operator | Sees encrypted kind21000 events | Censor, replay, reorder | NIP44 protects content; **no replay window**; relay can drop but not forge |
| T5 | Thief with physical access to ATM | Can dump `/var/lib/bitspire/.env`, root the box | Drain operator wallet, sign as operator on Nostr | **None** — operator's nsec is on disk |
| T6 | Insider at the LNbits host | Has DB access to LNbits | Mutate Payment.extra retroactively | **None**`extra` is plain JSON, no audit log |
| T7 | Attacker who knows operator's npub | Public knowledge | Spam fake kind21000 from a key they generated | Autoaccountfromnpub means they get a *different* wallet — but nothing stops them creating noise |
| T8 | Insider at the super (LNbits admin) | Owns the LNbits node | Skim more than super_fee_pct | Operators must trust their host (this is fundamental — pick a host you trust, or selfhost) |
| T9 | Customer at the ATM | Walks up, scans QR | Pay an invoice attributed to a *different* operator's machine | wallet_id routing prevents crossoperator landing **only if** the invoice was generated for that wallet — confirmed by the stalesintra incident: routing is walletlevel, not signed |
T3, T5, T6 are the ones that keep the hardware honest. T3 + T6 are *the* reason `platform_fee_sats` and `operator_fee_sats` are stored as **absolute BIGINTs** (not derived from a mutable pct) — that defends the audit trail, but doesn't defend the initial write.
---
## 4 · Audit findings — current state inventory
Pulled from the two recent codelevel audits of `~/dev/shared/extensions/spirekeeper` (operatorscoping inventory) and `~/dev/lnbits/nostr-transport` (transport primitives).
### 4.1 What's already strong
- **Operator scoping is consistent.** All 33 routes filter by `current_user.id`; `_machine_owned_by` and `_client_owned_by` return 404 (not 403) on crossoperator probes so attackers can't enumerate other operators' resources.
- **Settlement idempotency.** `dca_settlements.payment_hash` is `UNIQUE`. A replayed Nostr event / dispatcher doublefire cannot cause a double payout.
- **Optimisticlock claim pattern.** `claim_settlement_for_processing` prevents two concurrent `process_settlement` calls from racing the same row.
- **Settlement legs are typed and tagged.** `dca_payments.leg_type` ∈ {`dca`, `super_fee`, `commission_split`, `settlement`}; `Payment.tag = "satmachine:{npub}"` flows through LNbits' native payment filter UI.
- **Absolutesats fee storage.** `platform_fee_sats` and `operator_fee_sats` are BIGINT columns, not derived from a mutable pct. This is the "Stripe Connect application_fee_amount" pattern and makes audits possible even if the commission rate later changes.
- **Appendonly `notes` on settlements.** Partialdispense recomputes prepend a timestamped memo; operator notes are timestamped + authortagged. Tamperevident at the row level.
- **NIP44 v2 is correctly used in nostrtransport.** MAC verified before decrypt, outer Schnorr sig verified before MAC. (See `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/*`.)
### 4.2 What's weak — confirmed gaps
| ID | Gap | Where | Why it matters |
|---|---|---|---|
| **G1** | **Routing is by `wallet_id` only.** The ATM's signed identity is never reverified at settlement land time. | `tasks.py:59` `get_active_machine_by_wallet_id(payment.wallet_id)` | Once a wallet exists, anything paying it counts. No defence against T3, T7. |
| **G2** | **Payment.extra is unauthenticated.** We read `source`, `net_sats`, `fee_sats`, `machine_npub`, `exchange_rate` directly. Anyone with the wallet's admin key can mutate it. | `bitspire.py:103-135` | T3 / T6: forge favourable splits, dodge super fee, dispute history. |
| **G3** | **ATM private key sits on disk as the operator's nsec.** | `provision-atm.sh:99` writes `VITE_ATM_PRIVATE_KEY` | T5: physical compromise = total operator compromise on every relay. |
| **G4** | **No replay window on RPC events.** | nostrtransport handler accepts events up to 10min old | T4: a relay can stash and replay a "create invoice" RPC. NIP44 doesn't prevent replay; only NIP40 expiration tags + nonce tracking do. |
| **G5** | **`sender_pubkey` is not persisted onto `Payment.extra` by the dispatcher.** | LNbits `nostr_transport/auth.py:148-183` | We can't tell, after the fact, which Nostr identity actually triggered a payment. |
| **G6** | **`Account.prvkey` is nullable but in practice populated serverside.** | LNbits Account schema | An autocreated account holds a key it generated. Anyone with DB access can read it. (T6.) |
| **G7** | **No signedrequest primitive.** Nothing in nostrtransport requires a separate, scoped attestation on a payment — just the outer event sig. | nostrtransport | We can't bind "this is a real bitSpire settlement for machine X" cryptographically. |
| **G8** | **No rate limiting at the relay layer.** | — | T7 can spam our autoaccountfromnpub endpoint. |
| **G9** | **No ACL on which npubs may autocreate accounts.** | nostrtransport | First contact wins. Combined with G3 + a realworld incident, this lets a stale/test machine accept real funds. |
| **G10** | **Cashin path is not wired.** `_handle_payment` filters `is_in=True only`; cashin is *outbound* (LNbits pays an LNURLwithdraw the customer scanned at the ATM). | `tasks.py:57` | Today we'd never know a cashin happened. (Out of scope for this doc but flagged.) |
### 4.3 What's *not* protected by encryption (clarification)
NIP44 v2 makes the *transport* confidential and integritychecked. It does **not**:
- Prove the sender is authorised to act for any party other than themselves (G1, G3).
- Prevent replay of an old, legitimatelysigned event (G4).
- Bind a Lightning settlement to a particular kind21000 RPC after the fact (G7).
- Audit who mutated `Payment.extra` after settlement landed (G2, G6).
Treat NIP44 as TLS, not as authn/authz. We need additional NIPs for the rest.
---
## 5 · Design proposal — layered defence using what Nostr already offers
The trust model we want, in one sentence:
> **A settlement is genuine if (a) the operator delegated the ATM to act on their behalf, with a scoped, timebound, revocable token, and (b) the ATM published a signed attestation referencing the Lightning preimage, and (c) the relay/Payment.extra metadata is treated as a hint, never as truth.**
That's four primitives, each already specified in Nostr:
| Layer | NIP | What it gives us |
|---|---|---|
| Identity & delegation | **NIP26** (`~/dev/nostr-protocol/nips/26.md`) | Operator never gives their nsec to the ATM. Issues a kindbound, timebound `delegation` tag instead. |
| Settlement attestation | **NIP57style receipt** (`nips/57.md`) | ATM publishes a signed receipt event linking machine npub + Lightning preimage + amount/fiat. Receipt is the ground truth, not Payment.extra. |
| Replay protection | **NIP40** (`nips/40.md`) | Every RPC carries `["expiration", now+5m]`. Relays drop expired events; handler refuses them. |
| Permachine config | **NIP78** (`nips/78.md`) | `kind:30078` with `d="bitspire-config:<machine_id>"` is the operatorsigned source of truth for permachine policy (max withdrawal, allowed relays, fee schedule). ATM fetches on boot; LNbits crosschecks. |
| Future: bunker | **NIP46** (`nips/46.md`) | Operator's nsec stays on a phone (Amber) or HSM. ATM gets an ephemeral session key + remote signer. Endstate goal. |
What we **do not** adopt and why (from the NIP survey):
- **NIP42 relay auth.** Authenticates the connection to the relay; doesn't authorise the RPC payload. Useful for relay hygiene, but a red herring for our trust boundary.
- **NIP59 gift wrap.** Hides metadata from relays but breaks the very auditability we want from NIP57style receipts. Only useful if anonymity matters more than audit.
- **NIP32 labels.** Soft moderation signal, not enforcement. Fine as observability; useless as an access gate.
### 5.1 The new pathway (endstate)
```
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ OPERATOR (cold key on phone / Amber / Bunker — never on the ATM) │
│ │
│ 1. Generates delegation token (NIP-26): │
│ conditions = "kind=21000&created_at>T0&created_at<T0+90d" │
│ token = sign(operator_nsec, conditions || atm_pubkey) │
│ │
│ 2. Publishes per-machine config (NIP-78): │
│ kind:30078, d="bitspire-config:<machine_id>", content=signed JSON │
│ { allowed_relays, max_withdrawal_fiat, allowed_kinds, fee_schedule, ... } │
└──────────────────────┬──────────────────────────────────────────────────────────────────────┘
│ seed-URL pairing (one-shot, out-of-band)
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ bitSpire ATM (holds its OWN ephemeral keypair — not the operator's) │
│ │
│ Boot: │
│ • Loads delegation token from sealed config │
│ • Fetches NIP-78 per-machine config; verifies operator's sig │
│ │
│ Each RPC (e.g. create_invoice): │
│ • Builds kind-21000 event signed with ATM's OWN key │
│ • Includes delegation tag (NIP-26) proving operator authorised this kind, this window │
│ • Includes ["expiration", now+5min] (NIP-40) │
│ • NIP-44 v2 encrypts content to LNbits server pubkey │
└──────────────────────┬──────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ LNbits nostr-transport handler │
│ │
│ On inbound kind-21000: │
│ • Verify outer Schnorr sig (NIP-01) │
│ • Verify NIP-44 MAC, decrypt │
│ • Check ["expiration"]: reject if past (NIP-40) │
│ • Check delegation tag (NIP-26): │
│ - sig over conditions valid under claimed operator pubkey? │
│ - conditions match this event's kind + created_at? │
│ - operator pubkey ∈ LNbits user roster? │
│ • Check NIP-78 config: is ATM pubkey listed in operator's fleet for this machine? │
│ • Persist sender_pubkey + operator_pubkey on Payment.extra (signed by LNbits │
│ server key when the row is written, so it's tamper-evident in our DB) │
│ • Generate invoice │
└──────────────────────┬──────────────────────────────────────────────────────────────────────┘
│ Lightning settles
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ Settlement attestation (NIP-57-style receipt — see kind-rotation note in §6 / S3 row) │
│ │
│ LNbits publishes (signed by the LNbits server key): │
│ { kind: 9735, │
│ tags: [ │
│ ["e", <kind-21000 RPC event id>], // links back to the request │
│ ["p", <operator_pubkey>], │
│ ["P", <atm_pubkey>], │
│ ["bolt11", <invoice>], │
│ ["preimage", <32-byte hex>], │
│ ["amount", "<msat>"], │
│ ["fiat", "EUR:20.00"] │
│ ], │
│ content: "" } │
│ │
│ Operator audits: fetch all kind:9735 with #p=<my npub>; verify preimage hashes to │
│ payment_hash on every dca_settlements row. Mismatch = forged settlement. │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
```
### 5.2 Why each layer matters (juniordev framing)
- **Delegation (NIP26) closes G3.** The ATM doesn't *have* the operator's secret. It has a permission slip. Steal the ATM and you steal a permission slip that (a) only works for kind21000, (b) expires in 90 days, (c) you can't use to sign on `kind:1` or DM the operator's contacts, and (d) the operator can shorten by issuing a new one with an earlier cutoff. This is the same shape as an SSH certificate vs. an SSH key.
- **Receipts (NIP57 pattern) close G2 + G7.** The ground truth becomes a signed event referencing the preimage. Payment.extra remains as a hint (fast UI rendering), but disputes resolve against the receipt. If LNbits' DB is tampered with, the receipt on the relay is still there.
- **Expiration (NIP40) closes G4.** A 5minute window means a captured RPC can't be replayed at 3 a.m. when no human is watching the ATM.
- **NIP78 closes G1 + G9.** The operator's signed config says "machine_id 42 has fleet member npub_abc and may withdraw up to EUR 500." The handler crosschecks. Stale `npub1111…` rows can't accept real settlements because they're not in any operator's fleet.
- **NIP46 bunker (future) closes G5 + G6 properly.** The operator's nsec never touches LNbits' disk or the ATM's disk. It lives on the operator's phone or HSM and signs over an authenticated channel.
### 5.3 What we keep from today
- Absolutesats fee storage (already auditgrade).
- Operator scoping + 404not403 ownership pattern.
- Settlement idempotency on `payment_hash`.
- Optimisticlock claim for distribution.
- `dca_payments.leg_type` discriminator + LNbits `Payment.tag` for native filter UI.
None of those need to change. The new layers slot in *above* them.
---
## 6 · Phased roadmap
| Phase | Scope | Closes | Effort | Blocker |
|---|---|---|---|---|
| **S0 — SeedURL pairing + ATM keypair separation** | Provisioning script generates a fresh `nsec` for the ATM (already does — we just stop overwriting it with the operator's). Operator pastes a oneshot QR/seed URL containing `{atm_npub, operator_npub, relay_list, signed_delegation_token}` at ATM first boot. | G3 (most of it), G9 | 1 week | None — purely on our side. Use existing NIP26 spec. |
| **S1 — NIP40 expiration on all kind21000** | Every RPC carries `["expiration", now+5min]`. Handler refuses pastexpiration. ATM clock check on boot (warn if drift > 60s). | G4 | 12 days | Relay must support NIP40 (most do). |
| **S2 — NIP26 delegation enforcement in nostrtransport** | Handler parses `delegation` tag, validates sig over conditions, checks conditions match the event, looks up operator pubkey in roster. Reject events without a valid delegation. | G3 (rest), G7 (partially) | 12 weeks | LNbits PR upstream (or vendored fork on `aiolabs/lnbits` branch `nostr-transport-nip26`). |
| **S3 — NIP57style settlement receipts** | After LNbits internal payment legs complete, publish a signed receipt event per settlement (and per leg if we want leglevel audit). ATM subscribes; operator dashboard renders receipts sidebyside with `dca_settlements`. | G2, G7 | 12 weeks | **Kind allocation — DO NOT USE `kind:21001`.** That kind is claimed by CLINK (Offers) — collision caught during the 20260602 CLINK primer review. Rotation off 21001 is tracked at `aiolabs/spirekeeper#20`; target is the aiolabs reserved band **`2200022099`** per the workspace rule in `~/dev/CLAUDE.md` (§ "Nostr kind allocations — avoid the CLINK band"). The earlier 21001 lock across `aiolabs/lnbits#22`, `aiolabs/spirekeeper#11`, and the satmachine ATM is **SUPERSEDED** — pick the new kind before any of those land. Reusing `kind:9735` (zap receipt) is also off the table: NIP57 semantics don't apply to bitSpire cashout settlements. |
| **S4 — NIP78 permachine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler crosschecks ATM npub ∈ fleet; reads maxwithdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwardscompat path for preNIP78 machines. |
| **S5 — `sender_pubkey` persistence + signed metadata in Payment.extra** | When the dispatcher writes a Payment row, it stamps `Payment.extra.sender_pubkey`, `delegation_root`, and an HMAC over the key fields keyed by the LNbits server's own secret. Mutation postwrite breaks the HMAC. | G2 (DBside), G5, G6 | 35 days | LNbits PR — fairly localised. |
| **S6 — Rate limiting + rostergated autoaccount** | Autoaccountfromnpub only fires if the npub appears in some operator's NIP78 fleet OR if an explicit "open enrollment" flag is set. Relay/handlerlevel rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. |
| **S7 — NIP46 bunker option** | Operator can pair spirekeeper with a Bunker (Amber, Nunchuk Custody, etc.). Operator's nsec leaves LNbits' DB; LNbits stores only the bunker connection. | G6, partial G5 | 46 weeks | Largest. Defer until S0S5 land. |
| **S8 — Cashin path** | Wire `is_out=True` cashin handling: LNURLwithdraw with expiration matching the kind21000 invoice TTL, attestation receipt on settle, refund queue for stale links. | G10 | 2 weeks | Out of scope for this security doc but tracked here for completeness. |
Recommended sequencing for the *next sprint*: **S0 + S1 + S5**. They give us the biggest security delta with no upstream LNbits dependency for S0/S1 and a small, wellscoped LNbits patch for S5. S2/S3/S4 are the proper Nostrnative layer and should land in the sprint after.
---
## 7 · Operator & customer trust narrative
What we can say honestly to an operator after S0S5:
> "Your private key never goes on the ATM. The ATM has its own identity. You issue a permission slip — scoped to one kind of message, valid for 90 days, revocable from your phone. Every settlement publishes a public, signed receipt that anyone can verify against the Lightning preimage. If our database is ever tampered with, the receipts on the public relay are still there and still match. The platform fee and your fee are stored as absolute satoshi amounts — even if the rate changes tomorrow, last quarter's audit is exact."
And to a customer at the ATM:
> "This ATM identifies itself by a public key printed on the side of the unit. The receipt event the network publishes after your transaction will reference that same key and the Lightning payment preimage — two pieces of cryptographic evidence that no one can forge after the fact."
Compare to the Lamassu era: "the ATM has a TLS cert; if its fingerprint matches what the operator pinned, the connection is trustworthy." Same instinct, narrower surface. Nostr lets us extend that to *every settlement* without reinventing the wheel.
---
## 8 · Auditfriendliness checklist (opensource readiness)
Things a future auditor — or our opensource reviewers — will look for. Where we already pass, marked ✓; where we plan to pass after this work, marked →.
| Check | Status | Where |
|---|---|---|
| All moneymoving code paths have idempotency keys | ✓ | `dca_settlements.payment_hash UNIQUE` |
| All operator data scoped at the API boundary | ✓ | `_machine_owned_by` / `_client_owned_by` in `views_api.py` |
| No 403/404 enumeration oracle | ✓ | 404 on crossoperator probes |
| Fee storage is absolute (not derived from mutable %) | ✓ | `platform_fee_sats`, `operator_fee_sats` BIGINT |
| Audit trail is appendonly on settlements | ✓ | `dca_settlements.notes` prepended, never edited |
| Partialdispense recompute preserves original ratio | ✓ | `apply_partial_dispense_and_redistribute` (H6 fix) |
| Concurrent settlement processing is racefree | ✓ | `claim_settlement_for_processing` |
| Every settlement has a signed, public attestation | → | S3 (NIP57 receipts) |
| Operator's private key is not present on the ATM | → | S0 + S2 (NIP26 delegation) |
| RPC events cannot be replayed > 5 min later | → | S1 (NIP40 expiration) |
| Payment.extra mutation is detectable | → | S5 (serversigned HMAC) |
| Stale machine rows cannot accept real funds | → | S4 (NIP78 fleet roster crosscheck) |
| Autoaccountfromnpub is gated | → | S6 (roster + rate limit) |
| Key custody can be moved off LNbits' DB | → | S7 (NIP46 bunker) |
The state we want the opensource release to be in for v2.0 final: all ✓.
---
## 9 · Critical files (current code) and reference points
For an auditor or new contributor doing a walkthrough:
| File | Role | Note |
|---|---|---|
| `~/dev/shared/extensions/spirekeeper/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — loadbearing routing. |
| `~/dev/shared/extensions/spirekeeper/bitspire.py` | Parses Payment.extra. The trust boundary. | `parse_settlement:68-92` — happy vs fallback path. |
| `~/dev/shared/extensions/spirekeeper/distribution.py` | Threeleg distribution chain. | `process_settlement` — uses claim pattern. |
| `~/dev/shared/extensions/spirekeeper/crud.py` | Operatorscoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. |
| `~/dev/shared/extensions/spirekeeper/views_api.py` | 33 routes, all `check_user_exists` except superconfig PUT. | `_assert_wallet_owned_by` is the walletIDOR fix. |
| `~/dev/shared/extensions/spirekeeper/migrations.py` | Schema. | `dca_settlements` is the audit row; `dca_payments` is the leg row. |
| `~/dev/shocknet/lamassu-next/deploy/nixos/provision-atm.sh` | Where keys land on the ATM today. | `:81-99``VITE_ATM_PRIVATE_KEY` and the Option1 stopgap. |
| `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/` | LNbits transport handler (upstream we depend on). | NIP44 v2 crypto here; G5/G6/G7 fixes will live here. |
| `~/dev/nostr-protocol/nips/26.md` | Delegation. | Source for S2. |
| `~/dev/nostr-protocol/nips/40.md` | Expiration. | Source for S1. |
| `~/dev/nostr-protocol/nips/44.md` | Authenticated encryption v2. | Already in use; spec reference for review. |
| `~/dev/nostr-protocol/nips/46.md` | Bunker / Nostr Connect. | Source for S7. |
| `~/dev/nostr-protocol/nips/57.md` | Lightning zaps & signed receipts. | Pattern source for S3. |
| `~/dev/nostr-protocol/nips/78.md` | Appspecific replaceable events. | Source for S4. |
Existing Forgejo issues this report supersedes/consolidates: `aiolabs/spirekeeper#5` (v2 epic), `#11` (security audit findings), `#12` (ATM pairing + bunker deepdive), `aiolabs/lamassu-next#44` (Payment.extra split). This document is the design that closes the securityrelevant subset of those.
---
## 10 · Verification
How we'd test the proposed design endtoend, once S0S5 land:
1. **Negative test for G3:** Provision an ATM with seedURL pairing. Confirm `/var/lib/bitspire/.env` contains only the ATM's own nsec and a delegation token. Attempt to sign a nonkind21000 event with the ATM's key + delegation → handler rejects.
2. **Negative test for G4:** Record a kind21000 RPC. Wait 6 minutes. Replay it on the relay → handler refuses (expired).
3. **Negative test for G1/G9:** Create a stale machine row with placeholder npub. Send a real payment to its wallet → handler rejects because the npub isn't in the operator's NIP78 fleet list.
4. **Positive test for S3:** Run a full cashout. Confirm a `kind:9735`shaped receipt is published referencing the kind21000 RPC event id + preimage. Verify the preimage hashes to the `payment_hash` on the `dca_settlements` row.
5. **Positive test for S5:** After settlement, mutate `Payment.extra` directly in the LNbits DB. Confirm the HMAC check fails on the next read; operator dashboard flags the row as "tampered."
6. **Revocation test for S2:** Operator issues a new delegation with `created_at<` cutoff set to "now". ATM's next RPC (using old delegation) is rejected. ATM repairs with the new token; works again.
7. **Multioperator isolation:** Two operators on the same LNbits instance, each with one ATM. Confirm Operator A's NIP78 fleet doesn't list Operator B's ATM npub; LNbits crosschecks correctly.
8. **Endtoend smoke:** Real bitSpire on `~/dev/shocknet/lamassu-next/` (dev branch, `bun dev`) against the local LNbits stack (`~/dev/local/docker/regtest/docker-compose.dev.yml`, `LNBITS_SRC=~/dev/lnbits/nostr-transport`). One cashout → settlement lands → receipt published → operator dashboard reconciles all three artefacts.
---
## 11 · After this plan exits
Once approved:
1. The PDF for printing will be generated postplanmode (requires shell exec). Recommended path: render the markdown via `pandoc` to `~/dev/shared/extensions/spirekeeper/docs/security-pathway-v1.pdf`; the markdown source will live at `~/dev/shared/extensions/spirekeeper/docs/security-pathway-v1.md` so future contributors edit it inrepo.
2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0S7)."
3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbitsside primitives (S2, S5, S6).
4. Sequence sprint: **S0 + S1 + S5 first** (highest ratio of security delta to upstream coupling). S2/S3/S4 in the following sprint.