satmachineadmin/docs/security-pathway-v1.md
Padreug 9c4d2c1324 docs(security-pathway): flag kind:21001 CLINK collision + rotation
S3 settlement-receipt kind was provisionally 21001, but that kind is
claimed by CLINK (Offers). Replace the speculative kind text in the
cash-out diagram and the S3 row with an explicit DO-NOT-USE alert
citing the 2026-06-02 collision, the aiolabs/satmachineadmin#44
rotation tracker, and the 22000-22099 target band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 21:00:14 +02:00

40 KiB
Raw Permalink Blame History

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/satmachineadmin 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
                                                            satmachineadmin/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)

# 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)

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 Noneextra 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/satmachineadmin (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/satmachineadmin#44; 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/satmachineadmin#17, 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 satmachineadmin 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/satmachineadmin/tasks.py LNbits invoice listener. Entry point for all settlements today. _handle_payment:56-95 — loadbearing routing.
~/dev/shared/extensions/satmachineadmin/bitspire.py Parses Payment.extra. The trust boundary. parse_settlement:68-92 — happy vs fallback path.
~/dev/shared/extensions/satmachineadmin/distribution.py Threeleg distribution chain. process_settlement — uses claim pattern.
~/dev/shared/extensions/satmachineadmin/crud.py Operatorscoped DB layer. claim_settlement_for_processing, _machine_owned_by.
~/dev/shared/extensions/satmachineadmin/views_api.py 33 routes, all check_user_exists except superconfig PUT. _assert_wallet_owned_by is the walletIDOR fix.
~/dev/shared/extensions/satmachineadmin/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-99VITE_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/satmachineadmin#9 (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:9735shaped 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/satmachineadmin/docs/security-pathway-v1.pdf; the markdown source will live at ~/dev/shared/extensions/satmachineadmin/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.