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) <noreply@anthropic.com>
This commit is contained in:
parent
5c38947fc6
commit
25023df8bd
5 changed files with 142 additions and 0 deletions
20
crud.py
20
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"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,26 @@
|
|||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 col-lg-8 q-gutter-y-md">
|
||||
<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>
|
||||
<div class="row items-center no-wrap">
|
||||
<q-tabs
|
||||
|
|
|
|||
76
views_api.py
76
views_api.py
|
|
@ -39,6 +39,7 @@ from .crud import (
|
|||
get_last_direct_messages_time,
|
||||
get_merchant_by_pubkey,
|
||||
get_merchant_for_user,
|
||||
update_merchant_keys,
|
||||
get_order,
|
||||
get_order_by_event_id,
|
||||
get_orders,
|
||||
|
|
@ -184,6 +185,11 @@ async def api_get_merchant(
|
|||
assert merchant.time
|
||||
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
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
|
@ -229,6 +235,76 @@ async def api_delete_merchant(
|
|||
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}")
|
||||
async def api_update_merchant(
|
||||
merchant_id: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue