Add keypair rotation detection and migration feature
Some checks failed
ci.yml / Add keypair rotation detection and migration feature (pull_request) Failing after 0s
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:
parent
8606dce908
commit
e2fb28e90e
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)
|
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"""
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
76
views_api.py
76
views_api.py
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue