Migrates the cassette transport's encrypt/decrypt paths off direct `account.prvkey` reads to `signer.nip44_encrypt` / `signer.nip44_decrypt` on the NostrSigner ABC landed by aiolabs/lnbits PR #38 (phase 2.4). Closes the operator-side regression flagged at coord-log 2026-05-31T06:50Z: Greg's RemoteBunkerSigner-migrated account had `accounts.prvkey IS NULL` post-bunker, which the old code couldn't handle — consumer was logging WARN every poll cycle and skipping every inbound state event. ## What changed ### cassette_transport.py - New imports: `resolve_signer`, `SignerError`, `SignerUnavailableError`, `NsecBunkerTimeoutError`, `NsecBunkerRpcError` from the post-#38 lnbits surface. (The `try: from lnbits.core.signers import SignerError` block in the old code was permanently failing because `SignerError` actually lives in `lnbits.core.signers.base`, not the package root — fixed.) - New `_resolve_operator_signer(operator_user_id)`: single source of truth for "give me the operator's account + NostrSigner, or raise an operator-facing error." Used by both the publish path and the consumer task. - New `_nip44_encrypt_via_signer(account, signer, plaintext, peer)` and `_nip44_decrypt_via_signer(...)`: route through `signer.nip44_*` first; on `SignerUnavailableError` from a LocalSigner stub (the post-#38 ABC has LocalSigner raise on nip44_* explicitly — bunker migration required for NIP-44 v2), fall back to the hand-rolled impl against `account.prvkey`. Transitional until every operator on the instance is bunker-backed (S7). - `_sign_as_operator` simplified: now `await signer.sign_event(event)` (the ABC is async; the old code passed `signer.sign_event` to the caller without await, returning a coroutine — also broken but never hit because the ImportError fallback fired first). - `publish_to_atm` flow: `_resolve_operator_signer` → `_nip44_encrypt_ via_signer` → `_sign_as_operator` → publish. Each step maps bunker / signer errors to `OperatorIdentityMissing` (400) / `SignerUnavailable` (503) / `CassetteTransportError` (500) for the API handler. - `decrypt_and_parse_state_event` now `async` and takes `(event, account, signer)` instead of `(event, operator_privkey_hex)`. Maps `NsecBunkerTimeoutError` → `CassetteEventTransientError` (caller should retry on next poll, NOT advance `state_event_id`). `NsecBunkerRpcError` / `SignerUnavailableError` / `Nip44Error` / etc. → `CassetteEventDecodeError` (terminal — caller logs + skips). - New `CassetteEventTransientError` class for the bunker-timeout case. Distinct from `CassetteEventDecodeError` so the consumer can log at INFO + retry vs WARNING + advance. - Deleted `_get_operator_privkey_hex` (no longer needed). ### tasks.py — _handle_cassette_state_event - Resolves the signer via `_resolve_operator_signer(machine.operator_ user_id)`. On `CassetteTransportError` (OperatorIdentityMissing / SignerUnavailable), logs + skips. - Awaits `decrypt_and_parse_state_event(event_obj, account, signer)`. On `CassetteEventTransientError`, logs at INFO + returns (state_event_ id NOT advanced → consumer retries on next poll cycle). On `CassetteEventDecodeError`, logs at WARNING + returns (still state_event_id NOT advanced for v1; the WARN log surfaces the underlying issue for operator triage). ### tests/test_cassette_state_consumer.py — rewritten - Three test doubles: `_FakeBunkerSigner` (working nip44_decrypt via hand-rolled impl), `_FakeLocalSignerStub` (raises like the post-#38 LocalSigner stub), `_FakeRaisingSigner` (configurable exception). - `_fake_account` helper using SimpleNamespace — the code under test only reads `.signer_type` + `.prvkey`. - Five test classes covering: bunker-signer happy path (incl. multi- same-denom round-trip), LocalSigner transitional fallback, bunker-error mapping (timeout → transient, rpc reject → decode), payload validation (tamper / wrong-key / missing-fields / garbage JSON / wrong shape), d-tag construction (unchanged, kept as regression guard). - Async coroutines driven via `asyncio.run` — matches the existing project pattern (no pytest-asyncio plugin in CI; see test_init.py failure mode). ### nip44.py — docstring update Added a "Runtime status (post lnbits PR #38, 2026-05-31)" section documenting that runtime usage moved to `signer.nip44_*` and this module's role narrowed to (a) the LocalSigner transitional fallback called from `cassette_transport`, and (b) test-only fixtures in test_nip44_v2.py for spec-vector + bitspire cross-test validation. "Don't add new runtime call sites here. The signer abstraction is the path." ## Verification - 155 passed, 1 pre-existing async-plugin failure unchanged. The 19 consumer tests cover bunker happy path + LocalSigner fallback + bunker error mapping + payload validation + d-tag construction. - Live smoke against Greg's RemoteBunkerSigner-migrated account on the regtest container: consumer correctly resolves the bunker signer, fires `NIP-46 rpc -> method=nip44_decrypt`, catches the resulting `NsecBunkerTimeoutError` (the local nsecbunkerd is not responding within 15s — separate operational concern), maps to `CassetteEventTransientError`, logs at INFO with "will retry next poll", and crucially does NOT advance `state_event_id` on the cassette_configs rows. Retry semantics preserved. ## Outstanding - The bunker timeout itself is an operational issue (nsecbunkerd config / policy / process state for kind-less nip44_decrypt RPC) — not a satmachineadmin code concern; surface to the nsecbunkerd / lnbits sessions if it persists. - Once every operator on the instance is on RemoteBunkerSigner (S7 fully landed), the `_nip44_*_via_signer` helpers collapse to a direct `await signer.nip44_*` call, the LocalSigner fallback can be deleted, and `nip44.py`'s runtime exports retire (test-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4b128ca53c
commit
dcb7de0c27
4 changed files with 573 additions and 199 deletions
|
|
@ -44,18 +44,24 @@ import json
|
|||
import time
|
||||
from typing import Optional
|
||||
|
||||
import coincurve
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.utils.nostr import normalize_public_key, sign_event
|
||||
from lnbits.core.services.nip46_bunker_client import (
|
||||
NsecBunkerRpcError,
|
||||
NsecBunkerTimeoutError,
|
||||
)
|
||||
from lnbits.core.signers import resolve_signer
|
||||
from lnbits.core.signers.base import (
|
||||
NostrSigner,
|
||||
SignerError,
|
||||
SignerUnavailableError,
|
||||
)
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
from loguru import logger
|
||||
|
||||
from .models import Machine, PublishCassettesPayload
|
||||
from .nip44 import (
|
||||
Nip44Error,
|
||||
decrypt_with_conversation_key,
|
||||
encrypt_with_conversation_key,
|
||||
get_conversation_key,
|
||||
)
|
||||
from .nip44 import Nip44Error
|
||||
from .nip44 import decrypt_from as _nip44_local_decrypt
|
||||
from .nip44 import encrypt_for as _nip44_local_encrypt
|
||||
|
||||
_KIND_NIP78 = 30078
|
||||
_D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
|
||||
|
|
@ -91,7 +97,19 @@ class RelayUnavailable(CassetteTransportError):
|
|||
|
||||
class CassetteEventDecodeError(CassetteTransportError):
|
||||
"""Inbound state event failed validation: bad signature, NIP-44 v2
|
||||
decrypt failure, or payload didn't conform to PublishCassettesPayload."""
|
||||
decrypt failure, or payload didn't conform to PublishCassettesPayload.
|
||||
Terminal — caller should log + skip, advancing past the event."""
|
||||
|
||||
|
||||
class CassetteEventTransientError(CassetteTransportError):
|
||||
"""Inbound state event couldn't be decrypted because the signer
|
||||
component (typically the bunker) is transiently unavailable. Caller
|
||||
should NOT advance past the event; retry on next tick.
|
||||
|
||||
Distinct from CassetteEventDecodeError so the consumer task can
|
||||
differentiate "MAC failed, give up" from "bunker is partitioned, try
|
||||
again in a few seconds" — surfaced by lnbits at coord-log
|
||||
2026-05-31T07:10Z as the load-bearing distinction post-PR-#38."""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -129,28 +147,19 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def _sign_as_operator(
|
||||
operator_user_id: str, event: dict
|
||||
) -> Optional[dict]:
|
||||
"""Sign `event` using the operator's stored Nostr identity.
|
||||
async def _resolve_operator_signer(operator_user_id: str):
|
||||
"""Fetch the operator's account + resolve to a NostrSigner.
|
||||
|
||||
Mutates `event` to add `created_at` (now), `pubkey`, `id`, and `sig`.
|
||||
Returns the signed event, or raises a typed CassetteTransportError
|
||||
on a hard failure the caller should surface to the operator.
|
||||
Single source of truth for "give me the signer for this operator,
|
||||
or raise an operator-facing error if we can't." Returns
|
||||
`(account, signer)` so callers that need both (publish path needs
|
||||
`account.pubkey` for the event author and the signer for both
|
||||
encrypt + sign) don't double-fetch.
|
||||
|
||||
Routing: post-`aiolabs/lnbits#17` (signer abstraction) we go through
|
||||
`lnbits.core.signers.resolve_signer`, which transparently handles
|
||||
LocalSigner (envelope-encrypted nsec at rest, decrypted on demand)
|
||||
and ClientSideOnlySigner (raises SignerUnavailableError). On pre-#17
|
||||
lnbits versions the import fails and we fall back to a direct
|
||||
`account.prvkey` read. Both paths produce identical signed events.
|
||||
Pattern preserved from the removed nostr_publish.py at commit
|
||||
e13178d / 131ff92 — recovered here for the cassette transport.
|
||||
|
||||
Unlike the prior fleet-publish path (which soft-failed on missing
|
||||
operator identity since the publish was a CRUD side-effect), the
|
||||
cassette publish is operator-initiated so missing identity is a hard
|
||||
error surfaced as HTTP 400 by the API caller.
|
||||
Raises:
|
||||
- OperatorIdentityMissing — no account, or no pubkey on file
|
||||
- SignerUnavailable — signer resolve failed, or signer can't sign
|
||||
server-side (ClientSideOnly)
|
||||
"""
|
||||
account = await get_account(operator_user_id)
|
||||
if account is None or not account.pubkey:
|
||||
|
|
@ -159,30 +168,6 @@ async def _sign_as_operator(
|
|||
"Onboard via the LNbits Nostr-login flow to publish cassette "
|
||||
"config to your ATMs."
|
||||
)
|
||||
|
||||
# created_at is part of the BIP-340 event-id hash; must be set before
|
||||
# signing so both code paths below see the same value.
|
||||
event["created_at"] = int(time.time())
|
||||
|
||||
try:
|
||||
from lnbits.core.signers import ( # type: ignore[import-not-found]
|
||||
SignerError,
|
||||
SignerUnavailableError,
|
||||
resolve_signer,
|
||||
)
|
||||
except ImportError:
|
||||
# Pre-#17 lnbits — direct prvkey read. Removed once the #17
|
||||
# cascade lands on every host that runs this extension.
|
||||
if not account.prvkey:
|
||||
raise OperatorIdentityMissing(
|
||||
f"operator {operator_user_id[:8]}... has no signing key "
|
||||
"on file (pre-lnbits#17 path). Onboard via Nostr-login or "
|
||||
"wait for aiolabs/lnbits#18 bunker integration."
|
||||
)
|
||||
private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey))
|
||||
return sign_event(event, account.pubkey, private_key)
|
||||
|
||||
# Post-#17 lnbits — route through the signer abstraction.
|
||||
try:
|
||||
signer = resolve_signer(account)
|
||||
except SignerError as exc:
|
||||
|
|
@ -190,16 +175,32 @@ async def _sign_as_operator(
|
|||
f"signer resolve failed for operator {operator_user_id[:8]}...: "
|
||||
f"{exc}"
|
||||
) from exc
|
||||
|
||||
if not signer.can_sign():
|
||||
raise SignerUnavailable(
|
||||
f"operator {operator_user_id[:8]}... has a client-side-only "
|
||||
"signer; server can't publish on their behalf. Wait for bunker "
|
||||
"integration (lnbits#18) or operator-driven publishing."
|
||||
"signer; server can't sign or NIP-44-encrypt on their behalf. "
|
||||
"Operator must hold their nsec via a NIP-46 bunker (lnbits#18) "
|
||||
"or migrate to a server-signing account."
|
||||
)
|
||||
return account, signer
|
||||
|
||||
|
||||
async def _sign_as_operator(
|
||||
operator_user_id: str, event: dict
|
||||
) -> Optional[dict]:
|
||||
"""Sign `event` using the operator's signer (LocalSigner or
|
||||
RemoteBunkerSigner). Mutates `event` to add `created_at` (now),
|
||||
`pubkey`, `id`, and `sig`.
|
||||
|
||||
Raises typed CassetteTransportError subclasses on hard failure
|
||||
(the publish endpoint maps these to HTTP statuses); never returns
|
||||
None on the publish path.
|
||||
"""
|
||||
_account, signer = await _resolve_operator_signer(operator_user_id)
|
||||
# created_at is part of the BIP-340 event-id hash; set before signing.
|
||||
event["created_at"] = int(time.time())
|
||||
try:
|
||||
return signer.sign_event(event)
|
||||
return await signer.sign_event(event)
|
||||
except SignerUnavailableError as exc:
|
||||
raise SignerUnavailable(
|
||||
f"signer unavailable for operator {operator_user_id[:8]}...: "
|
||||
|
|
@ -207,27 +208,57 @@ async def _sign_as_operator(
|
|||
) from exc
|
||||
|
||||
|
||||
async def _get_operator_privkey_hex(operator_user_id: str) -> str:
|
||||
"""Fetch the operator's signing key hex for NIP-44 v2 encryption.
|
||||
async def _nip44_encrypt_via_signer(
|
||||
account, signer: NostrSigner, plaintext: str, peer_pubkey_hex: str
|
||||
) -> str:
|
||||
"""NIP-44 v2 encrypt via the signer abstraction, with a transitional
|
||||
fallback to direct-prvkey for LocalSigner accounts.
|
||||
|
||||
NIP-44 v2 ECDH needs the raw private scalar, which the signer
|
||||
abstraction's high-level `sign_event` doesn't expose. For v1 we
|
||||
read `account.prvkey` directly — same surface that the pre-#17
|
||||
fallback in `_sign_as_operator` uses. Post-bunker (lnbits#18)
|
||||
this becomes a NIP-44-over-bunker call routed through the bunker
|
||||
client (the operator's nsec never leaves the bunker process), but
|
||||
that path is v2 follow-up.
|
||||
The bunker (RemoteBunkerSigner) implements `nip44_encrypt` natively —
|
||||
the operator's nsec never leaves the bunker process. LocalSigner's
|
||||
`nip44_encrypt` stub explicitly raises SignerUnavailableError
|
||||
("LocalSigner does not implement nip44_encrypt") per the
|
||||
post-PR-#38 ABC — the spec is "migrate to bunker." For the
|
||||
transitional window where some operators are still on LocalSigner
|
||||
+ their `account.prvkey` is intact, we catch that signal and use
|
||||
our hand-rolled NIP-44 v2 impl against the stored prvkey. Same
|
||||
wire output either way.
|
||||
|
||||
Raises OperatorIdentityMissing on missing keys.
|
||||
Removed once every operator account on this instance is bunker-
|
||||
backed (S7 fully landed). At that point this helper collapses to
|
||||
`return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)`.
|
||||
"""
|
||||
account = await get_account(operator_user_id)
|
||||
if account is None or not account.prvkey:
|
||||
raise OperatorIdentityMissing(
|
||||
f"operator {operator_user_id[:8]}... has no signing key on "
|
||||
"file; can't NIP-44 v2 encrypt the cassette payload to the "
|
||||
"ATM. Onboard via the LNbits Nostr-login flow."
|
||||
try:
|
||||
return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)
|
||||
except SignerUnavailableError:
|
||||
if (
|
||||
account.signer_type == "LocalSigner"
|
||||
and account.prvkey
|
||||
):
|
||||
return _nip44_local_encrypt(
|
||||
plaintext, account.prvkey, peer_pubkey_hex
|
||||
)
|
||||
return account.prvkey
|
||||
# ClientSideOnly, or RemoteBunkerSigner with bunker comms failure
|
||||
# at config time — re-raise without wrapping; caller maps it.
|
||||
raise
|
||||
|
||||
|
||||
async def _nip44_decrypt_via_signer(
|
||||
account, signer: NostrSigner, ciphertext: str, peer_pubkey_hex: str
|
||||
) -> str:
|
||||
"""Decrypt mirror of `_nip44_encrypt_via_signer`. Same LocalSigner
|
||||
transitional fallback."""
|
||||
try:
|
||||
return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex)
|
||||
except SignerUnavailableError:
|
||||
if (
|
||||
account.signer_type == "LocalSigner"
|
||||
and account.prvkey
|
||||
):
|
||||
return _nip44_local_decrypt(
|
||||
ciphertext, account.prvkey, peer_pubkey_hex
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -269,18 +300,39 @@ async def publish_to_atm(
|
|||
Returns the signed event dict on success (caller may log event.id for
|
||||
audit). Raises CassetteTransportError subclasses on hard failures:
|
||||
- OperatorIdentityMissing → 400: operator hasn't onboarded
|
||||
- SignerUnavailable → 503: signer offline / client-side-only
|
||||
- SignerUnavailable → 503: signer offline / client-side-only / bunker
|
||||
timeout at the encrypt or sign step
|
||||
- RelayUnavailable → 503: nostrclient not installed
|
||||
- CassetteTransportError → 500: anything else
|
||||
"""
|
||||
atm_pubkey_hex = _atm_hex_pubkey(machine)
|
||||
|
||||
# Build the NIP-44 v2 encrypted content using the operator's privkey
|
||||
# as sender and the ATM pubkey as recipient.
|
||||
operator_privkey_hex = await _get_operator_privkey_hex(operator_user_id)
|
||||
# Single fetch + resolve — same signer is used for both encrypt and sign.
|
||||
account, signer = await _resolve_operator_signer(operator_user_id)
|
||||
|
||||
# NIP-44 v2 encrypt the wire payload. Bunker round-trip on
|
||||
# RemoteBunkerSigner; direct prvkey on LocalSigner (transitional).
|
||||
plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
|
||||
conversation_key = get_conversation_key(operator_privkey_hex, atm_pubkey_hex)
|
||||
content = encrypt_with_conversation_key(plaintext, conversation_key)
|
||||
try:
|
||||
content = await _nip44_encrypt_via_signer(
|
||||
account, signer, plaintext, atm_pubkey_hex
|
||||
)
|
||||
except NsecBunkerTimeoutError as exc:
|
||||
raise SignerUnavailable(
|
||||
f"bunker unreachable while encrypting cassette config for "
|
||||
f"operator {operator_user_id[:8]}...: {exc}"
|
||||
) from exc
|
||||
except NsecBunkerRpcError as exc:
|
||||
raise SignerUnavailable(
|
||||
f"bunker rejected nip44_encrypt for operator "
|
||||
f"{operator_user_id[:8]}... (policy / MAC / config issue): "
|
||||
f"{exc}"
|
||||
) from exc
|
||||
except SignerUnavailableError as exc:
|
||||
raise SignerUnavailable(
|
||||
f"signer cannot nip44-encrypt for operator "
|
||||
f"{operator_user_id[:8]}...: {exc}"
|
||||
) from exc
|
||||
|
||||
event: dict = {
|
||||
"kind": _KIND_NIP78,
|
||||
|
|
@ -289,11 +341,9 @@ async def publish_to_atm(
|
|||
["p", atm_pubkey_hex],
|
||||
],
|
||||
"content": content,
|
||||
# created_at is set inside _sign_as_operator before signing.
|
||||
}
|
||||
|
||||
signed = await _sign_as_operator(operator_user_id, event)
|
||||
# _sign_as_operator raises on hard failure; a None return would mean
|
||||
# an unexpected soft-path slipped through — treat as hard error here.
|
||||
if signed is None:
|
||||
raise CassetteTransportError(
|
||||
"sign_as_operator returned None unexpectedly — soft-fail path "
|
||||
|
|
@ -314,24 +364,32 @@ async def publish_to_atm(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
def decrypt_and_parse_state_event(
|
||||
event: dict, operator_privkey_hex: str
|
||||
async def decrypt_and_parse_state_event(
|
||||
event: dict, account, signer: NostrSigner
|
||||
) -> PublishCassettesPayload:
|
||||
"""Decrypt + parse an inbound `bitspire-cassettes-state:<atm_pubkey_hex>`
|
||||
event the ATM published toward the operator. Caller is responsible
|
||||
for:
|
||||
event the ATM published toward the operator.
|
||||
|
||||
Caller is responsible for:
|
||||
- filtering on `kind=30078` and the expected `#d` tag list
|
||||
- verifying the event signature (lnbits.utils.nostr.verify_event)
|
||||
- confirming `event["pubkey"]` matches a known ATM in the operator's
|
||||
machines table (the d-tag suffix == event pubkey == machine.machine_npub
|
||||
canonicalised)
|
||||
- confirming `event["pubkey"]` matches a known ATM (= machine.machine_npub
|
||||
canonicalised) — the consumer task does this before calling here
|
||||
- resolving the operator's account + signer via
|
||||
`_resolve_operator_signer(...)` and passing them in
|
||||
|
||||
This function does:
|
||||
- NIP-44 v2 decrypt of event["content"] using the sender's pubkey
|
||||
from event["pubkey"] and the operator's privkey
|
||||
- NIP-44 v2 decrypt of event["content"] via `signer.nip44_decrypt`
|
||||
(bunker round-trip on RemoteBunkerSigner; direct prvkey on the
|
||||
transitional LocalSigner path)
|
||||
- JSON parse + PublishCassettesPayload validation
|
||||
|
||||
Raises CassetteEventDecodeError on any decode/validate failure.
|
||||
Error mapping:
|
||||
- CassetteEventTransientError on NsecBunkerTimeoutError → caller
|
||||
should NOT advance state_event_id; retry on next consumer tick
|
||||
- CassetteEventDecodeError on anything else (bunker RPC reject,
|
||||
signer unavailable, MAC failure, JSON parse, payload shape) →
|
||||
terminal; caller logs + skips
|
||||
"""
|
||||
sender_pubkey = event.get("pubkey")
|
||||
content = event.get("content")
|
||||
|
|
@ -341,16 +399,31 @@ def decrypt_and_parse_state_event(
|
|||
)
|
||||
|
||||
try:
|
||||
conversation_key = get_conversation_key(
|
||||
operator_privkey_hex, sender_pubkey
|
||||
plaintext = await _nip44_decrypt_via_signer(
|
||||
account, signer, content, sender_pubkey
|
||||
)
|
||||
plaintext = decrypt_with_conversation_key(content, conversation_key)
|
||||
except Nip44Error as exc:
|
||||
except NsecBunkerTimeoutError as exc:
|
||||
raise CassetteEventTransientError(
|
||||
f"bunker unreachable while decrypting cassette state event: {exc}"
|
||||
) from exc
|
||||
except NsecBunkerRpcError as exc:
|
||||
raise CassetteEventDecodeError(
|
||||
f"NIP-44 v2 decrypt failed: {exc}"
|
||||
f"bunker rejected nip44_decrypt (policy / MAC / config): {exc}"
|
||||
) from exc
|
||||
except SignerUnavailableError as exc:
|
||||
raise CassetteEventDecodeError(
|
||||
f"signer cannot nip44-decrypt: {exc}"
|
||||
) from exc
|
||||
except Nip44Error as exc:
|
||||
# Hand-rolled LocalSigner fallback path (transitional) — MAC fail
|
||||
# / version mismatch / length issue.
|
||||
raise CassetteEventDecodeError(
|
||||
f"NIP-44 v2 decrypt failed (LocalSigner fallback path): {exc}"
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
# coincurve raises ValueError on a malformed pubkey hex.
|
||||
# coincurve raises ValueError on a malformed pubkey hex (only
|
||||
# reachable via the LocalSigner fallback path; the bunker handles
|
||||
# pubkey validation server-side).
|
||||
raise CassetteEventDecodeError(
|
||||
f"sender pubkey is malformed: {exc}"
|
||||
) from exc
|
||||
|
|
|
|||
38
nip44.py
38
nip44.py
|
|
@ -1,17 +1,37 @@
|
|||
"""
|
||||
NIP-44 v2 — versioned encrypted payloads (https://github.com/nostr-protocol/nips/blob/master/44.md).
|
||||
|
||||
Hand-rolled because lnbits ships only NIP-04 (AES-CBC) in `lnbits.utils.nostr.encrypt_content`,
|
||||
and the locked design at aiolabs/satmachineadmin#29 (paired with lamassu-next#56) wires
|
||||
cassette config over kind-30078 with NIP-44 v2 encrypted content. Adding a Python NIP-44
|
||||
v2 lib dep was an option per the plan; chose the hand-roll path to stay dep-light and
|
||||
keep the impl auditable inline.
|
||||
Hand-rolled because lnbits historically shipped only NIP-04 (AES-CBC) in
|
||||
`lnbits.utils.nostr.encrypt_content`, and the locked design at
|
||||
aiolabs/satmachineadmin#29 (paired with lamassu-next#56) wires cassette config
|
||||
over kind-30078 with NIP-44 v2 encrypted content.
|
||||
|
||||
Two safety nets keep this honest:
|
||||
## Runtime status (post lnbits PR #38, 2026-05-31)
|
||||
|
||||
**Runtime usage has migrated to the signer abstraction** via
|
||||
`signer.nip44_encrypt` / `signer.nip44_decrypt` on `lnbits.core.signers.base.
|
||||
NostrSigner`. For RemoteBunkerSigner-backed accounts the bunker performs the
|
||||
crypto and the operator's nsec never leaves the bunker process; for the
|
||||
transitional LocalSigner path `cassette_transport._nip44_*_via_signer` falls
|
||||
back to the helpers in this module against the stored `account.prvkey`.
|
||||
|
||||
This module's runtime export footprint is therefore:
|
||||
- `encrypt_for` / `decrypt_from` — called by the LocalSigner fallback in
|
||||
`cassette_transport` until every operator on the instance is bunker-backed
|
||||
(S7 / aiolabs/satmachineadmin#21). Then those calls disappear too.
|
||||
- Everything else (encrypt_with_conversation_key, decrypt_with_conversation_key,
|
||||
get_conversation_key, padding helpers, error classes) is **test-only**:
|
||||
referenced by `tests/test_nip44_v2.py` to validate the wire format against
|
||||
the canonical paulmillr/nip44 reference vectors and the bitspire cross-test
|
||||
fixture posted to the coordination log.
|
||||
|
||||
Don't add new runtime call sites here. The signer abstraction is the path.
|
||||
|
||||
Two safety nets keep the impl honest:
|
||||
1. tests/test_nip44_v2.py runs reference vectors + round-trip + tamper-detection.
|
||||
2. bitspire posts a sample event encrypted on their nostr-tools side to the coord log;
|
||||
test_decrypts_bitspire_sample_event_from_coord_log cross-checks our impl against
|
||||
theirs by decrypting that event with a known privkey.
|
||||
2. bitspire posts a sample event encrypted on their nostr-tools side to the
|
||||
coord log; test_decrypts_bitspire_sample_event cross-checks our impl
|
||||
against theirs by decrypting that event with a known privkey.
|
||||
|
||||
Wire format (per spec):
|
||||
payload = base64( 0x02 || nonce (32B) || ciphertext (var) || mac (32B) )
|
||||
|
|
|
|||
63
tasks.py
63
tasks.py
|
|
@ -386,18 +386,37 @@ async def _handle_cassette_state_event(
|
|||
get_machine_by_atm_pubkey_hex,
|
||||
apply_bootstrap_state,
|
||||
) -> None:
|
||||
"""Verify signature, route to the right operator's privkey, decrypt,
|
||||
parse, upsert. Each step that fails is logged at WARNING (not ERROR)
|
||||
so a noisy attacker can't fill the logs — this is data on a public
|
||||
relay, garbage is expected."""
|
||||
"""Verify signature, resolve the operator's signer, decrypt via the
|
||||
signer abstraction (bunker round-trip for RemoteBunkerSigner; direct
|
||||
prvkey on the LocalSigner transitional fallback inside the transport
|
||||
helper), parse, upsert.
|
||||
|
||||
Each step logs at WARNING (not ERROR) so a noisy attacker can't fill
|
||||
the logs — this is data on a public relay, garbage is expected.
|
||||
|
||||
Two skip outcomes:
|
||||
- Terminal (CassetteEventDecodeError / SignerUnavailable /
|
||||
OperatorIdentityMissing / etc.): log + return. `apply_bootstrap_
|
||||
state` is never called → `state_event_id` is not advanced →
|
||||
same event would re-process on next poll cycle but the consumer's
|
||||
WARN log surfaces the underlying issue immediately.
|
||||
- Transient (CassetteEventTransientError): log at INFO (less noisy)
|
||||
+ return. Same retry-via-no-advance semantics, just less
|
||||
alarming in the operator log feed.
|
||||
"""
|
||||
import json as _json
|
||||
from datetime import datetime as _datetime
|
||||
from datetime import timezone as _timezone
|
||||
|
||||
from lnbits.core.crud.users import get_account
|
||||
from lnbits.utils.nostr import verify_event
|
||||
|
||||
from .cassette_transport import decrypt_and_parse_state_event
|
||||
from .cassette_transport import (
|
||||
CassetteEventDecodeError,
|
||||
CassetteEventTransientError,
|
||||
CassetteTransportError,
|
||||
_resolve_operator_signer,
|
||||
decrypt_and_parse_state_event,
|
||||
)
|
||||
|
||||
event_raw = event_message.event
|
||||
if isinstance(event_raw, str):
|
||||
|
|
@ -430,16 +449,36 @@ async def _handle_cassette_state_event(
|
|||
)
|
||||
return
|
||||
|
||||
account = await get_account(machine.operator_user_id)
|
||||
if account is None or not account.prvkey:
|
||||
try:
|
||||
account, signer = await _resolve_operator_signer(
|
||||
machine.operator_user_id
|
||||
)
|
||||
except CassetteTransportError as exc:
|
||||
# OperatorIdentityMissing / SignerUnavailable — log + skip.
|
||||
logger.warning(
|
||||
f"satmachineadmin: operator {machine.operator_user_id[:8]}... "
|
||||
"has no privkey on file; can't decrypt cassette state event for "
|
||||
f"machine {machine.id}. Onboard via Nostr-login."
|
||||
f"satmachineadmin: can't resolve signer for operator "
|
||||
f"{machine.operator_user_id[:8]}... (machine {machine.id}): "
|
||||
f"{exc}"
|
||||
)
|
||||
return
|
||||
|
||||
payload = decrypt_and_parse_state_event(event_obj, account.prvkey)
|
||||
try:
|
||||
payload = await decrypt_and_parse_state_event(
|
||||
event_obj, account, signer
|
||||
)
|
||||
except CassetteEventTransientError as exc:
|
||||
logger.info(
|
||||
f"satmachineadmin: cassette state event for machine {machine.id} "
|
||||
f"hit a transient signer error (will retry next poll): {exc}"
|
||||
)
|
||||
return
|
||||
except CassetteEventDecodeError as exc:
|
||||
logger.warning(
|
||||
f"satmachineadmin: cassette state event decode failed for "
|
||||
f"machine {machine.id} (id={event_obj.get('id', '?')[:12]}...): "
|
||||
f"{exc}"
|
||||
)
|
||||
return
|
||||
|
||||
event_id = event_obj.get("id", "")
|
||||
created_at_unix = event_obj.get("created_at", 0)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,49 @@
|
|||
"""
|
||||
Tests for the cassette bootstrap consumer (`tasks._handle_cassette_state_event`
|
||||
and `cassette_transport.decrypt_and_parse_state_event`).
|
||||
Tests for the cassette bootstrap consumer's transport-decrypt path
|
||||
(`cassette_transport.decrypt_and_parse_state_event`) and d-tag construction.
|
||||
|
||||
Covers the consumer-side validation path end-to-end without standing up
|
||||
the full nostrclient relay subscription:
|
||||
- happy path: signed event from a known ATM → decrypt → parse → returns
|
||||
a position-keyed PublishCassettesPayload
|
||||
- multiple same-denom cassettes (v1.1 operational case) — round-trips
|
||||
- tampered ciphertext → CassetteEventDecodeError
|
||||
- wrong operator privkey → CassetteEventDecodeError (well-formed but
|
||||
decrypt fails because conversation key is wrong)
|
||||
- malformed pubkey → CassetteEventDecodeError
|
||||
- missing fields → CassetteEventDecodeError
|
||||
- decrypted garbage / wrong-shape JSON → CassetteEventDecodeError
|
||||
Post-PR-#38 migration (2026-05-31): the function takes an Account +
|
||||
NostrSigner instead of a raw privkey, and is async. Tests use:
|
||||
- `_FakeBunkerSigner` — implements async `nip44_decrypt/encrypt` against
|
||||
the hand-rolled `nip44` impl so tests don't need a live bunker.
|
||||
Exercises the "happy" RemoteBunkerSigner path.
|
||||
- `_FakeLocalSignerStub` — raises `SignerUnavailableError` from
|
||||
`nip44_decrypt`, mimicking the post-#38 `LocalSigner` stub. Combined
|
||||
with an Account that has `signer_type="LocalSigner"` + `prvkey`,
|
||||
exercises the transitional fallback path in
|
||||
`_nip44_decrypt_via_signer`.
|
||||
- `_FakeRaisingSigner` — raises an arbitrary exception, used to
|
||||
exercise the `NsecBunkerTimeoutError` → `CassetteEventTransientError`
|
||||
and `NsecBunkerRpcError` → `CassetteEventDecodeError` mappings.
|
||||
|
||||
Full handler tests (the dispatch through verify_event → get_machine_by_atm_
|
||||
pubkey_hex → apply_bootstrap_state) need a live LNbits DB; they're
|
||||
smoke-tested manually via the dev container per the project's existing
|
||||
convention (see test_deposit_currency.py).
|
||||
Coroutines are driven via `asyncio.run` so no pytest-asyncio config is
|
||||
required. Matches the existing project test pattern (test_init.py
|
||||
demonstrates the project lacks an asyncio plugin in CI; using asyncio.run
|
||||
inside the test body sidesteps that without changing project config).
|
||||
|
||||
Full handler tests (the dispatch through verify_event →
|
||||
get_machine_by_atm_pubkey_hex → apply_bootstrap_state) need a live LNbits
|
||||
DB; smoke-tested manually via the dev container per the project
|
||||
convention (see test_deposit_currency.py rationale).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
import coincurve
|
||||
import pytest
|
||||
|
||||
from lnbits.core.services.nip46_bunker_client import (
|
||||
NsecBunkerRpcError,
|
||||
NsecBunkerTimeoutError,
|
||||
)
|
||||
from lnbits.core.signers.base import SignerUnavailableError
|
||||
|
||||
from ..cassette_transport import (
|
||||
CassetteEventDecodeError,
|
||||
CassetteEventTransientError,
|
||||
_atm_hex_pubkey,
|
||||
_config_d_tag,
|
||||
_state_d_tag,
|
||||
|
|
@ -34,7 +51,13 @@ from ..cassette_transport import (
|
|||
decrypt_and_parse_state_event,
|
||||
)
|
||||
from ..models import Machine, PublishCassettesPayload
|
||||
from ..nip44 import encrypt_with_conversation_key, get_conversation_key
|
||||
from ..nip44 import (
|
||||
decrypt_from as _nip44_decrypt,
|
||||
)
|
||||
from ..nip44 import (
|
||||
encrypt_with_conversation_key,
|
||||
get_conversation_key,
|
||||
)
|
||||
|
||||
|
||||
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
|
||||
|
|
@ -54,6 +77,91 @@ _OP_PUB = _pub_hex(_OP_SEC)
|
|||
_ATM_PUB = _pub_hex(_ATM_SEC)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fake signers + account-shaped helper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _FakeBunkerSigner:
|
||||
"""Test double for RemoteBunkerSigner — implements async nip44_*
|
||||
against the hand-rolled `nip44` impl. Used to exercise the
|
||||
"signer.nip44_decrypt returns successfully" path without standing up
|
||||
a live bunker process."""
|
||||
|
||||
def __init__(self, privkey_hex: str):
|
||||
self._privkey_hex = privkey_hex
|
||||
|
||||
@property
|
||||
def pubkey(self) -> str:
|
||||
return _pub_hex(self._privkey_hex)
|
||||
|
||||
def can_sign(self) -> bool:
|
||||
return True
|
||||
|
||||
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
|
||||
ck = get_conversation_key(self._privkey_hex, peer_pubkey_hex)
|
||||
return encrypt_with_conversation_key(plaintext, ck)
|
||||
|
||||
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
|
||||
return _nip44_decrypt(ciphertext, self._privkey_hex, peer_pubkey_hex)
|
||||
|
||||
|
||||
class _FakeLocalSignerStub:
|
||||
"""Test double for the post-#38 LocalSigner stub — its nip44_* always
|
||||
raises SignerUnavailableError. Combined with an Account that has
|
||||
`signer_type='LocalSigner'` + `prvkey` populated, exercises the
|
||||
transitional fallback in `_nip44_decrypt_via_signer` (which catches
|
||||
the SignerUnavailableError and falls back to direct-prvkey via the
|
||||
hand-rolled impl)."""
|
||||
|
||||
def can_sign(self) -> bool:
|
||||
return True
|
||||
|
||||
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
|
||||
raise SignerUnavailableError(
|
||||
"LocalSigner does not implement nip44_encrypt"
|
||||
)
|
||||
|
||||
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
|
||||
raise SignerUnavailableError(
|
||||
"LocalSigner does not implement nip44_decrypt"
|
||||
)
|
||||
|
||||
|
||||
class _FakeRaisingSigner:
|
||||
"""Test double that raises a configurable exception on nip44_decrypt.
|
||||
Used to validate the bunker-error-mapping branches in
|
||||
decrypt_and_parse_state_event."""
|
||||
|
||||
def __init__(self, exc):
|
||||
self._exc = exc
|
||||
|
||||
def can_sign(self) -> bool:
|
||||
return True
|
||||
|
||||
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
|
||||
raise self._exc
|
||||
|
||||
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
|
||||
raise self._exc
|
||||
|
||||
|
||||
def _fake_account(
|
||||
signer_type: str = "RemoteBunkerSigner",
|
||||
prvkey: Optional[str] = None,
|
||||
):
|
||||
"""Account-shaped duck-typed object. decrypt_and_parse_state_event +
|
||||
_nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the
|
||||
rest is irrelevant."""
|
||||
return SimpleNamespace(
|
||||
id="test-operator",
|
||||
pubkey=_OP_PUB,
|
||||
prvkey=prvkey,
|
||||
signer_type=signer_type,
|
||||
signer_config=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_state_event(
|
||||
payload: PublishCassettesPayload,
|
||||
*,
|
||||
|
|
@ -63,10 +171,9 @@ def _make_state_event(
|
|||
event_id: str = "fake-event-id",
|
||||
created_at: int = 1234567890,
|
||||
) -> dict:
|
||||
"""Build a state event the way bitspire's ATM publisher would.
|
||||
Skips the actual sig-verify step (the handler-level test does
|
||||
that against verify_event); the transport-level decrypt path
|
||||
doesn't care about sig validity, only about the conversation key."""
|
||||
"""Build a state event the way bitspire's ATM publisher would. Skips
|
||||
the sig-verify step (handler-level concern); the transport-decrypt
|
||||
path doesn't depend on sig validity, only on conversation-key match."""
|
||||
plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
|
||||
ck = get_conversation_key(atm_sec, op_pub)
|
||||
content = encrypt_with_conversation_key(plaintext, ck)
|
||||
|
|
@ -84,17 +191,16 @@ def _make_state_event(
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# decrypt_and_parse_state_event — transport-decrypt path
|
||||
# decrypt_and_parse_state_event — RemoteBunkerSigner happy path
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDecryptAndParseStateEvent:
|
||||
"""The function the consumer task calls per inbound event. Verifies
|
||||
NIP-44 v2 decrypt + JSON-parse + PublishCassettesPayload validation.
|
||||
Sig verification is the caller's responsibility (the handler does it
|
||||
before reaching here)."""
|
||||
class TestDecryptViaBunkerSigner:
|
||||
"""The expected production path post-#38: operator account is bunker-
|
||||
backed, signer.nip44_decrypt routes through the bunker (mocked here
|
||||
via _FakeBunkerSigner), and the wire payload round-trips cleanly."""
|
||||
|
||||
def test_happy_path(self):
|
||||
def test_happy_path_recovers_positions_keyed_payload(self):
|
||||
payload = PublishCassettesPayload(
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 49},
|
||||
|
|
@ -102,7 +208,12 @@ class TestDecryptAndParseStateEvent:
|
|||
}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
account = _fake_account(signer_type="RemoteBunkerSigner")
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
|
||||
recovered = asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
assert sorted(recovered.positions.keys()) == [1, 2]
|
||||
assert recovered.positions[1].denomination == 20
|
||||
assert recovered.positions[1].count == 49
|
||||
|
|
@ -110,8 +221,8 @@ class TestDecryptAndParseStateEvent:
|
|||
assert recovered.positions[2].count == 100
|
||||
|
||||
def test_round_trips_multiple_same_denomination(self):
|
||||
"""v1.1 operational case from coord-log 18:45Z: real machines
|
||||
load multiple cassettes with the same denomination."""
|
||||
"""v1.1 operational case (coord-log 2026-05-30T18:45Z) — multiple
|
||||
bays carrying the same denomination."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 100},
|
||||
|
|
@ -121,44 +232,166 @@ class TestDecryptAndParseStateEvent:
|
|||
}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
|
||||
recovered = asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
assert len(recovered.positions) == 4
|
||||
for pos in (1, 2, 3, 4):
|
||||
assert recovered.positions[pos].denomination == 20
|
||||
assert recovered.positions[pos].count == 100
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# decrypt_and_parse_state_event — LocalSigner transitional fallback
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDecryptViaLocalSignerFallback:
|
||||
"""When the operator account is still on LocalSigner (pre-bunker
|
||||
migration), the LocalSigner stub raises SignerUnavailableError from
|
||||
nip44_decrypt. `_nip44_decrypt_via_signer` catches that and falls
|
||||
back to the hand-rolled impl using `account.prvkey`. Same wire
|
||||
output; transitional until S7 retires LocalSigner accounts entirely."""
|
||||
|
||||
def test_localsigner_with_prvkey_decrypts_via_fallback(self):
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC)
|
||||
signer = _FakeLocalSignerStub()
|
||||
|
||||
recovered = asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
assert recovered.positions[1].denomination == 20
|
||||
assert recovered.positions[1].count == 49
|
||||
|
||||
def test_localsigner_without_prvkey_raises_decode_error(self):
|
||||
"""A LocalSigner account whose prvkey field is None (impossible
|
||||
in practice — LocalSigner requires prvkey at provision time, but
|
||||
defensive in case the row is corrupt) should surface as a
|
||||
decode error, not silently succeed."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account(signer_type="LocalSigner", prvkey=None)
|
||||
signer = _FakeLocalSignerStub()
|
||||
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
def test_clientonlysigner_raises_decode_error(self):
|
||||
"""ClientSideOnlySigner has no server-side decrypt path at all;
|
||||
falling back to direct-prvkey is also impossible (no prvkey).
|
||||
Surface as a decode error so the consumer logs + skips."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account(
|
||||
signer_type="ClientSideOnlySigner", prvkey=None
|
||||
)
|
||||
signer = _FakeLocalSignerStub() # behaves the same way: raises
|
||||
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# decrypt_and_parse_state_event — bunker error mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBunkerErrorMapping:
|
||||
"""The post-#38 error hierarchy splits transient (bunker partitioned)
|
||||
from terminal (bunker policy reject, MAC failure). Consumer behaves
|
||||
differently — transient retries, terminal logs + skips. Validate the
|
||||
mapping from NsecBunker* exceptions to our CassetteEvent* types."""
|
||||
|
||||
def test_timeout_maps_to_transient_error(self):
|
||||
"""Bunker unreachable → NsecBunkerTimeoutError → caller-visible
|
||||
CassetteEventTransientError. Consumer treats this as retry-
|
||||
eligible (don't advance state_event_id)."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account()
|
||||
signer = _FakeRaisingSigner(
|
||||
NsecBunkerTimeoutError("bunker unreachable")
|
||||
)
|
||||
with pytest.raises(CassetteEventTransientError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
def test_rpc_reject_maps_to_decode_error(self):
|
||||
"""Bunker rejected the RPC (policy / MAC / config) →
|
||||
NsecBunkerRpcError → caller-visible CassetteEventDecodeError.
|
||||
Terminal — retrying won't help."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account()
|
||||
signer = _FakeRaisingSigner(
|
||||
NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised")
|
||||
)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# decrypt_and_parse_state_event — payload + envelope validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPayloadValidation:
|
||||
"""Errors that originate at the parse layer (post-decrypt), not the
|
||||
signer. Same set as pre-migration — covered through the bunker-signer
|
||||
path since LocalSigner is going away."""
|
||||
|
||||
def test_tampered_content_rejected(self):
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
# Flip a base64 character — corrupts the ciphertext or MAC
|
||||
# depending on where the flip lands.
|
||||
event["content"] = event["content"][:-2] + "AA"
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
def test_wrong_operator_privkey_rejected(self):
|
||||
"""The conversation key derives from operator-privkey + sender-pubkey.
|
||||
A wrong privkey gives a different conversation key, which yields a
|
||||
different hmac_key, so MAC verification inside NIP-44 v2 decrypt
|
||||
fails — surfaced as CassetteEventDecodeError."""
|
||||
def test_wrong_signer_privkey_rejected(self):
|
||||
"""Wrong privkey on the signer → wrong conversation key → MAC
|
||||
verification fails inside nip44_decrypt → surfaces as decode
|
||||
error (via the hand-rolled Nip44Error since this is the fake
|
||||
bunker signer; in production the bunker would raise
|
||||
NsecBunkerRpcError which also maps to CassetteEventDecodeError)."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
account = _fake_account()
|
||||
wrong_sec = "00" * 31 + "03"
|
||||
signer = _FakeBunkerSigner(wrong_sec)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
decrypt_and_parse_state_event(event, wrong_sec)
|
||||
|
||||
def test_malformed_sender_pubkey_rejected(self):
|
||||
payload = PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
event["pubkey"] = "not-a-real-pubkey"
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
|
||||
def test_missing_content_rejected(self):
|
||||
event = _make_state_event(
|
||||
|
|
@ -167,8 +400,12 @@ class TestDecryptAndParseStateEvent:
|
|||
)
|
||||
)
|
||||
del event["content"]
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
def test_missing_pubkey_rejected(self):
|
||||
event = _make_state_event(
|
||||
|
|
@ -177,14 +414,18 @@ class TestDecryptAndParseStateEvent:
|
|||
)
|
||||
)
|
||||
del event["pubkey"]
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
def test_decrypted_garbage_json_rejected(self):
|
||||
"""If the plaintext decrypts but isn't JSON, we surface an error
|
||||
rather than crashing the consumer loop."""
|
||||
"""If plaintext decrypts cleanly but isn't valid JSON, surface
|
||||
as decode error (not crash the consumer loop)."""
|
||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||
bad_plaintext_event = {
|
||||
event = {
|
||||
"kind": 30078,
|
||||
"pubkey": _ATM_PUB,
|
||||
"content": encrypt_with_conversation_key(
|
||||
|
|
@ -194,38 +435,42 @@ class TestDecryptAndParseStateEvent:
|
|||
"created_at": 0,
|
||||
"id": "x",
|
||||
}
|
||||
with pytest.raises(CassetteEventDecodeError) as exc:
|
||||
decrypt_and_parse_state_event(bad_plaintext_event, _OP_SEC)
|
||||
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
def test_decrypted_json_with_wrong_shape_rejected(self):
|
||||
"""Well-formed JSON but missing the 'positions' field is
|
||||
a payload-shape failure, not a decrypt failure."""
|
||||
def test_decrypted_wrong_shape_rejected(self):
|
||||
"""Well-formed JSON but missing 'positions' → payload-shape
|
||||
validation failure."""
|
||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||
bad_shape_event = {
|
||||
event = {
|
||||
"kind": 30078,
|
||||
"pubkey": _ATM_PUB,
|
||||
"content": encrypt_with_conversation_key(
|
||||
'{"wrong_field": 42}', ck
|
||||
),
|
||||
"content": encrypt_with_conversation_key('{"wrong_field": 42}', ck),
|
||||
"tags": [],
|
||||
"created_at": 0,
|
||||
"id": "x",
|
||||
}
|
||||
with pytest.raises(CassetteEventDecodeError) as exc:
|
||||
decrypt_and_parse_state_event(bad_shape_event, _OP_SEC)
|
||||
assert "didn't validate" in str(exc.value)
|
||||
account = _fake_account()
|
||||
signer = _FakeBunkerSigner(_OP_SEC)
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
asyncio.run(
|
||||
decrypt_and_parse_state_event(event, account, signer)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# d-tag construction — _atm_hex_pubkey, _config_d_tag, _state_d_tag, helper
|
||||
# d-tag construction — unchanged by the migration, kept as regression guard
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDTagConstruction:
|
||||
"""The `<m>` placeholder in d-tags = ATM hex pubkey (load-bearing per
|
||||
coord-log 11:50Z). These tests pin the canonical substitution so a
|
||||
refactor can't silently break wire compatibility."""
|
||||
coord-log 2026-05-30T11:50Z). These tests pin the canonical
|
||||
substitution so a refactor can't silently break wire compatibility."""
|
||||
|
||||
def _machine(self, npub: str, id_: str = "m1") -> Machine:
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -251,8 +496,6 @@ class TestDTagConstruction:
|
|||
assert _atm_hex_pubkey(self._machine(_ATM_PUB.upper())) == _ATM_PUB
|
||||
|
||||
def test_atm_hex_pubkey_canonicalises_bech32_to_hex(self):
|
||||
"""Operator may have entered npub1... in the UI; canonical d-tag
|
||||
substitution is always the hex form."""
|
||||
from lnbits.utils.nostr import hex_to_npub
|
||||
|
||||
npub_bech32 = hex_to_npub(_ATM_PUB)
|
||||
|
|
@ -260,8 +503,7 @@ class TestDTagConstruction:
|
|||
|
||||
def test_config_d_tag_uses_hex_pubkey_not_id(self):
|
||||
"""REGRESSION GUARD: d-tag must contain the ATM hex pubkey, NOT
|
||||
the internal machine UUID. If this test fails, bitspire's ATM
|
||||
won't see our publishes."""
|
||||
the internal machine UUID."""
|
||||
m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey")
|
||||
d_tag = _config_d_tag(_atm_hex_pubkey(m))
|
||||
assert d_tag == f"bitspire-cassettes:{_ATM_PUB}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue