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

@ -7,6 +7,26 @@ Three-layer protocol for metadata-protected messaging:
3. Gift Wrap (kind 1059) encrypts seal with ephemeral key, has recipient p-tag
Reference: https://github.com/nostr-protocol/nips/blob/master/59.md
## Bunker integration (aiolabs/nostrmarket#5)
Merchant-identity layers (rumor's sender-pubkey + seal's encryption +
seal's signature) route through the lnbits `NostrSigner` abstraction
so the merchant's nsec stays in the bunker — never reaches this
process. Specifically:
- `create_seal` is async; takes a `sender_signer` instead of a
plaintext nsec. The seal's NIP-44 encrypt + Schnorr sign happen
via `await sender_signer.nip44_encrypt(...)` +
`await sender_signer.sign_event(...)` over the NIP-46 channel.
- `unwrap_gift_wrap` + `unseal` are async; take a `recipient_signer`
and call `await recipient_signer.nip44_decrypt(...)` for each layer.
The **ephemeral keypair layer** (`create_gift_wrap`) stays synchronous
+ local: the ephemeral nsec exists for the lifetime of one wrap and
provides no merchant-identity capability, so there's no reason to
involve the bunker. Generating it locally avoids one round-trip per
DM.
"""
import json
@ -29,8 +49,10 @@ def _random_past_timestamp() -> int:
return int(time.time()) - secrets.randbelow(TWO_DAYS)
def _sign_event(event: NostrEvent, private_key_hex: str) -> NostrEvent:
"""Compute event id and sign it."""
def _sign_event_local(event: NostrEvent, private_key_hex: str) -> NostrEvent:
"""Compute event id and sign it locally with a privkey held in this
process. Used only for the ephemeral-keypair layer (gift wrap outer);
merchant-identity sign goes through the signer ABC instead."""
event.id = event.event_id
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex()
@ -66,26 +88,43 @@ def create_rumor(
return event
def create_seal(
async def create_seal(
rumor: NostrEvent,
sender_privkey: str,
sender_signer,
recipient_pubkey: str,
) -> NostrEvent:
"""
Create a kind 13 seal: encrypts the rumor for the recipient.
Signed by the sender. Tags are always empty.
Both crypto operations (NIP-44 encrypt + Schnorr sign) route
through the sender's `NostrSigner` (`sender_signer`) — the
plaintext nsec is never observable in this process.
"""
conv_key = get_conversation_key(sender_privkey, recipient_pubkey)
encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key)
encrypted_rumor = await sender_signer.nip44_encrypt(
rumor.stringify(), recipient_pubkey
)
seal = NostrEvent(
pubkey=_pubkey_from_privkey(sender_privkey),
pubkey=sender_signer.pubkey,
created_at=_random_past_timestamp(),
kind=13,
tags=[],
content=encrypted_rumor,
)
return _sign_event(seal, sender_privkey)
# The signer fills id + sig (computed bunker-side).
signed = await sender_signer.sign_event(
{
"pubkey": seal.pubkey,
"created_at": seal.created_at,
"kind": seal.kind,
"tags": seal.tags,
"content": seal.content,
}
)
seal.id = signed["id"]
seal.sig = signed["sig"]
return seal
def create_gift_wrap(
@ -95,6 +134,11 @@ def create_gift_wrap(
"""
Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key.
The only public metadata is the recipient's p-tag.
Stays synchronous + local: the ephemeral nsec exists only for the
lifetime of one wrap and provides no merchant-identity capability,
so there's no point routing through the bunker (would add one NIP-46
round-trip per DM with zero security benefit).
"""
ephemeral_privkey = secrets.token_bytes(32).hex()
ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
@ -109,33 +153,35 @@ def create_gift_wrap(
tags=[["p", recipient_pubkey]],
content=encrypted_seal,
)
return _sign_event(wrap, ephemeral_privkey)
return _sign_event_local(wrap, ephemeral_privkey)
def unwrap_gift_wrap(
async def unwrap_gift_wrap(
gift_wrap: NostrEvent,
recipient_privkey: str,
recipient_signer,
) -> NostrEvent:
"""
Decrypt a kind 1059 gift wrap to reveal the inner seal.
Uses the recipient's private key and the gift wrap's ephemeral pubkey.
Routes NIP-44 decrypt through the recipient's signer abstraction
so the recipient's nsec stays in the bunker.
"""
conv_key = get_conversation_key(recipient_privkey, gift_wrap.pubkey)
seal_json = nip44_decrypt(gift_wrap.content, conv_key)
seal_json = await recipient_signer.nip44_decrypt(
gift_wrap.content, gift_wrap.pubkey
)
return NostrEvent(**json.loads(seal_json))
def unseal(
async def unseal(
seal: NostrEvent,
recipient_privkey: str,
recipient_signer,
) -> NostrEvent:
"""
Decrypt a kind 13 seal to reveal the inner rumor.
Uses the recipient's private key and the seal's pubkey (the sender).
Validates that the rumor's pubkey matches the seal's pubkey.
Uses the recipient signer (their nsec stays in the bunker) and the
seal's pubkey (the sender). Validates that the rumor's pubkey
matches the seal's pubkey.
"""
conv_key = get_conversation_key(recipient_privkey, seal.pubkey)
rumor_json = nip44_decrypt(seal.content, conv_key)
rumor_json = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey)
rumor = NostrEvent(**json.loads(rumor_json))
if rumor.pubkey != seal.pubkey:
@ -149,30 +195,37 @@ def unseal(
# --- Convenience functions ---
def wrap_message(
async def wrap_message(
content: str,
sender_privkey: str,
sender_pubkey: str,
sender_signer,
recipient_pubkey: str,
kind: int = 14,
tags: Optional[list[list[str]]] = None,
) -> NostrEvent:
"""
Full wrap pipeline: create rumor -> seal -> gift wrap.
Full wrap pipeline: create rumor seal gift wrap.
Returns the gift wrap event ready to publish.
`sender_signer` is the sender merchant's `NostrSigner` (post-#5:
always a `RemoteBunkerSigner`). The merchant's nsec never leaves
the bunker.
"""
rumor = create_rumor(sender_pubkey, content, kind=kind, tags=tags)
seal = create_seal(rumor, sender_privkey, recipient_pubkey)
rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags)
seal = await create_seal(rumor, sender_signer, recipient_pubkey)
return create_gift_wrap(seal, recipient_pubkey)
def unwrap_message(
async def unwrap_message(
gift_wrap: NostrEvent,
recipient_privkey: str,
recipient_signer,
) -> NostrEvent:
"""
Full unwrap pipeline: gift wrap -> seal -> rumor.
Full unwrap pipeline: gift wrap seal rumor.
Returns the rumor with sender pubkey and plaintext content.
`recipient_signer` is the recipient merchant's `NostrSigner`. Both
NIP-44 decrypt layers (gift wrap seal, seal rumor) route
through the signer abstraction.
"""
seal = unwrap_gift_wrap(gift_wrap, recipient_privkey)
return unseal(seal, recipient_privkey)
seal = await unwrap_gift_wrap(gift_wrap, recipient_signer)
return await unseal(seal, recipient_signer)