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

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)