feat: NIP-17 gift-wrapped check-in DM on confirmation (#5) #12

Merged
padreug merged 4 commits from feat/checkin-dm into main 2026-07-19 20:55:34 +00:00
Showing only changes of commit 910db8156d - Show all commits

test: NIP-59 gift wrap structure + crypto round-trip

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
Padreug 2026-07-19 20:34:12 +02:00

87
tests/test_giftwrap.py Normal file
View 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