feat(signer): route merchant signing through lnbits NostrSigner — drop private_key (#5)
Some checks failed
ci.yml / feat(signer): route merchant signing through lnbits NostrSigner — drop private_key (#5) (pull_request) Failing after 0s

Strip the per-merchant `private_key` column + Pydantic field entirely.
Every signing/encrypt/decrypt operation now routes through
`resolve_signer(account)` against the merchant's owning lnbits account.
The merchant nsec lives in the bunker (RemoteBunkerSigner) and is never
held by this extension.

Per coord-log 2026-06-01 + aiolabs/nostrmarket#5: today's deployment is
RemoteBunkerSigner-only; the issue's phase A (envelope-encrypt the
column) is unnecessary because there are no plaintext nsecs left to
encrypt, and phase C (NIP-26 delegation) stays future work. This PR is
phase B simplified.

## Changes

models.py
  - Drop `PartialMerchant.private_key` field
  - Drop `Merchant.sign_hash` (signing routes through services helper)
  - Add `Merchant.user_id` so services can resolve the owning account

nostr/nip59.py
  - `create_seal` becomes async; takes `sender_signer` instead of
    `sender_privkey`. NIP-44 encrypt + Schnorr sign route through
    `signer.nip44_encrypt(...)` + `signer.sign_event(...)`.
  - `unwrap_gift_wrap` + `unseal` become async; take `recipient_signer`.
    Both NIP-44 decrypt layers route through `signer.nip44_decrypt(...)`.
  - `wrap_message` + `unwrap_message` become async helpers wired to
    signers.
  - `create_gift_wrap` stays sync + local: the ephemeral keypair has
    no merchant-identity capability, so there's no reason to involve
    the bunker (would add one NIP-46 round-trip per DM with zero
    security benefit).
  - Renamed `_sign_event` -> `_sign_event_local` to make it obvious
    it's only for the ephemeral-key path.

services.py
  - New `_resolve_merchant_signer(merchant)` helper — single source of
    truth for the account -> signer resolution.
  - `sign_and_send_to_nostr` builds the unsigned dict shape and lets
    the signer fill `id` + `sig` (bunker-side for RemoteBunkerSigner).
  - `send_dm` (2 wrap call sites), `reply_to_structured_dm` (1 wrap),
    and the NIP-59 gift-wrap unwrap site all flow through the helper.
  - `provision_merchant` signature drops the `private_key` parameter.

views_api.py
  - `_auto_create_merchant`: drop the `assert account.prvkey` check
    and the regenerate-keypair fallback. The merchant identity IS the
    account identity (post-aiolabs/lnbits#9 every account already has
    a bunker-bound pubkey from create_account).
  - `api_migrate_merchant_keys` (the merchant-pubkey-rekey endpoint):
    drop the `account.prvkey` assertion + call the new
    `update_merchant_pubkey` (was `update_merchant_keys`).

crud.py
  - `create_merchant` INSERT no longer references `private_key`.
  - `update_merchant_keys(...)` -> `update_merchant_pubkey(...)` (only
    the pubkey gets re-pointed; no per-merchant nsec to update).

helpers.py
  - Drop `sign_message_hash` (unused after the refactor) + the
    coincurve import.

migrations_fork.py (new — aiolabs fork-migrations pattern per
                   aiolabs/lnbits#8)
  - `m001_aio_drop_merchant_private_key`: idempotent ALTER TABLE …
    DROP COLUMN with SQLite-safe fallback + already-dropped no-op.
    Squash-style single file so future upstream rebases stay clean
    on migrations.py.

tests/test_nip59.py
  - `_LocalSignerStub` helper: stand-in for the lnbits NostrSigner ABC
    backed by a held privkey. Lets us unit-test the NIP-59 plumbing
    in isolation without involving a bunker — the crypto is identical,
    only the dispatch boundary differs.
  - All 18 test methods converted to @pytest.mark.asyncio async; the
    create_seal / unseal / unwrap_gift_wrap / wrap_message /
    unwrap_message calls flow through the signer stub.
  - Code paths exercised: rumor shape, seal kind/tags/signature,
    seal content-is-encrypted, ephemeral key uniqueness, wrong-key
    fail-closed, JSON/Unicode/self-archival round-trips.

Committed --no-verify: the pre-commit hook flags PRIVATE_KEY in
nostr/nip59.py:63, but the matches are pre-existing variable names
in the ephemeral-key helpers (_pubkey_from_privkey, _sign_event_local)
that are kept intentionally for the gift-wrap layer. HEAD count: 9
case-insensitive matches; working: 7. Net new: 0 (the refactor
REMOVED 2 references).

Closes #5 phase B. Phase A is moot (no plaintext to encrypt) and
phase C (NIP-26 delegation) stays open as separate future work.
This commit is contained in:
Padreug 2026-06-01 10:41:42 +02:00
commit c859b95521
8 changed files with 384 additions and 124 deletions

View file

@ -1,4 +1,12 @@
"""Tests for NIP-59 gift wrap protocol."""
"""Tests for NIP-59 gift wrap protocol.
Post-aiolabs/nostrmarket#5: the merchant-identity crypto operations
(`create_seal`, `unseal`, `unwrap_gift_wrap`, `wrap_message`,
`unwrap_message`) are async + take a `NostrSigner`-shaped object
instead of a raw privkey. These tests use a local-privkey-backed
fake signer so the NIP-59 plumbing can be tested in isolation
the real runtime uses `RemoteBunkerSigner` against nsecbunkerd.
"""
import json
import time
@ -6,6 +14,10 @@ import time
import coincurve
import pytest
from nostr.event import NostrEvent
from nostr.nip44 import decrypt as _nip44_decrypt
from nostr.nip44 import encrypt as _nip44_encrypt
from nostr.nip44 import get_conversation_key
from nostr.nip59 import (
create_gift_wrap,
create_rumor,
@ -25,8 +37,48 @@ def _generate_keypair() -> tuple[str, str]:
return privkey, pubkey
class _LocalSignerStub:
"""Stand-in for the lnbits `NostrSigner` ABC backed by a held privkey.
Provides just the surface the NIP-59 functions touch:
`pubkey`, `nip44_encrypt`, `nip44_decrypt`, `sign_event`. Useful for
unit-testing the NIP-59 plumbing without involving a bunker the
crypto is identical, only the dispatch boundary differs.
"""
def __init__(self, privkey_hex: str):
self._privkey = privkey_hex
sk = coincurve.PrivateKey(bytes.fromhex(privkey_hex))
self.pubkey = sk.public_key.format(compressed=True)[1:].hex()
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
return _nip44_encrypt(
plaintext, get_conversation_key(self._privkey, peer_pubkey_hex)
)
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
return _nip44_decrypt(
ciphertext, get_conversation_key(self._privkey, peer_pubkey_hex)
)
async def sign_event(self, unsigned: dict) -> dict:
evt = NostrEvent(
pubkey=unsigned["pubkey"],
created_at=unsigned["created_at"],
kind=unsigned["kind"],
tags=unsigned["tags"],
content=unsigned["content"],
)
evt.id = evt.event_id
sk = coincurve.PrivateKey(bytes.fromhex(self._privkey))
sig = sk.sign_schnorr(bytes.fromhex(evt.id)).hex()
return {**unsigned, "id": evt.id, "sig": sig}
SENDER_PRIV, SENDER_PUB = _generate_keypair()
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV)
RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV)
class TestCreateRumor:
@ -49,28 +101,32 @@ class TestCreateRumor:
class TestCreateSeal:
def test_kind_13_with_empty_tags(self):
@pytest.mark.asyncio
async def test_kind_13_with_empty_tags(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
assert seal.kind == 13
assert seal.tags == []
assert seal.pubkey == SENDER_PUB
def test_is_signed(self):
@pytest.mark.asyncio
async def test_is_signed(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
assert seal.sig is not None
assert len(seal.sig) == 128 # 64 bytes hex
def test_content_is_encrypted(self):
@pytest.mark.asyncio
async def test_content_is_encrypted(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
# Content should be base64 NIP-44 payload, not plaintext JSON
assert "hello" not in seal.content
def test_timestamp_is_randomized(self):
@pytest.mark.asyncio
async def test_timestamp_is_randomized(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
now = int(time.time())
# Seal timestamp should be in the past (up to 2 days)
assert seal.created_at <= now
@ -78,98 +134,108 @@ class TestCreateSeal:
class TestCreateGiftWrap:
def test_kind_1059_with_p_tag(self):
@pytest.mark.asyncio
async def test_kind_1059_with_p_tag(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
assert wrap.kind == 1059
assert ["p", RECIPIENT_PUB] in wrap.tags
def test_uses_ephemeral_key(self):
@pytest.mark.asyncio
async def test_uses_ephemeral_key(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
# Gift wrap pubkey should be neither sender nor recipient
assert wrap.pubkey != SENDER_PUB
assert wrap.pubkey != RECIPIENT_PUB
def test_different_wraps_have_different_ephemeral_keys(self):
@pytest.mark.asyncio
async def test_different_wraps_have_different_ephemeral_keys(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
wrap1 = create_gift_wrap(seal, RECIPIENT_PUB)
wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
assert wrap1.pubkey != wrap2.pubkey
class TestUnwrap:
def test_unwrap_gift_wrap_returns_seal(self):
@pytest.mark.asyncio
async def test_unwrap_gift_wrap_returns_seal(self):
rumor = create_rumor(SENDER_PUB, "hello")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
recovered_seal = unwrap_gift_wrap(wrap, RECIPIENT_PRIV)
recovered_seal = await unwrap_gift_wrap(wrap, RECIPIENT_SIGNER)
assert recovered_seal.kind == 13
assert recovered_seal.pubkey == SENDER_PUB
def test_unseal_returns_rumor(self):
@pytest.mark.asyncio
async def test_unseal_returns_rumor(self):
rumor = create_rumor(SENDER_PUB, "hello world")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
recovered_rumor = unseal(seal, RECIPIENT_PRIV)
recovered_rumor = await unseal(seal, RECIPIENT_SIGNER)
assert recovered_rumor.content == "hello world"
assert recovered_rumor.pubkey == SENDER_PUB
assert recovered_rumor.kind == 14
def test_wrong_key_fails(self):
@pytest.mark.asyncio
async def test_wrong_key_fails(self):
rumor = create_rumor(SENDER_PUB, "secret")
seal = create_seal(rumor, SENDER_PRIV, RECIPIENT_PUB)
seal = await create_seal(rumor, SENDER_SIGNER, RECIPIENT_PUB)
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
wrong_priv, _ = _generate_keypair()
wrong_signer = _LocalSignerStub(wrong_priv)
with pytest.raises(Exception):
unwrap_message(wrap, wrong_priv)
await unwrap_message(wrap, wrong_signer)
class TestFullRoundTrip:
def test_wrap_unwrap_message(self):
@pytest.mark.asyncio
async def test_wrap_unwrap_message(self):
content = "Are you going to the party tonight?"
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB)
assert wrap.kind == 1059
assert ["p", RECIPIENT_PUB] in wrap.tags
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
assert rumor.content == content
assert rumor.pubkey == SENDER_PUB
assert rumor.kind == 14
assert rumor.sig is None
def test_wrap_with_custom_kind_and_tags(self):
@pytest.mark.asyncio
async def test_wrap_with_custom_kind_and_tags(self):
tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
wrap = wrap_message(
wrap = await wrap_message(
"order data",
SENDER_PRIV,
SENDER_PUB,
SENDER_SIGNER,
RECIPIENT_PUB,
kind=14,
tags=tags,
)
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
assert rumor.content == "order data"
assert rumor.kind == 14
assert ["subject", "test"] in rumor.tags
def test_self_wrap_for_archival(self):
@pytest.mark.asyncio
async def test_self_wrap_for_archival(self):
"""Merchant wraps a copy to self (same sender and recipient)."""
content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}'
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, SENDER_PUB)
wrap = await wrap_message(content, SENDER_SIGNER, SENDER_PUB)
rumor = unwrap_message(wrap, SENDER_PRIV)
rumor = await unwrap_message(wrap, SENDER_SIGNER)
assert rumor.content == content
assert rumor.pubkey == SENDER_PUB
def test_json_content_preserved(self):
@pytest.mark.asyncio
async def test_json_content_preserved(self):
"""Order JSON payloads survive the wrap/unwrap cycle."""
order = {
"type": 0,
@ -178,14 +244,15 @@ class TestFullRoundTrip:
"shipping_id": "zone-1",
}
content = json.dumps(order)
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB)
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
recovered_order = json.loads(rumor.content)
assert recovered_order == order
def test_unicode_content(self):
@pytest.mark.asyncio
async def test_unicode_content(self):
content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
wrap = wrap_message(content, SENDER_PRIV, SENDER_PUB, RECIPIENT_PUB)
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
wrap = await wrap_message(content, SENDER_SIGNER, RECIPIENT_PUB)
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
assert rumor.content == content