From 25023df8bd0b2a995cd7cd7b276dc5d8972a8299 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 27 Apr 2026 12:38:00 +0200 Subject: [PATCH] Add keypair rotation detection and migration feature When a user rotates their Nostr keypair in account settings, the merchant still holds the old key. This adds: - key_mismatch flag on MerchantConfig (runtime, not persisted) - detected on each GET /api/v1/merchant by comparing account vs merchant pubkey - POST /api/v1/merchant/{id}/migrate-keys endpoint that updates the merchant keys, republishes all stalls/products under the new identity, and resubscribes - Warning banner in the UI with a "Migrate Keys" button and confirmation dialog - update_merchant_keys() crud function Orders and DM history are preserved since they reference customer pubkeys. Old stall/product events on relays become orphaned. Co-Authored-By: Claude Opus 4.6 (1M context) --- crud.py | 20 +++++++++ models.py | 2 + static/js/index.js | 24 ++++++++++ templates/nostrmarket/index.html | 20 +++++++++ views_api.py | 76 ++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+) diff --git a/crud.py b/crud.py index adc0836..7bb799b 100644 --- a/crud.py +++ b/crud.py @@ -55,6 +55,26 @@ async def update_merchant( return await get_merchant(user_id, merchant_id) +async def update_merchant_keys( + user_id: str, merchant_id: str, private_key: str, public_key: str +) -> Optional[Merchant]: + await db.execute( + f""" + UPDATE nostrmarket.merchants + SET private_key = :private_key, public_key = :public_key, + time = {db.timestamp_now} + WHERE id = :id AND user_id = :user_id + """, + { + "private_key": private_key, + "public_key": public_key, + "id": merchant_id, + "user_id": user_id, + }, + ) + return await get_merchant(user_id, merchant_id) + + async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]: await db.execute( f""" diff --git a/models.py b/models.py index 2c24dee..c766cc2 100644 --- a/models.py +++ b/models.py @@ -43,6 +43,8 @@ class MerchantConfig(MerchantProfile): # TODO: switched to True for AIO demo; determine if we leave this as True active: bool = True restore_in_progress: Optional[bool] = False + # Set at runtime (not persisted) when account keypair != merchant keypair + key_mismatch: Optional[bool] = False class CreateMerchantRequest(BaseModel): diff --git a/static/js/index.js b/static/js/index.js index e180d85..f5d2e62 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -36,6 +36,30 @@ window.app = Vue.createApp({ } }, methods: { + migrateKeys: async function () { + LNbits.utils + .confirmDialog( + 'This will update your merchant to use your current account Nostr keypair ' + + 'and republish all stalls and products under the new identity. ' + + 'Existing orders and messages are preserved. Continue?' + ) + .onOk(async () => { + try { + const {data} = await LNbits.api.request( + 'POST', + `/nostrmarket/api/v1/merchant/${this.merchant.id}/migrate-keys`, + this.g.user.wallets[0].adminkey + ) + this.merchant = data + this.$q.notify({ + type: 'positive', + message: 'Merchant keys migrated and stalls republished' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }) + }, toggleShowKeys: function () { this.showKeys = !this.showKeys }, diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index 2b6ea41..c4f7931 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -3,6 +3,26 @@
+ + + Your account Nostr keypair has changed since this merchant was created. + The merchant is still using the old key. Migrate to republish your + stalls and products under the new identity. + +
Merchant: + """ + Migrate a merchant to the current account keypair. + + When a user rotates their Nostr keypair, the merchant still holds the old + key. This endpoint updates the merchant's keys to match the account, + then republishes all stalls and products under the new identity. + + Orders and DM history are preserved (they reference customer pubkeys, + not the merchant key). Old stall/product events on relays become + orphaned — clients following the new pubkey will see the fresh events. + """ + try: + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Merchant cannot be found" + assert merchant.id == merchant_id, "Wrong merchant ID" + + account = await get_account(wallet.wallet.user) + assert account and account.pubkey and account.prvkey, ( + "Account has no Nostr keypair" + ) + + if account.pubkey == merchant.public_key: + return merchant # already in sync + + # Check no other merchant is using the new pubkey + existing = await get_merchant_by_pubkey(account.pubkey) + assert existing is None, ( + "Another merchant already uses this public key" + ) + + old_pubkey = merchant.public_key + + # Update merchant keys in DB + merchant = await update_merchant_keys( + wallet.wallet.user, merchant.id, + account.prvkey, account.pubkey, + ) + assert merchant + + # Republish all stalls and products under the new key + merchant = await update_merchant_to_nostr(merchant) + + logger.info( + f"[NOSTRMARKET] Migrated merchant {merchant.id} " + f"from {old_pubkey[:16]}... to {account.pubkey[:16]}..." + ) + + # Resubscribe with new pubkey + await resubscribe_to_all_merchants() + + return merchant + + except AssertionError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) from ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot migrate merchant keys", + ) from ex + + @nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}") async def api_update_merchant( merchant_id: str,