Compare commits
5 commits
signer-abs
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| adda751eb0 | |||
| 774c3586a1 | |||
| 14e7ea63eb | |||
| c677e1bb7d | |||
| c859b95521 |
8 changed files with 432 additions and 147 deletions
25
crud.py
25
crud.py
|
|
@ -23,16 +23,19 @@ from .models import (
|
||||||
|
|
||||||
async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
|
async def create_merchant(user_id: str, m: PartialMerchant) -> Merchant:
|
||||||
merchant_id = urlsafe_short_hash()
|
merchant_id = urlsafe_short_hash()
|
||||||
|
# Post-aiolabs/nostrmarket#5: no `private_key` column written. The
|
||||||
|
# legacy column is dropped by `migrations_fork.m001_aio_drop_private_key`
|
||||||
|
# for fresh installs and NULL-tolerated for the brief window between
|
||||||
|
# this code change deploying and the fork-migration running.
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO nostrmarket.merchants
|
INSERT INTO nostrmarket.merchants
|
||||||
(user_id, id, private_key, public_key, meta)
|
(user_id, id, public_key, meta)
|
||||||
VALUES (:user_id, :id, :private_key, :public_key, :meta)
|
VALUES (:user_id, :id, :public_key, :meta)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"id": merchant_id,
|
"id": merchant_id,
|
||||||
"private_key": m.private_key,
|
|
||||||
"public_key": m.public_key,
|
"public_key": m.public_key,
|
||||||
"meta": json.dumps(dict(m.config)),
|
"meta": json.dumps(dict(m.config)),
|
||||||
},
|
},
|
||||||
|
|
@ -55,18 +58,24 @@ async def update_merchant(
|
||||||
return await get_merchant(user_id, merchant_id)
|
return await get_merchant(user_id, merchant_id)
|
||||||
|
|
||||||
|
|
||||||
async def update_merchant_keys(
|
async def update_merchant_pubkey(
|
||||||
user_id: str, merchant_id: str, private_key: str, public_key: str
|
user_id: str, merchant_id: str, public_key: str
|
||||||
) -> Optional[Merchant]:
|
) -> Optional[Merchant]:
|
||||||
|
"""Re-point a merchant's identity to a new pubkey (e.g. after the
|
||||||
|
account migrated to a fresh RemoteBunkerSigner keypair).
|
||||||
|
|
||||||
|
Post-aiolabs/nostrmarket#5: there is no `private_key` column to
|
||||||
|
update — the merchant pubkey is the only stored identity material,
|
||||||
|
and the signing nsec lives entirely in the bunker against
|
||||||
|
`account.id` (== `merchant.user_id`) on the lnbits side.
|
||||||
|
"""
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE nostrmarket.merchants
|
UPDATE nostrmarket.merchants
|
||||||
SET private_key = :private_key, public_key = :public_key,
|
SET public_key = :public_key, time = {db.timestamp_now}
|
||||||
time = {db.timestamp_now}
|
|
||||||
WHERE id = :id AND user_id = :user_id
|
WHERE id = :id AND user_id = :user_id
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"private_key": private_key,
|
|
||||||
"public_key": public_key,
|
"public_key": public_key,
|
||||||
"id": merchant_id,
|
"id": merchant_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
|
|
||||||
10
helpers.py
10
helpers.py
|
|
@ -1,11 +1,9 @@
|
||||||
import coincurve
|
|
||||||
from bech32 import bech32_decode, convertbits
|
from bech32 import bech32_decode, convertbits
|
||||||
|
|
||||||
|
# NOTE (aiolabs/nostrmarket#5): `sign_message_hash` is gone. All merchant
|
||||||
def sign_message_hash(private_key: str, hash_: bytes) -> str:
|
# signing routes through the lnbits `NostrSigner` ABC via
|
||||||
privkey = coincurve.PrivateKey(bytes.fromhex(private_key))
|
# `services._resolve_merchant_signer(merchant)`. The nsec lives in the
|
||||||
sig = privkey.sign_schnorr(hash_)
|
# bunker, never in this process.
|
||||||
return sig.hex()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_public_key(pubkey: str) -> str:
|
def normalize_public_key(pubkey: str) -> str:
|
||||||
|
|
|
||||||
77
migrations_fork.py
Normal file
77
migrations_fork.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""
|
||||||
|
aiolabs fork-migrations for nostrmarket (companion to upstream
|
||||||
|
`migrations.py`).
|
||||||
|
|
||||||
|
Per the aiolabs/lnbits#8 fork-migrations pattern: every aio-only
|
||||||
|
schema delta lives in this single squashed function so we never
|
||||||
|
introduce conflicts in `migrations.py` (which stays byte-identical to
|
||||||
|
upstream and rebases cleanly).
|
||||||
|
|
||||||
|
The function is loaded by lnbits's patched `migrate_extension_database()`
|
||||||
|
under the `nostrmarket_fork` namespace in core `dbversions`, with the
|
||||||
|
following invariants:
|
||||||
|
- Every ALTER must be idempotent (use `_alter_drop_column_safe`-style
|
||||||
|
wrappers or `IF NOT EXISTS` / `IF EXISTS` predicates) so re-runs
|
||||||
|
are no-ops on already-migrated installs.
|
||||||
|
- Schema changes here MUST NOT depend on the version of upstream's
|
||||||
|
`migrations.py` they're running against — upstream rebases must
|
||||||
|
not require this file to be edited.
|
||||||
|
|
||||||
|
See `aiolabs/lnbits#8` for the pattern + `aiolabs/lnbits/core/services/
|
||||||
|
signer_migration.py` for the prior art on `_alter_*_safe` helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def _drop_column_safe(db, table: str, column: str) -> None:
|
||||||
|
"""SQLite-safe drop-column. Newer SQLite (3.35+) supports
|
||||||
|
`ALTER TABLE … DROP COLUMN`; older versions need the classic
|
||||||
|
create-new-table + copy + swap dance. Postgres handles
|
||||||
|
`ALTER TABLE … DROP COLUMN IF EXISTS` natively.
|
||||||
|
|
||||||
|
Idempotent: catches "no such column" + "column does not exist"
|
||||||
|
so re-runs are no-ops.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Postgres path (supports IF EXISTS natively); also works on
|
||||||
|
# SQLite ≥ 3.35.
|
||||||
|
await db.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column};")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
# SQLite < 3.35 doesn't support IF EXISTS; fall through to the
|
||||||
|
# bare DROP COLUMN attempt + swallow the not-found case.
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "syntax" not in msg and "if exists" not in msg:
|
||||||
|
# Something other than the IF-EXISTS unsupported case; surface.
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.execute(f"ALTER TABLE {table} DROP COLUMN {column};")
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "no such column" in msg or "does not exist" in msg:
|
||||||
|
# Already dropped; idempotent skip.
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def m001_aio_drop_merchant_private_key(db):
|
||||||
|
"""Drop the legacy `nostrmarket.merchants.private_key` column.
|
||||||
|
|
||||||
|
Per aiolabs/nostrmarket#5, the merchant's signing identity is owned
|
||||||
|
by the lnbits-side account: signing routes through
|
||||||
|
`resolve_signer(account).sign_event(...)` (which dispatches to
|
||||||
|
`RemoteBunkerSigner` post-aiolabs/lnbits#18 phase 2.x). The nsec
|
||||||
|
never lives in this extension's storage. Dropping the column makes
|
||||||
|
that contract enforced at the schema level rather than relying on
|
||||||
|
"nobody writes to it anymore."
|
||||||
|
|
||||||
|
Idempotent: re-runs no-op via `_drop_column_safe`.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"[NOSTRMARKET fork] m001: dropping merchants.private_key "
|
||||||
|
"(aiolabs/nostrmarket#5 — signing routes through lnbits NostrSigner)"
|
||||||
|
)
|
||||||
|
await _drop_column_safe(db, "nostrmarket.merchants", "private_key")
|
||||||
|
logger.info("[NOSTRMARKET fork] m001: done")
|
||||||
12
models.py
12
models.py
|
|
@ -7,7 +7,6 @@ from typing import Any, List, Optional, Tuple
|
||||||
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import btc_price, fiat_amount_as_satoshis
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .helpers import sign_message_hash
|
|
||||||
from .nostr.event import NostrEvent
|
from .nostr.event import NostrEvent
|
||||||
|
|
||||||
######################################## NOSTR ########################################
|
######################################## NOSTR ########################################
|
||||||
|
|
@ -52,17 +51,22 @@ class CreateMerchantRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PartialMerchant(BaseModel):
|
class PartialMerchant(BaseModel):
|
||||||
private_key: str
|
|
||||||
public_key: str
|
public_key: str
|
||||||
config: MerchantConfig = MerchantConfig()
|
config: MerchantConfig = MerchantConfig()
|
||||||
|
|
||||||
|
|
||||||
class Merchant(PartialMerchant, Nostrable):
|
class Merchant(PartialMerchant, Nostrable):
|
||||||
id: str
|
id: str
|
||||||
|
user_id: str
|
||||||
time: Optional[int] = 0
|
time: Optional[int] = 0
|
||||||
|
|
||||||
def sign_hash(self, hash_: bytes) -> str:
|
# NOTE (aiolabs/nostrmarket#5): no `sign_hash` / `encrypt_message` /
|
||||||
return sign_message_hash(self.private_key, hash_)
|
# `decrypt_message` methods on Merchant anymore. Signing + NIP-44 crypto
|
||||||
|
# for a merchant goes through the lnbits `NostrSigner` abstraction
|
||||||
|
# (`resolve_signer(account)`); merchant is now pure metadata pointing
|
||||||
|
# at its owning account via `user_id`. The bunker (`RemoteBunkerSigner`)
|
||||||
|
# holds the merchant's nsec — lnbits never has it server-side.
|
||||||
|
# See `services._resolve_merchant_signer()` for the resolution helper.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: dict) -> "Merchant":
|
def from_row(cls, row: dict) -> "Merchant":
|
||||||
|
|
|
||||||
115
nostr/nip59.py
115
nostr/nip59.py
|
|
@ -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
|
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
|
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
|
import json
|
||||||
|
|
@ -29,8 +49,10 @@ def _random_past_timestamp() -> int:
|
||||||
return int(time.time()) - secrets.randbelow(TWO_DAYS)
|
return int(time.time()) - secrets.randbelow(TWO_DAYS)
|
||||||
|
|
||||||
|
|
||||||
def _sign_event(event: NostrEvent, private_key_hex: str) -> NostrEvent:
|
def _sign_event_local(event: NostrEvent, private_key_hex: str) -> NostrEvent:
|
||||||
"""Compute event id and sign it."""
|
"""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
|
event.id = event.event_id
|
||||||
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
|
sk = coincurve.PrivateKey(bytes.fromhex(private_key_hex))
|
||||||
event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex()
|
event.sig = sk.sign_schnorr(bytes.fromhex(event.id)).hex()
|
||||||
|
|
@ -66,26 +88,43 @@ def create_rumor(
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
def create_seal(
|
async def create_seal(
|
||||||
rumor: NostrEvent,
|
rumor: NostrEvent,
|
||||||
sender_privkey: str,
|
sender_signer,
|
||||||
recipient_pubkey: str,
|
recipient_pubkey: str,
|
||||||
) -> NostrEvent:
|
) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Create a kind 13 seal: encrypts the rumor for the recipient.
|
Create a kind 13 seal: encrypts the rumor for the recipient.
|
||||||
Signed by the sender. Tags are always empty.
|
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 = await sender_signer.nip44_encrypt(
|
||||||
encrypted_rumor = nip44_encrypt(rumor.stringify(), conv_key)
|
rumor.stringify(), recipient_pubkey
|
||||||
|
)
|
||||||
|
|
||||||
seal = NostrEvent(
|
seal = NostrEvent(
|
||||||
pubkey=_pubkey_from_privkey(sender_privkey),
|
pubkey=sender_signer.pubkey,
|
||||||
created_at=_random_past_timestamp(),
|
created_at=_random_past_timestamp(),
|
||||||
kind=13,
|
kind=13,
|
||||||
tags=[],
|
tags=[],
|
||||||
content=encrypted_rumor,
|
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(
|
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.
|
Create a kind 1059 gift wrap: encrypts the seal with an ephemeral key.
|
||||||
The only public metadata is the recipient's p-tag.
|
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_privkey = secrets.token_bytes(32).hex()
|
||||||
ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
|
ephemeral_pubkey = _pubkey_from_privkey(ephemeral_privkey)
|
||||||
|
|
@ -109,33 +153,35 @@ def create_gift_wrap(
|
||||||
tags=[["p", recipient_pubkey]],
|
tags=[["p", recipient_pubkey]],
|
||||||
content=encrypted_seal,
|
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,
|
gift_wrap: NostrEvent,
|
||||||
recipient_privkey: str,
|
recipient_signer,
|
||||||
) -> NostrEvent:
|
) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Decrypt a kind 1059 gift wrap to reveal the inner seal.
|
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 = await recipient_signer.nip44_decrypt(
|
||||||
seal_json = nip44_decrypt(gift_wrap.content, conv_key)
|
gift_wrap.content, gift_wrap.pubkey
|
||||||
|
)
|
||||||
return NostrEvent(**json.loads(seal_json))
|
return NostrEvent(**json.loads(seal_json))
|
||||||
|
|
||||||
|
|
||||||
def unseal(
|
async def unseal(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipient_privkey: str,
|
recipient_signer,
|
||||||
) -> NostrEvent:
|
) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Decrypt a kind 13 seal to reveal the inner rumor.
|
Decrypt a kind 13 seal to reveal the inner rumor.
|
||||||
Uses the recipient's private key and the seal's pubkey (the sender).
|
Uses the recipient signer (their nsec stays in the bunker) and the
|
||||||
Validates that the rumor's pubkey matches the seal's pubkey.
|
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 = await recipient_signer.nip44_decrypt(seal.content, seal.pubkey)
|
||||||
rumor_json = nip44_decrypt(seal.content, conv_key)
|
|
||||||
rumor = NostrEvent(**json.loads(rumor_json))
|
rumor = NostrEvent(**json.loads(rumor_json))
|
||||||
|
|
||||||
if rumor.pubkey != seal.pubkey:
|
if rumor.pubkey != seal.pubkey:
|
||||||
|
|
@ -149,30 +195,37 @@ def unseal(
|
||||||
# --- Convenience functions ---
|
# --- Convenience functions ---
|
||||||
|
|
||||||
|
|
||||||
def wrap_message(
|
async def wrap_message(
|
||||||
content: str,
|
content: str,
|
||||||
sender_privkey: str,
|
sender_signer,
|
||||||
sender_pubkey: str,
|
|
||||||
recipient_pubkey: str,
|
recipient_pubkey: str,
|
||||||
kind: int = 14,
|
kind: int = 14,
|
||||||
tags: Optional[list[list[str]]] = None,
|
tags: Optional[list[list[str]]] = None,
|
||||||
) -> NostrEvent:
|
) -> 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.
|
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)
|
rumor = create_rumor(sender_signer.pubkey, content, kind=kind, tags=tags)
|
||||||
seal = create_seal(rumor, sender_privkey, recipient_pubkey)
|
seal = await create_seal(rumor, sender_signer, recipient_pubkey)
|
||||||
return create_gift_wrap(seal, recipient_pubkey)
|
return create_gift_wrap(seal, recipient_pubkey)
|
||||||
|
|
||||||
|
|
||||||
def unwrap_message(
|
async def unwrap_message(
|
||||||
gift_wrap: NostrEvent,
|
gift_wrap: NostrEvent,
|
||||||
recipient_privkey: str,
|
recipient_signer,
|
||||||
) -> NostrEvent:
|
) -> NostrEvent:
|
||||||
"""
|
"""
|
||||||
Full unwrap pipeline: gift wrap -> seal -> rumor.
|
Full unwrap pipeline: gift wrap → seal → rumor.
|
||||||
Returns the rumor with sender pubkey and plaintext content.
|
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)
|
seal = await unwrap_gift_wrap(gift_wrap, recipient_signer)
|
||||||
return unseal(seal, recipient_privkey)
|
return await unseal(seal, recipient_signer)
|
||||||
|
|
|
||||||
135
services.py
135
services.py
|
|
@ -3,8 +3,10 @@ import json
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from lnbits.bolt11 import decode
|
from lnbits.bolt11 import decode
|
||||||
from lnbits.core.crud import get_wallet
|
from lnbits.core.crud import get_account, get_wallet
|
||||||
from lnbits.core.services import create_invoice, websocket_updater
|
from lnbits.core.services import create_invoice, websocket_updater
|
||||||
|
from lnbits.core.signers import resolve_signer
|
||||||
|
from lnbits.core.signers.base import NostrSigner, SignerError
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from . import nostr_client
|
from . import nostr_client
|
||||||
|
|
@ -171,15 +173,57 @@ async def update_merchant_to_nostr(
|
||||||
return merchant
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_merchant_signer(merchant: Merchant) -> NostrSigner:
|
||||||
|
"""Resolve the lnbits NostrSigner for a merchant's owning account.
|
||||||
|
|
||||||
|
Post-#5 (aiolabs/nostrmarket#5), the merchant's nsec lives in the
|
||||||
|
bunker via the account's `signer_config`. No fast-path or caching
|
||||||
|
today — per-call lookup is fine for v1 throughput; if the events
|
||||||
|
extension or DM hot path becomes contended, revisit with a
|
||||||
|
process-local cache keyed on `merchant.user_id`.
|
||||||
|
|
||||||
|
Raises `SignerError` if the account can't be found or its signer
|
||||||
|
can't be resolved — callers should propagate, not silently skip,
|
||||||
|
so misconfigured rows surface loudly.
|
||||||
|
"""
|
||||||
|
account = await get_account(merchant.user_id)
|
||||||
|
if account is None:
|
||||||
|
raise SignerError(
|
||||||
|
f"merchant {merchant.id[:8]} references missing account "
|
||||||
|
f"{merchant.user_id[:8]} — can't resolve signer"
|
||||||
|
)
|
||||||
|
return resolve_signer(account)
|
||||||
|
|
||||||
|
|
||||||
async def sign_and_send_to_nostr(
|
async def sign_and_send_to_nostr(
|
||||||
merchant: Merchant, n: Nostrable, delete=False
|
merchant: Merchant, n: Nostrable, delete=False
|
||||||
) -> NostrEvent:
|
) -> NostrEvent:
|
||||||
|
"""Sign + publish a Nostrable as the merchant's identity.
|
||||||
|
|
||||||
|
Signing routes through the merchant's account `NostrSigner` (post-#5).
|
||||||
|
The signer fills `id` + `sig` server-side (bunker for the
|
||||||
|
`RemoteBunkerSigner` case) — this function builds the unsigned dict
|
||||||
|
shape, hands it to the signer, and copies the result back onto the
|
||||||
|
`NostrEvent` instance for the publisher.
|
||||||
|
"""
|
||||||
event = (
|
event = (
|
||||||
n.to_nostr_delete_event(merchant.public_key)
|
n.to_nostr_delete_event(merchant.public_key)
|
||||||
if delete
|
if delete
|
||||||
else n.to_nostr_event(merchant.public_key)
|
else n.to_nostr_event(merchant.public_key)
|
||||||
)
|
)
|
||||||
event.sig = merchant.sign_hash(bytes.fromhex(event.id))
|
|
||||||
|
signer = await _resolve_merchant_signer(merchant)
|
||||||
|
signed = await signer.sign_event(
|
||||||
|
{
|
||||||
|
"pubkey": event.pubkey,
|
||||||
|
"created_at": event.created_at,
|
||||||
|
"kind": event.kind,
|
||||||
|
"tags": event.tags,
|
||||||
|
"content": event.content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
event.id = signed["id"]
|
||||||
|
event.sig = signed["sig"]
|
||||||
await nostr_client.publish_nostr_event(event)
|
await nostr_client.publish_nostr_event(event)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
@ -189,7 +233,6 @@ async def provision_merchant(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
wallet_id: str,
|
wallet_id: str,
|
||||||
public_key: str,
|
public_key: str,
|
||||||
private_key: str,
|
|
||||||
display_name: Optional[str] = None,
|
display_name: Optional[str] = None,
|
||||||
config: Optional[MerchantConfig] = None,
|
config: Optional[MerchantConfig] = None,
|
||||||
) -> Merchant:
|
) -> Merchant:
|
||||||
|
|
@ -197,6 +240,13 @@ async def provision_merchant(
|
||||||
Provision a merchant with a default shipping zone and default stall,
|
Provision a merchant with a default shipping zone and default stall,
|
||||||
and publish the stall to Nostr relays.
|
and publish the stall to Nostr relays.
|
||||||
|
|
||||||
|
Post-aiolabs/nostrmarket#5: no `private_key` argument. The merchant
|
||||||
|
identity IS the lnbits account's identity (`public_key` parameter
|
||||||
|
must equal `account.pubkey` for the same `user_id`); signing routes
|
||||||
|
through the account's `NostrSigner` (`RemoteBunkerSigner` in the
|
||||||
|
target deployment). The merchant nsec lives in the bunker, never
|
||||||
|
server-side.
|
||||||
|
|
||||||
Single source of truth used by:
|
Single source of truth used by:
|
||||||
- LNbits user-creation hook (eager, on signup) — see
|
- LNbits user-creation hook (eager, on signup) — see
|
||||||
lnbits/core/services/users.py:_create_default_merchant
|
lnbits/core/services/users.py:_create_default_merchant
|
||||||
|
|
@ -211,7 +261,6 @@ async def provision_merchant(
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
partial_merchant = PartialMerchant(
|
partial_merchant = PartialMerchant(
|
||||||
private_key=private_key,
|
|
||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
config=config or MerchantConfig(),
|
config=config or MerchantConfig(),
|
||||||
)
|
)
|
||||||
|
|
@ -226,10 +275,11 @@ async def provision_merchant(
|
||||||
)
|
)
|
||||||
await create_zone(merchant.id, online_zone)
|
await create_zone(merchant.id, online_zone)
|
||||||
|
|
||||||
name = display_name or "My"
|
raw_owner_name = display_name or "My"
|
||||||
|
owner_name = raw_owner_name[:1].upper() + raw_owner_name[1:]
|
||||||
default_stall = Stall(
|
default_stall = Stall(
|
||||||
wallet=wallet_id,
|
wallet=wallet_id,
|
||||||
name=f"{name}'s Store",
|
name=f"{owner_name}'s Store",
|
||||||
currency="sat",
|
currency="sat",
|
||||||
shipping_zones=[online_zone],
|
shipping_zones=[online_zone],
|
||||||
)
|
)
|
||||||
|
|
@ -238,18 +288,57 @@ async def provision_merchant(
|
||||||
# Publish the kind 30017 stall event so customers' clients can resolve
|
# Publish the kind 30017 stall event so customers' clients can resolve
|
||||||
# the stall name when they fetch products. Non-fatal on failure: a
|
# the stall name when they fetch products. Non-fatal on failure: a
|
||||||
# later product publish (or webapp self-heal) will retry.
|
# later product publish (or webapp self-heal) will retry.
|
||||||
|
#
|
||||||
|
# Fire-and-forget: `nostr_client.publish_nostr_event` has no per-relay
|
||||||
|
# deadline and will block indefinitely if every configured relay is
|
||||||
|
# unreachable (cf. aiolabs/nostrmarket#7). When `provision_merchant`
|
||||||
|
# is called from the eager signup hook (lnbits/core/services/users.py
|
||||||
|
# ::_create_default_merchant, aiolabs/lnbits#46), inline-awaiting that
|
||||||
|
# publish hangs the uvicorn worker on `POST /auth/register` forever.
|
||||||
|
# The DB rows we just wrote are sufficient to serve the wallet UI;
|
||||||
|
# the stall event_id gets backfilled when the publish completes (or
|
||||||
|
# stays NULL until a later resubscribe-driven republish lands it).
|
||||||
|
asyncio.create_task(
|
||||||
|
_publish_default_stall_background(merchant.id, merchant, default_stall)
|
||||||
|
)
|
||||||
|
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
# Generous bound: signing through the bunker can take 1–2 s on a cold
|
||||||
|
# session, plus the relay publish itself. 30 s is well over both, and
|
||||||
|
# the cap matters only when the relay set is unreachable.
|
||||||
|
STALL_PUBLISH_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
async def _publish_default_stall_background(
|
||||||
|
merchant_id: str, merchant: Merchant, default_stall: Stall
|
||||||
|
) -> None:
|
||||||
|
"""Background helper for `provision_merchant`'s default-stall publish.
|
||||||
|
|
||||||
|
Bounded by `STALL_PUBLISH_TIMEOUT_S` so even a permanently-unreachable
|
||||||
|
relay set doesn't pin an asyncio task forever. Errors and timeouts are
|
||||||
|
logged at warning — never raised, since the caller scheduled-and-forgot.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
stall_event = await sign_and_send_to_nostr(merchant, default_stall)
|
stall_event = await asyncio.wait_for(
|
||||||
|
sign_and_send_to_nostr(merchant, default_stall),
|
||||||
|
timeout=STALL_PUBLISH_TIMEOUT_S,
|
||||||
|
)
|
||||||
default_stall.event_id = stall_event.id
|
default_stall.event_id = stall_event.id
|
||||||
await update_stall(merchant.id, default_stall)
|
await update_stall(merchant_id, default_stall)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"[NOSTRMARKET] Default stall publish for merchant "
|
||||||
|
f"{merchant_id} timed out after {STALL_PUBLISH_TIMEOUT_S}s; "
|
||||||
|
f"event_id stays NULL until a later republish lands it"
|
||||||
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[NOSTRMARKET] Failed to publish default stall for "
|
f"[NOSTRMARKET] Failed to publish default stall for "
|
||||||
f"merchant {merchant.id}: {ex}"
|
f"merchant {merchant_id}: {ex}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return merchant
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_order_paid(order_id: str, merchant_pubkey: str):
|
async def handle_order_paid(order_id: str, merchant_pubkey: str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -343,11 +432,15 @@ async def send_dm(
|
||||||
type_: int,
|
type_: int,
|
||||||
dm_content: str,
|
dm_content: str,
|
||||||
) -> DirectMessage:
|
) -> DirectMessage:
|
||||||
|
# Post-#5: nsec stays in the bunker; both the to-recipient wrap and
|
||||||
|
# the to-self archival wrap route their seal-layer crypto through
|
||||||
|
# the merchant's NostrSigner.
|
||||||
|
signer = await _resolve_merchant_signer(merchant)
|
||||||
|
|
||||||
# Wrap message to recipient via NIP-59 gift wrap
|
# Wrap message to recipient via NIP-59 gift wrap
|
||||||
gift_wrap = wrap_message(
|
gift_wrap = await wrap_message(
|
||||||
dm_content,
|
dm_content,
|
||||||
merchant.private_key,
|
signer,
|
||||||
merchant.public_key,
|
|
||||||
other_pubkey,
|
other_pubkey,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -363,10 +456,9 @@ async def send_dm(
|
||||||
await nostr_client.publish_nostr_event(gift_wrap)
|
await nostr_client.publish_nostr_event(gift_wrap)
|
||||||
|
|
||||||
# Also wrap a copy to self for archival
|
# Also wrap a copy to self for archival
|
||||||
self_wrap = wrap_message(
|
self_wrap = await wrap_message(
|
||||||
dm_content,
|
dm_content,
|
||||||
merchant.private_key,
|
signer,
|
||||||
merchant.public_key,
|
|
||||||
merchant.public_key,
|
merchant.public_key,
|
||||||
)
|
)
|
||||||
await nostr_client.publish_nostr_event(self_wrap)
|
await nostr_client.publish_nostr_event(self_wrap)
|
||||||
|
|
@ -541,7 +633,8 @@ async def _handle_gift_wrap(event: NostrEvent):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rumor = unwrap_message(event, merchant.private_key)
|
recipient_signer = await _resolve_merchant_signer(merchant)
|
||||||
|
rumor = await unwrap_message(event, recipient_signer)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
|
logger.error(f"[NOSTRMARKET] ❌ Failed to unwrap gift wrap {event.id}: {ex}")
|
||||||
return
|
return
|
||||||
|
|
@ -657,10 +750,10 @@ async def _persist_dm(
|
||||||
async def reply_to_structured_dm(
|
async def reply_to_structured_dm(
|
||||||
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
merchant: Merchant, customer_pubkey: str, dm_type: int, dm_reply: str
|
||||||
):
|
):
|
||||||
gift_wrap = wrap_message(
|
signer = await _resolve_merchant_signer(merchant)
|
||||||
|
gift_wrap = await wrap_message(
|
||||||
dm_reply,
|
dm_reply,
|
||||||
merchant.private_key,
|
signer,
|
||||||
merchant.public_key,
|
|
||||||
customer_pubkey,
|
customer_pubkey,
|
||||||
)
|
)
|
||||||
dm = PartialDirectMessage(
|
dm = PartialDirectMessage(
|
||||||
|
|
|
||||||
|
|
@ -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 json
|
||||||
import time
|
import time
|
||||||
|
|
@ -6,6 +14,10 @@ import time
|
||||||
import coincurve
|
import coincurve
|
||||||
import pytest
|
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 (
|
from nostr.nip59 import (
|
||||||
create_gift_wrap,
|
create_gift_wrap,
|
||||||
create_rumor,
|
create_rumor,
|
||||||
|
|
@ -25,8 +37,48 @@ def _generate_keypair() -> tuple[str, str]:
|
||||||
return privkey, pubkey
|
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()
|
SENDER_PRIV, SENDER_PUB = _generate_keypair()
|
||||||
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
|
RECIPIENT_PRIV, RECIPIENT_PUB = _generate_keypair()
|
||||||
|
SENDER_SIGNER = _LocalSignerStub(SENDER_PRIV)
|
||||||
|
RECIPIENT_SIGNER = _LocalSignerStub(RECIPIENT_PRIV)
|
||||||
|
|
||||||
|
|
||||||
class TestCreateRumor:
|
class TestCreateRumor:
|
||||||
|
|
@ -49,28 +101,32 @@ class TestCreateRumor:
|
||||||
|
|
||||||
|
|
||||||
class TestCreateSeal:
|
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")
|
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.kind == 13
|
||||||
assert seal.tags == []
|
assert seal.tags == []
|
||||||
assert seal.pubkey == SENDER_PUB
|
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")
|
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 seal.sig is not None
|
||||||
assert len(seal.sig) == 128 # 64 bytes hex
|
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")
|
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
|
# Content should be base64 NIP-44 payload, not plaintext JSON
|
||||||
assert "hello" not in seal.content
|
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")
|
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())
|
now = int(time.time())
|
||||||
# Seal timestamp should be in the past (up to 2 days)
|
# Seal timestamp should be in the past (up to 2 days)
|
||||||
assert seal.created_at <= now
|
assert seal.created_at <= now
|
||||||
|
|
@ -78,98 +134,108 @@ class TestCreateSeal:
|
||||||
|
|
||||||
|
|
||||||
class TestCreateGiftWrap:
|
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")
|
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)
|
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
||||||
assert wrap.kind == 1059
|
assert wrap.kind == 1059
|
||||||
assert ["p", RECIPIENT_PUB] in wrap.tags
|
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")
|
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)
|
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
||||||
# Gift wrap pubkey should be neither sender nor recipient
|
# Gift wrap pubkey should be neither sender nor recipient
|
||||||
assert wrap.pubkey != SENDER_PUB
|
assert wrap.pubkey != SENDER_PUB
|
||||||
assert wrap.pubkey != RECIPIENT_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")
|
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)
|
wrap1 = create_gift_wrap(seal, RECIPIENT_PUB)
|
||||||
wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
|
wrap2 = create_gift_wrap(seal, RECIPIENT_PUB)
|
||||||
assert wrap1.pubkey != wrap2.pubkey
|
assert wrap1.pubkey != wrap2.pubkey
|
||||||
|
|
||||||
|
|
||||||
class TestUnwrap:
|
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")
|
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)
|
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.kind == 13
|
||||||
assert recovered_seal.pubkey == SENDER_PUB
|
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")
|
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.content == "hello world"
|
||||||
assert recovered_rumor.pubkey == SENDER_PUB
|
assert recovered_rumor.pubkey == SENDER_PUB
|
||||||
assert recovered_rumor.kind == 14
|
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")
|
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)
|
wrap = create_gift_wrap(seal, RECIPIENT_PUB)
|
||||||
|
|
||||||
wrong_priv, _ = _generate_keypair()
|
wrong_priv, _ = _generate_keypair()
|
||||||
|
wrong_signer = _LocalSignerStub(wrong_priv)
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
unwrap_message(wrap, wrong_priv)
|
await unwrap_message(wrap, wrong_signer)
|
||||||
|
|
||||||
|
|
||||||
class TestFullRoundTrip:
|
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?"
|
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 wrap.kind == 1059
|
||||||
assert ["p", RECIPIENT_PUB] in wrap.tags
|
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.content == content
|
||||||
assert rumor.pubkey == SENDER_PUB
|
assert rumor.pubkey == SENDER_PUB
|
||||||
assert rumor.kind == 14
|
assert rumor.kind == 14
|
||||||
assert rumor.sig is None
|
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"]]
|
tags = [["p", RECIPIENT_PUB], ["subject", "test"]]
|
||||||
wrap = wrap_message(
|
wrap = await wrap_message(
|
||||||
"order data",
|
"order data",
|
||||||
SENDER_PRIV,
|
SENDER_SIGNER,
|
||||||
SENDER_PUB,
|
|
||||||
RECIPIENT_PUB,
|
RECIPIENT_PUB,
|
||||||
kind=14,
|
kind=14,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
rumor = unwrap_message(wrap, RECIPIENT_PRIV)
|
rumor = await unwrap_message(wrap, RECIPIENT_SIGNER)
|
||||||
assert rumor.content == "order data"
|
assert rumor.content == "order data"
|
||||||
assert rumor.kind == 14
|
assert rumor.kind == 14
|
||||||
assert ["subject", "test"] in rumor.tags
|
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)."""
|
"""Merchant wraps a copy to self (same sender and recipient)."""
|
||||||
content = '{"type": 1, "payment_options": [{"type": "ln", "link": "lnbc..."}]}'
|
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.content == content
|
||||||
assert rumor.pubkey == SENDER_PUB
|
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 JSON payloads survive the wrap/unwrap cycle."""
|
||||||
order = {
|
order = {
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -178,14 +244,15 @@ class TestFullRoundTrip:
|
||||||
"shipping_id": "zone-1",
|
"shipping_id": "zone-1",
|
||||||
}
|
}
|
||||||
content = json.dumps(order)
|
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)
|
recovered_order = json.loads(rumor.content)
|
||||||
assert recovered_order == order
|
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"
|
content = "Payment received! \u2705 Your order is being processed \U0001f4e6"
|
||||||
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)
|
||||||
assert rumor.content == content
|
assert rumor.content == content
|
||||||
|
|
|
||||||
54
views_api.py
54
views_api.py
|
|
@ -38,7 +38,7 @@ from .crud import (
|
||||||
get_last_direct_messages_time,
|
get_last_direct_messages_time,
|
||||||
get_merchant_by_pubkey,
|
get_merchant_by_pubkey,
|
||||||
get_merchant_for_user,
|
get_merchant_for_user,
|
||||||
update_merchant_keys,
|
update_merchant_pubkey,
|
||||||
get_order,
|
get_order,
|
||||||
get_order_by_event_id,
|
get_order_by_event_id,
|
||||||
get_orders,
|
get_orders,
|
||||||
|
|
@ -100,38 +100,28 @@ async def _auto_create_merchant(
|
||||||
) -> Merchant:
|
) -> Merchant:
|
||||||
"""
|
"""
|
||||||
Lazy fallback: provision a merchant from the user's account keypair when
|
Lazy fallback: provision a merchant from the user's account keypair when
|
||||||
the LNbits-side eager provisioning didn't run.
|
the LNbits-side eager provisioning didn't run (e.g., older accounts, or
|
||||||
|
upstream LNbits without our signup hook).
|
||||||
|
|
||||||
Delegates to services.provision_merchant — the canonical implementation.
|
Post-aiolabs/nostrmarket#5: the merchant identity IS the lnbits
|
||||||
|
account identity. No `private_key` is read here — signing routes
|
||||||
Pre-cascade bridge state (see aiolabs/nostrmarket#5):
|
through the account's `NostrSigner` (which holds a
|
||||||
After aiolabs/lnbits#17 m002 lands, `accounts.prvkey` is fail-closed
|
`RemoteBunkerSigner` in our target deployment, with the nsec
|
||||||
NULL'd for migrated accounts (the cleartext nsec lives encrypted in
|
living entirely in the bunker). The only precondition is that the
|
||||||
`signer_config`, owned by the core signer abstraction). Auto-provision
|
account already has a `pubkey` — every post-#9 account does, since
|
||||||
cannot extract that cleartext to copy into `merchants.private_key`,
|
`create_account` provisions one via the bunker on signup.
|
||||||
so this path fails-closed when prvkey is missing. The proper fix is
|
|
||||||
phase A (envelope-encrypt `merchants.private_key` → `signer_blob`)
|
|
||||||
followed by phase B (route `Merchant.sign_hash` through core's
|
|
||||||
`NostrSigner`) per aiolabs/nostrmarket#5. Until then, migrated
|
|
||||||
accounts must explicitly provision a merchant through the future
|
|
||||||
phase-A-aware flow.
|
|
||||||
|
|
||||||
The previous regenerate-and-write-back block (generated a fresh
|
|
||||||
keypair and stored it into the account) was removed because it
|
|
||||||
would silently undo m002's NULL'ing.
|
|
||||||
"""
|
"""
|
||||||
account = await get_account(wallet.wallet.user)
|
account = await get_account(wallet.wallet.user)
|
||||||
assert account, "User account not found"
|
assert account, "User account not found"
|
||||||
assert account.pubkey and account.prvkey, (
|
assert account.pubkey, (
|
||||||
"Account has no plaintext Nostr keypair available for merchant "
|
"Account has no Nostr pubkey — must be migrated to RemoteBunkerSigner "
|
||||||
"provisioning (see aiolabs/nostrmarket#5 for the phase A/B fix)"
|
"before a merchant can be provisioned (see aiolabs/nostrmarket#5)"
|
||||||
)
|
)
|
||||||
|
|
||||||
merchant = await provision_merchant(
|
merchant = await provision_merchant(
|
||||||
user_id=wallet.wallet.user,
|
user_id=wallet.wallet.user,
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
public_key=account.pubkey,
|
public_key=account.pubkey,
|
||||||
private_key=account.prvkey,
|
|
||||||
display_name=account.username,
|
display_name=account.username,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
@ -255,14 +245,7 @@ async def api_migrate_merchant_keys(
|
||||||
assert merchant.id == merchant_id, "Wrong merchant ID"
|
assert merchant.id == merchant_id, "Wrong merchant ID"
|
||||||
|
|
||||||
account = await get_account(wallet.wallet.user)
|
account = await get_account(wallet.wallet.user)
|
||||||
# account.prvkey is fail-closed NULL'd by aiolabs/lnbits#17 m002
|
assert account and account.pubkey, "Account has no Nostr pubkey"
|
||||||
# for migrated accounts. Rotation cannot copy a cleartext nsec
|
|
||||||
# into merchants.private_key until phase A lands — see
|
|
||||||
# aiolabs/nostrmarket#5 for the migration plan.
|
|
||||||
assert account and account.pubkey and account.prvkey, (
|
|
||||||
"Account has no plaintext Nostr keypair available for key "
|
|
||||||
"rotation (see aiolabs/nostrmarket#5 for the phase A/B fix)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if account.pubkey == merchant.public_key:
|
if account.pubkey == merchant.public_key:
|
||||||
return merchant # already in sync
|
return merchant # already in sync
|
||||||
|
|
@ -275,10 +258,11 @@ async def api_migrate_merchant_keys(
|
||||||
|
|
||||||
old_pubkey = merchant.public_key
|
old_pubkey = merchant.public_key
|
||||||
|
|
||||||
# Update merchant keys in DB
|
# Post-aiolabs/nostrmarket#5: re-pointing only the pubkey; the
|
||||||
merchant = await update_merchant_keys(
|
# signing nsec lives in the bunker and is keyed on account.id,
|
||||||
wallet.wallet.user, merchant.id,
|
# which is unchanged. No private_key column to update.
|
||||||
account.prvkey, account.pubkey,
|
merchant = await update_merchant_pubkey(
|
||||||
|
wallet.wallet.user, merchant.id, account.pubkey,
|
||||||
)
|
)
|
||||||
assert merchant
|
assert merchant
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue