From 910db8156d35bf5bbb7bee7d139294344150cd1c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 20:34:12 +0200 Subject: [PATCH] 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