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:
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user