diff --git a/docs/nostr-layer.md b/docs/nostr-layer.md index 6711e82..db9ab92 100644 --- a/docs/nostr-layer.md +++ b/docs/nostr-layer.md @@ -54,15 +54,20 @@ the new tag set lands. Each restaurant has an effective Nostr identity: - If `restaurant.nostr_pubkey` is set, that's a per-restaurant - identity (storage of the matching secret key is **out of scope** - in v1; the column is informational until a vault is wired up). -- Otherwise, the LNbits Account keypair of the wallet owner is - used (`account.pubkey` / `account.prvkey`). + identity (a per-restaurant signer/vault is **out of scope** + in v1; the column is informational until that's wired up). +- Otherwise, the wallet owner's signer is resolved via + `lnbits.core.signers.resolve_for_wallet` (wallet → account → + signer, with a `can_sign()` gate). The signer backend + (`LocalSigner` / `RemoteBunkerSigner` / `ClientSideOnlySigner`) + is transparent to this extension. -`nostr_publisher.publish_event(client, event, prvkey)` signs in -place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships -to the relay via the [[architecture|nostrclient extension's]] -internal WebSocket. +`nostr_publisher.publish_event(client, event, signer)` hands the +unsigned event dict to the resolved `NostrSigner`, which fills in +`id` / `pubkey` / `sig` per NIP-01 / BIP-340 and ships to the relay +via the [[architecture|nostrclient extension's]] internal WebSocket. +The extension itself does no direct Schnorr signing — that lives in +the signer abstraction. ## What gets listened for diff --git a/nostr_publisher.py b/nostr_publisher.py index 17137fc..28f5191 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -15,11 +15,13 @@ itself has no awareness of festivals. Signing ------- -Events are signed with the *restaurant's* keypair if `restaurant.nostr_pubkey` -is set, otherwise with the LNbits Account keypair of the wallet's owner. -This lets a single LNbits account host multiple restaurants under -distinct Nostr identities, while keeping a sane default for owners -who don't care about identity separation. +Events are signed via the core `NostrSigner` abstraction +(LocalSigner / RemoteBunkerSigner / ClientSideOnlySigner). The caller +resolves a signer for the restaurant's effective Nostr identity and +hands it to `publish_event` — the signer fills in `id`/`pubkey`/`sig` +per NIP-01. This lets a single LNbits account host multiple +restaurants under distinct Nostr identities while keeping a sane +default for owners who don't care about identity separation. """ import json @@ -27,7 +29,7 @@ import re import time from typing import Optional -import coincurve +from lnbits.core.signers import NostrSigner from loguru import logger from .models import MenuItem, Restaurant @@ -202,25 +204,33 @@ def build_delete_event( # --------------------------------------------------------------------- # -def sign_nostr_event(nostr_event: NostrEvent, private_key_hex: str) -> None: - """Schnorr-sign a NostrEvent in place (BIP-340).""" - privkey = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) - sig = privkey.sign_schnorr(bytes.fromhex(nostr_event.id)) - nostr_event.sig = sig.hex() - - async def publish_event( nostr_client, nostr_event: NostrEvent, - private_key_hex: str, + signer: NostrSigner, ) -> Optional[NostrEvent]: """Sign and publish a built NostrEvent. Returns the event on success - so callers can persist its id + created_at, or None on failure.""" + so callers can persist its id + created_at, or None on failure. + + The unsigned event dict (`kind`/`created_at`/`tags`/`content`) is + handed to the signer, which fills in `id`/`pubkey`/`sig` — same + NIP-01 serialization rules as our local `event_id` property uses, + so the returned id matches what we'd have computed locally. The + signer backend (LocalSigner / RemoteBunkerSigner) is transparent.""" if not nostr_client: logger.debug("[RESTAURANT] No NostrClient; skipping publish") return None try: - sign_nostr_event(nostr_event, private_key_hex) + 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) logger.info( f"[RESTAURANT] Published kind {nostr_event.kind} " diff --git a/views_api.py b/views_api.py index ad26520..7d44efb 100644 --- a/views_api.py +++ b/views_api.py @@ -22,9 +22,8 @@ from pydantic import BaseModel from starlette.exceptions import HTTPException from lnbits.core.crud import get_user -from lnbits.core.crud.users import get_account -from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import Account, WalletTypeInfo +from lnbits.core.signers import NostrSigner, resolve_for_wallet from lnbits.decorators import ( check_admin, check_user_exists, @@ -108,53 +107,47 @@ restaurant_api_router = APIRouter() # --------------------------------------------------------------------- # -async def _resolve_signing_keypair( +async def _resolve_signer( restaurant: Restaurant, -) -> Optional[tuple[str, str]]: +) -> Optional[NostrSigner]: """ - Resolve the (pubkey, prvkey) pair for signing Nostr events on behalf - of a restaurant. + Resolve a `NostrSigner` for signing events on behalf of a restaurant. Order of precedence: - 1. restaurant.nostr_pubkey is set → use a per-restaurant key. - (Storage of the corresponding prvkey is intentionally out of - scope here; for now this branch is a no-op until we ship a - secret-management approach. Returns None.) - 2. Otherwise → fall back to the LNbits Account keypair of the - wallet owner. + 1. restaurant.nostr_pubkey is set → per-restaurant identity. + (A per-restaurant signer/vault is out of scope here; until + that lands this branch is a no-op. Returns None.) + 2. Otherwise → fall back to the wallet owner's signer via + `resolve_for_wallet` (wallet → account → signer with a + can_sign-check; soft-fails to None on missing wallet, missing + account, unclassified row, or ClientSideOnlySigner accounts + where the server has no signing authority). """ if restaurant.nostr_pubkey: - # TODO: per-restaurant secret key vault. + # TODO: per-restaurant signer / secret vault. return None - wallet_obj = await get_wallet(restaurant.wallet) - if not wallet_obj: - return None - account = await get_account(wallet_obj.user) - if not account or not account.pubkey or not account.prvkey: - return None - return account.pubkey, account.prvkey + return await resolve_for_wallet(restaurant.wallet) async def _publish_restaurant(restaurant: Restaurant) -> None: settings = await get_settings() if not settings.nostr_publish_enabled: return - keypair = await _resolve_signing_keypair(restaurant) - if not keypair: + signer = await _resolve_signer(restaurant) + if signer is None: return - pubkey, prvkey = keypair from . import nostr_client - event = build_restaurant_metadata_event(restaurant, pubkey) - published = await publish_event(nostr_client, event, prvkey) + event = build_restaurant_metadata_event(restaurant, signer.pubkey) + published = await publish_event(nostr_client, event, signer) if published: restaurant.nostr_event_id = published.id restaurant.nostr_event_created_at = published.created_at if not restaurant.nostr_pubkey: # Echo back the resolved pubkey so the row carries it for # discovery (e.g. webapp follows this pubkey). - restaurant.nostr_pubkey = pubkey + restaurant.nostr_pubkey = signer.pubkey await update_restaurant(restaurant) @@ -188,18 +181,17 @@ async def _publish_menu_item(item: MenuItem) -> None: restaurant = await get_restaurant(item.restaurant_id) if not restaurant: return - keypair = await _resolve_signing_keypair(restaurant) - if not keypair: + signer = await _resolve_signer(restaurant) + if signer is None: return - pubkey, prvkey = keypair from . import nostr_client ancestors = await _ancestor_names_for_node(item.node_id) event = build_menu_item_event( - item, restaurant, pubkey, ancestor_names=ancestors + item, restaurant, signer.pubkey, ancestor_names=ancestors ) - published = await publish_event(nostr_client, event, prvkey) + published = await publish_event(nostr_client, event, signer) if published: item.nostr_event_id = published.id item.nostr_event_created_at = published.created_at @@ -213,15 +205,14 @@ async def _publish_menu_item_delete(item: MenuItem) -> None: restaurant = await get_restaurant(item.restaurant_id) if not restaurant: return - keypair = await _resolve_signing_keypair(restaurant) - if not keypair: + signer = await _resolve_signer(restaurant) + if signer is None: return - pubkey, prvkey = keypair from . import nostr_client - event = build_delete_event(30402, item.id, pubkey, "Menu item removed") - await publish_event(nostr_client, event, prvkey) + event = build_delete_event(30402, item.id, signer.pubkey, "Menu item removed") + await publish_event(nostr_client, event, signer) def _require_owner(restaurant: Restaurant, wallet: WalletTypeInfo) -> None: