From e307829b5090992a07c42eeba4863e885cc1fb88 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 27 May 2026 22:18:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(signer):=20migrate=20Nostr=20publishing=20?= =?UTF-8?q?off=20account.prvkey=20=E2=86=92=20resolve=5Ffor=5Fwallet=20(#3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes aiolabs/tasks#3. Pre-cascade prerequisite for aiolabs/lnbits#17 (signer abstraction phase 1), which lands an m002 startup job that NULLs the legacy `accounts.prvkey` column. After this migration, the tasks extension reads no plaintext nsec and works with any NostrSigner backend (LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). ## What changed ### nostr_hooks.py — three publisher entry points Was: `_account_keys(wallet_id)` helper pulled `(account.pubkey, account.prvkey)` from the wallet's owning account, returned None when prvkey was missing, then passed both to the publishers. Now: each of `publish_or_delete_task_event`, `publish_task_completion`, and `publish_completion_delete` calls `await resolve_for_wallet(...)` (the DRY helper from aiolabs/lnbits#23 — wallet → account → signer → can_sign-check in one call, returns None on any soft-fail). The resolved `NostrSigner` is passed to the publisher. Soft-skip on None (wallet missing, account unclassified, or ClientSideOnlySigner where the server has no signing authority). Removed the `_account_keys` helper entirely. ### nostr_publisher.py — three publishers Was: `publish_task_to_nostr`, `publish_completion_to_nostr`, and `publish_completion_delete_to_nostr` each accepted `(account_pubkey: str, account_prvkey: str)` and signed via a local `sign_nostr_event` helper that called `coincurve.PrivateKey .sign_schnorr` directly on the plaintext nsec. Now: each publisher accepts `signer: NostrSigner`. Signing is factored into a shared `_sign_and_publish` helper that builds the unsigned event dict (`kind`/`created_at`/`tags`/`content`), hands it to `await signer.sign_event(...)`, and writes `id`/`pubkey`/`sig` back onto the local `NostrEvent` model before publishing. The signer backend (LocalSigner / RemoteBunkerSigner) is transparent. Removed the `sign_nostr_event` helper entirely — the signer abstraction handles all signing now. Dropped the `coincurve` import; no direct crypto in this extension. ## Acceptance - [x] `_account_keys` helper removed (nostr_hooks no longer touches account.prvkey) - [x] all three publishers accept NostrSigner instead of (pubkey, prvkey) - [x] extension-local Schnorr code removed (sign_nostr_event gone) - [x] coincurve import dropped - [x] re-grep `tasks/`: zero `account.prvkey` references - [x] version bumped: 0.0.1 → 0.0.2 (catalog entry deferred until cascade lands) Manual smoke testing + tag + catalog entry follow the migration landing; will run against the regtest stack with lnbits on `issue-18-phase-2.3` (which validates both LocalSigner and RemoteBunkerSigner signing paths end-to-end). ## Cross-references - aiolabs/tasks#3 — issue this commit closes - aiolabs/lnbits#17 — the cascading signer-abstraction PR - aiolabs/lnbits#23 — the resolve_for_wallet helper this uses - aiolabs/lnbits#21 — umbrella audit (5 affected extensions) - aiolabs/events#23 — sister migration (already on signer-abstraction branch) Co-Authored-By: Claude Opus 4.7 (1M context) --- config.json | 2 +- nostr_hooks.py | 42 ++++++++++++---------------------- nostr_publisher.py | 57 +++++++++++++++++++++++++++------------------- 3 files changed, 50 insertions(+), 51 deletions(-) diff --git a/config.json b/config.json index eef2c1f..bbe422f 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "tasks", - "version": "0.0.1", + "version": "0.0.2", "name": "Tasks", "repo": "https://git.atitlan.io/aiolabs/tasks", "short_description": "Recurring tasks and chore-tracking, published over Nostr", diff --git a/nostr_hooks.py b/nostr_hooks.py index 295d473..7c56f07 100644 --- a/nostr_hooks.py +++ b/nostr_hooks.py @@ -14,21 +14,6 @@ from .nostr_publisher import ( ) -async def _account_keys(wallet_id: str) -> tuple[str, str] | None: - """Fetch (pubkey, prvkey) for the wallet's owning account. Returns None - when the account is missing keys, so callers can skip cleanly.""" - from lnbits.core.crud.users import get_account - from lnbits.core.crud.wallets import get_wallet - - wallet_obj = await get_wallet(wallet_id) - if not wallet_obj: - return None - account = await get_account(wallet_obj.user) - if not account or not account.pubkey or not account.prvkey: # type: ignore[attr-defined] - return None - return account.pubkey, account.prvkey # type: ignore[attr-defined] - - async def publish_or_delete_task_event( task: Task, *, delete: bool = False ) -> None: @@ -37,15 +22,16 @@ async def publish_or_delete_task_event( Errors are logged and swallowed so a Nostr outage doesn't break the HTTP flow that triggered the publish.""" try: + from lnbits.core.signers import resolve_for_wallet + from . import nostr_client - keys = await _account_keys(task.wallet) - if not keys: + signer = await resolve_for_wallet(task.wallet) + if signer is None: return - pubkey, prvkey = keys nostr_event = await publish_task_to_nostr( - nostr_client, task, pubkey, prvkey, delete=delete + nostr_client, task, signer, delete=delete ) if nostr_event and not delete: task.nostr_event_id = nostr_event.id @@ -62,15 +48,16 @@ async def publish_task_completion( caller can persist it as the completion's primary key, replacing the locally-generated hash from the optimistic insert.""" try: + from lnbits.core.signers import resolve_for_wallet + from . import nostr_client - keys = await _account_keys(task.wallet) - if not keys: + signer = await resolve_for_wallet(task.wallet) + if signer is None: return None - pubkey, prvkey = keys nostr_event = await publish_completion_to_nostr( - nostr_client, task.address, completion, pubkey, prvkey + nostr_client, task.address, completion, signer ) return nostr_event.id if nostr_event else None except Exception as exc: @@ -83,15 +70,16 @@ async def publish_completion_delete( ) -> None: """Publish a NIP-09 delete for a previously-published completion.""" try: + from lnbits.core.signers import resolve_for_wallet + from . import nostr_client - keys = await _account_keys(wallet_id) - if not keys: + signer = await resolve_for_wallet(wallet_id) + if signer is None: return - pubkey, prvkey = keys await publish_completion_delete_to_nostr( - nostr_client, completion_id, pubkey, prvkey + nostr_client, completion_id, signer ) except Exception as exc: logger.warning(f"[TASKS] Nostr completion delete failed: {exc}") diff --git a/nostr_publisher.py b/nostr_publisher.py index 4198906..82c97d4 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -9,13 +9,16 @@ Builds: - kind 5 deletes referencing either an `a` (for tasks) or `e` tag (for completions). -Signs with the wallet owner's Account keypair and publishes via the -NostrClient wrapping nostrclient's WebSocket. +Signing routes through the core `NostrSigner` abstraction +(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The signer +fills in `id`, `pubkey`, and `sig` per NIP-01 — same serialization +rules as our local `event_id` property uses, so the returned id +matches what we'd have computed locally. """ import time -import coincurve +from lnbits.core.signers import NostrSigner from loguru import logger from .models import Task, TaskCompletion @@ -128,17 +131,30 @@ def build_delete_completion_event( return nostr_event -def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None: - privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id)) - nostr_event.sig = sig.hex() +async def _sign_and_publish( + nostr_client, nostr_event: NostrEvent, signer: NostrSigner +) -> NostrEvent: + """Hand the unsigned event to the signer — it fills in `id`, `pubkey`, + and `sig` — then publish through the relay client. Mutates `nostr_event` + in place with the canonical signed values.""" + unsigned = { + "kind": nostr_event.kind, + "created_at": nostr_event.created_at, + "tags": nostr_event.tags, + "content": nostr_event.content, + } + signed = await signer.sign_event(unsigned) + nostr_event.id = signed["id"] + nostr_event.pubkey = signed["pubkey"] + nostr_event.sig = signed["sig"] + await nostr_client.publish_nostr_event(nostr_event) + return nostr_event async def publish_task_to_nostr( nostr_client, task: Task, - account_pubkey: str, - account_prvkey: str, + signer: NostrSigner, *, delete: bool = False, ) -> NostrEvent | None: @@ -147,12 +163,11 @@ async def publish_task_to_nostr( return None try: nostr_event = ( - build_delete_task_event(task, account_pubkey) + build_delete_task_event(task, signer.pubkey) if delete - else build_task_event(task, account_pubkey) + else build_task_event(task, signer.pubkey) ) - sign_nostr_event(nostr_event, account_prvkey) - await nostr_client.publish_nostr_event(nostr_event) + await _sign_and_publish(nostr_client, nostr_event, signer) logger.info( f"[TASKS] Published {'delete' if delete else '31922 task'} " f"{nostr_event.id[:16]}... (kind {nostr_event.kind})" @@ -167,18 +182,16 @@ async def publish_completion_to_nostr( nostr_client, task_address: str, completion: TaskCompletion, - account_pubkey: str, - account_prvkey: str, + signer: NostrSigner, ) -> NostrEvent | None: if not nostr_client: logger.debug("[TASKS] No NostrClient available, skipping publish") return None try: nostr_event = build_completion_event( - task_address, completion, account_pubkey + task_address, completion, signer.pubkey ) - sign_nostr_event(nostr_event, account_prvkey) - await nostr_client.publish_nostr_event(nostr_event) + await _sign_and_publish(nostr_client, nostr_event, signer) logger.info( f"[TASKS] Published 31925 completion {nostr_event.id[:16]}... " f"(status: {completion.task_status})" @@ -192,15 +205,13 @@ async def publish_completion_to_nostr( async def publish_completion_delete_to_nostr( nostr_client, completion_id: str, - account_pubkey: str, - account_prvkey: str, + signer: NostrSigner, ) -> NostrEvent | None: if not nostr_client: return None try: - nostr_event = build_delete_completion_event(completion_id, account_pubkey) - sign_nostr_event(nostr_event, account_prvkey) - await nostr_client.publish_nostr_event(nostr_event) + nostr_event = build_delete_completion_event(completion_id, signer.pubkey) + await _sign_and_publish(nostr_client, nostr_event, signer) logger.info( f"[TASKS] Published completion delete {nostr_event.id[:16]}..." )