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
This commit is contained in:
parent
df95fd37dc
commit
b6ca1b0e02
3 changed files with 166 additions and 3 deletions
115
nostr/giftwrap.py
Normal file
115
nostr/giftwrap.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""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)))
|
||||
|
|
@ -23,7 +23,7 @@ from loguru import logger
|
|||
|
||||
from .. import crud, services
|
||||
from ..models import Booking, Room
|
||||
from . import events
|
||||
from . import events, giftwrap
|
||||
from .kinds import KIND_AVAILABILITY_QUERY
|
||||
|
||||
try:
|
||||
|
|
@ -104,7 +104,12 @@ async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) ->
|
|||
return None
|
||||
if not signed:
|
||||
return None
|
||||
return _publish_signed(signed)
|
||||
|
||||
|
||||
def _publish_signed(signed: dict) -> str | None:
|
||||
"""Publish an already-signed event via nostrclient. Returns the event id,
|
||||
or None if nostrclient isn't installed."""
|
||||
try:
|
||||
_, nostr_client = _nostrclient()
|
||||
except _NostrclientUnavailable:
|
||||
|
|
@ -120,6 +125,40 @@ async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) ->
|
|||
return signed.get("id")
|
||||
|
||||
|
||||
async def send_checkin_dm(booking, room, settings) -> str | None:
|
||||
"""On confirmation, send the guest a NIP-17 gift-wrapped DM with the
|
||||
private check-in details. Encrypted end-to-end to the guest; soft-fails
|
||||
(returns None) if the operator signer can't encrypt (LocalSigner pre-
|
||||
bunker) or nostrclient isn't installed."""
|
||||
account, signer = await _operator_signer()
|
||||
if not signer:
|
||||
return None
|
||||
wrap = await giftwrap.build_dm(
|
||||
signer=signer,
|
||||
sender_pubkey=account.pubkey,
|
||||
recipient_pubkey=booking.guest_pubkey,
|
||||
content=_checkin_message(booking, room, settings),
|
||||
)
|
||||
if not wrap:
|
||||
return None
|
||||
return _publish_signed(wrap)
|
||||
|
||||
|
||||
def _checkin_message(booking, room, settings) -> str:
|
||||
lines = [
|
||||
f"Your booking at {room.title} is confirmed! 🏰",
|
||||
"",
|
||||
f"Check-in: {booking.check_in} from {settings.checkin_time}",
|
||||
f"Check-out: {booking.check_out} by {settings.checkout_time}",
|
||||
f"Guests: {booking.num_guests}",
|
||||
]
|
||||
if room.checkin_instructions:
|
||||
lines += ["", room.checkin_instructions]
|
||||
if settings.cancellation_policy:
|
||||
lines += ["", f"Cancellation policy: {settings.cancellation_policy}"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def publish_listing(room: Room) -> str | None:
|
||||
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
|
||||
return await _sign_and_publish(events.build_listing_event(room))
|
||||
|
|
|
|||
13
tasks.py
13
tasks.py
|
|
@ -42,8 +42,17 @@ async def on_invoice_paid(payment: Payment):
|
|||
await crud.update_booking(booking)
|
||||
booking.reservation_event_id = await nostr.publish_reservation(booking)
|
||||
await crud.update_booking(booking)
|
||||
# TODO(checkin): DM check-in details (address, gate code, times) to the
|
||||
# guest via NIP-17 giftwrap once relay plumbing lands.
|
||||
|
||||
# Send the guest their private check-in details (NIP-17 gift-wrapped DM).
|
||||
# Best-effort: a publish failure must not undo a confirmed, paid booking.
|
||||
try:
|
||||
room = await crud.get_room(booking.room_id)
|
||||
settings = await crud.get_or_create_settings()
|
||||
if room:
|
||||
await nostr.send_checkin_dm(booking, room, settings)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}")
|
||||
|
||||
logger.info(f"chatelet: booking {booking.id} confirmed")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue