Follow-up to the satmachineadmin->spirekeeper issue migration. The 20
open issues were recreated on aiolabs/spirekeeper with reassigned
numbers; this repoints in-repo references to the migrated issues at
their new spirekeeper numbers (#3->#1, #4->#2, #8->#4, #9->#5, #10->#6,
#17->#11, #21->#12, #28->#16, #44->#20). References to closed/non-
migrated satmachineadmin issues (#20/#22/#26/#29/#32/#37/#38/#39) stay
pointing at the original repo where they were resolved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-merge lint hygiene on the PR #30 touched files:
- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
`UP006` `typing.Optional` → `| None` modernization, `I001` import
sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
fixed:
- `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
| None` on the dedup fetch.
- `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
the `nostr_client.relay_manager.add_subscription` call; nostrclient's
upstream typing declares `list[str]` for filters but the actual
Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
(live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
this subscription); the typing mismatch is upstream's.
Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
validators (`cls` first-arg is correct for `@validator`-decorated
methods); 4 are N818 exception-name-suffix preferences on my new
exception classes (renaming would touch many call sites; keep
`OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
/ `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
long on docstrings (the long lines are formatted for clarity);
1 RUF002 unicode-minus in a docstring.
Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
LNbits ships only NIP-04 (AES-CBC) in lnbits.utils.nostr.encrypt_content,
but the locked design at #29 (paired with lamassu-next#56) wires kind-30078
cassette config with NIP-44 v2 content per the privacy-by-default
architecture (dcd0874). Hand-rolling rather than adding a Python lib dep
per the plan-approval (option A) — keeps the impl auditable inline and
avoids pulling in a non-trivial dep tree.
nip44.py covers the full envelope:
- get_conversation_key — ECDH x-coord + HKDF-extract with salt b"nip44-v2"
- encrypt_with_conversation_key / decrypt_with_conversation_key — low-level,
nonce-controllable for testing pinned vectors
- encrypt_for / decrypt_from — high-level pair-keyed API (the shape app
code reaches for)
- _pad / _unpad — NIP-44 v2 length-prefixed padding scheme
- HMAC-SHA256 verification on nonce || ciphertext, constant-time compare
via hmac.compare_digest
- Typed errors (Nip44VersionError / Nip44MacError / Nip44LengthError)
so callers can distinguish tamper from corruption from spec mismatch
Stack: coincurve for ECDH (already a transitive lnbits dep), cryptography
for ChaCha20 + HKDF-expand (also already there). No new pyproject deps.
34 tests in tests/test_nip44_v2.py, three layers:
1. Pinned reference vector — conversation_key for (sec=1, sec=2) matches
the canonical paulmillr/nip44 published value
(c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d).
Regression-fails loudly if key derivation drifts.
2. Round-trip + tamper detection — encrypt/decrypt across plaintext
lengths (1, 32, 33, 1000, 5000, 65535 bytes); flipped MAC byte;
flipped ciphertext byte; flipped nonce byte; wrong recipient privkey;
version-byte rejection; padding-formula spot checks.
3. Cross-impl byte-compat — placeholder test_decrypts_bitspire_sample
marked @pytest.mark.skip, pending bitspire posting a sample event
encrypted on their nostr-tools side to the coord log (per the
2026-05-30T15:55Z entry). Wire that fixture and unskip when posted.
Total: 132 passed, 1 skipped (cross-test fixture pending), 1 pre-existing
async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>