diff --git a/docs/event-flow.md b/docs/event-flow.md index 93b33bf..26b5976 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -93,28 +93,10 @@ 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 check-in DM) +today; **encrypted** events (reservation `30078`, and the `#5` 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 diff --git a/migrations.py b/migrations.py index 702b634..8f32e80 100644 --- a/migrations.py +++ b/migrations.py @@ -111,12 +111,3 @@ 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 8a239e3..78b7e27 100644 --- a/models.py +++ b/models.py @@ -99,10 +99,6 @@ 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): @@ -119,7 +115,6 @@ 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) diff --git a/nostr/giftwrap.py b/nostr/giftwrap.py deleted file mode 100644 index d9660a5..0000000 --- a/nostr/giftwrap.py +++ /dev/null @@ -1,115 +0,0 @@ -"""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 fc17c75..d2c6d7e 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, giftwrap +from . import events from .kinds import KIND_AVAILABILITY_QUERY try: @@ -104,12 +104,7 @@ 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: @@ -125,40 +120,6 @@ def _publish_signed(signed: dict) -> str | 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 3532299..ea27b8e 100644 --- a/tasks.py +++ b/tasks.py @@ -42,17 +42,8 @@ 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) - - # 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}") - + # TODO(checkin): DM check-in details (address, gate code, times) to the + # guest via NIP-17 giftwrap once relay plumbing lands. logger.info(f"chatelet: booking {booking.id} confirmed") diff --git a/tests/test_giftwrap.py b/tests/test_giftwrap.py deleted file mode 100644 index 6ebf80c..0000000 --- a/tests/test_giftwrap.py +++ /dev/null @@ -1,87 +0,0 @@ -"""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