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: