feat: NIP-17 gift-wrapped check-in DM on confirmation (#5) #12
7 changed files with 286 additions and 4 deletions
|
|
@ -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
|
`sign_event` but its `nip44_encrypt` raises (bunker-forward by design, lnbits
|
||||||
#18). So **public** events (listing `30402`, calendar `31923`, availability
|
#18). So **public** events (listing `30402`, calendar `31923`, availability
|
||||||
`22001` — availability is public info, so `22000/22001` are plaintext) publish
|
`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
|
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.
|
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
|
## Happy path
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,12 @@ async def m001_initial(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"CREATE INDEX idx_chatelet_blocks_room ON chatelet.blocks (room_id);"
|
"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 '';"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,10 @@ class CreateRoomData(BaseModel):
|
||||||
location: str = ""
|
location: str = ""
|
||||||
geohash: str = "" # -> NIP-99 "g" tag
|
geohash: str = "" # -> NIP-99 "g" tag
|
||||||
images: list[str] = Field(default_factory=list)
|
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):
|
class Room(BaseModel):
|
||||||
|
|
@ -115,6 +119,7 @@ class Room(BaseModel):
|
||||||
location: str = ""
|
location: str = ""
|
||||||
geohash: str = ""
|
geohash: str = ""
|
||||||
images: list[str] = Field(default_factory=list)
|
images: list[str] = Field(default_factory=list)
|
||||||
|
checkin_instructions: str = "" # private; sent in the check-in DM only
|
||||||
status: RoomStatus = RoomStatus.inactive
|
status: RoomStatus = RoomStatus.inactive
|
||||||
listing_event_id: str | None = None # id of the last published kind:30402
|
listing_event_id: str | None = None # id of the last published kind:30402
|
||||||
created_at: datetime = Field(default_factory=_now)
|
created_at: datetime = Field(default_factory=_now)
|
||||||
|
|
|
||||||
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 .. import crud, services
|
||||||
from ..models import Booking, Room
|
from ..models import Booking, Room
|
||||||
from . import events
|
from . import events, giftwrap
|
||||||
from .kinds import KIND_AVAILABILITY_QUERY
|
from .kinds import KIND_AVAILABILITY_QUERY
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -104,7 +104,12 @@ async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) ->
|
||||||
return None
|
return None
|
||||||
if not signed:
|
if not signed:
|
||||||
return None
|
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:
|
try:
|
||||||
_, nostr_client = _nostrclient()
|
_, nostr_client = _nostrclient()
|
||||||
except _NostrclientUnavailable:
|
except _NostrclientUnavailable:
|
||||||
|
|
@ -120,6 +125,40 @@ async def _sign_and_publish(unsigned: dict, *, encrypt_to: str | None = None) ->
|
||||||
return signed.get("id")
|
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:
|
async def publish_listing(room: Room) -> str | None:
|
||||||
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
|
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
|
||||||
return await _sign_and_publish(events.build_listing_event(room))
|
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)
|
await crud.update_booking(booking)
|
||||||
booking.reservation_event_id = await nostr.publish_reservation(booking)
|
booking.reservation_event_id = await nostr.publish_reservation(booking)
|
||||||
await crud.update_booking(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")
|
logger.info(f"chatelet: booking {booking.id} confirmed")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
87
tests/test_giftwrap.py
Normal file
87
tests/test_giftwrap.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue