S6 — Roster-gated auto-account-from-npub + rate limit (LNbits-side) #20

Closed
opened 2026-05-15 18:09:25 +00:00 by padreug · 3 comments
Owner

Part of #13. Closes gaps G8 (no rate limiting) and G9 (no ACL on auto-account).

Primary work is in aiolabs/lnbits nostr-transport. Tracked here because it directly mitigates the stale-npub1111… incident on this repo.

Problem

Today (per lnbits/core/services/nostr_transport/auth.py), the first kind-21000 from a previously-unknown npub triggers auto-account creation. There is no allowlist, no roster check, no rate limit. T7 (attacker who knows operator's npub) can spam our auto-account endpoint with arbitrary keys; combined with G3 (operator nsec on disk), this is a real foothold.

Fix

  1. Roster-gated auto-account. Auto-account-from-npub only fires when one of:
    • the npub appears in some operator's NIP-78 fleet roster (depends on S4)
    • the LNbits instance has NEW_ACCOUNTS_ALLOWED_OPEN=true explicitly set (public-provider mode)
  2. Per-pubkey rate limit at the dispatcher: e.g. 30 RPC/min per pubkey, configurable. Excess returns a NIP-46-style rate_limited error and the relay subscription doesn't get torn down.

Changes

LNbits side

  • nostr_transport/auth.py:resolve_nostr_auth consults the roster cache.
  • Dispatcher adds an in-memory leaky-bucket per pubkey.
  • New settings: NOSTR_TRANSPORT_ROSTER_REQUIRED (default true post-merge), NOSTR_TRANSPORT_RATE_LIMIT_PER_PUBKEY_PER_MIN (default 30).

This repo

  • No code changes — but the operator dashboard adds a "Rejected RPC" log view fed from the LNbits handler so operators can spot probing attempts.

Acceptance

  • Send 100 kind-21000 events from an unrostered npub → none create accounts; rate limit kicks in.
  • Healthy ATM in a published fleet roster operates normally.
  • LNbits log line per rejected event with reason (not_in_roster, rate_limited, delegation_invalid, etc.).

Reference

Design doc: docs/security-pathway-v1.md §5.1, §6.S6.

Part of #13. Closes gaps G8 (no rate limiting) and G9 (no ACL on auto-account). **Primary work is in `aiolabs/lnbits` nostr-transport.** Tracked here because it directly mitigates the stale-`npub1111…` incident on this repo. ## Problem Today (per `lnbits/core/services/nostr_transport/auth.py`), the first kind-21000 from a previously-unknown npub triggers auto-account creation. There is no allowlist, no roster check, no rate limit. T7 (attacker who knows operator's npub) can spam our auto-account endpoint with arbitrary keys; combined with G3 (operator nsec on disk), this is a real foothold. ## Fix 1. **Roster-gated auto-account.** Auto-account-from-npub only fires when one of: - the npub appears in some operator's NIP-78 fleet roster (depends on S4) - the LNbits instance has `NEW_ACCOUNTS_ALLOWED_OPEN=true` explicitly set (public-provider mode) 2. **Per-pubkey rate limit** at the dispatcher: e.g. 30 RPC/min per pubkey, configurable. Excess returns a NIP-46-style `rate_limited` error and the relay subscription doesn't get torn down. ## Changes **LNbits side** - `nostr_transport/auth.py:resolve_nostr_auth` consults the roster cache. - Dispatcher adds an in-memory leaky-bucket per pubkey. - New settings: `NOSTR_TRANSPORT_ROSTER_REQUIRED` (default true post-merge), `NOSTR_TRANSPORT_RATE_LIMIT_PER_PUBKEY_PER_MIN` (default 30). **This repo** - No code changes — but the operator dashboard adds a "Rejected RPC" log view fed from the LNbits handler so operators can spot probing attempts. ## Acceptance - [ ] Send 100 kind-21000 events from an unrostered npub → none create accounts; rate limit kicks in. - [ ] Healthy ATM in a published fleet roster operates normally. - [ ] LNbits log line per rejected event with reason (`not_in_roster`, `rate_limited`, `delegation_invalid`, etc.). ## Reference Design doc: `docs/security-pathway-v1.md` §5.1, §6.S6.
Author
Owner

2026-05-26 — status sync against lnbits-side trackers

This S6 work is captured on the LNbits side at aiolabs/lnbits#14 Item 3 (roster-gated auto-account + per-pubkey rate limit). That issue was updated 2026-05-26 with:

  • Item 2 (sender_pubkey persistence) shipped at lnbits commit 4ce568d8 — see aiolabs/satmachineadmin#19 closure.
  • Item 4 (NIP-40 expiration filter) shipped at lnbits commit e4b5bcd7 — see aiolabs/satmachineadmin#15 closure.
  • ⏸️ Item 1 (formerly NIP-26 delegation) pivoted to NIP-46 connection-token enforcement, blocked on aiolabs/lnbits#18 (sidecar nsecbunkerd integration).
  • 🔵 Item 3 (this issue's scope) — still live, not yet implemented.

Sequencing: the bunker pivot doesn't block S6 directly. The roster-gated auto-account work needs the NIP-78 fleet roster to exist (so LNbits has something to gate against), which depends on S4 (#18) publishing the operator's fleet kind:30078 events. S4 is in progress.

Order of operations:

  1. S4 publishes operator-signed kind:30078 roster events (d="bitspire-fleet").
  2. LNbits-side handler subscribes to those (or accepts them as configuration), maintains an in-memory fleet roster.
  3. resolve_nostr_auth gates auto-account-from-npub on roster membership.
  4. Leaky-bucket rate limit added per-pubkey.

S6 stays in Sprint 3 per the epic body (#13). When S4 is done, this issue's implementation has the input it needs.

## 2026-05-26 — status sync against lnbits-side trackers This S6 work is captured on the LNbits side at `aiolabs/lnbits#14` Item 3 (roster-gated auto-account + per-pubkey rate limit). That issue was updated 2026-05-26 with: - ✅ Item 2 (sender_pubkey persistence) **shipped** at lnbits commit `4ce568d8` — see `aiolabs/satmachineadmin#19` closure. - ✅ Item 4 (NIP-40 expiration filter) **shipped** at lnbits commit `e4b5bcd7` — see `aiolabs/satmachineadmin#15` closure. - ⏸️ Item 1 (formerly NIP-26 delegation) **pivoted** to NIP-46 connection-token enforcement, blocked on `aiolabs/lnbits#18` (sidecar `nsecbunkerd` integration). - 🔵 **Item 3 (this issue's scope)** — still live, not yet implemented. **Sequencing:** the bunker pivot doesn't block S6 directly. The roster-gated auto-account work needs the NIP-78 fleet roster to exist (so LNbits has *something* to gate against), which depends on **S4 (#18)** publishing the operator's fleet kind:30078 events. S4 is in progress. **Order of operations:** 1. S4 publishes operator-signed kind:30078 roster events (`d="bitspire-fleet"`). 2. LNbits-side handler subscribes to those (or accepts them as configuration), maintains an in-memory fleet roster. 3. `resolve_nostr_auth` gates auto-account-from-npub on roster membership. 4. Leaky-bucket rate limit added per-pubkey. S6 stays in Sprint 3 per the epic body (#13). When S4 is done, this issue's implementation has the input it needs.
Author
Owner

Design refresh — 2026-05-31

The original issue body is broken-as-spec'd post-dcd0874 and partially superseded by the S2 work that's now in-flight on the bunker. Capturing the corrections inline here so the design archaeology is preserved (issue body) alongside the post-dcd0874 updated direction (this comment).

Two design assumptions that no longer hold

  1. NIP-78 fleet roster as the source of truth for "is this npub a registered ATM?" — the original S6 design says auto-account-from-npub only fires "when the npub appears in some operator's NIP-78 fleet roster (depends on S4)." But S4 was shipped (131ff92) and reverted (dcd0874) for privacy reasons: the public-relay roster leaked operator fleet composition + ATM locations + fiat codes. The replacement default posture is privacy-first; there is no public roster to consult. S6 needs a different source of truth.

  2. S2 supersedes most of S6's gating. Re-reading aiolabs/satmachineadmin#16 (S2 — NIP-46 connection-token enforcement) carefully:

    The handler asks the bunker… whether sender_pubkey is a valid, unrevoked, unexpired connection token authorized to call sign_event:21000. Resolve the token → the target operator pubkey. Persist operator_pubkey onto Payment.extra

    Under S2, every inbound kind-21000 must come from a bunker-registered ATM token, and the handler knows the target operator pubkey before crediting any wallet. That collapses S6 from "roster-gated auto-account" to "disable auto-account-from-npub entirely when bunker is configured" + the rate-limit half.

Operational evidence the gap is real, not theoretical

Today (2026-05-30T21:33Z coord-log) a $20 cash-out from a Sintra ATM landed on an auto-created account's wallet (a94b564f8a8d4a768972ad7e2364fdb9 — pubkey = the ATM's own npub), not the operator's wallet (9927f101... for Greg). Symptom: ATM dispensed cash, customer got bills, satmachineadmin saw nothing, no dca_settlements row, no DCA distribution legs ran. Root cause: pre-bunker, the operator's accounts.pubkey happened to match the ATM's npub by collision (manual setup artifact). Post accounts.pubkey refresh, the collision broke + lnbits' nostr-transport auto-account-from-npub fired for the ATM, routing the invoice to the wrong wallet. Reproducer logged; sats sit in the auto-account, recoverable but unaccounted on the operator side.

Re-scoped acceptance for S6

Replacing the original AC list:

  • NOSTR_TRANSPORT_ROSTER_REQUIRED becomes NOSTR_TRANSPORT_BUNKER_REQUIRED (or similar) — auto-account-from-npub only fires when one of:
    • the npub is the registered client identity of a bunker-issued connection token (covered by S2 routing; this gate just makes the absence explicit)
    • the LNbits instance has NEW_ACCOUNTS_ALLOWED_OPEN=true explicitly set (public-provider mode)
  • Per-pubkey rate limit at the dispatcher: e.g. 30 RPC/min per pubkey, configurable. Independent of the roster-vs-bunker source-of-truth pivot; still valuable as DoS protection.
  • Healthy ATM in a bunker-issued token-roster operates normally.
  • Send 100 kind-21000 events from an unrostered npub → none create accounts; rate limit kicks in.
  • LNbits log line per rejected event with reason (not_in_bunker_roster, rate_limited, etc.).

Sequencing

The original sequencing was Sprint 3 (after S0+S2+S7 land in Sprint 2). That's still right — S6 logically follows S2, since S2 introduces the bunker-token-as-roster primitive S6 needs to consult. Once S2 ships, S6's scope is small (a few hundred lines + tests, ~few days) rather than the original "1 week" estimate against the now-defunct NIP-78 roster lookup.

Satmachineadmin-side defensive fallback (separate issue)

Independent of S6 landing, satmachineadmin has a defensive fix that catches the operational failure mode regardless of LNbits timing: when the listener gets a payment on an unknown wallet, fall back to checking payment.extra.nostr_sender_pubkey against dca_machines.machine_npub. If matched, process the settlement against that machine (sats sit in the auto-account; distribution legs draw from there). Doesn't fix the auto-account-spam attack surface S6 addresses, but does close the silent-drop failure mode. Filed separately so it can land independent of S2/S6 timing.

Cross-references

  • aiolabs/lnbits#14 Item 3 (the upstream LNbits-side tracker) — needs same update applied to its body.
  • aiolabs/satmachineadmin#16 (S2 — bunker-token enforcement) — the prerequisite that pivots S6's source-of-truth.
  • aiolabs/satmachineadmin#18 — closed via dcd0874 revert (S4 NIP-78 fleet roster killed for privacy).
  • aiolabs/satmachineadmin#27 — post-launch opt-in publishing tracker (the future of any voluntary public roster surface).
  • 2026-05-30T21:33Z coord-log entry (in archive/2026-05-31-pre-rotation.md) — the wallet-routing miss + diagnosis chain.
## Design refresh — 2026-05-31 The original issue body is **broken-as-spec'd** post-`dcd0874` and partially superseded by the S2 work that's now in-flight on the bunker. Capturing the corrections inline here so the design archaeology is preserved (issue body) alongside the post-`dcd0874` updated direction (this comment). ### Two design assumptions that no longer hold 1. **NIP-78 fleet roster as the source of truth for "is this npub a registered ATM?"** — the original S6 design says auto-account-from-npub only fires "when the npub appears in some operator's NIP-78 fleet roster (depends on S4)." But **S4 was shipped (`131ff92`) and reverted (`dcd0874`)** for privacy reasons: the public-relay roster leaked operator fleet composition + ATM locations + fiat codes. The replacement default posture is privacy-first; **there is no public roster to consult**. S6 needs a different source of truth. 2. **S2 supersedes most of S6's gating.** Re-reading `aiolabs/satmachineadmin#16` (S2 — NIP-46 connection-token enforcement) carefully: > The handler asks the bunker… whether `sender_pubkey` is a *valid, unrevoked, unexpired* connection token authorized to call `sign_event:21000`. Resolve the token → the *target* operator pubkey. Persist `operator_pubkey` onto `Payment.extra`… Under S2, every inbound kind-21000 must come from a bunker-registered ATM token, and the handler knows the target operator pubkey before crediting any wallet. **That collapses S6 from "roster-gated auto-account" to "disable auto-account-from-npub entirely when bunker is configured" + the rate-limit half.** ### Operational evidence the gap is real, not theoretical Today (`2026-05-30T21:33Z` coord-log) a $20 cash-out from a Sintra ATM landed on an auto-created account's wallet (`a94b564f8a8d4a768972ad7e2364fdb9` — pubkey = the ATM's own npub), not the operator's wallet (`9927f101...` for Greg). Symptom: ATM dispensed cash, customer got bills, satmachineadmin saw nothing, no `dca_settlements` row, no DCA distribution legs ran. Root cause: pre-bunker, the operator's `accounts.pubkey` happened to match the ATM's npub by collision (manual setup artifact). Post `accounts.pubkey` refresh, the collision broke + lnbits' nostr-transport auto-account-from-npub fired for the ATM, routing the invoice to the wrong wallet. Reproducer logged; sats sit in the auto-account, recoverable but unaccounted on the operator side. ### Re-scoped acceptance for S6 Replacing the original AC list: - [ ] **`NOSTR_TRANSPORT_ROSTER_REQUIRED` becomes `NOSTR_TRANSPORT_BUNKER_REQUIRED`** (or similar) — auto-account-from-npub only fires when one of: - the npub is the registered client identity of a bunker-issued connection token (covered by S2 routing; this gate just makes the absence explicit) - the LNbits instance has `NEW_ACCOUNTS_ALLOWED_OPEN=true` explicitly set (public-provider mode) - [ ] **Per-pubkey rate limit** at the dispatcher: e.g. 30 RPC/min per pubkey, configurable. Independent of the roster-vs-bunker source-of-truth pivot; still valuable as DoS protection. - [ ] Healthy ATM in a bunker-issued token-roster operates normally. - [ ] Send 100 kind-21000 events from an unrostered npub → none create accounts; rate limit kicks in. - [ ] LNbits log line per rejected event with reason (`not_in_bunker_roster`, `rate_limited`, etc.). ### Sequencing The original sequencing was Sprint 3 (after S0+S2+S7 land in Sprint 2). That's still right — S6 logically follows S2, since S2 introduces the bunker-token-as-roster primitive S6 needs to consult. Once S2 ships, S6's scope is small (a few hundred lines + tests, ~few days) rather than the original "1 week" estimate against the now-defunct NIP-78 roster lookup. ### Satmachineadmin-side defensive fallback (separate issue) Independent of S6 landing, satmachineadmin has a **defensive fix** that catches the operational failure mode regardless of LNbits timing: when the listener gets a payment on an unknown wallet, fall back to checking `payment.extra.nostr_sender_pubkey` against `dca_machines.machine_npub`. If matched, process the settlement against that machine (sats sit in the auto-account; distribution legs draw from there). Doesn't fix the auto-account-spam attack surface S6 addresses, but does close the silent-drop failure mode. Filed separately so it can land independent of S2/S6 timing. ### Cross-references - `aiolabs/lnbits#14` Item 3 (the upstream LNbits-side tracker) — needs same update applied to its body. - `aiolabs/satmachineadmin#16` (S2 — bunker-token enforcement) — the prerequisite that pivots S6's source-of-truth. - `aiolabs/satmachineadmin#18` — closed via `dcd0874` revert (S4 NIP-78 fleet roster killed for privacy). - `aiolabs/satmachineadmin#27` — post-launch opt-in publishing tracker (the future of any voluntary public roster surface). - `2026-05-30T21:33Z` coord-log entry (in `archive/2026-05-31-pre-rotation.md`) — the wallet-routing miss + diagnosis chain.
Author
Owner

Cross-link: implementation tracker on the lnbits side is aiolabs/lnbits#42feat(nostr-transport): roster-gated auto-account-from-npub (path B).

#20 stays the design archive (post-dcd0874 rescope onto path B via the resolver-callback ABC); #42 carries the lnbits-side roster.py registry + EnvSettings.nostr_transport_roster_required env field + the auth.py / auth_api.py:nostr_login gating.

Frozen shape contract

RouteHit(operator_user_id: str, wallet_id: str, source_extension: str) — confirmed across coord-log entries 14:40Z (sat-side proposal), 15:15Z (lnbits ack), 15:25Z (sat-side close-out). Adding a field is a coordination round.

Failure-mode posture

Aggressive / fail-closed across the board per the user's 15:20Z direction:

  • roster_required=true + no resolver match → reject + ERROR log
  • roster_required=true + no resolvers registered → reject + ERROR log (NOT permissive fall-through)
  • roster_required=true + resolver raises → reject + ERROR log
  • HTTP nostr_login entrypoint gated under the same flag (closes the second auto-account-creation door)
  • roster_required=false (default) preserves today's behaviour for back-compat

Satmachineadmin-side prep

Resolver-exposure branch (feat/roster-resolver off v2-bitspire@44f6c0b) is built + committed locally + held off the remote per coord-log 15:25Z sequencing rule. Ready to push the moment aiolabs/lnbits#42 lands on dev and the regtest pod picks up the new image.

Sequencing

  1. aiolabs/satmachineadmin#33 collision detection (PR open, awaiting merge — closes #32 independent of path B)
  2. aiolabs/lnbits#42 lnbits-side implementation
  3. Push feat/roster-resolver → satmachineadmin-side PR closes #20
  4. Joint regtest smoke: LNBITS_NOSTR_TRANSPORT_ROSTER_REQUIRED=true + cashout from Sintra → confirm sats land in operator's wallet, no auto-account row

refs: coord-log §14:40Z → §17:25Z, aiolabs/lnbits#42, aiolabs/satmachineadmin#31/#32/#33

Cross-link: implementation tracker on the lnbits side is **aiolabs/lnbits#42** — *feat(nostr-transport): roster-gated auto-account-from-npub (path B)*. #20 stays the design archive (post-`dcd0874` rescope onto path B via the resolver-callback ABC); #42 carries the lnbits-side `roster.py` registry + `EnvSettings.nostr_transport_roster_required` env field + the `auth.py` / `auth_api.py:nostr_login` gating. ### Frozen shape contract `RouteHit(operator_user_id: str, wallet_id: str, source_extension: str)` — confirmed across coord-log entries `14:40Z` (sat-side proposal), `15:15Z` (lnbits ack), `15:25Z` (sat-side close-out). Adding a field is a coordination round. ### Failure-mode posture Aggressive / fail-closed across the board per the user's `15:20Z` direction: - `roster_required=true` + no resolver match → reject + ERROR log - `roster_required=true` + no resolvers registered → reject + ERROR log (NOT permissive fall-through) - `roster_required=true` + resolver raises → reject + ERROR log - HTTP `nostr_login` entrypoint gated under the same flag (closes the second auto-account-creation door) - `roster_required=false` (default) preserves today's behaviour for back-compat ### Satmachineadmin-side prep Resolver-exposure branch (`feat/roster-resolver` off `v2-bitspire@44f6c0b`) is built + committed locally + held off the remote per coord-log `15:25Z` sequencing rule. Ready to push the moment `aiolabs/lnbits#42` lands on `dev` and the regtest pod picks up the new image. ### Sequencing 1. ✅ aiolabs/satmachineadmin#33 collision detection (PR open, awaiting merge — closes #32 independent of path B) 2. ⏳ aiolabs/lnbits#42 lnbits-side implementation 3. ⏳ Push `feat/roster-resolver` → satmachineadmin-side PR closes #20 4. ⏳ Joint regtest smoke: `LNBITS_NOSTR_TRANSPORT_ROSTER_REQUIRED=true` + cashout from Sintra → confirm sats land in operator's wallet, no auto-account row refs: coord-log §`14:40Z` → §`17:25Z`, aiolabs/lnbits#42, aiolabs/satmachineadmin#31/#32/#33
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/satmachineadmin#20
No description provided.