From df95fd37dcf69761b9a8fedf5e14093415ba6b0e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 20:34:12 +0200 Subject: [PATCH 1/4] feat: add Room.checkin_instructions + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private per-room access details (address, gate/door code). Sent to the guest only in the encrypted check-in DM after payment — never in the public listing. m002 adds the column (default ''). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- migrations.py | 9 +++++++++ models.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/migrations.py b/migrations.py index 8f32e80..702b634 100644 --- a/migrations.py +++ b/migrations.py @@ -111,3 +111,12 @@ async def m001_initial(db): await db.execute( "CREATE INDEX idx_chatelet_blocks_room ON chatelet.blocks (room_id);" ) + + +async def m002_room_checkin_instructions(db): + """Private per-room access details (address, gate code), sent to the guest + only in the encrypted NIP-17 check-in DM after payment — never public.""" + await db.execute( + "ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT " + "NOT NULL DEFAULT '';" + ) diff --git a/models.py b/models.py index 78b7e27..8a239e3 100644 --- a/models.py +++ b/models.py @@ -99,6 +99,10 @@ class CreateRoomData(BaseModel): location: str = "" geohash: str = "" # -> NIP-99 "g" tag images: list[str] = Field(default_factory=list) + # Private access details (address, gate/door code) sent to the guest only + # after payment, over the encrypted NIP-17 check-in DM. Never in the + # public listing. + checkin_instructions: str = "" class Room(BaseModel): @@ -115,6 +119,7 @@ class Room(BaseModel): location: str = "" geohash: str = "" images: list[str] = Field(default_factory=list) + checkin_instructions: str = "" # private; sent in the check-in DM only status: RoomStatus = RoomStatus.inactive listing_event_id: str | None = None # id of the last published kind:30402 created_at: datetime = Field(default_factory=_now) -- 2.53.0 From b6ca1b0e026c6e9eae67b52d6102b95319aa9de6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 20:34:12 +0200 Subject: [PATCH 2/4] feat: NIP-17 gift-wrapped check-in DM on confirmation (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- nostr/giftwrap.py | 115 ++++++++++++++++++++++++++++++++++++++++++++++ nostr/service.py | 41 ++++++++++++++++- tasks.py | 13 +++++- 3 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 nostr/giftwrap.py diff --git a/nostr/giftwrap.py b/nostr/giftwrap.py new file mode 100644 index 0000000..d9660a5 --- /dev/null +++ b/nostr/giftwrap.py @@ -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))) diff --git a/nostr/service.py b/nostr/service.py index d2c6d7e..fc17c75 100644 --- a/nostr/service.py +++ b/nostr/service.py @@ -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)) diff --git a/tasks.py b/tasks.py index ea27b8e..3532299 100644 --- a/tasks.py +++ b/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") -- 2.53.0 From 910db8156d35bf5bbb7bee7d139294344150cd1c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 20:34:12 +0200 Subject: [PATCH 3/4] test: NIP-59 gift wrap structure + crypto round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert the wrap is kind 1059, p-tagged to the guest, authored by an ephemeral key (not the operator), and that plaintext doesn't leak. Round- trip: decrypt the wrap with the guest key via core nip44_decrypt to recover the operator-authored seal (kind 13) — proves the ephemeral NIP-44 v2 layer is real + interoperable, not just structural. Soft-fail case returns None. 24 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- tests/test_giftwrap.py | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_giftwrap.py diff --git a/tests/test_giftwrap.py b/tests/test_giftwrap.py new file mode 100644 index 0000000..6ebf80c --- /dev/null +++ b/tests/test_giftwrap.py @@ -0,0 +1,87 @@ +"""NIP-59 gift wrap builder (#5): structure + soft-fail on a signer that +can't encrypt. The seal layer is faked (that's the operator/bunker boundary); +the gift-wrap layer uses the real core NIP-44 + schnorr signing, so the guest +key must be a real secp256k1 x-only pubkey.""" + +import asyncio +import json +import secrets + +import coincurve +from lnbits.core.services.nostr_transport.crypto import nip44_decrypt + +from ..nostr import giftwrap + + +def _keypair() -> tuple[str, str]: + priv = secrets.token_bytes(32).hex() + sk = coincurve.PrivateKey(bytes.fromhex(priv)) + pub = sk.public_key.format(compressed=True)[1:].hex() # x-only + return priv, pub + + +class _FakeSigner: + """Stands in for the operator's NostrSigner. `can_encrypt=False` mimics a + LocalSigner (nip44_encrypt raises).""" + + def __init__(self, can_encrypt: bool = True): + self._can = can_encrypt + + async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str: + if not self._can: + from lnbits.core.signers.base import SignerUnavailableError + + raise SignerUnavailableError("LocalSigner cannot nip44_encrypt") + return "SEALED_CIPHERTEXT" # opaque; the wrap layer re-encrypts the seal + + async def sign_event(self, event: dict) -> dict: + event["id"] = "aa" * 32 + event["sig"] = "bb" * 64 + return event + + +def test_build_dm_produces_kind_1059_gift_wrap(): + guest_priv, guest_pub = _keypair() + _, op_pub = _keypair() + + wrap = asyncio.run( + giftwrap.build_dm( + signer=_FakeSigner(), + sender_pubkey=op_pub, + recipient_pubkey=guest_pub, + content="Gate code 1234; door on the left.", + ) + ) + + assert wrap is not None + assert wrap["kind"] == 1059 + assert wrap["tags"] == [["p", guest_pub]] # only public metadata + assert len(wrap["pubkey"]) == 64 # ephemeral x-only key, not the operator + assert wrap["pubkey"] != op_pub + assert "id" in wrap and "sig" in wrap + # content is the NIP-44-encrypted seal — plaintext must not leak. + assert "Gate code" not in wrap["content"] + assert "SEALED_CIPHERTEXT" not in wrap["content"] + + # Crypto round-trip: the guest can NIP-44-decrypt the wrap with the + # ephemeral pubkey to recover the seal (kind 13). Proves the ephemeral + # ECDH + NIP-44 v2 layer is real and interoperable, not just structural. + seal = json.loads(nip44_decrypt(wrap["content"], guest_priv, wrap["pubkey"])) + assert seal["kind"] == 13 + assert seal["pubkey"] == op_pub # seal is authored by the operator + assert seal["content"] == "SEALED_CIPHERTEXT" # our faked inner ciphertext + + +def test_build_dm_soft_fails_when_operator_cannot_encrypt(): + _, guest_pub = _keypair() + _, op_pub = _keypair() + + wrap = asyncio.run( + giftwrap.build_dm( + signer=_FakeSigner(can_encrypt=False), + sender_pubkey=op_pub, + recipient_pubkey=guest_pub, + content="secret", + ) + ) + assert wrap is None # LocalSigner pre-bunker: soft-fail, no crash -- 2.53.0 From 43d071756e0986150458815b3b48b6b12581a10f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 20:34:12 +0200 Subject: [PATCH 4/4] docs: document the NIP-17/59 check-in DM flow Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- docs/event-flow.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/event-flow.md b/docs/event-flow.md index 26b5976..93b33bf 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -93,10 +93,28 @@ with a logged warning and the booking flow (HTTP/RPC) is unaffected. `sign_event` but its `nip44_encrypt` raises (bunker-forward by design, lnbits #18). So **public** events (listing `30402`, calendar `31923`, availability `22001` — availability is public info, so `22000/22001` are plaintext) publish -today; **encrypted** events (reservation `30078`, and the `#5` check-in DM) +today; **encrypted** events (reservation `30078`, and the check-in DM) sign-encrypt via the operator signer and soft-fail with a clear log until the operator has a bunker/server-signing signer. Nothing crashes either way. +### Check-in DM (NIP-17 / NIP-59) — issue #5 + +On settlement (`tasks.on_invoice_paid`), the guest is sent their private +check-in details (address, gate code from `room.checkin_instructions`, plus +times/policy from settings) as a **gift-wrapped** DM, built in +`nostr/giftwrap.py` from core primitives (no vendored crypto): + +1. **rumor** (kind 14, unsigned) — the message; sender is the operator. +2. **seal** (kind 13) — NIP-44-encrypts the rumor to the guest, **signed by + the operator** via the signer abstraction (bunker-forward; this is the + layer that soft-fails on a LocalSigner). +3. **gift wrap** (kind 1059) — NIP-44-encrypts the seal with a throwaway + **ephemeral** key (core `nip44_encrypt` + `sign_event`, local — no bunker + round-trip); only public metadata is the recipient `p`-tag. Seal + wrap + `created_at` are randomised into the past per NIP-59. + +Best-effort: a DM failure never undoes a confirmed, paid booking. + ## Happy path ```mermaid -- 2.53.0