From 725944ae9c50a4f8060150439c57e36547cefbbc Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 08:16:55 +0200 Subject: [PATCH] Replace NIP-04 messaging with NIP-17 (NIP-44 + NIP-59 gift wrapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __init__.py | 17 +++- description.md | 2 +- helpers.py | 61 -------------- models.py | 29 +------ nostr/nip44.py | 180 +++++++++++++++++++++++++++++++++++++++ nostr/nip59.py | 178 +++++++++++++++++++++++++++++++++++++++ nostr/nostr_client.py | 26 ++++-- services.py | 106 +++++++++++++++-------- tasks.py | 36 ++++++-- tests/conftest.py | 27 ++++++ tests/test_nip44.py | 139 ++++++++++++++++++++++++++++++ tests/test_nip59.py | 191 ++++++++++++++++++++++++++++++++++++++++++ views_api.py | 42 +++------- 13 files changed, 869 insertions(+), 165 deletions(-) create mode 100644 nostr/nip44.py create mode 100644 nostr/nip59.py create mode 100644 tests/conftest.py create mode 100644 tests/test_nip44.py create mode 100644 tests/test_nip59.py diff --git a/__init__.py b/__init__.py index 921c383..cffa9fa 100644 --- a/__init__.py +++ b/__init__.py @@ -27,7 +27,11 @@ def nostrmarket_renderer(): 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_api import * # noqa @@ -65,4 +69,13 @@ def nostrmarket_start(): task3 = create_permanent_unique_task( "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]) diff --git a/description.md b/description.md index 3cfe8bc..b4fc5d2 100644 --- a/description.md +++ b/description.md @@ -5,6 +5,6 @@ Its functions include: - Managing products, sales, and customer communication as a merchant - Browsing and ordering products as a customer - 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. diff --git a/helpers.py b/helpers.py index dd26116..35f0d0f 100644 --- a/helpers.py +++ b/helpers.py @@ -1,55 +1,5 @@ -import base64 -import secrets -from typing import Optional - import coincurve 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: @@ -58,17 +8,6 @@ def sign_message_hash(private_key: str, hash_: bytes) -> str: 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: if pubkey.startswith("npub1"): _, decoded_data = bech32_decode(pubkey) diff --git a/models.py b/models.py index b12b775..2c24dee 100644 --- a/models.py +++ b/models.py @@ -7,12 +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 ( - decrypt_message, - encrypt_message, - get_shared_secret, - sign_message_hash, -) +from .helpers import sign_message_hash from .nostr.event import NostrEvent ######################################## NOSTR ######################################## @@ -67,28 +62,6 @@ class Merchant(PartialMerchant, Nostrable): def sign_hash(self, hash_: bytes) -> str: 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 def from_row(cls, row: dict) -> "Merchant": merchant = cls(**row) diff --git a/nostr/nip44.py b/nostr/nip44.py new file mode 100644 index 0000000..908ad8a --- /dev/null +++ b/nostr/nip44.py @@ -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) diff --git a/nostr/nip59.py b/nostr/nip59.py new file mode 100644 index 0000000..2283bee --- /dev/null +++ b/nostr/nip59.py @@ -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) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py index 967bc1b..c51d19d 100644 --- a/nostr/nostr_client.py +++ b/nostr/nostr_client.py @@ -1,6 +1,8 @@ import asyncio import json +import time from asyncio import Queue +from collections import OrderedDict from threading import Thread from typing import Callable, List, Optional @@ -12,6 +14,8 @@ from lnbits.helpers import encrypt_internal_message, urlsafe_short_hash from .event import NostrEvent +MAX_SEEN_EVENTS = 1000 + class NostrClient: def __init__(self): @@ -20,6 +24,8 @@ class NostrClient: self.ws: Optional[WebSocketApp] = None self.subscription_id = "nostrmarket-" + urlsafe_short_hash()[:32] self.running = False + self._seen_events: OrderedDict[str, None] = OrderedDict() + self.last_event_at: float = 0 @property def is_websocket_connected(self): @@ -64,11 +70,21 @@ class NostrClient: logger.warning(ex) 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): value = await self.recieve_event_queue.get() if isinstance(value, ValueError): logger.error(f"[NOSTRMARKET] ❌ Queue returned error: {value}") raise value + self.last_event_at = time.time() return value async def publish_nostr_event(self, e: NostrEvent): @@ -134,13 +150,13 @@ class NostrClient: logger.debug(ex) def _filters_for_direct_messages(self, public_keys: List[str], since: int) -> List: - in_messages_filter = {"kinds": [4], "#p": public_keys} - out_messages_filter = {"kinds": [4], "authors": public_keys} + # 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). + gift_wrap_filter: dict = {"kinds": [1059], "#p": public_keys} if since and since != 0: - in_messages_filter["since"] = since - out_messages_filter["since"] = since + gift_wrap_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: stall_filter = {"kinds": [30017], "authors": public_keys} diff --git a/services.py b/services.py index 3057292..2a7159e 100644 --- a/services.py +++ b/services.py @@ -56,6 +56,7 @@ from .models import ( Stall, ) from .nostr.event import NostrEvent +from .nostr.nip59 import unwrap_message, wrap_message async def create_new_order( @@ -270,19 +271,34 @@ async def send_dm( other_pubkey: str, type_: int, dm_content: str, -): - dm_event = merchant.build_dm_event(dm_content, other_pubkey) +) -> DirectMessage: + # 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( - event_id=dm_event.id, - event_created_at=dm_event.created_at, + event_id=gift_wrap.id, + event_created_at=gift_wrap.created_at, message=dm_content, public_key=other_pubkey, type=type_, ) 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( merchant.id, @@ -295,6 +311,8 @@ async def send_dm( ), ) + return dm_reply + async def compute_products_new_quantity( merchant_id: str, product_ids: List[str], items: List[OrderItem] @@ -332,11 +350,15 @@ async def process_nostr_message(msg: str): return _, event = rest event = NostrEvent(**event) - + + # Deduplicate events (overlap resubscriptions may deliver duplicates) + if nostr_client.is_duplicate_event(event.id): + return + if event.kind == 0: await _handle_customer_profile_update(event) - elif event.kind == 4: - await _handle_nip04_message(event) + elif event.kind == 1059: + await _handle_gift_wrap(event) elif event.kind == 30017: await _handle_stall(event) elif event.kind == 30018: @@ -430,30 +452,41 @@ async def extract_customer_order_from_dm( 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") - - # PRIORITY 1: Check if any recipient (p_tag) is a merchant → incoming message TO merchant - for p_tag in p_tags: - if p_tag: - potential_merchant = await get_merchant_by_pubkey(p_tag) - if potential_merchant: - clear_text_msg = potential_merchant.decrypt_message(event.content, event.pubkey) - await _handle_incoming_dms(event, potential_merchant, clear_text_msg) - return # IMPORTANT: Return immediately to prevent double processing - - # 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] + if not p_tags: + logger.warning(f"[NOSTRMARKET] ⚠️ Gift wrap has no p-tag: {event.id}") + return + + # The p-tag identifies the recipient of the gift wrap + recipient_pubkey = p_tags[0] + merchant = await get_merchant_by_pubkey(recipient_pubkey) + if not merchant: + logger.warning( + f"[NOSTRMARKET] ⚠️ No merchant found for gift wrap recipient: {recipient_pubkey}" ) - await _handle_outgoing_dms(event, sender_merchant, clear_text_msg) - return # IMPORTANT: Return immediately - - # No merchant found in either direction + return + + try: + 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( @@ -553,16 +586,21 @@ async def _persist_dm( async def reply_to_structured_dm( 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( - event_id=dm_event.id, - event_created_at=dm_event.created_at, + event_id=gift_wrap.id, + event_created_at=gift_wrap.created_at, message=dm_reply, public_key=customer_pubkey, type=dm_type, ) 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( merchant.id, diff --git a/tasks.py b/tasks.py index 774951f..c147936 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,5 @@ import asyncio +import time from asyncio import Queue from lnbits.core.models import Payment @@ -9,9 +10,13 @@ from .nostr.nostr_client import NostrClient from .services import ( handle_order_paid, process_nostr_message, + resubscribe_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(): invoice_queue = Queue() @@ -35,17 +40,38 @@ async def on_invoice_paid(payment: Payment) -> None: 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: try: - logger.info("[NOSTRMARKET DEBUG] Subscribing to all merchants...") + logger.info("[NOSTRMARKET] Subscribing to all merchants...") await subscribe_to_all_merchants() while True: - logger.debug("[NOSTRMARKET DEBUG] Waiting for nostr event...") message = await nostr_client.get_event() - logger.info(f"[NOSTRMARKET DEBUG] Received event from nostr_client: {message[:100]}...") await process_nostr_message(message) 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) + + +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}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..22ffb83 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_nip44.py b/tests/test_nip44.py new file mode 100644 index 0000000..3e767a6 --- /dev/null +++ b/tests/test_nip44.py @@ -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)) diff --git a/tests/test_nip59.py b/tests/test_nip59.py new file mode 100644 index 0000000..e518abf --- /dev/null +++ b/tests/test_nip59.py @@ -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 diff --git a/views_api.py b/views_api.py index 7bf3574..f974345 100644 --- a/views_api.py +++ b/views_api.py @@ -84,6 +84,7 @@ from .services import ( create_or_update_order_from_dm, reply_to_structured_dm, resubscribe_to_all_merchants, + send_dm, sign_and_send_to_nostr, subscribe_to_all_merchants, update_merchant_to_nostr, @@ -881,27 +882,11 @@ async def api_update_order_status( ensure_ascii=False, ) - dm_event = merchant.build_dm_event(dm_content, order.public_key) - - dm = PartialDirectMessage( - event_id=dm_event.id, - event_created_at=dm_event.created_at, - 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(), - } - ), + await send_dm( + merchant, + order.public_key, + DirectMessageType.ORDER_PAID_OR_SHIPPED.value, + dm_content, ) return order @@ -1079,14 +1064,13 @@ async def api_create_message( merchant = await get_merchant_for_user(wallet.wallet.user) assert merchant, "Merchant cannot be found" - dm_event = merchant.build_dm_event(data.message, data.public_key) - data.event_id = dm_event.id - data.event_created_at = dm_event.created_at - - dm = await create_direct_message(merchant.id, data) - await nostr_client.publish_nostr_event(dm_event) - - return dm + dm_reply = await send_dm( + merchant, + data.public_key, + data.type, + data.message, + ) + return dm_reply except AssertionError as ex: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST,