fix: bind removal plans to full key identity

Require both the current record ID and decrypted full key to match a prepared removal plan before deleting local state.

Invalidate mismatched plans before returning KEYRING_REMOVE_PLAN_INVALID so a replacement with the same canonical ID cannot be deleted and the stale plan cannot be reused.

Cover normal, idempotent, rebased, duplicate-ID, and same-ID replacement removal behavior while preserving the replacement generation.
This commit is contained in:
Sucukdeluxe
2026-08-22 16:13:43 +02:00
parent c5d4f4aef8
commit 15c363912f
2 changed files with 32 additions and 1 deletions
+6 -1
View File
@@ -507,10 +507,15 @@ function createOnlineBackupKeyring({
const state = await readState(); const state = await readState();
const blockingIssue = firstBlockingIssue(state); const blockingIssue = firstBlockingIssue(state);
if (blockingIssue) throw issueError(blockingIssue); if (blockingIssue) throw issueError(blockingIssue);
if (!state.entries.some(current => current.id === plan.id)) { const currentEntry = state.entries.find(current => current.id === plan.id);
if (!currentEntry) {
removalPlans.delete(plan); removalPlans.delete(plan);
return false; return false;
} }
if (currentEntry.key !== plan.key) {
removalPlans.delete(plan);
throw issueError(KEYRING_ERROR_CODES.plan);
}
await writeEntries( await writeEntries(
state.entries.filter(current => current.id !== plan.id), state.entries.filter(current => current.id !== plan.id),
nextGeneration(state.generation) nextGeneration(state.generation)
+26
View File
@@ -284,6 +284,32 @@ describe('encrypted online backup keyring', () => {
assert.deepEqual((await keyring.list()).entries, []); assert.deepEqual((await keyring.list()).entries, []);
}); });
it('invalidates a stale removal plan when the same ID now belongs to a different key', async () => {
const { filePath, keyring } = fixture();
const original = validKey();
const replacement = keyWithRecordId(original, 0x5a);
const id = parseOnlineBackupKey(original).id;
await keyring.commit(keyring.prepare(original, timestamp));
const stalePlan = await keyring.prepareRemove(id);
const currentPlan = await keyring.prepareRemove(id);
assert.equal(await keyring.commitRemove(currentPlan), true);
assert.equal(await keyring.commit(keyring.prepare(replacement, '2026-08-22T11:00:00.000Z')), true);
const generationBefore = JSON.parse(fs.readFileSync(filePath, 'utf8')).generation;
await assert.rejects(
keyring.commitRemove(stalePlan),
error => error.code === 'KEYRING_REMOVE_PLAN_INVALID'
);
await assert.rejects(
keyring.commitRemove(stalePlan),
error => error.code === 'KEYRING_REMOVE_PLAN_INVALID'
);
assert.equal(await keyring.getKey(id), replacement);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).generation, generationBefore);
assert.equal(generationBefore, 3);
});
it('blocks removal before remote work when any neighboring entry is corrupt', async () => { it('blocks removal before remote work when any neighboring entry is corrupt', async () => {
const { filePath, keyring } = fixture(); const { filePath, keyring } = fixture();
const valid = validKey(); const valid = validKey();