fix: separate legacy recovery from versioned keyring state
Keep a fully validated v1 primary authoritative over differing legacy recovery snapshots, recover missing or unusable v1 primaries from the best valid legacy candidate, and migrate the next mutation to v2 generation one. Rebase prepared removals onto the current serialized keyring state so intervening creates survive, generations remain monotonic, and already absent targets complete idempotently. Restrict canonical same-generation conflict blocking to true v2 documents and add separate legacy and v2 regression coverage without exposing key material.
This commit is contained in:
@@ -239,6 +239,7 @@ function createOnlineBackupKeyring({
|
|||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
|
version: source.document.version,
|
||||||
generation: source.document.generation,
|
generation: source.document.generation,
|
||||||
payload: canonicalPayload(source.document),
|
payload: canonicalPayload(source.document),
|
||||||
entries,
|
entries,
|
||||||
@@ -261,6 +262,16 @@ function createOnlineBackupKeyring({
|
|||||||
return selectEquivalentState(matches);
|
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() {
|
async function readState() {
|
||||||
const paths = [filePath, ...await recoveryCandidates()];
|
const paths = [filePath, ...await recoveryCandidates()];
|
||||||
const states = [];
|
const states = [];
|
||||||
@@ -270,7 +281,13 @@ function createOnlineBackupKeyring({
|
|||||||
if (candidate.status === 'missing') continue;
|
if (candidate.status === 'missing') continue;
|
||||||
observedCandidate = true;
|
observedCandidate = true;
|
||||||
if (candidate.status !== 'valid') continue;
|
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 (states.length === 0) {
|
||||||
if (observedCandidate) throw issueError(KEYRING_ERROR_CODES.structure);
|
if (observedCandidate) throw issueError(KEYRING_ERROR_CODES.structure);
|
||||||
@@ -283,12 +300,24 @@ function createOnlineBackupKeyring({
|
|||||||
recovered: false
|
recovered: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const highestObservedGeneration = Math.max(...states.map(state => state.generation));
|
const v2States = states.filter(state => state.version === 2);
|
||||||
selectGeneration(states, highestObservedGeneration);
|
if (v2States.length > 0) {
|
||||||
const validStates = states.filter(state => !firstBlockingIssue(state));
|
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
|
||||||
if (validStates.length === 0) return selectGeneration(states, highestObservedGeneration);
|
selectGeneration(v2States, highestObservedGeneration);
|
||||||
const highestValidGeneration = Math.max(...validStates.map(state => state.generation));
|
const validV2States = v2States.filter(state => !firstBlockingIssue(state));
|
||||||
return selectGeneration(validStates, highestValidGeneration);
|
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) {
|
function firstBlockingIssue(state) {
|
||||||
@@ -468,18 +497,24 @@ function createOnlineBackupKeyring({
|
|||||||
const entry = state.entries.find(current => current.id === id);
|
const entry = state.entries.find(current => current.id === id);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const plan = Object.freeze({ id: entry.id, key: entry.key });
|
const plan = Object.freeze({ id: entry.id, key: entry.key });
|
||||||
removalPlans.set(plan, {
|
removalPlans.set(plan, true);
|
||||||
generation: state.generation,
|
|
||||||
entries: state.entries.filter(current => current.id !== id)
|
|
||||||
});
|
|
||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitRemove(plan) {
|
function commitRemove(plan) {
|
||||||
return serialize(async () => {
|
return serialize(async () => {
|
||||||
const prepared = removalPlans.get(plan);
|
if (!removalPlans.has(plan)) throw issueError(KEYRING_ERROR_CODES.plan);
|
||||||
if (!prepared) throw issueError(KEYRING_ERROR_CODES.plan);
|
const state = await readState();
|
||||||
await writeEntries(prepared.entries, nextGeneration(prepared.generation));
|
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);
|
removalPlans.delete(plan);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,26 +95,35 @@ describe('encrypted online backup keyring', () => {
|
|||||||
assert.equal(await keyring.getKey(prepared.id), key);
|
assert.equal(await keyring.getKey(prepared.id), key);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads v1 as generation zero and migrates the next mutation to v2 generation one', async () => {
|
it('keeps a valid v1 primary authoritative over an older v1 backup and migrates to v2', async () => {
|
||||||
const { filePath, keyring } = fixture();
|
const { filePath, backupPath, keyring } = fixture();
|
||||||
const older = validKey();
|
const backupKey = validKey();
|
||||||
const newer = validKey();
|
const primaryKey = validKey();
|
||||||
writeKeyring(filePath, [{
|
const addedKey = validKey();
|
||||||
id: parseOnlineBackupKey(older).id,
|
writeKeyring(backupPath, [{
|
||||||
encryptedKey: encrypt(older),
|
id: parseOnlineBackupKey(backupKey).id,
|
||||||
|
encryptedKey: encrypt(backupKey),
|
||||||
createdAt: timestamp
|
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.deepEqual((await keyring.list()).entries.map(entry => entry.id), [parseOnlineBackupKey(primaryKey).id]);
|
||||||
assert.equal(await keyring.commit(keyring.prepare(newer, '2026-08-22T11:00:00.000Z')), true);
|
assert.equal(await keyring.commit(keyring.prepare(addedKey, '2026-08-22T12:00:00.000Z')), true);
|
||||||
|
|
||||||
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']);
|
assert.deepEqual(Object.keys(document).sort(), ['generation', 'keys', 'version']);
|
||||||
assert.equal(document.version, 2);
|
assert.equal(document.version, 2);
|
||||||
assert.equal(document.generation, 1);
|
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), [
|
assert.deepEqual((await keyring.list()).entries.map(entry => entry.id), [
|
||||||
parseOnlineBackupKey(newer).id,
|
parseOnlineBackupKey(addedKey).id,
|
||||||
parseOnlineBackupKey(older).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]);
|
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 () => {
|
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();
|
||||||
@@ -542,6 +580,74 @@ describe('encrypted online backup keyring', () => {
|
|||||||
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
|
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 () => {
|
it('recovers a cryptographically valid backup when the primary only passes structural validation', async () => {
|
||||||
const first = fixture();
|
const first = fixture();
|
||||||
const key = validKey();
|
const key = validKey();
|
||||||
@@ -608,7 +714,7 @@ describe('encrypted online backup keyring', () => {
|
|||||||
assert.deepEqual(snapshot.issues, ['KEYRING_RECOVERED']);
|
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 first = fixture();
|
||||||
const primaryKey = validKey();
|
const primaryKey = validKey();
|
||||||
const backupKey = validKey();
|
const backupKey = validKey();
|
||||||
@@ -616,12 +722,12 @@ describe('encrypted online backup keyring', () => {
|
|||||||
id: parseOnlineBackupKey(primaryKey).id,
|
id: parseOnlineBackupKey(primaryKey).id,
|
||||||
encryptedKey: encrypt(primaryKey),
|
encryptedKey: encrypt(primaryKey),
|
||||||
createdAt: timestamp
|
createdAt: timestamp
|
||||||
}]);
|
}], 4);
|
||||||
writeKeyring(first.backupPath, [{
|
writeKeyring(first.backupPath, [{
|
||||||
id: parseOnlineBackupKey(backupKey).id,
|
id: parseOnlineBackupKey(backupKey).id,
|
||||||
encryptedKey: encrypt(backupKey),
|
encryptedKey: encrypt(backupKey),
|
||||||
createdAt: timestamp
|
createdAt: timestamp
|
||||||
}]);
|
}], 4);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
first.keyring.list(),
|
first.keyring.list(),
|
||||||
|
|||||||
Reference in New Issue
Block a user