From 6f6c8985e427afe42e4e772e7110efa500abafe0 Mon Sep 17 00:00:00 2001 From: Rahul Raj Date: Tue, 25 Aug 2026 14:14:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20sync,=20phase=20one=20=E2=80=94=20a=20f?= =?UTF-8?q?ile,=20a=20button,=20and=20a=20merge=20that=20keeps=20both=20si?= =?UTF-8?q?des?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two machines stay in step through a file the user picks inside a folder their computer already synchronises: iCloud Drive, Dropbox, OneDrive, Syncthing. No OAuth, no token, no provider API, and no network request — the syncing is done by software they already trust with their files, and KeyVault never learns which one it is. The manifest gains no permission. The merge is three-way and pure: no storage, no network, no clock. Two-way merging cannot tell "I added this" from "they deleted this", so each device keeps the state it last agreed with — sealed, in local storage — and compares both sides against it. Two rules carry the safety of the whole feature. A conflict produces both versions, never a winner. Any rule that picks one — newest, longest, local — silently destroys somebody's password some of the time. A duplicate is visible and takes two clicks to resolve; a lost password is a support request that cannot be answered. An edit beats a delete. Deleted here and edited there means the entry comes back, and the UI says why. Losing an edit is invisible; a restored item is obvious. Ordering uses a per-entry revision counter, added here and bumped on every write including the soft-delete transitions. `updatedAt` is untrustworthy across machines: one laptop set to the wrong year would win every conflict forever, and a test pins that it is ignored. Refusals worth having: a file whose KDF salt does not match is a different vault and is rejected outright rather than merged into a vault belonging to neither; a file that does not decrypt fails closed rather than being treated as an empty remote, which would let the next write overwrite a good file with this device's copy alone. Settings stay local. They are per-device preferences — an auto-lock timer that suits a laptop need not suit a desktop — and merging them would be the least valuable place to spend the risk. Manual on purpose. Merging two vaults is where password managers lose data, and it should be watched working before anything automatic depends on it. Tested through the real router and real sealing, not just the merge function: two devices exchanging a file converge, a genuine conflict keeps both passwords, a second sync with nothing new reports "Already up to date", and the file never contains a decrypted entry. 915 unit tests, 43 end-to-end. --- src/background/messages.js | 17 +++ src/background/vault-service.js | 91 +++++++++++++ src/core/entry.js | 14 +- src/core/sync-merge.js | 207 +++++++++++++++++++++++++++++ src/ui/lib/sync-file.js | 143 ++++++++++++++++++++ src/ui/vault/Settings.jsx | 8 ++ src/ui/vault/SyncSection.jsx | 160 ++++++++++++++++++++++ tests/background/sync.test.js | 156 ++++++++++++++++++++++ tests/core/sync-merge.test.js | 226 ++++++++++++++++++++++++++++++++ 9 files changed, 1017 insertions(+), 5 deletions(-) create mode 100644 src/core/sync-merge.js create mode 100644 src/ui/lib/sync-file.js create mode 100644 src/ui/vault/SyncSection.jsx create mode 100644 tests/background/sync.test.js create mode 100644 tests/core/sync-merge.test.js diff --git a/src/background/messages.js b/src/background/messages.js index c09ae13..907aa91 100644 --- a/src/background/messages.js +++ b/src/background/messages.js @@ -19,6 +19,7 @@ import { import { entriesForUrl, entryMatchesUrl, toHostname, toOrigin } from '../core/url-match.js'; import { parseTotpInput, generateTotp, totpTimeRemaining } from '../core/totp.js'; import { computeSecurityScore, auditCredentials } from '../core/security-score.js'; +import { describeMerge } from '../core/sync-merge.js'; import { searchableText, countFields } from '../core/custom-fields.js'; import { findMissingTwoFactor, knownSiteCount } from '../core/two-factor-sites.js'; import { buildRecoveryKit } from '../core/recovery-kit.js'; @@ -447,6 +448,22 @@ export function createMessageRouter({ // ---- Backup (trusted contexts only) ---- + 'sync/merge': { + contentScript: false, + /** + * Merge a vault document read from the sync file, and hand back the + * document to write in its place. + * + * The file is read and written by the UI, because the background has + * no file access. The key never leaves here, so the UI only ever + * handles sealed bytes. + */ + handle: async ({ remoteDocument, remoteName }) => { + const { document, report } = await vault.syncWith(remoteDocument ?? null, remoteName); + return { document, report, summary: describeMerge(report) }; + }, + }, + 'backup/create': { contentScript: false, /** diff --git a/src/background/vault-service.js b/src/background/vault-service.js index bcc1643..240d97c 100644 --- a/src/background/vault-service.js +++ b/src/background/vault-service.js @@ -12,6 +12,7 @@ import { KeyVaultError } from '../core/errors.js'; import { createVaultStorage } from './storage.js'; import { createSessionKeyStore } from './session-key.js'; import { wrapVaultKey, unwrapVaultKey } from '../core/device-key.js'; +import { mergeVaults } from '../core/sync-merge.js'; /** * Where the device-wrapped vault key lives. @@ -19,6 +20,9 @@ import { wrapVaultKey, unwrapVaultKey } from '../core/device-key.js'; * Persistent, unlike the session key: the point is that it survives a * browser restart. It is useless without this device's authenticator. */ +/** The last state this device agreed with, kept sealed. */ +const SYNC_BASE_KEY = 'keyvault.syncBase'; + const DEVICE_UNLOCK_KEY = 'keyvault.deviceUnlock'; /** Raised when an operation needs an unlocked vault and the vault is locked. */ @@ -292,6 +296,93 @@ export function createVaultService({ chrome, kdfOverrides = {}, onDerive = null * * @returns {Promise} */ + /** + * Merge a vault document from another device into this one. + * + * The remote document is the same sealed format this vault already + * writes, so it opens with the same key — which is only true if both + * devices share a master password, and that is the point: whatever holds + * the file in between sees ciphertext. + * + * The base — the state this device last agreed with — is kept sealed in + * local storage rather than in the clear. Without it the merge would be + * two-way and could not tell "I added this" from "they deleted this". + * + * @param {object|null} remoteDocument + * @param {string} remoteName + * @returns {Promise<{document: object, report: object}>} + */ + async syncWith(remoteDocument, remoteName = 'another device') { + const key = await requireKey(); + const doc = await storage.load(); + if (doc === null) { + throw new Error('no vault to sync'); + } + const local = await openVaultData(key, doc.data); + + // Nothing there yet: this device seeds the file. + if (remoteDocument === null || remoteDocument === undefined) { + const document = await storage.load(); + await chrome.storage.local.set({ [SYNC_BASE_KEY]: document }); + return { + document, + report: { + unchanged: 0, + fromLocal: local.entries.length, + fromRemote: 0, + conflicts: [], + resurrected: [], + }, + }; + } + + // Refuse two unrelated vaults outright. The salt is per-vault, so a + // mismatch means somebody pointed this at the wrong file — and merging + // would produce a vault belonging to neither. + if (remoteDocument?.kdf?.salt !== doc.kdf.salt) { + throw new Error( + 'that file belongs to a different vault — check the sync file, or its vault ID in the recovery kit', + ); + } + + // Fails closed: a truncated or tampered file will not decrypt, and a + // good local vault must never be overwritten by one that did not. + const remote = await openVaultData(key, remoteDocument.data); + + const stored = await chrome.storage.local.get(SYNC_BASE_KEY); + const baseDocument = stored[SYNC_BASE_KEY] ?? null; + const base = + baseDocument === null ? { entries: [] } : await openVaultData(key, baseDocument.data); + + const { entries, report } = mergeVaults({ + base: base.entries, + local: local.entries, + remote: remote.entries, + remoteName, + now: Date.now(), + }); + + // Settings stay local in this phase. They are per-device preferences + // — an auto-lock timer that suits a laptop need not suit a desktop — + // and merging them would be the least valuable place to spend the + // risk. + await this.replaceEntries(entries); + + const document = await storage.load(); + await chrome.storage.local.set({ [SYNC_BASE_KEY]: document }); + return { document, report }; + }, + + /** + * Replace the entry list wholesale. Used only by the merge, which has + * already decided what the vault should contain. + * + * @param {object[]} entries + */ + async replaceEntries(entries) { + await this.mutate((data) => ({ ...data, entries })); + }, + async exportDocument() { return storage.load(); }, diff --git a/src/core/entry.js b/src/core/entry.js index 1e5ae67..62f69cc 100644 --- a/src/core/entry.js +++ b/src/core/entry.js @@ -24,7 +24,7 @@ const IMMUTABLE_FIELDS = ['id', 'createdAt', 'passwordHistory']; * @returns {object} */ export function trashEntry(entry, now = Date.now()) { - return { ...entry, deletedAt: now }; + return { ...entry, deletedAt: now, rev: (entry.rev ?? 0) + 1 }; } /** @@ -32,7 +32,7 @@ export function trashEntry(entry, now = Date.now()) { * @returns {object} */ export function restoreEntry(entry) { - return { ...entry, deletedAt: null }; + return { ...entry, deletedAt: null, rev: (entry.rev ?? 0) + 1 }; } /** @@ -61,12 +61,12 @@ export function isTrashed(entry) { * decision. */ export function archiveEntry(entry, now = Date.now()) { - return { ...entry, archivedAt: now }; + return { ...entry, archivedAt: now, rev: (entry.rev ?? 0) + 1 }; } /** Put an archived entry back into circulation. */ export function unarchiveEntry(entry) { - return { ...entry, archivedAt: null }; + return { ...entry, archivedAt: null, rev: (entry.rev ?? 0) + 1 }; } /** @param {object} entry */ @@ -126,6 +126,10 @@ export function createEntry(fields = {}, now = Date.now()) { passwordHistory: [], createdAt: now, updatedAt: now, + // Incremented on every write. Sync orders changes by this rather than by + // `updatedAt`, because one machine with a wrong clock would otherwise + // win every conflict forever. + rev: 1, lastUsedAt: null, // Soft delete. Nothing holds a copy of this vault, so a mis-click must // not be the end of a credential. @@ -171,5 +175,5 @@ export function updateEntry(entry, changes = {}, now = Date.now()) { ) : entry.passwordHistory; - return { ...entry, ...safe, passwordHistory, updatedAt: now }; + return { ...entry, ...safe, passwordHistory, rev: (entry.rev ?? 0) + 1, updatedAt: now }; } diff --git a/src/core/sync-merge.js b/src/core/sync-merge.js new file mode 100644 index 0000000..e15126f --- /dev/null +++ b/src/core/sync-merge.js @@ -0,0 +1,207 @@ +/** + * Merging two copies of a vault. + * + * This is the part of sync that loses people's passwords, so it is pure: no + * storage, no network, no clock. Give it three vaults and it returns a + * fourth, with a report of what it did. + * + * The merge is three-way. Two-way merging cannot tell "I added this" from + * "they deleted this" — both look like an entry present on one side only — + * so every sync remembers the state it last agreed with (the **base**) and + * compares both sides against it. + * + * Two rules carry the safety of the whole feature: + * + * **A conflict produces both versions, never a winner.** Any rule that picks + * one — newest, longest, local — silently destroys somebody's password some + * of the time. A duplicate is visible and takes two clicks to resolve; a lost + * password is a support request that cannot be answered. + * + * **An edit beats a delete.** If one machine deleted an entry while another + * edited it, the entry comes back. Losing an edit is invisible; a resurrected + * item is obvious and can be deleted again. + * + * Ordering uses each entry's `rev` counter rather than `updatedAt`. Clocks + * across machines disagree, and one laptop set to the wrong year would + * otherwise win every conflict forever. + */ + +import { randomId } from './random.js'; + +/** Fields that describe the record rather than its content. */ +const BOOKKEEPING = Object.freeze(['rev', 'updatedAt', 'lastUsedAt']); + +/** + * Do two entries hold the same content? + * + * Bookkeeping is ignored: two machines that made the identical edit have not + * conflicted, even though their counters differ. `lastUsedAt` in particular + * changes just from filling a form, and treating that as a conflict would + * produce duplicates for doing nothing at all. + * + * @param {object} a + * @param {object} b + */ +export function sameContent(a, b) { + const strip = (entry) => { + const copy = { ...entry }; + for (const field of BOOKKEEPING) { + delete copy[field]; + } + return JSON.stringify(copy, Object.keys(copy).sort()); + }; + return strip(a) === strip(b); +} + +/** @param {object|undefined} entry */ +function isDeleted(entry) { + return typeof entry?.deletedAt === 'number'; +} + +/** + * Did this side change relative to base? + * + * An entry absent from base is new, which counts as a change. An entry whose + * counter matches base has not been touched. + */ +function changed(side, base) { + if (side === undefined) { + return false; + } + if (base === undefined) { + return true; + } + return (side.rev ?? 0) !== (base.rev ?? 0); +} + +/** + * Merge two vaults against the state they last agreed on. + * + * @param {object} options + * @param {object[]} options.base entries at the last successful sync, or [] + * @param {object[]} options.local + * @param {object[]} options.remote + * @param {string} [options.remoteName] shown in a conflict copy's title + * @param {number} [options.now] + * @param {() => string} [options.newId] injectable for deterministic tests + * @returns {{entries: object[], report: object}} + */ +export function mergeVaults({ + base = [], + local = [], + remote = [], + remoteName = 'another device', + now = Date.now(), + newId = randomId, +}) { + const byId = (list) => new Map((list ?? []).map((entry) => [entry.id, entry])); + const baseMap = byId(base); + const localMap = byId(local); + const remoteMap = byId(remote); + + const merged = []; + const report = { + unchanged: 0, + fromLocal: 0, + fromRemote: 0, + conflicts: [], + resurrected: [], + }; + + const ids = new Set([...localMap.keys(), ...remoteMap.keys()]); + + for (const id of ids) { + const b = baseMap.get(id); + const l = localMap.get(id); + const r = remoteMap.get(id); + + // Present on one side only. If base knew about it, the other side + // deleted it *hard* — which this model never does, so treat an absence + // as "not yet arrived" and keep what we have. Never drop an entry + // because it is missing from one copy. + if (l === undefined) { + merged.push(r); + report.fromRemote += 1; + continue; + } + if (r === undefined) { + merged.push(l); + report.fromLocal += 1; + continue; + } + + const localChanged = changed(l, b); + const remoteChanged = changed(r, b); + + if (!localChanged && !remoteChanged) { + merged.push(l); + report.unchanged += 1; + continue; + } + if (localChanged && !remoteChanged) { + merged.push(l); + report.fromLocal += 1; + continue; + } + if (!localChanged && remoteChanged) { + merged.push(r); + report.fromRemote += 1; + continue; + } + + // Both changed. + if (sameContent(l, r)) { + // The same edit made twice is not a disagreement. Keep the higher + // counter so neither side syncs it again forever. + merged.push((l.rev ?? 0) >= (r.rev ?? 0) ? l : r); + report.unchanged += 1; + continue; + } + + // An edit beats a delete, in either direction. + if (isDeleted(l) !== isDeleted(r)) { + const survivor = isDeleted(l) ? r : l; + merged.push(survivor); + report.resurrected.push({ id, title: survivor.title }); + continue; + } + + // A genuine disagreement. Keep both. + merged.push(l); + const copy = { + ...r, + id: newId(), + title: `${r.title} (conflict from ${remoteName})`, + tags: [...new Set([...(r.tags ?? []), 'conflict'])], + rev: 1, + updatedAt: now, + }; + merged.push(copy); + report.conflicts.push({ id, copyId: copy.id, title: r.title }); + } + + return { entries: merged, report }; +} + +/** + * A one-line summary of a merge, for the UI. + * + * @param {object} report + * @returns {string} + */ +export function describeMerge(report) { + const parts = []; + if (report.fromRemote > 0) { + parts.push(`${report.fromRemote} received`); + } + if (report.fromLocal > 0) { + parts.push(`${report.fromLocal} sent`); + } + if (report.resurrected.length > 0) { + parts.push(`${report.resurrected.length} restored (edited elsewhere after being deleted here)`); + } + if (report.conflicts.length > 0) { + parts.push(`${report.conflicts.length} kept as both versions`); + } + return parts.length === 0 ? 'Already up to date' : parts.join(', '); +} diff --git a/src/ui/lib/sync-file.js b/src/ui/lib/sync-file.js new file mode 100644 index 0000000..4578c5c --- /dev/null +++ b/src/ui/lib/sync-file.js @@ -0,0 +1,143 @@ +/** + * The sync file, and remembering which one it is. + * + * Transport is deliberately the least clever part of sync: the user picks a + * file inside a folder their machine already synchronises — iCloud Drive, + * Dropbox, OneDrive, Syncthing — and KeyVault reads and writes it. There is + * no OAuth, no token, no provider API and no network request. The syncing is + * done by software the user already trusts with their files, and KeyVault + * never learns which one it is. + * + * The handle is kept in IndexedDB because a `FileSystemFileHandle` is + * structured-cloneable and `localStorage` is not. Browsers do not always + * carry the permission across a restart, so `ensurePermission` may need a + * click — which is why sync is a button in this phase rather than something + * that happens quietly in the background. + */ + +const DB_NAME = 'keyvault-sync'; +const STORE = 'handles'; +const HANDLE_KEY = 'syncFile'; + +/** @returns {Promise} */ +function openDb() { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** + * @param {'readonly'|'readwrite'} mode + * @param {(store: IDBObjectStore) => IDBRequest} run + */ +async function withStore(mode, run) { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const request = run(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } +} + +/** @returns {Promise} */ +export async function storedHandle() { + try { + return (await withStore('readonly', (store) => store.get(HANDLE_KEY))) ?? null; + } catch { + return null; + } +} + +/** @param {FileSystemFileHandle} handle */ +export async function rememberHandle(handle) { + await withStore('readwrite', (store) => store.put(handle, HANDLE_KEY)); +} + +export async function forgetHandle() { + await withStore('readwrite', (store) => store.delete(HANDLE_KEY)); +} + +/** Is this browser capable of the file-based sync at all? */ +export function isSupported() { + return typeof window.showSaveFilePicker === 'function'; +} + +/** + * Ask the user to choose the sync file. + * + * @returns {Promise} null if they cancelled + */ +export async function chooseFile() { + try { + const handle = await window.showSaveFilePicker({ + suggestedName: 'keyvault.sync.json', + types: [{ description: 'KeyVault sync file', accept: { 'application/json': ['.json'] } }], + }); + await rememberHandle(handle); + return handle; + } catch (error) { + // Cancelling a picker is an AbortError, and is not a failure. + if (error?.name === 'AbortError') { + return null; + } + throw error; + } +} + +/** + * Confirm we may still read and write the remembered file. + * + * @param {FileSystemFileHandle} handle + * @returns {Promise} + */ +export async function ensurePermission(handle) { + const options = { mode: 'readwrite' }; + if ((await handle.queryPermission(options)) === 'granted') { + return true; + } + return (await handle.requestPermission(options)) === 'granted'; +} + +/** + * Read the sync file, or null if it is empty or absent. + * + * An unreadable file is reported as an error rather than as "empty": + * treating a failed read as an empty remote would let the next write + * overwrite a good file with this device's copy alone. + * + * @param {FileSystemFileHandle} handle + * @returns {Promise} + */ +export async function readDocument(handle) { + const file = await handle.getFile(); + if (file.size === 0) { + return null; + } + const text = await file.text(); + if (text.trim() === '') { + return null; + } + return JSON.parse(text); +} + +/** + * @param {FileSystemFileHandle} handle + * @param {object} document + */ +export async function writeDocument(handle, document) { + const writable = await handle.createWritable(); + try { + await writable.write(JSON.stringify(document)); + } finally { + // Closing is what commits the write. Skipping it on an error path leaves + // a zero-length file where the vault used to be. + await writable.close(); + } +} diff --git a/src/ui/vault/Settings.jsx b/src/ui/vault/Settings.jsx index 0e1ecb6..7e2a3ba 100644 --- a/src/ui/vault/Settings.jsx +++ b/src/ui/vault/Settings.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'preact/hooks'; import { BackupSection } from './BackupSection.jsx'; +import { SyncSection } from './SyncSection.jsx'; import { DeviceUnlockSection } from './DeviceUnlock.jsx'; import { send } from '../lib/messaging.js'; import { MIN_LENGTH as GENERATOR_MIN, MAX_LENGTH as GENERATOR_MAX } from '../../core/generator.js'; @@ -211,6 +212,13 @@ export function Settings({ onChanged }) { +
+ +
+

KeyVault stores your vault on this device only. There is no account and no server, which diff --git a/src/ui/vault/SyncSection.jsx b/src/ui/vault/SyncSection.jsx new file mode 100644 index 0000000..3886f7e --- /dev/null +++ b/src/ui/vault/SyncSection.jsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'preact/hooks'; +import { Icon } from './primitives.jsx'; +import { Button } from '../components/Button.jsx'; +import { send } from '../lib/messaging.js'; +import { + isSupported, + storedHandle, + chooseFile, + forgetHandle, + ensurePermission, + readDocument, + writeDocument, +} from '../lib/sync-file.js'; + +/** + * Sync, phase one: a button. + * + * You pick a file inside a folder your computer already synchronises, and + * KeyVault reads and writes it. Nothing here talks to a provider — the + * syncing is done by software you already trust with your files, so there is + * no account to create, no token to store, and no request to anywhere. + * + * Manual on purpose to begin with. Merging two copies of a vault is the part + * of a password manager that loses data, and it should be watched working + * before anything automatic depends on it. + */ +export function SyncSection() { + const [handle, setHandle] = useState(null); + const [state, setState] = useState({ status: 'idle' }); + + useEffect(() => { + storedHandle().then(setHandle); + }, []); + + if (!isSupported()) { + return ( +

+ This browser cannot open a file for reading and writing, so file-based sync is not available + here. Exporting an encrypted backup still works. +

+ ); + } + + async function pick() { + const chosen = await chooseFile(); + if (chosen !== null) { + setHandle(chosen); + setState({ status: 'idle' }); + } + } + + async function syncNow() { + setState({ status: 'working' }); + try { + if (!(await ensurePermission(handle))) { + setState({ + status: 'error', + message: 'Permission to use that file was declined. Choose it again to continue.', + }); + return; + } + + // Read, merge in the background where the key lives, write back what + // it returns. The file's contents are sealed at every point on this + // path; this page never sees a decrypted vault. + const remoteDocument = await readDocument(handle); + const { document, summary, report } = await send('sync/merge', { + remoteDocument, + remoteName: 'another device', + }); + await writeDocument(handle, document); + + setState({ status: 'done', summary, report }); + } catch (error) { + setState({ status: 'error', message: error.message }); + } + } + + return ( +
+

+ Choose a file inside a folder your computer already syncs — iCloud Drive, Dropbox, OneDrive, + Syncthing. KeyVault writes your encrypted vault there and reads it back. Whatever carries + the file between machines sees ciphertext and nothing else, and KeyVault never learns which + service it is. +

+ + {handle === null ? ( + + ) : ( + <> +
+ + {handle.name} + +
+ +
+ + + {state.status === 'done' && ( + + {state.summary} + + )} + {state.status === 'error' && ( + + {state.message} + + )} +
+ + {/* A conflict is not a failure, but it does need explaining, or the + duplicate looks like a bug. */} + {state.status === 'done' && state.report?.conflicts?.length > 0 && ( +

+ Both machines had changed{' '} + {state.report.conflicts.map((conflict) => conflict.title).join(', ')} since the last + sync, so both versions were kept and the incoming one is tagged conflict. + Nothing was overwritten — compare them and delete whichever you do not want. +

+ )} + + {state.status === 'done' && state.report?.resurrected?.length > 0 && ( +

+ {state.report.resurrected.map((item) => item.title).join(', ')} came back: deleted + here, but edited on the other machine after that. An edit beats a delete, because a + lost edit is invisible and a restored item is not. +

+ )} + + )} + +

+ Manual for now, deliberately. Merging two copies of a vault is where password managers lose + data, and it is worth watching it work before anything automatic depends on it. Both + machines must use the same master password — that is what makes the file readable on the + other end, and unreadable to whatever carries it. +

+
+ ); +} diff --git a/tests/background/sync.test.js b/tests/background/sync.test.js new file mode 100644 index 0000000..bb78c98 --- /dev/null +++ b/tests/background/sync.test.js @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createFakeChrome } from '../helpers/fake-chrome.js'; +import { createVaultService } from '../../src/background/vault-service.js'; +import { createAutoLock } from '../../src/background/auto-lock.js'; +import { createMessageRouter } from '../../src/background/messages.js'; +import { createEntry, updateEntry } from '../../src/core/entry.js'; +import { addEntry, replaceEntry, findEntry } from '../../src/core/vault-data.js'; + +const FAST = { iterations: 1000 }; +const MASTER = 'correct-horse-battery-staple'; +const TRUSTED = { url: 'chrome-extension://fake-extension-id/popup.html' }; + +/** + * Two devices, one file between them. + * + * Exercised through the real message router and the real sealing, because + * the failure this must not have — a vault that loses an entry across a + * sync — lives in the wiring as much as in the merge. + */ +function makeDevice() { + const chrome = createFakeChrome(); + const vault = createVaultService({ chrome, kdfOverrides: FAST }); + const autoLock = createAutoLock({ chrome, vault }); + const router = createMessageRouter({ chrome, vault, autoLock }); + const send = (type, payload) => router.handle({ type, payload }, TRUSTED); + return { chrome, vault, send }; +} + +/** The file the two devices share. */ +function makeFile() { + let contents = null; + return { + read: () => contents, + write: (document) => { + contents = JSON.parse(JSON.stringify(document)); + }, + }; +} + +async function syncDevice(device, file, name) { + const result = await device.send('sync/merge', { + remoteDocument: file.read(), + remoteName: name, + }); + expect(result.ok, result.error?.message).toBe(true); + file.write(result.data.document); + return result.data; +} + +async function titles(device) { + const data = await device.vault.getData(); + return data.entries.map((entry) => entry.title).sort(); +} + +describe('sync between two devices', () => { + let a; + let b; + let file; + + beforeEach(async () => { + file = makeFile(); + a = makeDevice(); + await a.vault.create(MASTER); + + // The second device starts from the first one's vault, which is how a + // real second machine begins: restore a backup, then sync. + b = makeDevice(); + const document = await a.vault.exportDocument(); + await b.chrome.storage.local.set({ 'keyvault.vault': document }); + await b.vault.unlock(MASTER); + }); + + it('carries a new entry from one device to the other', async () => { + await a.vault.mutate((data) => addEntry(data, createEntry({ title: 'GitHub' }))); + + await syncDevice(a, file, 'A'); + await syncDevice(b, file, 'A'); + + expect(await titles(b)).toContain('GitHub'); + }); + + it('brings both devices to the same contents', async () => { + await a.vault.mutate((data) => addEntry(data, createEntry({ title: 'From A' }))); + await b.vault.mutate((data) => addEntry(data, createEntry({ title: 'From B' }))); + + await syncDevice(a, file, 'B'); + await syncDevice(b, file, 'A'); + await syncDevice(a, file, 'B'); + + expect(await titles(a)).toEqual(['From A', 'From B']); + expect(await titles(b)).toEqual(['From A', 'From B']); + }); + + it('keeps both versions when the same entry changed on both', async () => { + // The rule the feature rests on: never pick a winner. + const entry = createEntry({ title: 'Bank', password: 'original' }); + await a.vault.mutate((data) => addEntry(data, entry)); + await syncDevice(a, file, 'B'); + await syncDevice(b, file, 'A'); + + await a.vault.mutate((data) => + replaceEntry(data, updateEntry(findEntry(data, entry.id), { password: 'from-a' })), + ); + await b.vault.mutate((data) => + replaceEntry(data, updateEntry(findEntry(data, entry.id), { password: 'from-b' })), + ); + + await syncDevice(a, file, 'B'); + const result = await syncDevice(b, file, 'MacBook'); + + expect(result.report.conflicts).toHaveLength(1); + const passwords = (await b.vault.getData()).entries.map((e) => e.password).sort(); + expect(passwords).toEqual(['from-a', 'from-b']); + }); + + it('settles: a second sync with nothing new reports no change', async () => { + // If a merge is not idempotent, two devices generate conflict copies of + // each other's copies forever. + await a.vault.mutate((data) => addEntry(data, createEntry({ title: 'GitHub' }))); + await syncDevice(a, file, 'B'); + await syncDevice(b, file, 'A'); + await syncDevice(a, file, 'B'); + + const again = await syncDevice(b, file, 'A'); + expect(again.report.conflicts).toEqual([]); + expect(again.summary).toBe('Already up to date'); + expect(await titles(a)).toEqual(await titles(b)); + }); + + it('refuses a file belonging to a different vault', async () => { + // Merging two unrelated vaults would produce one belonging to neither. + const stranger = makeDevice(); + await stranger.vault.create('a-completely-different-master'); + file.write(await stranger.vault.exportDocument()); + + const result = await a.send('sync/merge', { remoteDocument: file.read() }); + expect(result.ok).toBe(false); + expect(result.error.message).toMatch(/different vault/i); + }); + + it('never writes a decrypted vault into the file', async () => { + await a.vault.mutate((data) => + addEntry(data, createEntry({ title: 'Bank', password: 'S3cr3t-value' })), + ); + await syncDevice(a, file, 'B'); + + expect(JSON.stringify(file.read())).not.toContain('S3cr3t-value'); + expect(JSON.stringify(file.read())).not.toContain('Bank'); + }); + + it('refuses to sync while locked', async () => { + await a.vault.lock(); + const result = await a.send('sync/merge', { remoteDocument: null }); + expect(result.ok).toBe(false); + }); +}); diff --git a/tests/core/sync-merge.test.js b/tests/core/sync-merge.test.js new file mode 100644 index 0000000..0c6eb2e --- /dev/null +++ b/tests/core/sync-merge.test.js @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { mergeVaults, sameContent, describeMerge } from '../../src/core/sync-merge.js'; + +const NOW = 1_700_000_000_000; +const ids = () => { + let n = 0; + return () => `copy-${(n += 1)}`; +}; + +const entry = (over = {}) => ({ + id: over.id ?? 'a', + rev: over.rev ?? 1, + title: over.title ?? 'Bank', + password: over.password ?? 'original', + tags: over.tags ?? [], + deletedAt: over.deletedAt ?? null, + archivedAt: over.archivedAt ?? null, + updatedAt: over.updatedAt ?? NOW, + lastUsedAt: over.lastUsedAt ?? null, +}); + +const merge = (base, local, remote, extra = {}) => + mergeVaults({ base, local, remote, now: NOW, newId: ids(), remoteName: 'MacBook', ...extra }); + +describe('the cases where nothing is in dispute', () => { + it('keeps an entry neither side touched', () => { + const b = [entry()]; + const { entries, report } = merge(b, [entry()], [entry()]); + expect(entries).toHaveLength(1); + expect(report.unchanged).toBe(1); + }); + + it('takes a local edit the remote has not seen', () => { + const { entries } = merge([entry()], [entry({ rev: 2, password: 'newer' })], [entry()]); + expect(entries[0].password).toBe('newer'); + }); + + it('takes a remote edit the local has not seen', () => { + const { entries } = merge([entry()], [entry()], [entry({ rev: 2, password: 'theirs' })]); + expect(entries[0].password).toBe('theirs'); + }); + + it('accepts an entry that is new on the remote', () => { + const { entries, report } = merge([], [], [entry({ id: 'new' })]); + expect(entries.map((e) => e.id)).toEqual(['new']); + expect(report.fromRemote).toBe(1); + }); + + it('keeps an entry that is new locally and not yet uploaded', () => { + const { entries } = merge([], [entry({ id: 'mine' })], []); + expect(entries.map((e) => e.id)).toEqual(['mine']); + }); + + it('never drops an entry merely because one copy lacks it', () => { + // The failure that would quietly empty a vault: treating "absent from + // the remote" as "deleted remotely". + const { entries } = merge([entry()], [entry()], []); + expect(entries).toHaveLength(1); + }); +}); + +describe('conflicts', () => { + it('keeps both versions when the two sides disagree', () => { + // The rule the whole feature rests on. Picking a winner destroys a + // password some of the time, and does it silently. + const { entries, report } = merge( + [entry()], + [entry({ rev: 2, password: 'mine' })], + [entry({ rev: 2, password: 'theirs' })], + ); + expect(entries).toHaveLength(2); + expect(entries.map((e) => e.password).sort()).toEqual(['mine', 'theirs']); + expect(report.conflicts).toHaveLength(1); + }); + + it('labels the copy so it can be told apart and found later', () => { + const { entries } = merge( + [entry()], + [entry({ rev: 2, password: 'mine' })], + [entry({ rev: 2, password: 'theirs' })], + ); + const copy = entries.find((e) => e.password === 'theirs'); + expect(copy.title).toBe('Bank (conflict from MacBook)'); + expect(copy.tags).toContain('conflict'); + }); + + it('gives the copy a new id, so it does not overwrite the original', () => { + const { entries } = merge( + [entry()], + [entry({ rev: 2, password: 'mine' })], + [entry({ rev: 2, password: 'theirs' })], + ); + expect(new Set(entries.map((e) => e.id)).size).toBe(2); + }); + + it('is not a conflict when both sides made the identical edit', () => { + // Two machines told to do the same thing have not disagreed, and a + // duplicate for that would be pure noise. + const { entries, report } = merge( + [entry()], + [entry({ rev: 2, password: 'same' })], + [entry({ rev: 3, password: 'same' })], + ); + expect(entries).toHaveLength(1); + expect(report.conflicts).toHaveLength(0); + }); + + it('does not conflict over bookkeeping alone', () => { + // lastUsedAt changes just from filling a form. Treating that as a + // disagreement would produce duplicates for doing nothing. + const { entries } = merge( + [entry()], + [entry({ rev: 2, lastUsedAt: 111 })], + [entry({ rev: 2, lastUsedAt: 222 })], + ); + expect(entries).toHaveLength(1); + }); +}); + +describe('deletion', () => { + it('propagates a delete the other side has not seen', () => { + const { entries } = merge([entry()], [entry()], [entry({ rev: 2, deletedAt: NOW })]); + expect(entries[0].deletedAt).toBe(NOW); + }); + + it('lets an edit beat a delete, in either direction', () => { + // Losing an edit is invisible. A resurrected item is obvious and takes + // two clicks to delete again. + const deletedHere = merge( + [entry()], + [entry({ rev: 2, deletedAt: NOW })], + [entry({ rev: 2, password: 'still-wanted' })], + ); + expect(deletedHere.entries[0].password).toBe('still-wanted'); + expect(deletedHere.entries[0].deletedAt).toBeNull(); + expect(deletedHere.report.resurrected).toHaveLength(1); + + const deletedThere = merge( + [entry()], + [entry({ rev: 2, password: 'still-wanted' })], + [entry({ rev: 2, deletedAt: NOW })], + ); + expect(deletedThere.entries[0].password).toBe('still-wanted'); + }); + + it('accepts a delete both sides agree on, without a conflict copy', () => { + const { entries, report } = merge( + [entry()], + [entry({ rev: 2, deletedAt: NOW })], + [entry({ rev: 2, deletedAt: NOW })], + ); + expect(entries).toHaveLength(1); + expect(report.conflicts).toHaveLength(0); + }); +}); + +describe('ordering does not depend on the clock', () => { + it('ignores updatedAt entirely when deciding what changed', () => { + // One laptop set to the wrong year must not win every conflict forever. + const { entries } = merge( + [entry()], + [entry({ rev: 2, password: 'correct', updatedAt: 1 })], + [entry({ rev: 1, password: 'original', updatedAt: 9_999_999_999_999 })], + ); + expect(entries[0].password).toBe('correct'); + }); +}); + +describe('sameContent', () => { + it('is insensitive to key order', () => { + expect(sameContent({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + }); + + it('ignores rev, updatedAt and lastUsedAt', () => { + expect(sameContent(entry({ rev: 1 }), entry({ rev: 9, updatedAt: 5, lastUsedAt: 7 }))).toBe( + true, + ); + }); + + it('notices a real difference', () => { + expect(sameContent(entry(), entry({ password: 'other' }))).toBe(false); + }); +}); + +describe('describeMerge', () => { + it('says nothing happened when nothing did', () => { + expect( + describeMerge({ unchanged: 3, fromLocal: 0, fromRemote: 0, conflicts: [], resurrected: [] }), + ).toBe('Already up to date'); + }); + + it('reports conflicts in plain terms', () => { + const text = describeMerge({ + unchanged: 0, + fromLocal: 1, + fromRemote: 2, + conflicts: [{}], + resurrected: [], + }); + expect(text).toContain('2 received'); + expect(text).toContain('1 sent'); + expect(text).toContain('both versions'); + }); +}); + +describe('a whole round trip', () => { + it('converges: merging twice changes nothing the second time', () => { + // If a merge is not idempotent, two devices ping-pong forever, each + // producing conflict copies of the other's copies. + const base = [entry({ id: 'a' })]; + const local = [entry({ id: 'a', rev: 2, password: 'mine' }), entry({ id: 'b' })]; + const remote = [entry({ id: 'a', rev: 2, password: 'theirs' }), entry({ id: 'c' })]; + + const first = merge(base, local, remote); + const second = mergeVaults({ + base: first.entries, + local: first.entries, + remote: first.entries, + now: NOW, + newId: ids(), + }); + + expect(second.entries).toHaveLength(first.entries.length); + expect(second.report.conflicts).toHaveLength(0); + }); +});