fix(admin): make create_new_key idempotent — never clobber an existing key (#39) #40

Merged
padreug merged 1 commit from fix-create-new-key-idempotent into dev 2026-06-21 14:01:48 +00:00
Showing only changes of commit 2cf3e55bf4 - Show all commits

fix(admin): make create_new_key idempotent — never clobber an existing key
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled

`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-<id>` 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.
Padreug 2026-06-21 15:51:13 +02:00

View file

@ -2,6 +2,8 @@ import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } fr
import AdminInterface from "../index.js"; import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js"; import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import { saveEncrypted } from "../../../commands/add.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"; import { setupSkeletonProfile } from "../../lib/profile.js";
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) { 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 (!keyName || !passphrase) throw new Error("Invalid params");
if (!admin.loadNsec) throw new Error("No unlockKey method"); 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-<id>`) 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; let key;
if (_nsec) { if (_nsec) {