Replace NIP-04 messaging with NIP-17 (NIP-44 + NIP-59 gift wrapping)
Some checks failed
ci.yml / Replace NIP-04 messaging with NIP-17 (NIP-44 + NIP-59 gift wrapping) (pull_request) Failing after 0s
Some checks failed
ci.yml / Replace NIP-04 messaging with NIP-17 (NIP-44 + NIP-59 gift wrapping) (pull_request) Failing after 0s
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:
parent
f85cbaa65e
commit
1b39744daa
13 changed files with 869 additions and 165 deletions
17
__init__.py
17
__init__.py
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ The Nostr Market extension includes:
|
||||||
- A merchant client to manage products, sales and communication with customers.
|
- A merchant client to manage products, sales and communication with customers.
|
||||||
- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
|
- A customer client to find and order products from merchants, communicate with merchants and track status of ordered products.
|
||||||
|
|
||||||
All communication happens over NIP04 encrypted DMs.
|
All communication happens over NIP-17 private direct messages (NIP-44 encryption + NIP-59 gift wrapping).
|
||||||
|
|
|
||||||
61
helpers.py
61
helpers.py
|
|
@ -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)
|
||||||
|
|
|
||||||
29
models.py
29
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 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
180
nostr/nip44.py
Normal 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
178
nostr/nip59.py
Normal 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)
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
106
services.py
106
services.py
|
|
@ -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,
|
||||||
|
|
|
||||||
36
tasks.py
36
tasks.py
|
|
@ -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
27
tests/conftest.py
Normal 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
139
tests/test_nip44.py
Normal 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
191
tests/test_nip59.py
Normal 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
|
||||||
42
views_api.py
42
views_api.py
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue