Replace NIP-04 messaging with NIP-17 (NIP-44 + NIP-59) #2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "refactor/nip17-messaging"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
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 and NIP-59 gift wrapping. No backwards compatibility retained.
Why: NIP-04 is deprecated — it leaks metadata (participant identities visible in tags, timestamps public), provides no deniability, and uses weaker AES-256-CBC encryption. NIP-17 fixes all of these via gift-wrapped, unsigned rumors with randomized timestamps and ephemeral keys.
Merchant-to-merchant ordering bug (resolved by design)
Prior to commit
e0fdada, there was a bug where if one merchant (B) ordered from another merchant (A), the order would never reach A. The old_handle_nip04_messagelooked up the sender first:When merchant B ordered from merchant A (
event.pubkey= B, p-tag = A):merchant = Bevent.pubkey == merchant_public_key→ true → treated as outgoing DM from BCommit
e0fdadafixed this by flipping the priority — check p-tags (recipient) first, then fall back to sender. But this was fundamentally a workaround for an ambiguity in NIP-04: both sender and recipient are visible at the same level in the event, so the code has to guess which pubkey is "our" merchant.NIP-59 eliminates this class of bug entirely by design. The gift wrap protocol structurally separates recipient from sender across different layers:
In the new
_handle_gift_wrap, the recipient is always resolved from the outer p-tag (plaintext), and the sender is only known after unwrapping (encrypted). There is no ambiguity to resolve:Merchant B ordering from merchant A: gift wrap p-tag = A → find A → unwrap → sender is B → incoming order. No priority heuristics needed.
What changed
New: NIP-44 v2 Encryption (
nostr/nip44.py)Full implementation of the NIP-44 v2 spec:
get_conversation_key()— secp256k1 ECDH + HKDF-extract (salt:nip44-v2)get_message_keys()— HKDF-expand → ChaCha20 key/nonce + HMAC keyencrypt()/decrypt()— ChaCha20 + HMAC-SHA256 with power-of-two paddingsec1=0x01, sec2=0x02)coincurveandcryptographypackagesNew: NIP-59 Gift Wrap Protocol (
nostr/nip59.py)Three-layer metadata protection per NIP-59:
p-tag to recipientConvenience functions:
wrap_message()— full pipeline: rumor → seal → gift wrapunwrap_message()— reverse: gift wrap → seal → rumor (validates pubkey chain)Changed: Message Handling (
services.py)_handle_nip04_message()— decrypt AES-256-CBC, route by pubkey matching_handle_gift_wrap()— unwrap NIP-59, route by rumor sendersend_dm()—merchant.build_dm_event()(kind 4)send_dm()—wrap_message()to recipient + self-archive copyreply_to_structured_dm()—merchant.build_dm_event()reply_to_structured_dm()—wrap_message()process_nostr_message()dispatches kind 4The order flow JSON types (0=order, 1=payment_request, 2=status_update) are unchanged — only the transport layer changed.
Changed: Subscription Filters (
nostr/nostr_client.py){"kinds": [4], "#p": keys}+{"kinds": [4], "authors": keys}→ single{"kinds": [1059], "#p": keys}is_duplicate_event()— bounded LRU set (~1000 events) to prevent re-processing on overlap resubscriptionslast_event_attracking for health monitoringChanged: Merchant Model (
models.py)Removed NIP-04 crypto methods from
Merchant:— was AES-256-CBC with ECDH shared secretdecrypt_message()— sameencrypt_message()— constructed kind 4 eventsbuild_dm_event()Kept
sign_hash()— still needed for signing stall (kind 30017) and product (kind 30018) events.Changed: Helpers (
helpers.py)Removed:
— NIP-04 ECDH (replaced byget_shared_secret()nip44.get_conversation_key())/encrypt_message()— NIP-04 AES-256-CBCdecrypt_message()test_decrypt_encrypt()Kept:
sign_message_hash()— Schnorr signatures for nostr eventsnormalize_public_key()— bech32 npub ↔ hex conversionChanged: Views API (
views_api.py)Both direct
merchant.build_dm_event()call sites (order status update + create message endpoints) replaced with thesend_dm()service function. This consolidates DM creation, NIP-59 wrapping, relay publishing, and websocket notification into one place.New: Subscription Health Monitor (
tasks.py)Addresses order discovery reliability issues documented in
misc-docs/ORDER-DISCOVERY-ANALYSIS.md:subscription_health_monitor()background task checks every 30se0fdadaNew: Test Suite (
tests/)test_nip44.py(26 tests) — conversation key, message keys, padding, encrypt/decrypt, spec vector validation, error casestest_nip59.py(18 tests) — rumor/seal/gift-wrap creation, unwrapping, round-trips, self-wrap archival, JSON content preservation, wrong-key rejectionconftest.py— stubs LNbits dependencies so nostr/* tests run standaloneWhat did NOT change
Stats
Test plan
🤖 Generated with Claude Code
When a user rotates their Nostr keypair in account settings, the merchant still holds the old key. This adds: - key_mismatch flag on MerchantConfig (runtime, not persisted) - detected on each GET /api/v1/merchant by comparing account vs merchant pubkey - POST /api/v1/merchant/{id}/migrate-keys endpoint that updates the merchant keys, republishes all stalls/products under the new identity, and resubscribes - Warning banner in the UI with a "Migrate Keys" button and confirmation dialog - update_merchant_keys() crud function Orders and DM history are preserved since they reference customer pubkeys. Old stall/product events on relays become orphaned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>16e50d67f905ebf042ac