From e13178d3acb8ca1f37399e0637022d3e86598b89 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 22:24:29 +0200 Subject: [PATCH] feat(v2): route nostr_publish signing through lnbits#17 signer abstraction (hybrid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to the lnbits session's 19:30Z coordination-log flag: PR #17 will NULL `accounts.prvkey` on cascade via the m002 classify job, which would break the S4 fleet-roster publishing path (`131ff92`) — it reads `account.prvkey` directly. Hybrid migration in `_sign_as_operator`: 1. Try `from lnbits.core.signers import resolve_signer` — post-#17 lnbits provides this; routes through the per-account signer that understands LocalSigner (envelope-encrypted nsec at rest), ClientSideOnlySigner (server can't sign — soft-fail), and the future RemoteBunkerSigner (lnbits#18; phase 2). 2. On ImportError, fall through to the direct `account.prvkey` read identical to the pre-#17 implementation. Same wire-level signed event either way; the fallback exists only to avoid a hard ordering dependency between this commit and the lnbits #17 cascade landing on the host. Soft-failure surfaces (all log + skip, don't break machine CRUD): - operator has no pubkey on file → skip. - signer resolve fails (unclassified account, etc.) → skip. - `signer.can_sign()` False (ClientSideOnlySigner) → skip. - `SignerUnavailableError` raised at sign time → skip. Why hybrid instead of waiting for #17 to land first: pre-#17 lnbits is what's currently in production / dev. If we ship a hard `from lnbits.core.signers import ...` now, satmachineadmin breaks at import time on every host running the current nostr-transport branch. The try/except guard is the same shape lnbits core uses for cross-extension imports (nostrmarket / nostrrelay). Sister migrations on other extensions (nostrmarket, restaurant, tasks, events) are tracked at `aiolabs/lnbits#21` umbrella + per-extension issues that the lnbits session filed in the 2026-05-26T20:00Z audit. Co-Authored-By: Claude Opus 4.7 (1M context) --- nostr_publish.py | 89 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/nostr_publish.py b/nostr_publish.py index a9d5168..e2851c9 100644 --- a/nostr_publish.py +++ b/nostr_publish.py @@ -79,33 +79,94 @@ async def _publish_signed_event(signed_event: dict) -> None: async def _sign_as_operator( operator_user_id: str, event: dict ) -> Optional[dict]: - """Sign `event` using the operator's stored Nostr nsec. + """Sign `event` on behalf of the operator. Mutates `event` to add `created_at` (now), `pubkey`, `id`, and `sig`. Returns the signed event; returns None (with a warning log) if the - operator account doesn't have a pubkey + nsec pair on file — covers + operator account doesn't have an available signer — covers (a) accounts created via non-Nostr login that never set up identity, - (b) post-bunker future (lnbits#18) where the nsec is moved off-disk - and the bunker client isn't yet wired through here, - (c) misconfiguration. + (b) accounts where the server has only the pubkey + (`ClientSideOnlySigner`), + (c) post-bunker future (lnbits#18) where signing routes through a + NIP-46 bunker that isn't reachable. Soft-failure is the right behaviour — publishing kind:30078 is a - side-effect of CRUD, not a precondition for it. The machine row - still gets written; only the public-facing event is skipped. + side-effect of machine CRUD, not a precondition for it. The machine + row still gets written; only the public-facing event is skipped. + + 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` — we + treat as soft-fail). On pre-#17 lnbits versions the import fails and + we fall back to a direct `account.prvkey` read so this code keeps + working during the #17 cascade rollout window. Both paths produce + identical signed events; the hybrid avoids a hard ordering + dependency between this extension and the lnbits #17 PR landing. """ account = await get_account(operator_user_id) - if account is None or not account.pubkey or not account.prvkey: + if account is None or not account.pubkey: logger.warning( f"satmachineadmin: operator {operator_user_id[:8]}... has no " - f"Nostr keypair on file; skipping kind:{event['kind']} publish. " - "Onboard via the LNbits Nostr-login flow, or wait for " - "aiolabs/lnbits#18 bunker integration." + f"Nostr pubkey on file; skipping kind:{event['kind']} publish. " + "Onboard via the LNbits Nostr-login flow." ) return None + + # `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()) - event["pubkey"] = account.pubkey - private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey)) - return sign_event(event, account.pubkey, private_key) + + try: + from lnbits.core.signers import ( # type: ignore[import-not-found] + SignerError, + SignerUnavailableError, + resolve_signer, + ) + except ImportError: + # Pre-#17 lnbits — direct prvkey read. Identical to the + # original implementation; the abstraction takes over once + # #17 cascades to this host. + if not account.prvkey: + logger.warning( + f"satmachineadmin: operator {operator_user_id[:8]}... has " + f"no signing key on file; skipping kind:{event['kind']} " + f"publish. Onboard via the LNbits Nostr-login flow, or " + f"wait for aiolabs/lnbits#18 bunker integration." + ) + return None + 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: + logger.warning( + f"satmachineadmin: signer resolve failed for operator " + f"{operator_user_id[:8]}...: {exc}. Skipping kind:" + f"{event['kind']} publish." + ) + return None + + if not signer.can_sign(): + logger.warning( + f"satmachineadmin: operator {operator_user_id[:8]}... has a " + f"client-side-only signer; server can't publish kind:" + f"{event['kind']} on their behalf. Wait for bunker " + f"integration (lnbits#18) or operator-driven publishing." + ) + return None + + try: + return signer.sign_event(event) + except SignerUnavailableError as exc: + logger.warning( + f"satmachineadmin: signer unavailable for operator " + f"{operator_user_id[:8]}...: {exc}. Skipping kind:" + f"{event['kind']} publish." + ) + return None async def publish_machine_config(machine: Machine) -> None: