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); + }); +});