stop the generic wallet PATCH from being able to replace the seed

the route typed its body as {name, defaultBip, config} and passed the
parsed object straight to updateWallet, which also accepted sealedSeed
for the passphrase change. a TypeScript annotation strips nothing at
runtime, so any authenticated caller could send a sealedSeed key and
overwrite the encrypted seed — no passphrase, no unlock. encryptSecret
encrypts nonsense happily, so the damage would have surfaced at the next
unlock, not at the write.

updateWallet can no longer touch the seed at all; resealing moves to
replaceSealedSeed, whose only caller has already proved knowledge of the
old passphrase. the route rebuilds its patch field by field as well, so
the next field added there cannot re-open it.

verified against the test wallet: the envelope is byte-identical after
the same request that previously would have replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:20:12 +00:00
co-authored by Claude Opus 5
parent d2290baa56
commit 1d9a648ff7
3 changed files with 34 additions and 4 deletions
+1
View File
@@ -208,6 +208,7 @@ export {
getSealedSeed,
createWallet,
updateWallet,
replaceSealedSeed,
setActiveWallet,
deleteWallet,
getWalletLabels,
+23 -2
View File
@@ -176,16 +176,19 @@ export async function createWallet(params: CreateWalletParams): Promise<WalletSu
});
}
/**
* Mutable wallet metadata. CANNOT TOUCH THE SEED — see replaceSealedSeed below for why that is a
* separate function rather than one more optional field here.
*/
export async function updateWallet(
userId: number,
id: number,
patch: { name?: string; config?: Record<string, unknown>; defaultBip?: number; sealedSeed?: string },
patch: { name?: string; config?: Record<string, unknown>; defaultBip?: number },
): Promise<WalletSummary | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (patch.name !== undefined) set.name = patch.name;
if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
if (patch.config !== undefined) set.config = encryptSecret(JSON.stringify(patch.config));
if (patch.sealedSeed !== undefined) set.seedEnvelope = encryptSecret(patch.sealedSeed);
const [row] = await db
.update(walletWallets)
@@ -195,6 +198,24 @@ export async function updateWallet(
return row ? toSummary(row) : null;
}
/**
* Overwrite the encrypted seed. The ONLY caller is the passphrase change, which reseals the same words
* under a new passphrase and has already proved knowledge of the old one.
*
* It is a function of its own, rather than a fourth field on updateWallet, because it was one: the PATCH
* route typed its request body as `{name, defaultBip, config}` and passed the parsed object straight
* through, and a TypeScript annotation strips nothing at runtime. Any authenticated caller could send a
* `sealedSeed` key and replace the seed — no passphrase, no unlock, no confirmation — and since
* encryptSecret happily encrypts nonsense, the damage only surfaced at the next unlock attempt. Keeping
* the two apart means the next field added to that body cannot re-open it.
*/
export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise<void> {
await db
.update(walletWallets)
.set({ seedEnvelope: encryptSecret(sealedSeed), updatedAt: new Date() })
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
}
/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */
export async function setActiveWallet(userId: number, id: number): Promise<void> {
await db.transaction(async (tx) => {
+10 -2
View File
@@ -4,6 +4,7 @@ import {
getSealedSeed,
createWallet,
updateWallet,
replaceSealedSeed,
setActiveWallet,
deleteWallet,
getActiveWallet,
@@ -173,7 +174,14 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
if (name.length > 64) return badRequest('name is too long (64 characters max)');
patch.name = name;
}
const updated = await updateWallet(userId, walletId, patch);
// Rebuilt field by field rather than forwarded: `body<T>` is a cast, so the parsed object holds
// whatever the caller sent, not what the type says. updateWallet can no longer write the seed
// either — that is the belt to this brace.
const updated = await updateWallet(userId, walletId, {
name: patch.name,
defaultBip: patch.defaultBip,
config: patch.config,
});
invalidate(walletId);
return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
}
@@ -520,7 +528,7 @@ async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Pro
const env = await loadEnvelope(ctx.userId, walletId);
const resealed = await changePassphrase(env, oldPassphrase, newPassphrase);
await updateWallet(ctx.userId, walletId, { sealedSeed: JSON.stringify(resealed) });
await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed));
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
sessionFor(walletId).lock();
return json({ ok: true });