Replace NIP-04 messaging with NIP-17 (NIP-44 + NIP-59 gift wrapping)

Modernize the entire customer-merchant communication layer from deprecated
NIP-04 (kind 4, AES-256-CBC) to NIP-17 private direct messages using
NIP-44 v2 encryption (ChaCha20 + HMAC-SHA256) and NIP-59 gift wrapping
(rumor/seal/gift-wrap protocol). No backwards compatibility retained.

New modules:
- nostr/nip44.py: NIP-44 v2 encryption verified against official spec vectors
- nostr/nip59.py: NIP-59 gift wrap with wrap/unwrap convenience functions
- tests/: 44 unit tests for NIP-44 and NIP-59

Key changes:
- Subscription filters: kind 4 → kind 1059 gift wraps
- Message handler: _handle_nip04_message → _handle_gift_wrap (unwrap + route)
- send_dm/reply_to_structured_dm: NIP-59 gift wrap to recipient + self-archive
- Merchant model: removed NIP-04 crypto methods (decrypt/encrypt/build_dm_event)
- helpers.py: removed NIP-04 functions, kept Schnorr signing + key normalization
- views_api.py: consolidated DM sending through send_dm() service function

Reliability improvements:
- Event deduplication via bounded LRU set in NostrClient
- Subscription health monitor (resubscribes after 120s of silence)
- Preserved 5-minute lenient time window from prior work

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-27 08:16:55 +02:00
commit 725944ae9c
13 changed files with 869 additions and 165 deletions

View file

@ -27,7 +27,11 @@ def nostrmarket_renderer():
nostr_client: NostrClient = NostrClient() nostr_client: NostrClient = NostrClient()
from .tasks import wait_for_nostr_events, wait_for_paid_invoices # noqa from .tasks import ( # noqa
subscription_health_monitor,
wait_for_nostr_events,
wait_for_paid_invoices,
)
from .views import * # noqa from .views import * # noqa
from .views_api import * # noqa from .views_api import * # noqa
@ -65,4 +69,13 @@ def nostrmarket_start():
task3 = create_permanent_unique_task( task3 = create_permanent_unique_task(
"ext_nostrmarket_wait_for_events", _wait_for_nostr_events "ext_nostrmarket_wait_for_events", _wait_for_nostr_events
) )
scheduled_tasks.extend([task1, task2, task3])
async def _health_monitor():
# start after the subscription is active
await asyncio.sleep(20)
await subscription_health_monitor(nostr_client)
task4 = create_permanent_unique_task(
"ext_nostrmarket_health_monitor", _health_monitor
)
scheduled_tasks.extend([task1, task2, task3, task4])

View file

@ -5,6 +5,6 @@ Its functions include:
- Managing products, sales, and customer communication as a merchant - Managing products, sales, and customer communication as a merchant
- Browsing and ordering products as a customer - Browsing and ordering products as a customer
- Tracking order status and delivery - Tracking order status and delivery
- Communicating via NIP-04 encrypted direct messages - Communicating via NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping)
A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication. A decentralized commerce solution for merchants and buyers who want to trade goods and services over Nostr with end-to-end encrypted communication.

View file

@ -1,55 +1,5 @@
import base64
import secrets
from typing import Optional
import coincurve import coincurve
from bech32 import bech32_decode, convertbits from bech32 import bech32_decode, convertbits
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def get_shared_secret(privkey: str, pubkey: str):
pk = coincurve.PublicKey(bytes.fromhex("02" + pubkey))
sk = coincurve.PrivateKey(bytes.fromhex(privkey))
shared_point = pk.multiply(sk.secret)
shared_point_bytes = shared_point.format(compressed=False)
x_coord = shared_point_bytes[1:33]
return x_coord
def decrypt_message(encoded_message: str, encryption_key) -> str:
encoded_data = encoded_message.split("?iv=")
if len(encoded_data) == 1:
return encoded_data[0]
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
iv = base64.b64decode(encoded_iv)
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
encrypted_content = base64.b64decode(encoded_content)
decryptor = cipher.decryptor()
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
return unpadded_data.decode()
def encrypt_message(message: str, encryption_key, iv: Optional[bytes] = None) -> str:
padder = padding.PKCS7(128).padder()
padded_data = padder.update(message.encode()) + padder.finalize()
iv = iv if iv else secrets.token_bytes(16)
cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv))
encryptor = cipher.encryptor()
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
base64_message = base64.b64encode(encrypted_message).decode()
base64_iv = base64.b64encode(iv).decode()
return f"{base64_message}?iv={base64_iv}"
def sign_message_hash(private_key: str, hash_: bytes) -> str: def sign_message_hash(private_key: str, hash_: bytes) -> str:
@ -58,17 +8,6 @@ def sign_message_hash(private_key: str, hash_: bytes) -> str:
return sig.hex() return sig.hex()
def test_decrypt_encrypt(encoded_message: str, encryption_key):
msg = decrypt_message(encoded_message, encryption_key)
# ecrypt using the same initialisation vector
iv = base64.b64decode(encoded_message.split("?iv=")[1])
ecrypted_msg = encrypt_message(msg, encryption_key, iv)
assert (
encoded_message == ecrypted_msg
), f"expected '{encoded_message}', but got '{ecrypted_msg}'"
def normalize_public_key(pubkey: str) -> str: def normalize_public_key(pubkey: str) -> str:
if pubkey.startswith("npub1"): if pubkey.startswith("npub1"):
_, decoded_data = bech32_decode(pubkey) _, decoded_data = bech32_decode(pubkey)

View file

@ -7,12 +7,7 @@ from typing import Any, List, Optional, Tuple
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
from pydantic import BaseModel from pydantic import BaseModel
from .helpers import ( from .helpers import sign_message_hash
decrypt_message,
encrypt_message,
get_shared_secret,
sign_message_hash,
)
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
######################################## NOSTR ######################################## ######################################## NOSTR ########################################
@ -67,28 +62,6 @@ class Merchant(PartialMerchant, Nostrable):
def sign_hash(self, hash_: bytes) -> str: def sign_hash(self, hash_: bytes) -> str:
return sign_message_hash(self.private_key, hash_) return sign_message_hash(self.private_key, hash_)
def decrypt_message(self, encrypted_message: str, public_key: str) -> str:
encryption_key = get_shared_secret(self.private_key, public_key)
return decrypt_message(encrypted_message, encryption_key)
def encrypt_message(self, clear_text_message: str, public_key: str) -> str:
encryption_key = get_shared_secret(self.private_key, public_key)
return encrypt_message(clear_text_message, encryption_key)
def build_dm_event(self, message: str, to_pubkey: str) -> NostrEvent:
content = self.encrypt_message(message, to_pubkey)
event = NostrEvent(
pubkey=self.public_key,
created_at=round(time.time()),
kind=4,
tags=[["p", to_pubkey]],
content=content,
)
event.id = event.event_id
event.sig = self.sign_hash(bytes.fromhex(event.id))
return event
@classmethod @classmethod
def from_row(cls, row: dict) -> "Merchant": def from_row(cls, row: dict) -> "Merchant":
merchant = cls(**row) merchant = cls(**row)

180
nostr/nip44.py Normal file
View file

@ -0,0 +1,180 @@
"""
NIP-44 v2: Encrypted Payloads (Versioned)
secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256, base64
Reference: https://github.com/nostr-protocol/nips/blob/master/44.md
"""
import base64
import hashlib
import hmac
import math
import secrets
import struct
import coincurve
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
from cryptography.hazmat.primitives import hashes
VERSION = 2
MIN_PLAINTEXT_SIZE = 1
MAX_PLAINTEXT_SIZE = 65535
def get_conversation_key(private_key_hex: str, public_key_hex: str) -> bytes:
"""
Calculate long-term conversation key between two users via ECDH + HKDF-extract.
Symmetric: get_conversation_key(a, B) == get_conversation_key(b, A)
"""
pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
shared_point = pk.multiply(sk.secret)
shared_x = shared_point.format(compressed=False)[1:33]
# HKDF-extract only (not expand) with salt='nip44-v2'
conversation_key = hmac.new(b"nip44-v2", shared_x, hashlib.sha256).digest()
return conversation_key
def get_message_keys(
conversation_key: bytes, nonce: bytes
) -> tuple[bytes, bytes, bytes]:
"""
Derive per-message keys from conversation_key and nonce using HKDF-expand.
Returns (chacha_key, chacha_nonce, hmac_key).
"""
if len(conversation_key) != 32:
raise ValueError("invalid conversation_key length")
if len(nonce) != 32:
raise ValueError("invalid nonce length")
keys = HKDFExpand(
algorithm=hashes.SHA256(),
length=76,
info=nonce,
).derive(conversation_key)
chacha_key = keys[0:32]
chacha_nonce = keys[32:44]
hmac_key = keys[44:76]
return chacha_key, chacha_nonce, hmac_key
def calc_padded_len(unpadded_len: int) -> int:
"""Calculate padded length using power-of-two chunking."""
if unpadded_len <= 0:
raise ValueError("invalid plaintext length")
if unpadded_len <= 32:
return 32
next_power = 1 << (math.floor(math.log2(unpadded_len - 1)) + 1)
if next_power <= 256:
chunk = 32
else:
chunk = next_power // 8
return chunk * (math.floor((unpadded_len - 1) / chunk) + 1)
def _pad(plaintext: str) -> bytes:
"""Convert plaintext string to padded byte array."""
unpadded = plaintext.encode("utf-8")
unpadded_len = len(unpadded)
if unpadded_len < MIN_PLAINTEXT_SIZE or unpadded_len > MAX_PLAINTEXT_SIZE:
raise ValueError(
f"invalid plaintext length: {unpadded_len} "
f"(must be {MIN_PLAINTEXT_SIZE}..{MAX_PLAINTEXT_SIZE})"
)
prefix = struct.pack(">H", unpadded_len)
padded_len = calc_padded_len(unpadded_len)
suffix = b"\x00" * (padded_len - unpadded_len)
return prefix + unpadded + suffix
def _unpad(padded: bytes) -> str:
"""Convert padded byte array back to plaintext string."""
unpadded_len = struct.unpack(">H", padded[0:2])[0]
unpadded = padded[2 : 2 + unpadded_len]
if (
unpadded_len == 0
or len(unpadded) != unpadded_len
or len(padded) != 2 + calc_padded_len(unpadded_len)
):
raise ValueError("invalid padding")
return unpadded.decode("utf-8")
def _hmac_aad(key: bytes, message: bytes, aad: bytes) -> bytes:
"""HMAC-SHA256 with AAD: hmac(key, aad || message)."""
if len(aad) != 32:
raise ValueError("AAD associated data must be 32 bytes")
return hmac.new(key, aad + message, hashlib.sha256).digest()
def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
"""ChaCha20 encrypt/decrypt with initial counter = 0."""
# cryptography's ChaCha20 takes a 16-byte nonce: 4-byte counter (LE) + 12-byte nonce
full_nonce = b"\x00\x00\x00\x00" + nonce
cipher = Cipher(algorithms.ChaCha20(key, full_nonce), mode=None)
encryptor = cipher.encryptor()
return encryptor.update(data) + encryptor.finalize()
def _decode_payload(payload: str) -> tuple[bytes, bytes, bytes]:
"""Decode base64 payload into (nonce, ciphertext, mac)."""
plen = len(payload)
if plen == 0 or payload[0] == "#":
raise ValueError("unknown version")
if plen < 132 or plen > 87472:
raise ValueError("invalid payload size")
data = base64.b64decode(payload)
dlen = len(data)
if dlen < 99 or dlen > 65603:
raise ValueError("invalid data size")
vers = data[0]
if vers != VERSION:
raise ValueError(f"unknown version {vers}")
nonce = data[1:33]
ciphertext = data[33 : dlen - 32]
mac = data[dlen - 32 : dlen]
return nonce, ciphertext, mac
def encrypt(
plaintext: str,
conversation_key: bytes,
nonce: bytes | None = None,
) -> str:
"""
Encrypt plaintext using NIP-44 v2.
Returns base64-encoded payload.
"""
if nonce is None:
nonce = secrets.token_bytes(32)
if len(nonce) != 32:
raise ValueError("invalid nonce length")
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
padded = _pad(plaintext)
ciphertext = _chacha20(chacha_key, chacha_nonce, padded)
mac = _hmac_aad(hmac_key, ciphertext, nonce)
return base64.b64encode(
struct.pack("B", VERSION) + nonce + ciphertext + mac
).decode("ascii")
def decrypt(payload: str, conversation_key: bytes) -> str:
"""
Decrypt a NIP-44 v2 base64 payload.
Returns plaintext string.
"""
nonce, ciphertext, mac = _decode_payload(payload)
chacha_key, chacha_nonce, hmac_key = get_message_keys(conversation_key, nonce)
calculated_mac = _hmac_aad(hmac_key, ciphertext, nonce)
if not hmac.compare_digest(calculated_mac, mac):
raise ValueError("invalid MAC")
padded_plaintext = _chacha20(chacha_key, chacha_nonce, ciphertext)
return _unpad(padded_plaintext)

178
nostr/nip59.py Normal file
View file

@ -0,0 +1,178 @@
"""
NIP-59: Gift Wrap
Three-layer protocol for metadata-protected messaging:
1. Rumor (unsigned event) carries content, deniable if leaked
2. Seal (kind 13) encrypts rumor, signed by author, no recipient metadata
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
"""
import json
import secrets
import time
from typing import Optional
import coincurve
from .event import NostrEvent
from .nip44 import decrypt as nip44_decrypt
from .nip44 import encrypt as nip44_encrypt
from .nip44 import get_conversation_key
TWO_DAYS = 2 * 24 * 60 * 60
def _random_past_timestamp() -> int:
"""Generate a timestamp randomly in the past 0-2 days for metadata protection."""
return int(time.time()) - secrets.randbelow(TWO_DAYS)
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()
return event
def _pubkey_from_privkey(private_key_hex: str) -> str:
"""Derive x-only public key hex from private key hex."""
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
return sk.public_key.format(compressed=True)[1:].hex()
def create_rumor(
pubkey: str,
content: str,
kind: int = 14,
tags: Optional[list[list[str]]] = None,
created_at: Optional[int] = None,
) -> NostrEvent:
"""
Create an unsigned rumor event.
The event has an id but no signature, making it deniable.
"""
event = NostrEvent(
pubkey=pubkey,
created_at=created_at or int(time.time()),
kind=kind,
tags=tags or [],
content=content,
)
event.id = event.event_id
# sig intentionally left as None (unsigned)
return event
def create_seal(
rumor: NostrEvent,
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.
"""
conv_key = get_conversation_key(sender_privkey, recipient_pubkey)
encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key)
seal = NostrEvent(
pubkey=_pubkey_from_privkey(sender_privkey),
created_at=_random_past_timestamp(),
kind=13,
tags=[],
content=encrypted_rumor,
)
return _sign_event(seal, sender_privkey)
def create_gift_wrap(
seal: NostrEvent,
recipient_pubkey: str,
) -> NostrEvent:
"""
Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key.
The only public metadata is the recipient's p-tag.
"""
ephemeral_privkey = secrets.token_bytes(32).hex()
ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
conv_key = get_conversation_key(ephemeral_privkey, recipient_pubkey)
encrypted_seal = nip44_encrypt(seal.stringify(), conv_key)
wrap = NostrEvent(
pubkey=ephemeral_pubkey,
created_at=_random_past_timestamp(),
kind=1059,
tags=[["p", recipient_pubkey]],
content=encrypted_seal,
)
return _sign_event(wrap, ephemeral_privkey)
def unwrap_gift_wrap(
gift_wrap: NostrEvent,
recipient_privkey: str,
) -> NostrEvent:
"""
Decrypt a kind 1059 gift wrap to reveal the inner seal.
Uses the recipient's private key and the gift wrap's ephemeral 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))
def unseal(
seal: NostrEvent,
recipient_privkey: str,
) -> NostrEvent:
"""
Decrypt a kind 13 seal to reveal the inner rumor.
Uses the recipient's private key and the seal's pubkey (the sender).
Validates that the rumor's pubkey matches the seal's 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:
raise ValueError(
f"rumor pubkey ({rumor.pubkey}) does not match "
f"seal pubkey ({seal.pubkey})"
)
return rumor
# --- Convenience functions ---
def wrap_message(
content: str,
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.
Returns the gift wrap event ready to publish.
"""
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)
def unwrap_message(
gift_wrap: NostrEvent,
recipient_privkey: str,
) -> NostrEvent:
"""
Full unwrap pipeline: gift wrap -> seal -> rumor.
Returns the rumor with sender pubkey and plaintext content.
"""
seal = unwrap_gift_wrap(gift_wrap, recipient_privkey)
return unseal(seal, recipient_privkey)

View file

@ -1,6 +1,8 @@
import asyncio import asyncio
import json import json
import time
from asyncio import Queue from asyncio import Queue
from collections import OrderedDict
from threading import Thread from threading import Thread
from typing import Callable, List, Optional from typing import Callable, List, Optional
@ -12,6 +14,8 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash
from .event import NostrEvent from .event import NostrEvent
MAX_SEEN_EVENTS = 1000
class NostrClient: class NostrClient:
def __init__(self): def __init__(self):
@ -20,6 +24,8 @@ class NostrClient:
self.ws: Optional[WebSocketApp] = None self.ws: Optional[WebSocketApp] = None
self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32]
self.running = False self.running = False
self._seen_events: OrderedDict[str, None] = OrderedDict()
self.last_event_at: float = 0
@property @property
def is_websocket_connected(self): def is_websocket_connected(self):
@ -64,11 +70,21 @@ class NostrClient:
logger.warning(ex) logger.warning(ex)
await asyncio.sleep(60) await asyncio.sleep(60)
def is_duplicate_event(self, event_id: str) -> bool:
"""Check if an event has been seen recently. Returns True if duplicate."""
if event_id in self._seen_events:
return True
self._seen_events[event_id] = None
if len(self._seen_events) > MAX_SEEN_EVENTS:
self._seen_events.popitem(last=False)
return False
async def get_event(self): async def get_event(self):
value = await self.recieve_event_queue.get() value = await self.recieve_event_queue.get()
if isinstance(value, ValueError): if isinstance(value, ValueError):
logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}") logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}")
raise value raise value
self.last_event_at = time.time()
return value return value
async def publish_nostr_event(self, e: NostrEvent): async def publish_nostr_event(self, e: NostrEvent):
@ -134,13 +150,13 @@ class NostrClient:
logger.debug(ex) logger.debug(ex)
def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List: def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List:
in_messages_filter = {"kinds": [4], "#p": public_keys} # NIP-17/NIP-59: subscribe to kind 1059 gift wraps addressed to our merchants.
out_messages_filter = {"kinds": [4], "authors": public_keys} # With gift wrapping, outgoing messages are self-wrapped (same p-tag filter).
gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys}
if since and since != 0: if since and since != 0:
in_messages_filter["since"] = since gift_wrap_filter["since"] = since
out_messages_filter["since"] = since
return [in_messages_filter, out_messages_filter] return [gift_wrap_filter]
def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List: def _filters_for_stall_events(self, public_keys: List[str], since: int) -> List:
stall_filter = {"kinds": [30017], "authors": public_keys} stall_filter = {"kinds": [30017], "authors": public_keys}

View file

@ -56,6 +56,7 @@ from .models import (
Stall, Stall,
) )
from .nostr.event import NostrEvent from .nostr.event import NostrEvent
from .nostr.nip59 import unwrap_message, wrap_message
async def create_new_order( async def create_new_order(
@ -270,19 +271,34 @@ async def send_dm(
other_pubkey: str, other_pubkey: str,
type_: int, type_: int,
dm_content: str, dm_content: str,
): ) -> DirectMessage:
dm_event = merchant.build_dm_event(dm_content, other_pubkey) # Wrap message to recipient via NIP-59 gift wrap
gift_wrap = wrap_message(
dm_content,
merchant.private_key,
merchant.public_key,
other_pubkey,
)
dm = PartialDirectMessage( dm = PartialDirectMessage(
event_id=dm_event.id, event_id=gift_wrap.id,
event_created_at=dm_event.created_at, event_created_at=gift_wrap.created_at,
message=dm_content, message=dm_content,
public_key=other_pubkey, public_key=other_pubkey,
type=type_, type=type_,
) )
dm_reply = await create_direct_message(merchant.id, dm) dm_reply = await create_direct_message(merchant.id, dm)
await nostr_client.publish_nostr_event(dm_event) await nostr_client.publish_nostr_event(gift_wrap)
# Also wrap a copy to self for archival
self_wrap = wrap_message(
dm_content,
merchant.private_key,
merchant.public_key,
merchant.public_key,
)
await nostr_client.publish_nostr_event(self_wrap)
await websocket_updater( await websocket_updater(
merchant.id, merchant.id,
@ -295,6 +311,8 @@ async def send_dm(
), ),
) )
return dm_reply
async def compute_products_new_quantity( async def compute_products_new_quantity(
merchant_id: str, product_ids: List[str], items: List[OrderItem] merchant_id: str, product_ids: List[str], items: List[OrderItem]
@ -332,11 +350,15 @@ async def process_nostr_message(msg: str):
return return
_, event = rest _, event = rest
event = NostrEvent(**event) event = NostrEvent(**event)
# Deduplicate events (overlap resubscriptions may deliver duplicates)
if nostr_client.is_duplicate_event(event.id):
return
if event.kind == 0: if event.kind == 0:
await _handle_customer_profile_update(event) await _handle_customer_profile_update(event)
elif event.kind == 4: elif event.kind == 1059:
await _handle_nip04_message(event) await _handle_gift_wrap(event)
elif event.kind == 30017: elif event.kind == 30017:
await _handle_stall(event) await _handle_stall(event)
elif event.kind == 30018: elif event.kind == 30018:
@ -430,30 +452,41 @@ async def extract_customer_order_from_dm(
return order return order
async def _handle_nip04_message(event: NostrEvent): async def _handle_gift_wrap(event: NostrEvent):
"""Handle an incoming kind 1059 gift wrap event (NIP-59/NIP-17)."""
p_tags = event.tag_values("p") p_tags = event.tag_values("p")
if not p_tags:
# PRIORITY 1: Check if any recipient (p_tag) is a merchant → incoming message TO merchant logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}")
for p_tag in p_tags: return
if p_tag:
potential_merchant = await get_merchant_by_pubkey(p_tag) # The p-tag identifies the recipient of the gift wrap
if potential_merchant: recipient_pubkey = p_tags[0]
clear_text_msg = potential_merchant.decrypt_message(event.content, event.pubkey) merchant = await get_merchant_by_pubkey(recipient_pubkey)
await _handle_incoming_dms(event, potential_merchant, clear_text_msg) if not merchant:
return # IMPORTANT: Return immediately to prevent double processing logger.warning(
f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}"
# PRIORITY 2: If no recipient merchant found, check if sender is a merchant → outgoing message FROM merchant
sender_merchant = await get_merchant_by_pubkey(event.pubkey)
if sender_merchant:
assert len(event.tag_values("p")) != 0, "Outgoing message has no 'p' tag"
clear_text_msg = sender_merchant.decrypt_message(
event.content, event.tag_values("p")[0]
) )
await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) return
return # IMPORTANT: Return immediately
try:
# No merchant found in either direction rumor = unwrap_message(event, merchant.private_key)
except Exception as ex:
logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
return
sender_pubkey = rumor.pubkey
if sender_pubkey == merchant.public_key:
# This is a self-addressed wrap (outgoing message archive)
# Extract the actual recipient from the rumor's p-tags
rumor_p_tags = rumor.tag_values("p")
if rumor_p_tags:
await _handle_outgoing_dms(rumor, merchant, rumor.content)
return
# Incoming message from a customer
await _handle_incoming_dms(rumor, merchant, rumor.content)
async def _handle_incoming_dms( async def _handle_incoming_dms(
@ -553,16 +586,21 @@ async def _persist_dm(
async def reply_to_structured_dm( async def reply_to_structured_dm(
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
): ):
dm_event = merchant.build_dm_event(dm_reply, customer_pubkey) gift_wrap = wrap_message(
dm_reply,
merchant.private_key,
merchant.public_key,
customer_pubkey,
)
dm = PartialDirectMessage( dm = PartialDirectMessage(
event_id=dm_event.id, event_id=gift_wrap.id,
event_created_at=dm_event.created_at, event_created_at=gift_wrap.created_at,
message=dm_reply, message=dm_reply,
public_key=customer_pubkey, public_key=customer_pubkey,
type=dm_type, type=dm_type,
) )
await create_direct_message(merchant.id, dm) await create_direct_message(merchant.id, dm)
await nostr_client.publish_nostr_event(dm_event) await nostr_client.publish_nostr_event(gift_wrap)
await websocket_updater( await websocket_updater(
merchant.id, merchant.id,

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import time
from asyncio import Queue from asyncio import Queue
from lnbits.core.models import Payment from lnbits.core.models import Payment
@ -9,9 +10,13 @@ from .nostr.nostr_client import NostrClient
from .services import ( from .services import (
handle_order_paid, handle_order_paid,
process_nostr_message, process_nostr_message,
resubscribe_to_all_merchants,
subscribe_to_all_merchants, subscribe_to_all_merchants,
) )
HEALTH_CHECK_INTERVAL = 30 # seconds between health checks
STALE_THRESHOLD = 120 # seconds without events before resubscribing
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = Queue() invoice_queue = Queue()
@ -35,17 +40,38 @@ async def on_invoice_paid(payment: Payment) -> None:
async def wait_for_nostr_events(nostr_client: NostrClient): async def wait_for_nostr_events(nostr_client: NostrClient):
logger.info("[NOSTRMARKET DEBUG] Starting wait_for_nostr_events task") logger.info("[NOSTRMARKET] Starting wait_for_nostr_events task")
while True: while True:
try: try:
logger.info("[NOSTRMARKET DEBUG] Subscribing to all merchants...") logger.info("[NOSTRMARKET] Subscribing to all merchants...")
await subscribe_to_all_merchants() await subscribe_to_all_merchants()
while True: while True:
logger.debug("[NOSTRMARKET DEBUG] Waiting for nostr event...")
message = await nostr_client.get_event() message = await nostr_client.get_event()
logger.info(f"[NOSTRMARKET DEBUG] Received event from nostr_client: {message[:100]}...")
await process_nostr_message(message) await process_nostr_message(message)
except Exception as e: except Exception as e:
logger.warning(f"[NOSTRMARKET DEBUG] Subscription failed. Will retry in 10 seconds: {e}") logger.warning(f"[NOSTRMARKET] Subscription failed. Retrying in 10s: {e}")
await asyncio.sleep(10) await asyncio.sleep(10)
async def subscription_health_monitor(nostr_client: NostrClient):
"""
Periodically check if events are flowing. If no events have been
received for STALE_THRESHOLD seconds, force a resubscription with
overlap to catch any missed events.
"""
logger.info("[NOSTRMARKET] Starting subscription health monitor")
while True:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
try:
if not nostr_client.is_websocket_connected:
continue
elapsed = time.time() - nostr_client.last_event_at
if nostr_client.last_event_at > 0 and elapsed > STALE_THRESHOLD:
logger.warning(
f"[NOSTRMARKET] ⚠️ No events for {elapsed:.0f}s, resubscribing..."
)
await resubscribe_to_all_merchants()
except Exception as e:
logger.error(f"[NOSTRMARKET] Health monitor error: {e}")

27
tests/conftest.py Normal file
View file

@ -0,0 +1,27 @@
"""
Stub out the nostrmarket root package and all LNbits dependencies so that
nostr/* unit tests can run without the full LNbits environment.
pytest walks up from tests/ and tries to import the parent __init__.py,
which pulls in fastapi, lnbits, websocket, etc. We preemptively register
the parent package as a simple module so that import never happens.
"""
import sys
import types
from pathlib import Path
# Register 'nostrmarket' as an already-imported namespace package
# pointing at the extension root, so pytest doesn't try to exec __init__.py
_ext_root = Path(__file__).resolve().parent.parent
_pkg = types.ModuleType("nostrmarket")
_pkg.__path__ = [str(_ext_root)]
_pkg.__package__ = "nostrmarket"
sys.modules["nostrmarket"] = _pkg
# Also ensure the nostr subpackage is importable
_nostr_dir = _ext_root / "nostr"
_nostr_pkg = types.ModuleType("nostrmarket.nostr")
_nostr_pkg.__path__ = [str(_nostr_dir)]
_nostr_pkg.__package__ = "nostrmarket.nostr"
sys.modules["nostrmarket.nostr"] = _nostr_pkg

139
tests/test_nip44.py Normal file
View file

@ -0,0 +1,139 @@
"""Tests for NIP-44 v2 encryption against official spec test vectors."""
import coincurve
import pytest
from nostr.nip44 import (
calc_padded_len,
decrypt,
encrypt,
get_conversation_key,
get_message_keys,
)
def pubkey_from_secret(secret_hex: str) -> str:
"""Derive x-only public key hex from secret key hex."""
sk = coincurve.PrivateKey(bytes.fromhex(secret_hex))
return sk.public_key.format(compressed=True)[1:].hex()
# --- Test vector from NIP-44 spec ---
SPEC_VECTOR = {
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb",
}
class TestConversationKey:
def test_spec_vector(self):
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
assert key.hex() == SPEC_VECTOR["conversation_key"]
def test_symmetric(self):
"""conv(a, B) == conv(b, A)"""
pub1 = pubkey_from_secret(SPEC_VECTOR["sec1"])
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
key_ab = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
key_ba = get_conversation_key(SPEC_VECTOR["sec2"], pub1)
assert key_ab == key_ba
class TestMessageKeys:
def test_returns_correct_lengths(self):
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
chacha_key, chacha_nonce, hmac_key = get_message_keys(conv_key, nonce)
assert len(chacha_key) == 32
assert len(chacha_nonce) == 12
assert len(hmac_key) == 32
def test_rejects_bad_key_length(self):
with pytest.raises(ValueError):
get_message_keys(b"\x00" * 16, b"\x00" * 32)
def test_rejects_bad_nonce_length(self):
with pytest.raises(ValueError):
get_message_keys(b"\x00" * 32, b"\x00" * 16)
class TestPadding:
@pytest.mark.parametrize(
"unpadded,expected",
[
(1, 32),
(2, 32),
(31, 32),
(32, 32),
(33, 64),
(64, 64),
(65, 96),
(256, 256),
(257, 320),
(1024, 1024),
(65535, 65536),
],
)
def test_calc_padded_len(self, unpadded, expected):
assert calc_padded_len(unpadded) == expected
def test_rejects_zero(self):
with pytest.raises(ValueError):
calc_padded_len(0)
class TestEncryptDecrypt:
def test_spec_vector(self):
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
nonce = bytes.fromhex(SPEC_VECTOR["nonce"])
payload = encrypt(SPEC_VECTOR["plaintext"], conv_key, nonce)
assert payload == SPEC_VECTOR["payload"]
def test_spec_vector_decrypt(self):
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
plaintext = decrypt(SPEC_VECTOR["payload"], conv_key)
assert plaintext == SPEC_VECTOR["plaintext"]
def test_round_trip_short(self):
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
msg = "x"
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
def test_round_trip_long(self):
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
msg = "A" * 65535
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
def test_round_trip_unicode(self):
pub2 = pubkey_from_secret(SPEC_VECTOR["sec2"])
conv_key = get_conversation_key(SPEC_VECTOR["sec1"], pub2)
msg = "hello world! \U0001f680\U0001f30e\U0001f4ac"
assert decrypt(encrypt(msg, conv_key), conv_key) == msg
def test_tampered_mac_rejected(self):
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
payload = SPEC_VECTOR["payload"]
tampered = payload[:-1] + ("a" if payload[-1] != "a" else "b")
with pytest.raises(ValueError, match="invalid MAC"):
decrypt(tampered, conv_key)
def test_empty_plaintext_rejected(self):
conv_key = bytes.fromhex(SPEC_VECTOR["conversation_key"])
with pytest.raises(ValueError, match="invalid plaintext length"):
encrypt("", conv_key)
def test_unknown_version_rejected(self):
with pytest.raises(ValueError, match="unknown version"):
decrypt("#invalid", bytes(32))
def test_short_payload_rejected(self):
with pytest.raises(ValueError, match="invalid payload size"):
decrypt("AAAA", bytes(32))

191
tests/test_nip59.py Normal file
View file

@ -0,0 +1,191 @@
"""Tests for NIP-59 gift wrap protocol."""
import json
import time
import coincurve
import pytest
from nostr.nip59 import (
create_gift_wrap,
create_rumor,
create_seal,
unseal,
unwrap_gift_wrap,
unwrap_message,
wrap_message,
)
def _generate_keypair() -> tuple[str, str]:
"""Generate a (privkey_hex, pubkey_hex) pair."""
sk = coincurve.PrivateKey()
privkey = sk.secret.hex()
pubkey = sk.public_key.format(compressed=True)[1:].hex()
return privkey, pubkey
SENDER_PRIV, SENDER_PUB = _generate_keypair()
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
class TestCreateRumor:
def test_has_id_but_no_sig(self):
rumor = create_rumor(SENDER_PUB, "hello", kind=14)
assert rumor.id != ""
assert rumor.sig is None
def test_kind_and_content(self):
rumor = create_rumor(SENDER_PUB, "test message", kind=14, tags=[["p", RECIPIENT_PUB]])
assert rumor.kind == 14
assert rumor.content == "test message"
assert rumor.pubkey == SENDER_PUB
assert ["p", RECIPIENT_PUB] in rumor.tags
def test_custom_timestamp(self):
ts = 1700000000
rumor = create_rumor(SENDER_PUB, "hello", created_at=ts)
assert rumor.created_at == ts
class TestCreateSeal:
def test_kind_13_with_empty_tags(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
assert seal.kind == 13
assert seal.tags == []
assert seal.pubkey == SENDER_PUB
def test_is_signed(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
assert seal.sig is not None
assert len(seal.sig) == 128 # 64 bytes hex
def test_content_is_encrypted(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
# Content should be base64 NIP-44 payload, not plaintext JSON
assert "hello" not in seal.content
def test_timestamp_is_randomized(self):
rumor = create_rumor(SENDER_PUB, "hello")
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
assert seal.created_at >= now - (2 * 24 * 60 * 60 + 10)
class TestCreateGiftWrap:
def test_kind_1059_with_p_tag(self):
rumor = create_rumor(SENDER_PUB, "hello")
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
def test_uses_ephemeral_key(self):
rumor = create_rumor(SENDER_PUB, "hello")
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
def test_different_wraps_have_different_ephemeral_keys(self):
rumor = create_rumor(SENDER_PUB, "hello")
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:
def test_unwrap_gift_wrap_returns_seal(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV)
assert recovered_seal.kind == 13
assert recovered_seal.pubkey == SENDER_PUB
def test_unseal_returns_rumor(self):
rumor = create_rumor(SENDER_PUB, "hello world")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
recovered_rumor = unseal(seal, RECIPIENT_PRIV)
assert recovered_rumor.content == "hello world"
assert recovered_rumor.pubkey == SENDER_PUB
assert recovered_rumor.kind == 14
def test_wrong_key_fails(self):
rumor = create_rumor(SENDER_PUB, "secret")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
wrong_priv, _ = _generate_keypair()
with pytest.raises(Exception):
unwrap_message(wrap, wrong_priv)
class TestFullRoundTrip:
def test_wrap_unwrap_message(self):
content = "Are you going to the party tonight?"
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
assert wrap.kind == 1059
assert ["p", RECIPIENT_PUB] in wrap.tags
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
assert rumor.content == content
assert rumor.pubkey == SENDER_PUB
assert rumor.kind == 14
assert rumor.sig is None
def test_wrap_with_custom_kind_and_tags(self):
tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
wrap = wrap_message(
"order data",
SENDER_PRIV,
SENDER_PUB,
RECIPIENT_PUB,
kind=14,
tags=tags,
)
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
assert rumor.content == "order data"
assert rumor.kind == 14
assert ["subject", "test"] in rumor.tags
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 = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB)
rumor = unwrap_message(wrap, SENDER_PRIV)
assert rumor.content == content
assert rumor.pubkey == SENDER_PUB
def test_json_content_preserved(self):
"""Order JSON payloads survive the wrap/unwrap cycle."""
order = {
"type": 0,
"id": "test-order-123",
"items": [{"product_id": "abc", "quantity": 2}],
"shipping_id": "zone-1",
}
content = json.dumps(order)
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
recovered_order = json.loads(rumor.content)
assert recovered_order == order
def test_unicode_content(self):
content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
assert rumor.content == content

View file

@ -84,6 +84,7 @@ from .services import (
create_or_update_order_from_dm, create_or_update_order_from_dm,
reply_to_structured_dm, reply_to_structured_dm,
resubscribe_to_all_merchants, resubscribe_to_all_merchants,
send_dm,
sign_and_send_to_nostr, sign_and_send_to_nostr,
subscribe_to_all_merchants, subscribe_to_all_merchants,
update_merchant_to_nostr, update_merchant_to_nostr,
@ -881,27 +882,11 @@ async def api_update_order_status(
ensure_ascii=False, ensure_ascii=False,
) )
dm_event = merchant.build_dm_event(dm_content, order.public_key) await send_dm(
merchant,
dm = PartialDirectMessage( order.public_key,
event_id=dm_event.id, DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
event_created_at=dm_event.created_at, dm_content,
message=dm_content,
public_key=order.public_key,
type=DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
)
await create_direct_message(merchant.id, dm)
await nostr_client.publish_nostr_event(dm_event)
await websocket_updater(
merchant.id,
json.dumps(
{
"type": f"dm:{dm.type}",
"customerPubkey": order.public_key,
"dm": dm.dict(),
}
),
) )
return order return order
@ -1079,14 +1064,13 @@ async def api_create_message(
merchant = await get_merchant_for_user(wallet.wallet.user) merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found" assert merchant, "Merchant cannot be found"
dm_event = merchant.build_dm_event(data.message, data.public_key) dm_reply = await send_dm(
data.event_id = dm_event.id merchant,
data.event_created_at = dm_event.created_at data.public_key,
data.type,
dm = await create_direct_message(merchant.id, data) data.message,
await nostr_client.publish_nostr_event(dm_event) )
return dm_reply
return dm
except AssertionError as ex: except AssertionError as ex:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,