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:
parent
319d5eeb04
commit
725944ae9c
13 changed files with 869 additions and 165 deletions
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 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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue