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>
178 lines
5.1 KiB
Python
178 lines
5.1 KiB
Python
"""
|
|
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)
|