S5 — Persist sender_pubkey on Payment.extra (LNbits-side) #19

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

Part of #13. Closes gap G5 (sender_pubkey not persisted by the nostr-transport dispatcher).

Primary work is in aiolabs/lnbits nostr-transport. This issue tracks both the LNbits-side fix and the satmachineadmin-side consumption.

Scope changes since first filing

  • G6 dropped — already closed. The merge-readiness audit on the nostr-transport branch (see comment on aiolabs/lnbits#14) confirmed auth.py:69-73 constructs auto-accounts with prvkey=None, falling through crud/users.py:36-45's existing "Nostr login user" branch. Server never holds the key. No work needed here.
  • G2 (HMAC over Payment.extra) split out. Originally bundled with G5 in this issue. It's a deeper change with rotation/versioning concerns and is not a merge prerequisite for the nostr-transport branch. To be filed as a standalone follow-up after S0-S5 land.

Problem (G5)

When LNbits' nostr-transport dispatcher creates a Payment row from a kind-21000 RPC, the originating event's pubkey (the ATM's npub, the client's npub) is not stamped onto Payment.extra. The dispatcher captures it on the request object (dispatcher.py:72) but _handle_create_invoice (line 161) passes extra=body.get("extra") — the sender is dropped before persistence.

Consequence: after the fact we cannot tell which Nostr identity triggered a payment. No audit trail for "which ATM asked for this invoice." For our threat model this is the missing link that turns delivery into attribution.

Fix

In the dispatcher's Payment-creating handlers (_handle_create_invoice and any future siblings), merge the originating event's pubkey into Payment.extra before persistence:

extra = dict(body.get("extra") or {})
extra["nostr_sender_pubkey"] = request.sender_pubkey   # the signing key of the kind-21000
extra["nostr_event_id"]      = request.event_id        # ties Payment back to the RPC

Use a nostr_ prefix to avoid colliding with any consumer-set keys (bitSpire sets source, net_sats, etc.).

satmachineadmin-side consumption

  • bitspire.parse_settlement reads extra["nostr_sender_pubkey"] and cross-checks it against the resolved machine's machine_npub. Mismatch ⇒ log + mark the settlement errored with a tamper-evident note + skip distribution.
  • Operator dashboard surfaces mismatches in a dedicated banner.

Note: until S4 (NIP-78 fleet roster, #18) lands, the cross-check is "wallet's account pubkey == sender pubkey" — which is a 1:1 mapping today. Once fleets exist, the check becomes "sender pubkey ∈ operator's fleet for this machine."

Acceptance criteria

  • dispatcher.py:_handle_create_invoice appends nostr_sender_pubkey and nostr_event_id to Payment.extra before persisting the row.
  • Any future Payment-creating handler (pay_invoice, etc.) does the same.
  • LNbits unit test: kind-21000 create_invoice RPC → resulting Payment row has both fields.
  • bitspire.parse_settlement reads and cross-checks nostr_sender_pubkey against the machine's wallet account pubkey.
  • Negative test: forge a Payment with a different nostr_sender_pubkey than the machine — satmachineadmin flags the row, doesn't distribute.

Why this matters

Per the hook discussion in the security epic: LNbits commits to delivery of payment events, not to attribution. Persisting sender_pubkey is the minimum-cost way to give downstream consumers (us, future extensions) the attribution data point — without coupling LNbits to any particular consumer's trust model. The HMAC work (G2, split out) is the next layer on top: signing the metadata itself so post-write mutation is detectable. This issue only addresses writing the metadata in the first place.

Merge blocker status

Per the nostr-transport branch audit: this is one of three blockers for upstream PR of the transport branch. The other two are S1 (#15, NIP-40 expiration) and ruff lint cleanup. Doing this now serves two goals: closes G5 in the security epic and unblocks the upstream PR.

Reference

Design doc: docs/security-pathway-v1.md §5.1, §6.S5.
LNbits primitive review: design doc §4.2 G5.
Audit findings: comment on aiolabs/lnbits#14.

Part of #13. Closes gap **G5** (sender_pubkey not persisted by the nostr-transport dispatcher). **Primary work is in `aiolabs/lnbits` nostr-transport.** This issue tracks both the LNbits-side fix and the satmachineadmin-side consumption. ## Scope changes since first filing - **G6 dropped — already closed.** The merge-readiness audit on the nostr-transport branch (see comment on `aiolabs/lnbits#14`) confirmed `auth.py:69-73` constructs auto-accounts with `prvkey=None`, falling through `crud/users.py:36-45`'s existing "Nostr login user" branch. Server never holds the key. No work needed here. - **G2 (HMAC over Payment.extra) split out.** Originally bundled with G5 in this issue. It's a deeper change with rotation/versioning concerns and is *not* a merge prerequisite for the nostr-transport branch. To be filed as a standalone follow-up after S0-S5 land. ## Problem (G5) When LNbits' nostr-transport dispatcher creates a `Payment` row from a kind-21000 RPC, the originating event's `pubkey` (the ATM's npub, the client's npub) is not stamped onto `Payment.extra`. The dispatcher captures it on the request object (`dispatcher.py:72`) but `_handle_create_invoice` (line 161) passes `extra=body.get("extra")` — the sender is dropped before persistence. Consequence: after the fact we cannot tell which Nostr identity triggered a payment. No audit trail for "which ATM asked for this invoice." For our threat model this is the missing link that turns *delivery* into *attribution*. ## Fix In the dispatcher's Payment-creating handlers (`_handle_create_invoice` and any future siblings), merge the originating event's pubkey into `Payment.extra` before persistence: ```python extra = dict(body.get("extra") or {}) extra["nostr_sender_pubkey"] = request.sender_pubkey # the signing key of the kind-21000 extra["nostr_event_id"] = request.event_id # ties Payment back to the RPC ``` Use a `nostr_` prefix to avoid colliding with any consumer-set keys (bitSpire sets `source`, `net_sats`, etc.). ## satmachineadmin-side consumption - `bitspire.parse_settlement` reads `extra["nostr_sender_pubkey"]` and cross-checks it against the resolved machine's `machine_npub`. Mismatch ⇒ log + mark the settlement `errored` with a tamper-evident note + skip distribution. - Operator dashboard surfaces mismatches in a dedicated banner. Note: until S4 (NIP-78 fleet roster, #18) lands, the cross-check is "wallet's account pubkey == sender pubkey" — which is a 1:1 mapping today. Once fleets exist, the check becomes "sender pubkey ∈ operator's fleet for this machine." ## Acceptance criteria - [ ] `dispatcher.py:_handle_create_invoice` appends `nostr_sender_pubkey` and `nostr_event_id` to `Payment.extra` before persisting the row. - [ ] Any future Payment-creating handler (pay_invoice, etc.) does the same. - [ ] LNbits unit test: kind-21000 create_invoice RPC → resulting Payment row has both fields. - [ ] `bitspire.parse_settlement` reads and cross-checks `nostr_sender_pubkey` against the machine's wallet account pubkey. - [ ] Negative test: forge a Payment with a different `nostr_sender_pubkey` than the machine — satmachineadmin flags the row, doesn't distribute. ## Why this matters Per the hook discussion in the security epic: LNbits commits to *delivery* of payment events, not to *attribution*. Persisting `sender_pubkey` is the minimum-cost way to give downstream consumers (us, future extensions) the attribution data point — without coupling LNbits to any particular consumer's trust model. The HMAC work (G2, split out) is the next layer on top: signing *the metadata itself* so post-write mutation is detectable. This issue only addresses *writing the metadata in the first place*. ## Merge blocker status Per the nostr-transport branch audit: **this is one of three blockers for upstream PR of the transport branch.** The other two are S1 (#15, NIP-40 expiration) and ruff lint cleanup. Doing this now serves two goals: closes G5 in the security epic and unblocks the upstream PR. ## Reference Design doc: `docs/security-pathway-v1.md` §5.1, §6.S5. LNbits primitive review: design doc §4.2 G5. Audit findings: comment on `aiolabs/lnbits#14`.
padreug changed title from S5 — sender_pubkey persistence + HMAC over Payment.extra key fields (LNbits-side) to S5 — Persist sender_pubkey on Payment.extra (LNbits-side) 2026-05-15 19:15:13 +00:00
Author
Owner

Closing as done — landed before the sprint started.

LNbits side (aiolabs/lnbits nostr-transport branch):

  • 4ce568d8 feat(nostr-transport): persist sender_pubkey + event_id on Payment.extra (G5 / S5)dispatcher.py:_extra_with_nostr_attribution stamps nostr_sender_pubkey + nostr_event_id on Payment.extra for both _handle_create_invoice and _handle_pay_invoice.

satmachineadmin side (this repo, v2-bitspire):

  • 9414a18 feat(v2): reject settlements that fail nostr attribution cross-check (S5 G5)bitspire.assert_nostr_attribution() enforces sender_pubkey == machine.machine_npub at tasks.py:91; mismatches insert as rejected settlements via crud.create_settlement_idempotent.
  • Test coverage: tests/test_nostr_attribution.py covers happy / missing / mismatched / npub-vs-hex normalisation.

Acceptance criteria status:

  • dispatcher._handle_create_invoice appends both fields before persistence.
  • _handle_pay_invoice (future Payment-creating handler) does the same.
  • LNbits unit test coverage (in nostr-transport branch tests).
  • bitspire.parse_settlement reads + cross-checks against the machine wallet's account pubkey.
  • Negative test: forged sender_pubkey rejects + flags the row.

Gap G5 closed.

Closing as **done** — landed before the sprint started. **LNbits side** (`aiolabs/lnbits` nostr-transport branch): - `4ce568d8 feat(nostr-transport): persist sender_pubkey + event_id on Payment.extra (G5 / S5)` — `dispatcher.py:_extra_with_nostr_attribution` stamps `nostr_sender_pubkey` + `nostr_event_id` on Payment.extra for both `_handle_create_invoice` and `_handle_pay_invoice`. **satmachineadmin side** (this repo, `v2-bitspire`): - `9414a18 feat(v2): reject settlements that fail nostr attribution cross-check (S5 G5)` — `bitspire.assert_nostr_attribution()` enforces sender_pubkey == machine.machine_npub at `tasks.py:91`; mismatches insert as `rejected` settlements via `crud.create_settlement_idempotent`. - Test coverage: `tests/test_nostr_attribution.py` covers happy / missing / mismatched / npub-vs-hex normalisation. Acceptance criteria status: - [x] `dispatcher._handle_create_invoice` appends both fields before persistence. - [x] `_handle_pay_invoice` (future Payment-creating handler) does the same. - [x] LNbits unit test coverage (in nostr-transport branch tests). - [x] `bitspire.parse_settlement` reads + cross-checks against the machine wallet's account pubkey. - [x] Negative test: forged sender_pubkey rejects + flags the row. Gap **G5 closed**.
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#19
No description provided.