diff --git a/crud.py b/crud.py index 2fe8453..7bb799b 100644 --- a/crud.py +++ b/crud.py @@ -23,19 +23,16 @@ from .models import ( async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant: merchant_id = urlsafe_short_hash() - # Post-aiolabs/nostrmarket#5: no `private_key` column written. The - # legacy column is dropped by `migrations_fork.m001_aio_drop_private_key` - # for fresh installs and NULL-tolerated for the brief window between - # this code change deploying and the fork-migration running. await db.execute( """ INSERT INTO nostrmarket.merchants - (user_id, id, public_key, meta) - VALUES (:user_id, :id, :public_key, :meta) + (user_id, id, private_key, public_key, meta) + VALUES (:user_id, :id, :private_key, :public_key, :meta) """, { "user_id": user_id, "id": merchant_id, + "private_key": m.private_key, "public_key": m.public_key, "meta": json.dumps(dict(m.config)), }, @@ -58,24 +55,18 @@ async def update_merchant( return await get_merchant(user_id, merchant_id) -async def update_merchant_pubkey( - user_id: str, merchant_id: str, public_key: str +async def update_merchant_keys( + user_id: str, merchant_id: str, private_key: str, public_key: str ) -> Optional[Merchant]: - """Re-point a merchant's identity to a new pubkey (e.g. after the - account migrated to a fresh RemoteBunkerSigner keypair). - - Post-aiolabs/nostrmarket#5: there is no `private_key` column to - update — the merchant pubkey is the only stored identity material, - and the signing nsec lives entirely in the bunker against - `account.id` (== `merchant.user_id`) on the lnbits side. - """ await db.execute( f""" UPDATE nostrmarket.merchants - SET public_key = :public_key, time = {db.timestamp_now} + SET private_key = :private_key, public_key = :public_key, + time = {db.timestamp_now} WHERE id = :id AND user_id = :user_id """, { + "private_key": private_key, "public_key": public_key, "id": merchant_id, "user_id": user_id, diff --git a/helpers.py b/helpers.py index 1bc81b6..35f0d0f 100644 --- a/helpers.py +++ b/helpers.py @@ -1,9 +1,11 @@ +import coincurve from bech32 import bech32_decode, convertbits -# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant -# signing routes through the lnbits `NostrSigner` ABC via -# `services._resolve_merchant_signer(merchant)`. The nsec lives in the -# bunker, never in this process. + +def sign_message_hash(private_key: str, hash_: bytes) -> str: + privkey = coincurve.PrivateKey(bytes.fromhex(private_key)) + sig = privkey.sign_schnorr(hash_) + return sig.hex() def normalize_public_key(pubkey: str) -> str: diff --git a/migrations_fork.py b/migrations_fork.py deleted file mode 100644 index 22bf38e..0000000 --- a/migrations_fork.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -aiolabs fork-migrations for nostrmarket (companion to upstream -`migrations.py`). - -Per the aiolabs/lnbits#8 fork-migrations pattern: every aio-only -schema delta lives in this single squashed function so we never -introduce conflicts in `migrations.py` (which stays byte-identical to -upstream and rebases cleanly). - -The function is loaded by lnbits's patched `migrate_extension_database()` -under the `nostrmarket_fork` namespace in core `dbversions`, with the -following invariants: - - Every ALTER must be idempotent (use `_alter_drop_column_safe`-style - wrappers or `IF NOT EXISTS` / `IF EXISTS` predicates) so re-runs - are no-ops on already-migrated installs. - - Schema changes here MUST NOT depend on the version of upstream's - `migrations.py` they're running against — upstream rebases must - not require this file to be edited. - -See `aiolabs/lnbits#8` for the pattern + `aiolabs/lnbits/core/services/ -signer_migration.py` for the prior art on `_alter_*_safe` helpers. -""" - -from loguru import logger - - -async def _drop_column_safe(db, table: str, column: str) -> None: - """SQLite-safe drop-column. Newer SQLite (3.35+) supports - `ALTER TABLE … DROP COLUMN`; older versions need the classic - create-new-table + copy + swap dance. Postgres handles - `ALTER TABLE … DROP COLUMN IF EXISTS` natively. - - Idempotent: catches "no such column" + "column does not exist" - so re-runs are no-ops. - """ - try: - # Postgres path (supports IF EXISTS natively); also works on - # SQLite ≥ 3.35. - await db.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column};") - return - except Exception as exc: - # SQLite < 3.35 doesn't support IF EXISTS; fall through to the - # bare DROP COLUMN attempt + swallow the not-found case. - msg = str(exc).lower() - if "syntax" not in msg and "if exists" not in msg: - # Something other than the IF-EXISTS unsupported case; surface. - raise - - try: - await db.execute(f"ALTER TABLE {table} DROP COLUMN {column};") - except Exception as exc: - msg = str(exc).lower() - if "no such column" in msg or "does not exist" in msg: - # Already dropped; idempotent skip. - return - raise - - -async def m001_aio_drop_merchant_private_key(db): - """Drop the legacy `nostrmarket.merchants.private_key` column. - - Per aiolabs/nostrmarket#5, the merchant's signing identity is owned - by the lnbits-side account: signing routes through - `resolve_signer(account).sign_event(...)` (which dispatches to - `RemoteBunkerSigner` post-aiolabs/lnbits#18 phase 2.x). The nsec - never lives in this extension's storage. Dropping the column makes - that contract enforced at the schema level rather than relying on - "nobody writes to it anymore." - - Idempotent: re-runs no-op via `_drop_column_safe`. - """ - logger.info( - "[NOSTRMARKET fork] m001: dropping merchants.private_key " - "(aiolabs/nostrmarket#5 — signing routes through lnbits NostrSigner)" - ) - await _drop_column_safe(db, "nostrmarket.merchants", "private_key") - logger.info("[NOSTRMARKET fork] m001: done") diff --git a/models.py b/models.py index 6a4ae3b..c766cc2 100644 --- a/models.py +++ b/models.py @@ -7,6 +7,7 @@ from typing import Any, List, Optional, Tuple from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from pydantic import BaseModel +from .helpers import sign_message_hash from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -51,22 +52,17 @@ class CreateMerchantRequest(BaseModel): class PartialMerchant(BaseModel): + private_key: str public_key: str config: MerchantConfig = MerchantConfig() class Merchant(PartialMerchant, Nostrable): id: str - user_id: str time: Optional[int] = 0 - # NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` / - # `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto - # for a merchant goes through the lnbits `NostrSigner` abstraction - # (`resolve_signer(account)`); merchant is now pure metadata pointing - # at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`) - # holds the merchant's nsec — lnbits never has it server-side. - # See `services._resolve_merchant_signer()` for the resolution helper. + def sign_hash(self, hash_: bytes) -> str: + return sign_message_hash(self.private_key, hash_) @classmethod def from_row(cls, row: dict) -> "Merchant": diff --git a/nostr/nip59.py b/nostr/nip59.py index 19cc718..2283bee 100644 --- a/nostr/nip59.py +++ b/nostr/nip59.py @@ -7,26 +7,6 @@ Three-layer protocol for metadata-protected messaging: 3. Gift Wrap (kind 1059) — encrypts seal with ephemeral key, has recipient p-tag Reference: https://github.com/nostr-protocol/nips/blob/master/59.md - -## Bunker integration (aiolabs/nostrmarket#5) - -Merchant-identity layers (rumor's sender-pubkey + seal's encryption + -seal's signature) route through the lnbits `NostrSigner` abstraction -so the merchant's nsec stays in the bunker — never reaches this -process. Specifically: - -- `create_seal` is async; takes a `sender_signer` instead of a - plaintext nsec. The seal's NIP-44 encrypt + Schnorr sign happen - via `await sender_signer.nip44_encrypt(...)` + - `await sender_signer.sign_event(...)` over the NIP-46 channel. -- `unwrap_gift_wrap` + `unseal` are async; take a `recipient_signer` - and call `await recipient_signer.nip44_decrypt(...)` for each layer. - -The **ephemeral keypair layer** (`create_gift_wrap`) stays synchronous -+ local: the ephemeral nsec exists for the lifetime of one wrap and -provides no merchant-identity capability, so there's no reason to -involve the bunker. Generating it locally avoids one round-trip per -DM. """ import json @@ -49,10 +29,8 @@ def _random_past_timestamp() -> int: return int(time.time()) - secrets.randbelow(TWO_DAYS) -def _sign_event_local(event: NostrEvent, private_key_hex: str) -> NostrEvent: - """Compute event id and sign it locally with a privkey held in this - process. Used only for the ephemeral-keypair layer (gift wrap outer); - merchant-identity sign goes through the signer ABC instead.""" +def _sign_event(event: NostrEvent, private_key_hex: str) -> NostrEvent: + """Compute event id and sign it.""" event.id = event.event_id sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex)) event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex() @@ -88,43 +66,26 @@ def create_rumor( return event -async def create_seal( +def create_seal( rumor: NostrEvent, - sender_signer, + sender_privkey: str, recipient_pubkey: str, ) -> NostrEvent: """ Create a kind 13 seal: encrypts the rumor for the recipient. Signed by the sender. Tags are always empty. - - Both crypto operations (NIP-44 encrypt + Schnorr sign) route - through the sender's `NostrSigner` (`sender_signer`) — the - plaintext nsec is never observable in this process. """ - encrypted_rumor = await sender_signer.nip44_encrypt( - rumor.stringify(), recipient_pubkey - ) + conv_key = get_conversation_key(sender_privkey, recipient_pubkey) + encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key) seal = NostrEvent( - pubkey=sender_signer.pubkey, + pubkey=_pubkey_from_privkey(sender_privkey), created_at=_random_past_timestamp(), kind=13, tags=[], content=encrypted_rumor, ) - # The signer fills id + sig (computed bunker-side). - signed = await sender_signer.sign_event( - { - "pubkey": seal.pubkey, - "created_at": seal.created_at, - "kind": seal.kind, - "tags": seal.tags, - "content": seal.content, - } - ) - seal.id = signed["id"] - seal.sig = signed["sig"] - return seal + return _sign_event(seal, sender_privkey) def create_gift_wrap( @@ -134,11 +95,6 @@ def create_gift_wrap( """ Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key. The only public metadata is the recipient's p-tag. - - Stays synchronous + local: the ephemeral nsec exists only for the - lifetime of one wrap and provides no merchant-identity capability, - so there's no point routing through the bunker (would add one NIP-46 - round-trip per DM with zero security benefit). """ ephemeral_privkey = secrets.token_bytes(32).hex() ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey) @@ -153,35 +109,33 @@ def create_gift_wrap( tags=[["p", recipient_pubkey]], content=encrypted_seal, ) - return _sign_event_local(wrap, ephemeral_privkey) + return _sign_event(wrap, ephemeral_privkey) -async def unwrap_gift_wrap( +def unwrap_gift_wrap( gift_wrap: NostrEvent, - recipient_signer, + recipient_privkey: str, ) -> NostrEvent: """ Decrypt a kind 1059 gift wrap to reveal the inner seal. - Routes NIP-44 decrypt through the recipient's signer abstraction - so the recipient's nsec stays in the bunker. + Uses the recipient's private key and the gift wrap's ephemeral pubkey. """ - seal_json = await recipient_signer.nip44_decrypt( - gift_wrap.content, gift_wrap.pubkey - ) + conv_key = get_conversation_key(recipient_privkey, gift_wrap.pubkey) + seal_json = nip44_decrypt(gift_wrap.content, conv_key) return NostrEvent(**json.loads(seal_json)) -async def unseal( +def unseal( seal: NostrEvent, - recipient_signer, + recipient_privkey: str, ) -> NostrEvent: """ Decrypt a kind 13 seal to reveal the inner rumor. - Uses the recipient signer (their nsec stays in the bunker) and the - seal's pubkey (the sender). Validates that the rumor's pubkey - matches the seal's pubkey. + Uses the recipient's private key and the seal's pubkey (the sender). + Validates that the rumor's pubkey matches the seal's pubkey. """ - rumor_json = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey) + conv_key = get_conversation_key(recipient_privkey, seal.pubkey) + rumor_json = nip44_decrypt(seal.content, conv_key) rumor = NostrEvent(**json.loads(rumor_json)) if rumor.pubkey != seal.pubkey: @@ -195,37 +149,30 @@ async def unseal( # --- Convenience functions --- -async def wrap_message( +def wrap_message( content: str, - sender_signer, + sender_privkey: str, + sender_pubkey: str, recipient_pubkey: str, kind: int = 14, tags: Optional[list[list[str]]] = None, ) -> NostrEvent: """ - Full wrap pipeline: create rumor → seal → gift wrap. + Full wrap pipeline: create rumor -> seal -> gift wrap. Returns the gift wrap event ready to publish. - - `sender_signer` is the sender merchant's `NostrSigner` (post-#5: - always a `RemoteBunkerSigner`). The merchant's nsec never leaves - the bunker. """ - rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags) - seal = await create_seal(rumor, sender_signer, recipient_pubkey) + rumor = create_rumor(sender_pubkey, content, kind=kind, tags=tags) + seal = create_seal(rumor, sender_privkey, recipient_pubkey) return create_gift_wrap(seal, recipient_pubkey) -async def unwrap_message( +def unwrap_message( gift_wrap: NostrEvent, - recipient_signer, + recipient_privkey: str, ) -> NostrEvent: """ - Full unwrap pipeline: gift wrap → seal → rumor. + Full unwrap pipeline: gift wrap -> seal -> rumor. Returns the rumor with sender pubkey and plaintext content. - - `recipient_signer` is the recipient merchant's `NostrSigner`. Both - NIP-44 decrypt layers (gift wrap → seal, seal → rumor) route - through the signer abstraction. """ - seal = await unwrap_gift_wrap(gift_wrap, recipient_signer) - return await unseal(seal, recipient_signer) + seal = unwrap_gift_wrap(gift_wrap, recipient_privkey) + return unseal(seal, recipient_privkey) diff --git a/services.py b/services.py index 444a9d5..f57788c 100644 --- a/services.py +++ b/services.py @@ -3,10 +3,8 @@ import json from typing import List, Optional, Tuple from lnbits.bolt11 import decode -from lnbits.core.crud import get_account, get_wallet +from lnbits.core.crud import get_wallet from lnbits.core.services import create_invoice, websocket_updater -from lnbits.core.signers import resolve_signer -from lnbits.core.signers.base import NostrSigner, SignerError from loguru import logger from . import nostr_client @@ -173,57 +171,15 @@ async def update_merchant_to_nostr( return merchant -async def _resolve_merchant_signer(merchant: Merchant) -> NostrSigner: - """Resolve the lnbits NostrSigner for a merchant's owning account. - - Post-#5 (aiolabs/nostrmarket#5), the merchant's nsec lives in the - bunker via the account's `signer_config`. No fast-path or caching - today — per-call lookup is fine for v1 throughput; if the events - extension or DM hot path becomes contended, revisit with a - process-local cache keyed on `merchant.user_id`. - - Raises `SignerError` if the account can't be found or its signer - can't be resolved — callers should propagate, not silently skip, - so misconfigured rows surface loudly. - """ - account = await get_account(merchant.user_id) - if account is None: - raise SignerError( - f"merchant {merchant.id[:8]} references missing account " - f"{merchant.user_id[:8]} — can't resolve signer" - ) - return resolve_signer(account) - - async def sign_and_send_to_nostr( merchant: Merchant, n: Nostrable, delete=False ) -> NostrEvent: - """Sign + publish a Nostrable as the merchant's identity. - - Signing routes through the merchant's account `NostrSigner` (post-#5). - The signer fills `id` + `sig` server-side (bunker for the - `RemoteBunkerSigner` case) — this function builds the unsigned dict - shape, hands it to the signer, and copies the result back onto the - `NostrEvent` instance for the publisher. - """ event = ( n.to_nostr_delete_event(merchant.public_key) if delete else n.to_nostr_event(merchant.public_key) ) - - signer = await _resolve_merchant_signer(merchant) - signed = await signer.sign_event( - { - "pubkey": event.pubkey, - "created_at": event.created_at, - "kind": event.kind, - "tags": event.tags, - "content": event.content, - } - ) - event.id = signed["id"] - event.sig = signed["sig"] + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) await nostr_client.publish_nostr_event(event) return event @@ -233,6 +189,7 @@ async def provision_merchant( user_id: str, wallet_id: str, public_key: str, + private_key: str, display_name: Optional[str] = None, config: Optional[MerchantConfig] = None, ) -> Merchant: @@ -240,13 +197,6 @@ async def provision_merchant( Provision a merchant with a default shipping zone and default stall, and publish the stall to Nostr relays. - Post-aiolabs/nostrmarket#5: no `private_key` argument. The merchant - identity IS the lnbits account's identity (`public_key` parameter - must equal `account.pubkey` for the same `user_id`); signing routes - through the account's `NostrSigner` (`RemoteBunkerSigner` in the - target deployment). The merchant nsec lives in the bunker, never - server-side. - Single source of truth used by: - LNbits user-creation hook (eager, on signup) — see lnbits/core/services/users.py:_create_default_merchant @@ -261,6 +211,7 @@ async def provision_merchant( return existing partial_merchant = PartialMerchant( + private_key=private_key, public_key=public_key, config=config or MerchantConfig(), ) @@ -275,11 +226,10 @@ async def provision_merchant( ) await create_zone(merchant.id, online_zone) - raw_owner_name = display_name or "My" - owner_name = raw_owner_name[:1].upper() + raw_owner_name[1:] + name = display_name or "My" default_stall = Stall( wallet=wallet_id, - name=f"{owner_name}'s Store", + name=f"{name}'s Store", currency="sat", shipping_zones=[online_zone], ) @@ -288,57 +238,18 @@ async def provision_merchant( # Publish the kind 30017 stall event so customers' clients can resolve # the stall name when they fetch products. Non-fatal on failure: a # later product publish (or webapp self-heal) will retry. - # - # Fire-and-forget: `nostr_client.publish_nostr_event` has no per-relay - # deadline and will block indefinitely if every configured relay is - # unreachable (cf. aiolabs/nostrmarket#7). When `provision_merchant` - # is called from the eager signup hook (lnbits/core/services/users.py - # ::_create_default_merchant, aiolabs/lnbits#46), inline-awaiting that - # publish hangs the uvicorn worker on `POST /auth/register` forever. - # The DB rows we just wrote are sufficient to serve the wallet UI; - # the stall event_id gets backfilled when the publish completes (or - # stays NULL until a later resubscribe-driven republish lands it). - asyncio.create_task( - _publish_default_stall_background(merchant.id, merchant, default_stall) - ) - - return merchant - - -# Generous bound: signing through the bunker can take 1–2 s on a cold -# session, plus the relay publish itself. 30 s is well over both, and -# the cap matters only when the relay set is unreachable. -STALL_PUBLISH_TIMEOUT_S = 30.0 - - -async def _publish_default_stall_background( - merchant_id: str, merchant: Merchant, default_stall: Stall -) -> None: - """Background helper for `provision_merchant`'s default-stall publish. - - Bounded by `STALL_PUBLISH_TIMEOUT_S` so even a permanently-unreachable - relay set doesn't pin an asyncio task forever. Errors and timeouts are - logged at warning — never raised, since the caller scheduled-and-forgot. - """ try: - stall_event = await asyncio.wait_for( - sign_and_send_to_nostr(merchant, default_stall), - timeout=STALL_PUBLISH_TIMEOUT_S, - ) + stall_event = await sign_and_send_to_nostr(merchant, default_stall) default_stall.event_id = stall_event.id - await update_stall(merchant_id, default_stall) - except asyncio.TimeoutError: - logger.warning( - f"[NOSTRMARKET] Default stall publish for merchant " - f"{merchant_id} timed out after {STALL_PUBLISH_TIMEOUT_S}s; " - f"event_id stays NULL until a later republish lands it" - ) + await update_stall(merchant.id, default_stall) except Exception as ex: logger.warning( f"[NOSTRMARKET] Failed to publish default stall for " - f"merchant {merchant_id}: {ex}" + f"merchant {merchant.id}: {ex}" ) + return merchant + async def handle_order_paid(order_id: str, merchant_pubkey: str): try: @@ -432,15 +343,11 @@ async def send_dm( type_: int, dm_content: str, ) -> DirectMessage: - # Post-#5: nsec stays in the bunker; both the to-recipient wrap and - # the to-self archival wrap route their seal-layer crypto through - # the merchant's NostrSigner. - signer = await _resolve_merchant_signer(merchant) - # Wrap message to recipient via NIP-59 gift wrap - gift_wrap = await wrap_message( + gift_wrap = wrap_message( dm_content, - signer, + merchant.private_key, + merchant.public_key, other_pubkey, ) @@ -456,9 +363,10 @@ async def send_dm( await nostr_client.publish_nostr_event(gift_wrap) # Also wrap a copy to self for archival - self_wrap = await wrap_message( + self_wrap = wrap_message( dm_content, - signer, + merchant.private_key, + merchant.public_key, merchant.public_key, ) await nostr_client.publish_nostr_event(self_wrap) @@ -633,8 +541,7 @@ async def _handle_gift_wrap(event: NostrEvent): return try: - recipient_signer = await _resolve_merchant_signer(merchant) - rumor = await unwrap_message(event, recipient_signer) + rumor = unwrap_message(event, merchant.private_key) except Exception as ex: logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}") return @@ -750,10 +657,10 @@ async def _persist_dm( async def reply_to_structured_dm( merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str ): - signer = await _resolve_merchant_signer(merchant) - gift_wrap = await wrap_message( + gift_wrap = wrap_message( dm_reply, - signer, + merchant.private_key, + merchant.public_key, customer_pubkey, ) dm = PartialDirectMessage( diff --git a/tests/test_nip59.py b/tests/test_nip59.py index 5751990..e518abf 100644 --- a/tests/test_nip59.py +++ b/tests/test_nip59.py @@ -1,12 +1,4 @@ -"""Tests for NIP-59 gift wrap protocol. - -Post-aiolabs/nostrmarket#5: the merchant-identity crypto operations -(`create_seal`, `unseal`, `unwrap_gift_wrap`, `wrap_message`, -`unwrap_message`) are async + take a `NostrSigner`-shaped object -instead of a raw privkey. These tests use a local-privkey-backed -fake signer so the NIP-59 plumbing can be tested in isolation — -the real runtime uses `RemoteBunkerSigner` against nsecbunkerd. -""" +"""Tests for NIP-59 gift wrap protocol.""" import json import time @@ -14,10 +6,6 @@ import time import coincurve import pytest -from nostr.event import NostrEvent -from nostr.nip44 import decrypt as _nip44_decrypt -from nostr.nip44 import encrypt as _nip44_encrypt -from nostr.nip44 import get_conversation_key from nostr.nip59 import ( create_gift_wrap, create_rumor, @@ -37,48 +25,8 @@ def _generate_keypair() -> tuple[str, str]: return privkey, pubkey -class _LocalSignerStub: - """Stand-in for the lnbits `NostrSigner` ABC backed by a held privkey. - - Provides just the surface the NIP-59 functions touch: - `pubkey`, `nip44_encrypt`, `nip44_decrypt`, `sign_event`. Useful for - unit-testing the NIP-59 plumbing without involving a bunker — the - crypto is identical, only the dispatch boundary differs. - """ - - def __init__(self, privkey_hex: str): - self._privkey = privkey_hex - sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex)) - self.pubkey = sk.public_key.format(compressed=True)[1:].hex() - - async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str: - return _nip44_encrypt( - plaintext, get_conversation_key(self._privkey, peer_pubkey_hex) - ) - - async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str: - return _nip44_decrypt( - ciphertext, get_conversation_key(self._privkey, peer_pubkey_hex) - ) - - async def sign_event(self, unsigned: dict) -> dict: - evt = NostrEvent( - pubkey=unsigned["pubkey"], - created_at=unsigned["created_at"], - kind=unsigned["kind"], - tags=unsigned["tags"], - content=unsigned["content"], - ) - evt.id = evt.event_id - sk = coincurve.PrivateKey(bytes.fromhex(self._privkey)) - sig = sk.sign_schnorr(bytes.fromhex(evt.id)).hex() - return {**unsigned, "id": evt.id, "sig": sig} - - SENDER_PRIV, SENDER_PUB = _generate_keypair() RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair() -SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV) -RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV) class TestCreateRumor: @@ -101,32 +49,28 @@ class TestCreateRumor: class TestCreateSeal: - @pytest.mark.asyncio - async def test_kind_13_with_empty_tags(self): + def test_kind_13_with_empty_tags(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) assert seal.kind == 13 assert seal.tags == [] assert seal.pubkey == SENDER_PUB - @pytest.mark.asyncio - async def test_is_signed(self): + def test_is_signed(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) assert seal.sig is not None assert len(seal.sig) == 128 # 64 bytes hex - @pytest.mark.asyncio - async def test_content_is_encrypted(self): + def test_content_is_encrypted(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) # Content should be base64 NIP-44 payload, not plaintext JSON assert "hello" not in seal.content - @pytest.mark.asyncio - async def test_timestamp_is_randomized(self): + def test_timestamp_is_randomized(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) now = int(time.time()) # Seal timestamp should be in the past (up to 2 days) assert seal.created_at <= now @@ -134,108 +78,98 @@ class TestCreateSeal: class TestCreateGiftWrap: - @pytest.mark.asyncio - async def test_kind_1059_with_p_tag(self): + def test_kind_1059_with_p_tag(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB) assert wrap.kind == 1059 assert ["p", RECIPIENT_PUB] in wrap.tags - @pytest.mark.asyncio - async def test_uses_ephemeral_key(self): + def test_uses_ephemeral_key(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB) # Gift wrap pubkey should be neither sender nor recipient assert wrap.pubkey != SENDER_PUB assert wrap.pubkey != RECIPIENT_PUB - @pytest.mark.asyncio - async def test_different_wraps_have_different_ephemeral_keys(self): + def test_different_wraps_have_different_ephemeral_keys(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) wrap1 = create_gift_wrap(seal, RECIPIENT_PUB) wrap2 = create_gift_wrap(seal, RECIPIENT_PUB) assert wrap1.pubkey != wrap2.pubkey class TestUnwrap: - @pytest.mark.asyncio - async def test_unwrap_gift_wrap_returns_seal(self): + def test_unwrap_gift_wrap_returns_seal(self): rumor = create_rumor(SENDER_PUB, "hello") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB) - recovered_seal = await unwrap_gift_wrap(wrap, RECIPIENT_SIGNER) + recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV) assert recovered_seal.kind == 13 assert recovered_seal.pubkey == SENDER_PUB - @pytest.mark.asyncio - async def test_unseal_returns_rumor(self): + def test_unseal_returns_rumor(self): rumor = create_rumor(SENDER_PUB, "hello world") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) - recovered_rumor = await unseal(seal, RECIPIENT_SIGNER) + recovered_rumor = unseal(seal, RECIPIENT_PRIV) assert recovered_rumor.content == "hello world" assert recovered_rumor.pubkey == SENDER_PUB assert recovered_rumor.kind == 14 - @pytest.mark.asyncio - async def test_wrong_key_fails(self): + def test_wrong_key_fails(self): rumor = create_rumor(SENDER_PUB, "secret") - seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB) + seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB) wrap = create_gift_wrap(seal, RECIPIENT_PUB) wrong_priv, _ = _generate_keypair() - wrong_signer = _LocalSignerStub(wrong_priv) with pytest.raises(Exception): - await unwrap_message(wrap, wrong_signer) + unwrap_message(wrap, wrong_priv) class TestFullRoundTrip: - @pytest.mark.asyncio - async def test_wrap_unwrap_message(self): + def test_wrap_unwrap_message(self): content = "Are you going to the party tonight?" - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) + wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB) assert wrap.kind == 1059 assert ["p", RECIPIENT_PUB] in wrap.tags - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) + rumor = unwrap_message(wrap, RECIPIENT_PRIV) assert rumor.content == content assert rumor.pubkey == SENDER_PUB assert rumor.kind == 14 assert rumor.sig is None - @pytest.mark.asyncio - async def test_wrap_with_custom_kind_and_tags(self): + def test_wrap_with_custom_kind_and_tags(self): tags = [["p", RECIPIENT_PUB], ["subject", "test"]] - wrap = await wrap_message( + wrap = wrap_message( "order data", - SENDER_SIGNER, + SENDER_PRIV, + SENDER_PUB, RECIPIENT_PUB, kind=14, tags=tags, ) - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) + rumor = unwrap_message(wrap, RECIPIENT_PRIV) assert rumor.content == "order data" assert rumor.kind == 14 assert ["subject", "test"] in rumor.tags - @pytest.mark.asyncio - async def test_self_wrap_for_archival(self): + def test_self_wrap_for_archival(self): """Merchant wraps a copy to self (same sender and recipient).""" content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}' - wrap = await wrap_message(content, SENDER_SIGNER, SENDER_PUB) + wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB) - rumor = await unwrap_message(wrap, SENDER_SIGNER) + rumor = unwrap_message(wrap, SENDER_PRIV) assert rumor.content == content assert rumor.pubkey == SENDER_PUB - @pytest.mark.asyncio - async def test_json_content_preserved(self): + def test_json_content_preserved(self): """Order JSON payloads survive the wrap/unwrap cycle.""" order = { "type": 0, @@ -244,15 +178,14 @@ class TestFullRoundTrip: "shipping_id": "zone-1", } content = json.dumps(order) - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) + wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB) - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) + rumor = unwrap_message(wrap, RECIPIENT_PRIV) recovered_order = json.loads(rumor.content) assert recovered_order == order - @pytest.mark.asyncio - async def test_unicode_content(self): + def test_unicode_content(self): content = "Payment received! \u2705 Your order is being processed \U0001f4e6" - wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB) - rumor = await unwrap_message(wrap, RECIPIENT_SIGNER) + wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB) + rumor = unwrap_message(wrap, RECIPIENT_PRIV) assert rumor.content == content diff --git a/views_api.py b/views_api.py index 550cca1..9e0ce8b 100644 --- a/views_api.py +++ b/views_api.py @@ -38,7 +38,7 @@ from .crud import ( get_last_direct_messages_time, get_merchant_by_pubkey, get_merchant_for_user, - update_merchant_pubkey, + update_merchant_keys, get_order, get_order_by_event_id, get_orders, @@ -100,28 +100,38 @@ async def _auto_create_merchant( ) -> Merchant: """ Lazy fallback: provision a merchant from the user's account keypair when - the LNbits-side eager provisioning didn't run (e.g., older accounts, or - upstream LNbits without our signup hook). + the LNbits-side eager provisioning didn't run. - Post-aiolabs/nostrmarket#5: the merchant identity IS the lnbits - account identity. No `private_key` is read here — signing routes - through the account's `NostrSigner` (which holds a - `RemoteBunkerSigner` in our target deployment, with the nsec - living entirely in the bunker). The only precondition is that the - account already has a `pubkey` — every post-#9 account does, since - `create_account` provisions one via the bunker on signup. + Delegates to services.provision_merchant — the canonical implementation. + + Pre-cascade bridge state (see aiolabs/nostrmarket#5): + After aiolabs/lnbits#17 m002 lands, `accounts.prvkey` is fail-closed + NULL'd for migrated accounts (the cleartext nsec lives encrypted in + `signer_config`, owned by the core signer abstraction). Auto-provision + cannot extract that cleartext to copy into `merchants.private_key`, + so this path fails-closed when prvkey is missing. The proper fix is + phase A (envelope-encrypt `merchants.private_key` → `signer_blob`) + followed by phase B (route `Merchant.sign_hash` through core's + `NostrSigner`) per aiolabs/nostrmarket#5. Until then, migrated + accounts must explicitly provision a merchant through the future + phase-A-aware flow. + + The previous regenerate-and-write-back block (generated a fresh + keypair and stored it into the account) was removed because it + would silently undo m002's NULL'ing. """ account = await get_account(wallet.wallet.user) assert account, "User account not found" - assert account.pubkey, ( - "Account has no Nostr pubkey — must be migrated to RemoteBunkerSigner " - "before a merchant can be provisioned (see aiolabs/nostrmarket#5)" + assert account.pubkey and account.prvkey, ( + "Account has no plaintext Nostr keypair available for merchant " + "provisioning (see aiolabs/nostrmarket#5 for the phase A/B fix)" ) merchant = await provision_merchant( user_id=wallet.wallet.user, wallet_id=wallet.wallet.id, public_key=account.pubkey, + private_key=account.prvkey, display_name=account.username, config=config, ) @@ -245,7 +255,14 @@ async def api_migrate_merchant_keys( assert merchant.id == merchant_id, "Wrong merchant ID" account = await get_account(wallet.wallet.user) - assert account and account.pubkey, "Account has no Nostr pubkey" + # account.prvkey is fail-closed NULL'd by aiolabs/lnbits#17 m002 + # for migrated accounts. Rotation cannot copy a cleartext nsec + # into merchants.private_key until phase A lands — see + # aiolabs/nostrmarket#5 for the migration plan. + assert account and account.pubkey and account.prvkey, ( + "Account has no plaintext Nostr keypair available for key " + "rotation (see aiolabs/nostrmarket#5 for the phase A/B fix)" + ) if account.pubkey == merchant.public_key: return merchant # already in sync @@ -258,11 +275,10 @@ async def api_migrate_merchant_keys( old_pubkey = merchant.public_key - # Post-aiolabs/nostrmarket#5: re-pointing only the pubkey; the - # signing nsec lives in the bunker and is keyed on account.id, - # which is unchanged. No private_key column to update. - merchant = await update_merchant_pubkey( - wallet.wallet.user, merchant.id, account.pubkey, + # Update merchant keys in DB + merchant = await update_merchant_keys( + wallet.wallet.user, merchant.id, + account.prvkey, account.pubkey, ) assert merchant