Operator-account pubkey ↔ ATM-npub collision detection #32

Closed
opened 2026-05-31 12:08:19 +00:00 by padreug · 1 comment
Owner

Problem

If an LNbits operator's accounts.pubkey happens to equal a registered ATM's dca_machines.machine_npub, the lnbits nostr-transport handler will route inbound kind-21000 invoices from the ATM to the operator's wallet by collision — not by design. This silently "works" until the collision breaks (e.g. operator's pubkey rotates, account is re-provisioned, or a fresh ATM is onboarded against the operator's existing npub) at which point invoices start landing on auto-created accounts instead.

The failure mode is invisible from the operator's perspective: cash gets dispensed by the ATM (which trusts the Lightning payment notification regardless of which wallet was credited), but no dca_settlements row appears, no DCA distribution runs. The operator eats the loss.

Real instance surfaced 2026-05-30 (coord-log archive 2026-05-31-pre-rotation.md, 17:55Z entry, carry-over item 4): Greg's accounts.pubkey was seeded as the same value as Sintra's machine_npub (522a4538…) during manual setup. The collision masked the routing problem for days. When Greg's pubkey was refreshed during the bunker migration the collision broke, auto-account-from-npub fired, and $20 cash-out from Sintra silently dropped on the satmachineadmin side (21:33Z thread).

The defensive listener fallback (filed separately as #31) catches the symptom but doesn't surface the underlying drift. This issue addresses the root cause.

What this issue covers

One-time safety check (immediately runnable)

Bitspire suggested in 17:55Z carry-over #4:

SELECT
  a.id AS operator_account_id,
  a.username,
  a.pubkey,
  m.id AS machine_id,
  m.machine_npub
FROM accounts a
JOIN ext_satoshimachine_dca_machines m
  ON a.pubkey = m.machine_npub;

Any row returned is a collision. Run on every LNbits instance with satmachineadmin installed; document the result + remediation in this issue's comments.

Ongoing detection (code change)

Add a guard at machine-creation time (crud.create_machine / views_api.api_create_machine):

async def _assert_no_pubkey_collision(machine_npub: str) -> None:
    """Defence-in-depth: refuse to register a machine whose npub matches
    any LNbits operator account's pubkey. Such a collision routes
    invoices from the ATM directly to that operator's wallet via the
    nostr-transport auto-account-from-npub path — works by coincidence,
    breaks silently when the operator's pubkey rotates. See
    aiolabs/satmachineadmin#32 for the operational failure mode."""
    from lnbits.core.crud import get_account_by_pubkey
    canonical = normalize_public_key(machine_npub).lower()
    matching = await get_account_by_pubkey(canonical)
    if matching is not None:
        raise HTTPException(
            HTTPStatus.BAD_REQUEST,
            (
                f"machine_npub {canonical[:12]}... collides with operator "
                f"account {matching.id[:8]}...'s pubkey. Registering this "
                "ATM under this npub would silently route invoices via a "
                "pubkey-collision dependency that breaks on operator pubkey "
                "rotation. Use a fresh ATM keypair (lamassu-next "
                "provision-atm regenerates one with `ATM_PRIVATE_KEY` "
                "unset). See aiolabs/satmachineadmin#32."
            ),
        )

Wire into api_create_machine before create_machine is called.

Reverse direction — detect at account-creation too

If an account is being created (or has its pubkey rotated) with a pubkey that matches a registered ATM's machine_npub, similar concern. This requires hooking into the LNbits account-create path, which is upstream territory — file as aiolabs/lnbits follow-up after this satmachineadmin-side guard lands.

Acceptance

  • One-time SQL check run on the regtest instance + any production instance with satmachineadmin installed; result documented as a comment on this issue. (Greg's instance was the trigger; bitspire flagged "worth a check on any other operator accounts in the wild.")
  • api_create_machine rejects with 400 + clear error message when machine_npub matches any existing accounts.pubkey. Test with a controlled collision.
  • Documentation note in CLAUDE.md (or wherever the operator-onboarding doc lives) explaining the no-collision invariant and pointing at this issue for context.
  • Follow-up filed upstream against aiolabs/lnbits for the reverse-direction account-creation guard.

Out of scope

  • Fixing existing collisions retroactively (case-by-case operator-side intervention).
  • The full S6 architectural fix (#20) — this is one of several guards that together close the auto-account-from-npub gap.
  • Symptom-side mitigation when the collision DOES break and routing drifts — tracked at #31. Together with this issue they form the two layers of defence: this issue prevents the dependency from existing; #31 catches the symptom when it breaks.

Sequencing

Standalone. Ship-able today. ~1-2 hours.

Cross-references

  • Coord-log archive 2026-05-31-pre-rotation.md 17:55Z carry-over item 4 — bitspire's flag.
  • aiolabs/satmachineadmin#20 — S6 / lnbits-side proper fix this complements.
  • aiolabs/satmachineadmin#31 — defensive listener fallback (symptom-side mitigation pair).
  • aiolabs/satmachineadmin#13 — epic context (security pathway hardening).
## Problem If an LNbits operator's `accounts.pubkey` happens to equal a registered ATM's `dca_machines.machine_npub`, the lnbits nostr-transport handler will route inbound kind-21000 invoices from the ATM to the operator's wallet *by collision* — not by design. This silently "works" until the collision breaks (e.g. operator's pubkey rotates, account is re-provisioned, or a fresh ATM is onboarded against the operator's existing npub) at which point invoices start landing on auto-created accounts instead. The failure mode is invisible from the operator's perspective: cash gets dispensed by the ATM (which trusts the Lightning payment notification regardless of which wallet was credited), but no `dca_settlements` row appears, no DCA distribution runs. The operator eats the loss. **Real instance** surfaced 2026-05-30 (coord-log archive `2026-05-31-pre-rotation.md`, `17:55Z` entry, carry-over item 4): Greg's `accounts.pubkey` was seeded as the same value as Sintra's `machine_npub` (`522a4538…`) during manual setup. The collision masked the routing problem for days. When Greg's pubkey was refreshed during the bunker migration the collision broke, auto-account-from-npub fired, and $20 cash-out from Sintra silently dropped on the satmachineadmin side (`21:33Z` thread). The defensive listener fallback (filed separately as `#31`) catches the symptom but doesn't surface the underlying drift. This issue addresses the root cause. ## What this issue covers ### One-time safety check (immediately runnable) Bitspire suggested in `17:55Z` carry-over #4: ```sql SELECT a.id AS operator_account_id, a.username, a.pubkey, m.id AS machine_id, m.machine_npub FROM accounts a JOIN ext_satoshimachine_dca_machines m ON a.pubkey = m.machine_npub; ``` Any row returned is a collision. Run on every LNbits instance with satmachineadmin installed; document the result + remediation in this issue's comments. ### Ongoing detection (code change) Add a guard at machine-creation time (`crud.create_machine` / `views_api.api_create_machine`): ```python async def _assert_no_pubkey_collision(machine_npub: str) -> None: """Defence-in-depth: refuse to register a machine whose npub matches any LNbits operator account's pubkey. Such a collision routes invoices from the ATM directly to that operator's wallet via the nostr-transport auto-account-from-npub path — works by coincidence, breaks silently when the operator's pubkey rotates. See aiolabs/satmachineadmin#32 for the operational failure mode.""" from lnbits.core.crud import get_account_by_pubkey canonical = normalize_public_key(machine_npub).lower() matching = await get_account_by_pubkey(canonical) if matching is not None: raise HTTPException( HTTPStatus.BAD_REQUEST, ( f"machine_npub {canonical[:12]}... collides with operator " f"account {matching.id[:8]}...'s pubkey. Registering this " "ATM under this npub would silently route invoices via a " "pubkey-collision dependency that breaks on operator pubkey " "rotation. Use a fresh ATM keypair (lamassu-next " "provision-atm regenerates one with `ATM_PRIVATE_KEY` " "unset). See aiolabs/satmachineadmin#32." ), ) ``` Wire into `api_create_machine` before `create_machine` is called. ### Reverse direction — detect at account-creation too If an account is being created (or has its pubkey rotated) with a pubkey that matches a registered ATM's `machine_npub`, similar concern. This requires hooking into the LNbits account-create path, which is upstream territory — file as `aiolabs/lnbits` follow-up after this satmachineadmin-side guard lands. ## Acceptance - [ ] One-time SQL check run on the regtest instance + any production instance with satmachineadmin installed; result documented as a comment on this issue. (Greg's instance was the trigger; bitspire flagged "worth a check on any other operator accounts in the wild.") - [ ] `api_create_machine` rejects with 400 + clear error message when `machine_npub` matches any existing `accounts.pubkey`. Test with a controlled collision. - [ ] Documentation note in `CLAUDE.md` (or wherever the operator-onboarding doc lives) explaining the no-collision invariant and pointing at this issue for context. - [ ] Follow-up filed upstream against `aiolabs/lnbits` for the reverse-direction account-creation guard. ## Out of scope - Fixing existing collisions retroactively (case-by-case operator-side intervention). - The full S6 architectural fix (`#20`) — this is one of several guards that together close the auto-account-from-npub gap. - Symptom-side mitigation when the collision DOES break and routing drifts — tracked at `#31`. Together with this issue they form the two layers of defence: this issue prevents the dependency from existing; `#31` catches the symptom when it breaks. ## Sequencing Standalone. Ship-able today. ~1-2 hours. ## Cross-references - Coord-log archive `2026-05-31-pre-rotation.md` `17:55Z` carry-over item 4 — bitspire's flag. - `aiolabs/satmachineadmin#20` — S6 / lnbits-side proper fix this complements. - `aiolabs/satmachineadmin#31` — defensive listener fallback (symptom-side mitigation pair). - `aiolabs/satmachineadmin#13` — epic context (security pathway hardening).
Author
Owner

Regtest SQL collision check — 2026-05-31

Per AC item 1, ran the diagnostic SQL against the regtest LNbits + satmachineadmin DBs:

SELECT a.id, a.username, a.pubkey, m.id, m.machine_npub
FROM accounts a
JOIN ext_satoshimachine.dca_machines m ON LOWER(a.pubkey) = LOWER(m.machine_npub);

Result

Active machine_npub Collision found
522a4538… (Greg's Sintra) Auto-account orphan a94b564f… (username=None — auto-account signature, created during 2026-05-30T21:33Z silent-drop failure mode). NOT a legitimate operator account.

Greg's actual operator account ac35c9fc… carries pubkey 197a4cf4… post-bunker migration; no collision there.

Disposition

  • No real-operator collisions remain on the regtest instance.
  • The orphan auto-account a94b564f… is operational cleanup (sweep its wallet balance + delete the account row). Separate from this code fix; not blocking PR #33.
  • This is the only known LNbits instance running satmachineadmin today; no other deployed instances need to run the check.

Implementation + PR

PR #33 (feat/collision-detection) opens against v2-bitspire. Implements the _assert_no_pubkey_collision guard, 7 unit tests, and the CLAUDE.md doc note per AC items 2 and 3.

AC item 4 (upstream aiolabs/lnbits follow-up for reverse-direction account-creation guard) deferred — will file once the path-B handoff with lnbits is settled (coord-log 2026-05-31T15:25Z thread).

## Regtest SQL collision check — 2026-05-31 Per AC item 1, ran the diagnostic SQL against the regtest LNbits + satmachineadmin DBs: ```sql SELECT a.id, a.username, a.pubkey, m.id, m.machine_npub FROM accounts a JOIN ext_satoshimachine.dca_machines m ON LOWER(a.pubkey) = LOWER(m.machine_npub); ``` ### Result | Active `machine_npub` | Collision found | |---|---| | `522a4538…` (Greg's Sintra) | Auto-account orphan `a94b564f…` (username=None — auto-account signature, created during 2026-05-30T21:33Z silent-drop failure mode). NOT a legitimate operator account. | Greg's actual operator account `ac35c9fc…` carries pubkey `197a4cf4…` post-bunker migration; no collision there. ### Disposition - **No real-operator collisions remain** on the regtest instance. - The orphan auto-account `a94b564f…` is operational cleanup (sweep its wallet balance + delete the account row). Separate from this code fix; not blocking PR #33. - This is the only known LNbits instance running satmachineadmin today; no other deployed instances need to run the check. ## Implementation + PR PR #33 (`feat/collision-detection`) opens against `v2-bitspire`. Implements the `_assert_no_pubkey_collision` guard, 7 unit tests, and the CLAUDE.md doc note per AC items 2 and 3. AC item 4 (upstream `aiolabs/lnbits` follow-up for reverse-direction account-creation guard) deferred — will file once the path-B handoff with lnbits is settled (coord-log `2026-05-31T15:25Z` thread).
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#32
No description provided.