Compare commits

..

No commits in common. "main" and "v1.1.0-aio.2" have entirely different histories.

9 changed files with 134 additions and 437 deletions

25
crud.py
View file

@ -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,

View file

@ -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:

View file

@ -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")

View file

@ -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":

View file

@ -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)

View file

@ -152,13 +152,10 @@ class NostrClient:
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
# NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants.
# With gift wrapping, outgoing messages are self-wrapped (same p-tag filter).
#
# Do NOT apply `since` here. Per NIP-59, gift wraps use randomized past
# timestamps (up to 2 days back) to defeat metadata correlation, so a
# `since` derived from the latest DM in our DB will reject fresh wraps
# whose randomized created_at is older than that window. Server-side
# dedup + the client's is_duplicate_event() guard handle replays.
gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys}
if since and since != 0:
gift_wrap_filter["since"] = since
return [gift_wrap_filter]
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:

View file

@ -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 12 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(

View file

@ -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

View file

@ -4,7 +4,7 @@ from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
from lnbits.core.crud import get_account
from lnbits.core.crud import get_account, update_account
from lnbits.core.services import websocket_updater
from lnbits.decorators import (
WalletTypeInfo,
@ -12,6 +12,7 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies
from lnbits.utils.nostr import generate_keypair
from loguru import logger
from . import nostr_client, nostrmarket_ext
@ -38,7 +39,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,
@ -103,25 +104,24 @@ async def _auto_create_merchant(
the LNbits-side eager provisioning didn't run (e.g., older accounts, or
upstream LNbits without our signup hook).
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.
"""
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)"
)
# In our fork, accounts always have keypairs. Generate as fallback only
# if somehow missing (e.g., upstream LNbits where this isn't auto-set).
if not account.pubkey or not account.prvkey:
private_key, public_key = generate_keypair()
account.pubkey = public_key
account.prvkey = private_key
await update_account(account)
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 +245,9 @@ 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"
assert account and account.pubkey and account.prvkey, (
"Account has no Nostr keypair"
)
if account.pubkey == merchant.public_key:
return merchant # already in sync
@ -258,11 +260,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