Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/background/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
/**
Expand Down
91 changes: 91 additions & 0 deletions src/background/vault-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ 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.
*
* 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. */
Expand Down Expand Up @@ -292,6 +296,93 @@ export function createVaultService({ chrome, kdfOverrides = {}, onDerive = null
*
* @returns {Promise<object|null>}
*/
/**
* 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();
},
Expand Down
14 changes: 9 additions & 5 deletions src/core/entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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 };
}

/**
* @param {object} entry
* @returns {object}
*/
export function restoreEntry(entry) {
return { ...entry, deletedAt: null };
return { ...entry, deletedAt: null, rev: (entry.rev ?? 0) + 1 };
}

/**
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
}
Loading
Loading