chatelet/nostr/giftwrap.py
Padreug b6ca1b0e02 feat: NIP-17 gift-wrapped check-in DM on confirmation (#5)
On settlement, send the guest their private check-in details as a NIP-59
gift-wrapped DM (nostr/giftwrap.py, built from lnbits core primitives — no
vendored crypto):

- rumor (kind 14) -> seal (kind 13, operator-encrypted + operator-signed via
  the signer abstraction) -> gift wrap (kind 1059, ephemeral-key encrypted +
  signed locally via core nip44_encrypt + sign_event). created_at randomised
  into the past per NIP-59.
- service.send_checkin_dm builds the message (room.checkin_instructions +
  settings times/policy) and publishes via nostrclient (_publish_signed,
  extracted from _sign_and_publish).
- tasks.on_invoice_paid calls it best-effort — a DM failure never undoes a
  confirmed, paid booking.

Encrypted layer (seal) soft-fails on a LocalSigner until bunker/server-
signing (lnbits#18), same as the reservation event; the ephemeral wrap layer
always works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 20:34:12 +02:00

115 lines
3.9 KiB
Python

"""NIP-59 gift wrap for NIP-17 private DMs, built from LNbits core primitives
(no vendored crypto).
Three layers (https://github.com/nostr-protocol/nips/blob/master/59.md):
1. rumor (kind 14, unsigned) — the actual message; deniable if leaked.
2. seal (kind 13) — NIP-44-encrypts the rumor to the recipient, signed by
the SENDER. Sender-identity crypto → routed through the operator's
`NostrSigner` so the nsec stays in the bunker. On a LocalSigner this
raises (bunker-forward), so `build_dm` returns None (soft-fail).
3. gift wrap (kind 1059) — NIP-44-encrypts the seal with a throwaway
EPHEMERAL key; only public metadata is the recipient p-tag. The
ephemeral key has no identity value, so it's generated + used locally
(core `nip44_encrypt` + `sign_event`), no bunker round-trip.
Timestamps on seal + wrap are randomised into the past (NIP-59) so relays
can't correlate by created_at.
"""
import hashlib
import json
import secrets
import time
from loguru import logger
try:
import coincurve
from lnbits.core.services.nostr_transport.crypto import nip44_encrypt
from lnbits.core.signers.base import SignerUnavailableError
from lnbits.utils.nostr import json_dumps, sign_event
_GIFTWRAP_AVAILABLE = True
except ImportError: # pre-nostr-transport lnbits — check-in DMs soft-disable
_GIFTWRAP_AVAILABLE = False
_TWO_DAYS = 2 * 24 * 60 * 60
def _random_past() -> int:
return int(time.time()) - secrets.randbelow(_TWO_DAYS)
def _event_id(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str:
# NIP-01 id — identical serialization to lnbits.utils.nostr.sign_event.
ser = json_dumps([0, pubkey, created_at, kind, tags, content])
return hashlib.sha256(ser.encode()).hexdigest()
def _xonly_pubkey(privkey_hex: str) -> str:
sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex))
return sk.public_key.format(compressed=True)[1:].hex()
async def build_dm(
*,
signer,
sender_pubkey: str,
recipient_pubkey: str,
content: str,
inner_tags: list | None = None,
) -> dict | None:
"""Build a NIP-59 gift-wrapped NIP-17 DM (kind 1059) ready to publish.
Returns the signed gift-wrap event, or None if giftwrap primitives
aren't available or the operator signer can't encrypt the seal
(LocalSigner pre-bunker). Never raises for those soft-fail cases.
"""
if not _GIFTWRAP_AVAILABLE:
return None
# 1. rumor (kind 14, unsigned) — sender is the operator.
tags = [["p", recipient_pubkey], *(inner_tags or [])]
created = int(time.time())
rumor = {
"pubkey": sender_pubkey,
"created_at": created,
"kind": 14,
"tags": tags,
"content": content,
"id": _event_id(sender_pubkey, created, 14, tags, content),
}
# 2. seal (kind 13) — operator-encrypted + operator-signed.
try:
sealed = await signer.nip44_encrypt(json.dumps(rumor), recipient_pubkey)
except SignerUnavailableError as exc:
logger.warning(
"chatelet: cannot seal check-in DM (operator signer can't NIP-44 "
f"encrypt — needs bunker/server-signing, lnbits#18): {exc}"
)
return None
seal = {
"pubkey": sender_pubkey,
"created_at": _random_past(),
"kind": 13,
"tags": [],
"content": sealed,
}
seal = await signer.sign_event(seal) # operator fills id + sig
if not seal:
return None
# 3. gift wrap (kind 1059) — ephemeral key, local encrypt + sign.
eph_priv = secrets.token_bytes(32).hex()
eph_pub = _xonly_pubkey(eph_priv)
wrap_content = nip44_encrypt(json.dumps(seal), eph_priv, recipient_pubkey)
wrap = {
"pubkey": eph_pub,
"created_at": _random_past(),
"kind": 1059,
"tags": [["p", recipient_pubkey]],
"content": wrap_content,
}
return sign_event(wrap, eph_pub, coincurve.PrivateKey(bytes.fromhex(eph_priv)))