Add keypair rotation detection and migration feature
Some checks failed
ci.yml / Add keypair rotation detection and migration feature (pull_request) Failing after 0s

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-27 12:38:00 +02:00
commit e2fb28e90e
5 changed files with 142 additions and 0 deletions

20
crud.py
View file

@ -55,6 +55,26 @@ 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(
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]: async def touch_merchant(user_id: str, merchant_id: str) -> Optional[Merchant]:
await db.execute( await db.execute(
f""" f"""

View file

@ -43,6 +43,8 @@ class MerchantConfig(MerchantProfile):
# TODO: switched to True for AIO demo; determine if we leave this as True # TODO: switched to True for AIO demo; determine if we leave this as True
active: bool = True active: bool = True
restore_in_progress: Optional[bool] = False restore_in_progress: Optional[bool] = False
# Set at runtime (not persisted) when account keypair != merchant keypair
key_mismatch: Optional[bool] = False
class CreateMerchantRequest(BaseModel): class CreateMerchantRequest(BaseModel):

View file

@ -36,6 +36,30 @@ window.app = Vue.createApp({
} }
}, },
methods: { 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 () { toggleShowKeys: function () {
this.showKeys = !this.showKeys this.showKeys = !this.showKeys
}, },

View file

@ -3,6 +3,26 @@
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-7 col-lg-8 q-gutter-y-md"> <div class="col-12 col-md-7 col-lg-8 q-gutter-y-md">
<div v-if="merchant && merchant.id"> <div v-if="merchant && merchant.id">
<q-banner
v-if="merchant.config && merchant.config.key_mismatch"
class="bg-warning text-white q-mb-md"
rounded
>
<template v-slot:avatar>
<q-icon name="warning" color="white"></q-icon>
</template>
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.
<template v-slot:action>
<q-btn
flat
color="white"
label="Migrate Keys"
@click="migrateKeys"
></q-btn>
</template>
</q-banner>
<q-card> <q-card>
<div class="row items-center no-wrap"> <div class="row items-center no-wrap">
<q-tabs <q-tabs

View file

@ -39,6 +39,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,
get_order, get_order,
get_order_by_event_id, get_order_by_event_id,
get_orders, get_orders,
@ -184,6 +185,11 @@ async def api_get_merchant(
assert merchant.time assert merchant.time
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30 merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
# Detect keypair rotation: account key no longer matches merchant key
account = await get_account(wallet.wallet.user)
if account and account.pubkey and account.pubkey != merchant.public_key:
merchant.config.key_mismatch = True
return merchant return merchant
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
@ -229,6 +235,76 @@ async def api_delete_merchant(
await subscribe_to_all_merchants() await subscribe_to_all_merchants()
@nostrmarket_ext.post("/api/v1/merchant/{merchant_id}/migrate-keys")
async def api_migrate_merchant_keys(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> 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}") @nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}")
async def api_update_merchant( async def api_update_merchant(
merchant_id: str, merchant_id: str,