S4 — NIP-78 per-machine config + fleet roster cross-check #18

Closed
opened 2026-05-15 18:08:56 +00:00 by padreug · 1 comment
Owner

Part of #13. Closes gaps G1 (routing is wallet_id-only) and G9 (no ACL on auto-account-from-npub).

Problem

The stale-npub1111… incident: a test machine row routed a real cash-in because routing is get_active_machine_by_wallet_id(payment.wallet_id) — no signed identity check. Any wallet on the LNbits instance with a dca_machines row pointing at it can absorb settlements.

Fix

Operator publishes a signed NIP-78 replaceable event per machine + a fleet roster:

kind:30078, d="bitspire-config:<machine_id>", pubkey=operator
{
  "atm_pubkey": "<hex>",
  "allowed_relays": ["wss://…"],
  "max_withdrawal_fiat": 500,
  "fee_schedule": { … },
  "allowed_kinds": [21000],
  "name": "Antigua-1",
  "location": "Antigua, GT"
}

Plus an aggregate roster (d="bitspire-fleet") listing every machine pubkey the operator owns.

LNbits handler and satmachineadmin's settlement-land code cross-check that the inbound's atm_pubkey is in the operator's roster, with config matching.

Changes

This repo (aiolabs/satmachineadmin)

  • New crud.publish_machine_config(machine) — composes + signs the kind:30078 event using the operator's nsec (via LNbits Account.prvkey today; via NIP-46 bunker once S7 lands).
  • On any POST/PUT /api/v1/dca/machines, publish/update config.
  • On any DELETE /api/v1/dca/machines/:id, republish roster without the machine — relays drop the older replaceable.
  • tasks.py adds a post-router check: machine.machine_npub == settlement.sender_pubkey (from S5 Payment.extra) ∧ machine_npub ∈ operator's fleet roster. If either fails, refuse to record + alert.
  • Beacon ingestion (read-only, opportunistic) for upstream lamassu-next#43 — render whatever fields the beacon carries; em-dash for absent ones.

LNbits side

  • Optional: handler can cache fleet rosters and refuse RPC from npubs not in any operator's roster (combined with S6 enforcement).

Acceptance

  • Operator adds a machine → kind:30078 lands on the configured relays within 2s.
  • Operator deletes a machine → relay no longer serves that d="bitspire-config:…".
  • Stale dca_machines row pointing at an npub not in any operator's published roster refuses to record settlements.
  • Two operators on the same LNbits instance — each operator's roster is independent.

Reference

NIP-78 spec: ~/dev/nostr-protocol/nips/78.md.
Adjacent upstream: aiolabs/lamassu-next#43 (richer kind-30078 beacon).
Design doc: docs/security-pathway-v1.md §5.1, §6.S4.

Part of #13. Closes gaps G1 (routing is wallet_id-only) and G9 (no ACL on auto-account-from-npub). ## Problem The stale-`npub1111…` incident: a test machine row routed a real cash-in because routing is `get_active_machine_by_wallet_id(payment.wallet_id)` — no signed identity check. Any wallet on the LNbits instance with a `dca_machines` row pointing at it can absorb settlements. ## Fix Operator publishes a signed **NIP-78 replaceable event** per machine + a fleet roster: ``` kind:30078, d="bitspire-config:<machine_id>", pubkey=operator { "atm_pubkey": "<hex>", "allowed_relays": ["wss://…"], "max_withdrawal_fiat": 500, "fee_schedule": { … }, "allowed_kinds": [21000], "name": "Antigua-1", "location": "Antigua, GT" } ``` Plus an aggregate roster (`d="bitspire-fleet"`) listing every machine pubkey the operator owns. LNbits handler **and** satmachineadmin's settlement-land code cross-check that the inbound's `atm_pubkey` is in the operator's roster, with config matching. ## Changes **This repo (`aiolabs/satmachineadmin`)** - New `crud.publish_machine_config(machine)` — composes + signs the kind:30078 event using the operator's nsec (via LNbits Account.prvkey today; via NIP-46 bunker once S7 lands). - On any `POST/PUT /api/v1/dca/machines`, publish/update config. - On any `DELETE /api/v1/dca/machines/:id`, republish roster *without* the machine — relays drop the older replaceable. - `tasks.py` adds a post-router check: `machine.machine_npub == settlement.sender_pubkey` (from S5 Payment.extra) ∧ `machine_npub ∈ operator's fleet roster`. If either fails, refuse to record + alert. - Beacon ingestion (read-only, opportunistic) for upstream `lamassu-next#43` — render whatever fields the beacon carries; em-dash for absent ones. **LNbits side** - Optional: handler can cache fleet rosters and refuse RPC from npubs not in *any* operator's roster (combined with S6 enforcement). ## Acceptance - [ ] Operator adds a machine → kind:30078 lands on the configured relays within 2s. - [ ] Operator deletes a machine → relay no longer serves that `d="bitspire-config:…"`. - [ ] Stale `dca_machines` row pointing at an npub *not in any operator's published roster* refuses to record settlements. - [ ] Two operators on the same LNbits instance — each operator's roster is independent. ## Reference NIP-78 spec: `~/dev/nostr-protocol/nips/78.md`. Adjacent upstream: `aiolabs/lamassu-next#43` (richer kind-30078 beacon). Design doc: `docs/security-pathway-v1.md` §5.1, §6.S4.
Author
Owner

Shipped on v2-bitspire at commit 131ff92. Builds on the canonical-vocabulary commit d717a6e from earlier today.

Implementation:

  • New module nostr_publish.py:
    • publish_machine_config(machine) — per-machine kind:30078 with d="bitspire-config:<machine_id>" + ["p", atm_npub] tag.
    • publish_fleet_roster(operator_user_id) — aggregate kind:30078 with d="bitspire-fleet" + one ["p", atm_npub] tag per active machine.
    • tombstone_machine_config(operator, machine_id, atm_npub) — replaceable kind:30078 with content.deleted=true on delete (NIP-09-friendly tombstone pattern, survives non-deletion-compliant relays).
  • Hooked into views_api.api_create_machine / api_update_machine / api_delete_machine — publish + roster-refresh on every CRUD mutation. Re-publishing replaceable events is idempotent.

Publish path: direct in-process singleton import (from nostrclient.router import nostr_client; nostr_client.relay_manager.publish_message(...)). Bypasses the private WebSocket entirely after the pre-flight survey found no existing in-process consumer of the encrypted ws_id endpoint. Cross-extension guard pattern matches lnbits core's nostrmarket.services / nostrrelay.crud imports.

Soft-failure model:

  • nostrclient not installed → log + skip (kind:30078 never lands but the machine row writes fine).
  • Operator account has no Nostr keypair on file → same.
  • The settlement / distribution / dispense path does not depend on publish — these events are public-facing artefacts, not internal flow control.

Acceptance status:

  • Operator adds a machine → kind:30078 lands on the configured relays via nostrclient.
  • Operator deletes a machine → tombstone kind:30078 (content.deleted=true) + roster refreshes without the machine.
  • Operator updates a machine → per-machine config + roster both refreshed.
  • Stale dca_machines row pointing at an npub not in any operator's published roster refuses to record settlements. Deferred to S6 (lnbits#14 Item 3) — this is the LNbits-side roster-gating in the nostr-transport handler. satmachineadmin's job here is to publish the roster; the consumer side lives upstream.
  • Two operators on the same LNbits instance — each operator's roster is independent (replaceable events are keyed by (author_pubkey, d-tag)).

Cross-codebase implications for future ATM-side consumption (lamassu-next):

When the ATM-side machine app (per aiolabs/lamassu-next#42 / fleet management) wants to learn its operator's config, it should:

  1. Subscribe {"kinds": [30078], "#p": ["<my_atm_npub>"]} on the configured relays.
  2. Filter results by (author_pubkey == known_operator_pubkey) to confirm the config came from the operator we expect.
  3. Parse content JSON for name, location, fiat_code, is_active. If content.deleted == true treat as "operator removed me — exit gracefully."

Not blocking for this issue; flagging so the bitspire session has the consumer spec when they pick this up.

Closing.

Shipped on `v2-bitspire` at commit `131ff92`. Builds on the canonical-vocabulary commit `d717a6e` from earlier today. **Implementation:** - New module `nostr_publish.py`: - `publish_machine_config(machine)` — per-machine kind:30078 with `d="bitspire-config:<machine_id>"` + `["p", atm_npub]` tag. - `publish_fleet_roster(operator_user_id)` — aggregate kind:30078 with `d="bitspire-fleet"` + one `["p", atm_npub]` tag per active machine. - `tombstone_machine_config(operator, machine_id, atm_npub)` — replaceable kind:30078 with `content.deleted=true` on delete (NIP-09-friendly tombstone pattern, survives non-deletion-compliant relays). - Hooked into `views_api.api_create_machine` / `api_update_machine` / `api_delete_machine` — publish + roster-refresh on every CRUD mutation. Re-publishing replaceable events is idempotent. **Publish path:** direct in-process singleton import (`from nostrclient.router import nostr_client; nostr_client.relay_manager.publish_message(...)`). Bypasses the private WebSocket entirely after the pre-flight survey found no existing in-process consumer of the encrypted ws_id endpoint. Cross-extension guard pattern matches lnbits core's `nostrmarket.services` / `nostrrelay.crud` imports. **Soft-failure model:** - nostrclient not installed → log + skip (kind:30078 never lands but the machine row writes fine). - Operator account has no Nostr keypair on file → same. - The settlement / distribution / dispense path does **not** depend on publish — these events are public-facing artefacts, not internal flow control. **Acceptance status:** - [x] Operator adds a machine → kind:30078 lands on the configured relays via nostrclient. - [x] Operator deletes a machine → tombstone kind:30078 (`content.deleted=true`) + roster refreshes without the machine. - [x] Operator updates a machine → per-machine config + roster both refreshed. - [ ] Stale `dca_machines` row pointing at an npub *not in any operator's published roster* refuses to record settlements. **Deferred to S6 (lnbits#14 Item 3)** — this is the LNbits-side roster-gating in the nostr-transport handler. satmachineadmin's job here is to *publish* the roster; the consumer side lives upstream. - [x] Two operators on the same LNbits instance — each operator's roster is independent (replaceable events are keyed by `(author_pubkey, d-tag)`). **Cross-codebase implications for future ATM-side consumption (lamassu-next):** When the ATM-side machine app (per `aiolabs/lamassu-next#42` / fleet management) wants to learn its operator's config, it should: 1. Subscribe `{"kinds": [30078], "#p": ["<my_atm_npub>"]}` on the configured relays. 2. Filter results by `(author_pubkey == known_operator_pubkey)` to confirm the config came from the operator we expect. 3. Parse `content` JSON for `name`, `location`, `fiat_code`, `is_active`. If `content.deleted == true` treat as "operator removed me — exit gracefully." Not blocking for this issue; flagging so the bitspire session has the consumer spec when they pick this up. Closing.
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#18
No description provided.