From 2cf3e55bf43e5c7c8493d4301c8a475ffa6f18b2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 15:51:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(admin):=20make=20create=5Fnew=5Fkey=20idemp?= =?UTF-8?q?otent=20=E2=80=94=20never=20clobber=20an=20existing=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_new_key` unconditionally generated a fresh keypair and let `saveEncrypted` overwrite `config.keys[keyName]` on disk. So calling it with a name that already exists SILENTLY DESTROYED the in-use signing key: the old encrypted nsec was overwritten, surviving only in the running process's in-memory Backend until the next restart, then gone. This breaks the contract callers already rely on. spirekeeper's `pair_spire` re-pairs a machine through the same `spire-` keyName and documents the assumption verbatim — "create_new_key is idempotent — returns the existing key if the name is taken" (pairing.py). It wasn't: a re-pair rotated the machine's spire identity and orphaned everything bound to the old key (tokens, beacons, wallet routing), unrecoverably. Guard at the top of the command: if a key with this name already exists, recover and return it instead of generating + overwriting. - no `_nsec`: decrypt the existing entry with the supplied passphrase and return its npub (the idempotent re-pair path). - explicit `_nsec`, or an unrecognized/!iv/!data entry, or a passphrase that doesn't decrypt the existing key: throw rather than overwrite — destroying a key must never be a silent side effect of "create". Functionally verified on the dev bunker: a repeat `/pair` for an already-paired machine now returns the existing spire pubkey with the on-disk key entry unchanged, where before it minted a new identity and clobbered the old blob. --- src/daemon/admin/commands/create_new_key.ts | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/daemon/admin/commands/create_new_key.ts b/src/daemon/admin/commands/create_new_key.ts index 468c3cf..cdedcd3 100644 --- a/src/daemon/admin/commands/create_new_key.ts +++ b/src/daemon/admin/commands/create_new_key.ts @@ -2,6 +2,8 @@ import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } fr import AdminInterface from "../index.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { saveEncrypted } from "../../../commands/add.js"; +import { getCurrentConfig } from "../../../config/index.js"; +import { decryptNsec } from "../../../config/keys.js"; import { setupSkeletonProfile } from "../../lib/profile.js"; export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) { @@ -10,6 +12,42 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq if (!keyName || !passphrase) throw new Error("Invalid params"); if (!admin.loadNsec) throw new Error("No unlockKey method"); + // Idempotency guard. Callers re-pair a machine through the same keyName + // (e.g. spirekeeper's `spire-`) and rely on create_new_key being + // idempotent — "returns the existing key if the name is taken". Without + // this guard the command unconditionally generated a FRESH key and + // `saveEncrypted` overwrote the existing encrypted blob on disk, silently + // destroying the in-use signing identity (unrecoverable — the old nsec then + // survives only in the running process until the next restart). NEVER + // generate-and-overwrite an existing name: recover and return it instead. + const currentConfig = await getCurrentConfig(admin.configFile); + const existing = currentConfig.keys?.[keyName]; + if (existing) { + if (_nsec) { + throw new Error( + `key '${keyName}' already exists; refusing to overwrite it with an ` + + `imported nsec — delete it explicitly first if replacement is intended`, + ); + } + if (!existing.iv || !existing.data) { + throw new Error( + `key '${keyName}' already exists in an unrecognized form; refusing to overwrite`, + ); + } + let existingNsec: string; + try { + existingNsec = decryptNsec(existing.iv, existing.data, passphrase); + } catch (e: any) { + throw new Error( + `key '${keyName}' already exists but the supplied passphrase did not ` + + `decrypt it; refusing to overwrite (${e.message})`, + ); + } + const existingUser = await new NDKPrivateKeySigner(existingNsec).user(); + const result = JSON.stringify({ npub: existingUser.npub }); + return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND); + } + let key; if (_nsec) {