diff --git a/lib/online-backup-keyring.js b/lib/online-backup-keyring.js index 21843d5..bf61127 100644 --- a/lib/online-backup-keyring.js +++ b/lib/online-backup-keyring.js @@ -239,6 +239,7 @@ function createOnlineBackupKeyring({ ]); return { source, + version: source.document.version, generation: source.document.generation, payload: canonicalPayload(source.document), entries, @@ -261,6 +262,16 @@ function createOnlineBackupKeyring({ return selectEquivalentState(matches); } + function selectLegacyState(states) { + const primary = states.find(state => state.source.path === filePath); + if (primary) return primary; + return [...states].sort((left, right) => + right.source.modified - left.source.modified + || candidatePriority(left.source.path) - candidatePriority(right.source.path) + || left.source.path.localeCompare(right.source.path) + )[0]; + } + async function readState() { const paths = [filePath, ...await recoveryCandidates()]; const states = []; @@ -270,7 +281,13 @@ function createOnlineBackupKeyring({ if (candidate.status === 'missing') continue; observedCandidate = true; if (candidate.status !== 'valid') continue; - states.push(inspectSource({ ...candidate, recovered: candidatePath !== filePath })); + let modified = 0; + if (candidate.document.version === 1) { + try { + modified = (await fsImpl.stat(candidatePath)).mtimeMs; + } catch {} + } + states.push(inspectSource({ ...candidate, modified, recovered: candidatePath !== filePath })); } if (states.length === 0) { if (observedCandidate) throw issueError(KEYRING_ERROR_CODES.structure); @@ -283,12 +300,24 @@ function createOnlineBackupKeyring({ recovered: false }); } - const highestObservedGeneration = Math.max(...states.map(state => state.generation)); - selectGeneration(states, highestObservedGeneration); - const validStates = states.filter(state => !firstBlockingIssue(state)); - if (validStates.length === 0) return selectGeneration(states, highestObservedGeneration); - const highestValidGeneration = Math.max(...validStates.map(state => state.generation)); - return selectGeneration(validStates, highestValidGeneration); + const v2States = states.filter(state => state.version === 2); + if (v2States.length > 0) { + const highestObservedGeneration = Math.max(...v2States.map(state => state.generation)); + selectGeneration(v2States, highestObservedGeneration); + const validV2States = v2States.filter(state => !firstBlockingIssue(state)); + if (validV2States.length > 0) { + const highestValidGeneration = Math.max(...validV2States.map(state => state.generation)); + return selectGeneration(validV2States, highestValidGeneration); + } + } + const legacyStates = states.filter(state => state.version === 1); + const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state)); + if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates); + if (v2States.length > 0) { + const highestObservedGeneration = Math.max(...v2States.map(state => state.generation)); + return selectGeneration(v2States, highestObservedGeneration); + } + return selectLegacyState(legacyStates); } function firstBlockingIssue(state) { @@ -468,18 +497,24 @@ function createOnlineBackupKeyring({ const entry = state.entries.find(current => current.id === id); if (!entry) return null; const plan = Object.freeze({ id: entry.id, key: entry.key }); - removalPlans.set(plan, { - generation: state.generation, - entries: state.entries.filter(current => current.id !== id) - }); + removalPlans.set(plan, true); return plan; } function commitRemove(plan) { return serialize(async () => { - const prepared = removalPlans.get(plan); - if (!prepared) throw issueError(KEYRING_ERROR_CODES.plan); - await writeEntries(prepared.entries, nextGeneration(prepared.generation)); + if (!removalPlans.has(plan)) throw issueError(KEYRING_ERROR_CODES.plan); + const state = await readState(); + const blockingIssue = firstBlockingIssue(state); + if (blockingIssue) throw issueError(blockingIssue); + if (!state.entries.some(current => current.id === plan.id)) { + removalPlans.delete(plan); + return false; + } + await writeEntries( + state.entries.filter(current => current.id !== plan.id), + nextGeneration(state.generation) + ); removalPlans.delete(plan); return true; }); diff --git a/tests/online-backup-keyring.test.js b/tests/online-backup-keyring.test.js index 0b25a64..fb687fe 100644 --- a/tests/online-backup-keyring.test.js +++ b/tests/online-backup-keyring.test.js @@ -95,26 +95,35 @@ describe('encrypted online backup keyring', () => { assert.equal(await keyring.getKey(prepared.id), key); }); - it('reads v1 as generation zero and migrates the next mutation to v2 generation one', async () => { - const { filePath, keyring } = fixture(); - const older = validKey(); - const newer = validKey(); - writeKeyring(filePath, [{ - id: parseOnlineBackupKey(older).id, - encryptedKey: encrypt(older), + it('keeps a valid v1 primary authoritative over an older v1 backup and migrates to v2', async () => { + const { filePath, backupPath, keyring } = fixture(); + const backupKey = validKey(); + const primaryKey = validKey(); + const addedKey = validKey(); + writeKeyring(backupPath, [{ + id: parseOnlineBackupKey(backupKey).id, + encryptedKey: encrypt(backupKey), createdAt: timestamp }]); + writeKeyring(filePath, [{ + id: parseOnlineBackupKey(primaryKey).id, + encryptedKey: encrypt(primaryKey), + createdAt: '2026-08-22T11:00:00.000Z' + }]); - assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(older).id]); - assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true); + assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(primaryKey).id]); + assert.equal(await keyring.commit(keyring.prepare(addedKey, '2026-08-22T12:00:00.000Z')), true); const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']); assert.equal(document.version, 2); assert.equal(document.generation, 1); + assert.equal(fs.readFileSync(filePath, 'utf8').includes(primaryKey), false); + assert.equal(fs.readFileSync(filePath, 'utf8').includes(backupKey), false); + assert.equal(fs.readFileSync(filePath, 'utf8').includes(addedKey), false); assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [ - parseOnlineBackupKey(newer).id, - parseOnlineBackupKey(older).id + parseOnlineBackupKey(addedKey).id, + parseOnlineBackupKey(primaryKey).id ]); }); @@ -246,6 +255,35 @@ describe('encrypted online backup keyring', () => { assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(retained).id]); }); + it('rebases a stale removal plan onto the current state without dropping newer keys', async () => { + const { filePath, keyring } = fixture(); + const removed = validKey(); + const added = validKey(); + await keyring.commit(keyring.prepare(removed, timestamp)); + const plan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id); + await keyring.commit(keyring.prepare(added, '2026-08-22T11:00:00.000Z')); + + assert.equal(await keyring.commitRemove(plan), true); + + const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); + assert.equal(document.version, 2); + assert.equal(document.generation, 3); + assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(added).id]); + assert.equal(await keyring.getKey(parseOnlineBackupKey(removed).id), null); + }); + + it('treats a stale removal plan whose target is already absent as idempotent', async () => { + const { keyring } = fixture(); + const removed = validKey(); + await keyring.commit(keyring.prepare(removed, timestamp)); + const stalePlan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id); + const currentPlan = await keyring.prepareRemove(parseOnlineBackupKey(removed).id); + assert.equal(await keyring.commitRemove(currentPlan), true); + + assert.equal(await keyring.commitRemove(stalePlan), false); + assert.deepEqual((await keyring.list()).entries, []); + }); + it('blocks removal before remote work when any neighboring entry is corrupt', async () => { const { filePath, keyring } = fixture(); const valid = validKey(); @@ -542,6 +580,74 @@ describe('encrypted online backup keyring', () => { assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); }); + it('recovers a valid v1 backup when the v1 primary is cryptographically unusable', async () => { + const first = fixture(); + const backupKey = validKey(); + const brokenKey = validKey(); + writeKeyring(first.backupPath, [{ + id: parseOnlineBackupKey(backupKey).id, + encryptedKey: encrypt(backupKey), + createdAt: timestamp + }]); + writeKeyring(first.filePath, [{ + id: parseOnlineBackupKey(brokenKey).id, + encryptedKey: 'enc:v1:YWJjZA==', + createdAt: '2026-08-22T11:00:00.000Z' + }]); + + const snapshot = await first.keyring.list(); + + assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(backupKey).id]); + assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); + assert.equal(JSON.stringify(snapshot).includes(backupKey), false); + assert.equal(JSON.stringify(snapshot).includes(brokenKey), false); + }); + + it('loads a valid v1 backup when no primary exists', async () => { + const first = fixture(); + const backupKey = validKey(); + writeKeyring(first.backupPath, [{ + id: parseOnlineBackupKey(backupKey).id, + encryptedKey: encrypt(backupKey), + createdAt: timestamp + }]); + + const snapshot = await first.keyring.list(); + + assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(backupKey).id]); + assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); + assert.equal(JSON.stringify(snapshot).includes(backupKey), false); + }); + + it('uses modification time only to choose among valid v1 recovery candidates', async () => { + const first = fixture(); + const backupKey = validKey(); + const recoveryKey = validKey(); + fs.writeFileSync(first.filePath, '{broken-primary'); + writeKeyring(first.backupPath, [{ + id: parseOnlineBackupKey(backupKey).id, + encryptedKey: encrypt(backupKey), + createdAt: timestamp + }]); + const recoveryTemp = path.join(first.directory, `.online-backup-keyring.json.${process.pid}.${crypto.randomUUID()}.recovery.tmp`); + writeKeyring(recoveryTemp, [{ + id: parseOnlineBackupKey(recoveryKey).id, + encryptedKey: encrypt(recoveryKey), + createdAt: '2026-08-22T11:00:00.000Z' + }]); + const older = new Date('2026-08-22T12:00:00.000Z'); + const newer = new Date('2026-08-22T13:00:00.000Z'); + fs.utimesSync(first.backupPath, older, older); + fs.utimesSync(recoveryTemp, newer, newer); + + const snapshot = await first.keyring.list(); + + assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(recoveryKey).id]); + assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); + assert.equal(JSON.stringify(snapshot).includes(backupKey), false); + assert.equal(JSON.stringify(snapshot).includes(recoveryKey), false); + }); + it('recovers a cryptographically valid backup when the primary only passes structural validation', async () => { const first = fixture(); const key = validKey(); @@ -608,7 +714,7 @@ describe('encrypted online backup keyring', () => { assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']); }); - it('blocks conflicting canonical payloads at the same generation', async () => { + it('blocks conflicting v2 canonical payloads at the same positive generation', async () => { const first = fixture(); const primaryKey = validKey(); const backupKey = validKey(); @@ -616,12 +722,12 @@ describe('encrypted online backup keyring', () => { id: parseOnlineBackupKey(primaryKey).id, encryptedKey: encrypt(primaryKey), createdAt: timestamp - }]); + }], 4); writeKeyring(first.backupPath, [{ id: parseOnlineBackupKey(backupKey).id, encryptedKey: encrypt(backupKey), createdAt: timestamp - }]); + }], 4); await assert.rejects( first.keyring.list(),