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
87 lines
3 KiB
Python
87 lines
3 KiB
Python
"""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
|