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

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

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

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

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

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

180
nostr/nip44.py Normal file
View file

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

178
nostr/nip59.py Normal file
View file

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

View file

@ -1,6 +1,8 @@
import asyncio
import 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}