mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-04 21:55:45 +00:00
vault: autosync the personal vault to the logicsrc account (#226)
`logicsrc vault` (OpenCreds) lived only in ~/.config/logicsrc/opencreds. Lose the machine and the vault went with it, and a second machine had no way to get it. It now syncs to the logged-in account. Server (apps/pwa, /api/opencreds, session or lsk_ bearer): - opencreds_vaults: one per user. meta (key material already wrapped under the master password) + an encrypted folder list, each with a revision. - opencreds_items: one row per item, as the spec asks; envelope NULL is a tombstone so purges propagate; seq for incremental pulls. - Every write names its base revision; a stale one gets 409 with the current row. Writes are conditional and read back (each envelope's random IV identifies our write), so this needs nothing dialect-specific from SQLite or Postgres. - Stores ciphertext only. The server learns item count and type codes, as OpenCreds security.md already accepts. Client (@logicsrc/opencreds sync.ts, key-free except where noted): - Pull before every vault command, push after. Offline, the command still works and the change goes up next time. - Conflicts never lose data: the account's edit keeps the id and this machine's becomes "<name> (conflict copy)" (needs the unlocked key, so it waits otherwise). An edit beats a purge. Byte-identical envelopes are adopted, not split, so a lost sync.json is harmless. - Two different vaults (meta.createdAt differs) are never merged: refused, with `vault sync --use-remote` (backs this machine's up to .bak-NNN) or `--use-local`. - Remote item ids must be plain ids; anything else is ignored and never becomes a path. - Folder names are AES-GCM encrypted under the user key before upload. CLI (0.6.0): `logicsrc vault sync [--status|--use-remote|--use-local]`. Only the default vault syncs, and only when logged in; a --home or OPENCREDS_HOME vault stays local unless LOGICSRC_VAULT_SYNC=on; LOGICSRC_VAULT_SYNC=off disables it. The standalone `opencreds` binary gets no remote and never syncs. `init` on a machine that just downloaded the account's vault says to unlock it instead of suggesting --force. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5bebc50beb
commit
d38db62e8b
14 changed files with 1293 additions and 7 deletions
|
|
@ -8,7 +8,7 @@
|
|||
* contracts.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, renameSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
|
||||
|
|
@ -56,6 +56,8 @@ import {
|
|||
} from "./types.js";
|
||||
import { createVault, resetRecoveryKey, rewrapUserKey, unlockVault, unlockWithRecoveryKey } from "./vault-key.js";
|
||||
import { formatDiagnostics, hasErrors, validateDocument } from "./validate.js";
|
||||
import { DivergedVaultError, describeSync, pullVault, pushVault, readSyncState, syncVault, type SyncRemote } from "./sync.js";
|
||||
import type { VaultStore } from "./store.js";
|
||||
|
||||
/** Exit codes are part of the contract; see docs/opencreds/cli.md. */
|
||||
export const EXIT = {
|
||||
|
|
@ -117,13 +119,20 @@ function requireMeta(store: ReturnType<typeof createVaultStore>) {
|
|||
* A live session is used when there is one; otherwise the master password is
|
||||
* asked for. Nothing else unlocks a vault.
|
||||
*/
|
||||
/**
|
||||
* The user key this process unlocked, if any. Sync reads it after a command
|
||||
* runs, to encrypt the folder list and to split a conflicting edit; it is
|
||||
* never written anywhere by this module.
|
||||
*/
|
||||
let unlockedKey: Uint8Array | undefined;
|
||||
|
||||
async function unlock(store: ReturnType<typeof createVaultStore>): Promise<Uint8Array> {
|
||||
const meta = requireMeta(store);
|
||||
const session = readSession(store.baseDir);
|
||||
if (session) return session;
|
||||
if (session) return (unlockedKey = session);
|
||||
const password = await promptSecret("Master password: ");
|
||||
try {
|
||||
return await unlockVault(meta, password);
|
||||
return (unlockedKey = await unlockVault(meta, password));
|
||||
} catch (err) {
|
||||
store.appendAudit(
|
||||
auditEvent({ action: "vault.unlock_failed", namespace: meta.namespace, profile: meta.profile, outcome: "failed" }),
|
||||
|
|
@ -286,13 +295,27 @@ function printItemLine(item: Item): string {
|
|||
}
|
||||
|
||||
/** Register every OpenCreds command onto `parent`. */
|
||||
export function registerCredsCommands(parent: Command): void {
|
||||
export interface RegisterOptions {
|
||||
/**
|
||||
* Where this vault syncs to, if anywhere. Called per command with the store
|
||||
* the command will use; return undefined to stay local. The standalone
|
||||
* `opencreds` binary passes nothing, so it never syncs.
|
||||
*/
|
||||
remote?: (store: VaultStore) => SyncRemote | undefined;
|
||||
}
|
||||
|
||||
// Commands that never touch a vault on disk, plus `sync`, which syncs itself.
|
||||
const NO_AUTOSYNC = new Set(["validate", "conformance", "manifest", "sync"]);
|
||||
|
||||
export function registerCredsCommands(parent: Command, options: RegisterOptions = {}): void {
|
||||
const names: string[] = [];
|
||||
for (let c: Command | null = parent; c; c = c.parent) names.unshift(c.name());
|
||||
cli = names.join(" ");
|
||||
const examples = (lines: string) => `\nExamples:\n${lines.replace(/^\n/, "").replace(/\$CLI/g, cli)}\n`;
|
||||
parent.option("--home <dir>", "vault directory (default $OPENCREDS_HOME)");
|
||||
|
||||
if (options.remote) registerSync(parent, options.remote, examples);
|
||||
|
||||
// ---------------------------------------------------------------- vault ---
|
||||
|
||||
parent
|
||||
|
|
@ -312,6 +335,13 @@ export function registerCredsCommands(parent: Command): void {
|
|||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
if (store.exists() && !opts.force) {
|
||||
if (readSyncState(store).vaultCreatedAt === store.readMeta()?.createdAt) {
|
||||
fail(
|
||||
`This machine already has your vault, synced from your account. Unlock it with your master password: \`${cli} unlock\`. ` +
|
||||
"(--force would start a separate, empty vault.)",
|
||||
EXIT.REFUSED,
|
||||
);
|
||||
}
|
||||
fail(`A vault already exists at ${store.baseDir}; pass --force to replace it`, EXIT.REFUSED);
|
||||
}
|
||||
// Scripted provisioning reads one line and skips the confirmation; a
|
||||
|
|
@ -1044,3 +1074,122 @@ export function registerCredsCommands(parent: Command): void {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- sync ---
|
||||
|
||||
/**
|
||||
* Autosync plus an explicit `sync` command, registered only when the host CLI
|
||||
* provides a remote.
|
||||
*
|
||||
* Every vault command pulls first, so `list` on a second machine shows what
|
||||
* the first one added, and pushes after, so a write is on the account before
|
||||
* the prompt comes back. Sync never fails a command: offline, the vault works
|
||||
* as before and the change goes up next time. Only `sync` itself reports
|
||||
* failure as an exit code.
|
||||
*/
|
||||
function registerSync(
|
||||
parent: Command,
|
||||
remoteFor: (store: VaultStore) => SyncRemote | undefined,
|
||||
examples: (lines: string) => string,
|
||||
): void {
|
||||
const say = (text: string) => {
|
||||
if (text) process.stderr.write(`${text}\n`);
|
||||
};
|
||||
const warn = (err: unknown) => {
|
||||
const message = (err as Error).message;
|
||||
say(
|
||||
err instanceof DivergedVaultError
|
||||
? `vault sync: ${message}`
|
||||
: `vault sync: skipped (${message}); changes stay on this machine and go up next time.`,
|
||||
);
|
||||
};
|
||||
const autosync = (command: Command) => !NO_AUTOSYNC.has(command.name()) && process.env.OPENCREDS_SYNC !== "off";
|
||||
|
||||
parent.hook("preAction", async (_self, command) => {
|
||||
if (!autosync(command)) return;
|
||||
const store = storeFor(command);
|
||||
const remote = remoteFor(store);
|
||||
if (!remote) return;
|
||||
try {
|
||||
say(describeSync(await pullVault(store, remote), remote.label));
|
||||
} catch (err) {
|
||||
warn(err);
|
||||
}
|
||||
});
|
||||
|
||||
parent.hook("postAction", async (_self, command) => {
|
||||
if (!autosync(command) || process.exitCode) return;
|
||||
const store = storeFor(command);
|
||||
const remote = remoteFor(store);
|
||||
if (!remote || !store.exists()) return;
|
||||
try {
|
||||
say(describeSync(await pushVault(store, remote, unlockedKey), remote.label));
|
||||
} catch (err) {
|
||||
warn(err);
|
||||
}
|
||||
});
|
||||
|
||||
parent
|
||||
.command("sync")
|
||||
.description("sync this vault with your account now (it also happens around every command)")
|
||||
.addHelpText("after", examples(`
|
||||
$CLI sync pull then push, now
|
||||
$CLI sync --status what is synced, what is waiting
|
||||
$CLI sync --use-remote two different vaults: keep the account's (this machine's is backed up first)
|
||||
$CLI sync --use-local two different vaults: replace the account's with this machine's
|
||||
OPENCREDS_SYNC=off $CLI list one command without syncing`))
|
||||
.option("--status", "show sync state and change nothing")
|
||||
.option("--use-remote", "replace this machine's vault with the account's (backed up first)")
|
||||
.option("--use-local", "replace the account's vault with this machine's")
|
||||
.action(async function (this: Command, opts: { status?: boolean; useRemote?: boolean; useLocal?: boolean }) {
|
||||
await run(async () => {
|
||||
let store = storeFor(this);
|
||||
const remote = remoteFor(store);
|
||||
if (!remote) fail("This vault is not linked to an account. Log in first (logicsrc login).", EXIT.USAGE);
|
||||
if (opts.useRemote && opts.useLocal) fail("Pick one of --use-remote and --use-local", EXIT.USAGE);
|
||||
|
||||
if (opts.status) {
|
||||
const state = readSyncState(store);
|
||||
const envelopes = store.listEnvelopes();
|
||||
const here = new Set(envelopes.map((e) => e.id));
|
||||
const waiting =
|
||||
envelopes.filter((e) => e.revision !== state.items[e.id]).length +
|
||||
Object.keys(state.items).filter((id) => !here.has(id)).length;
|
||||
const rv = await remote.getVault();
|
||||
process.stdout.write(
|
||||
`Account ${remote.label}${rv ? "" : " (no vault there yet)"}\n` +
|
||||
`Local ${store.exists() ? `${envelopes.length} item(s) at ${store.baseDir}` : `no vault at ${store.baseDir}`}\n` +
|
||||
`Synced ${Object.keys(state.items).length} item(s)${state.lastSyncAt ? `, last ${state.lastSyncAt}` : ", never"}\n` +
|
||||
`Waiting ${waiting} change(s) to push\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.useRemote) {
|
||||
if (!(await remote.getVault())) fail(`There is no vault on ${remote.label} to use.`, EXIT.USAGE);
|
||||
if (store.exists()) {
|
||||
let n = 1;
|
||||
const backup = () => `${store.baseDir}.bak-${String(n).padStart(3, "0")}`;
|
||||
while (existsSync(backup())) n++;
|
||||
clearSession(store.baseDir);
|
||||
renameSync(store.baseDir, backup());
|
||||
process.stderr.write(`Moved this machine's vault to ${backup()}\n`);
|
||||
}
|
||||
store = createVaultStore(store.baseDir);
|
||||
} else if (opts.useLocal) {
|
||||
if (!store.exists()) fail(`No vault at ${store.baseDir} to upload.`, EXIT.USAGE);
|
||||
const ok = await confirm(`Replace the vault on ${remote.label} with this machine's? Its items there are deleted.`);
|
||||
if (!ok) fail("Nothing changed.", EXIT.REFUSED);
|
||||
await remote.reset();
|
||||
store.writeSyncState({});
|
||||
}
|
||||
|
||||
const key = store.exists() ? (readSession(store.baseDir) ?? undefined) : undefined;
|
||||
const report = await syncVault(store, remote, key);
|
||||
process.stdout.write(`${describeSync(report, remote.label) || `vault sync (${remote.label}): up to date`}\n`);
|
||||
if (!key && store.exists()) {
|
||||
process.stderr.write(`Folders sync on the next unlocked command (or \`${cli} unlock --persist\` first).\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,22 @@ export {
|
|||
|
||||
export { createVaultStore, opencredsHome, type VaultStore } from "./store.js";
|
||||
|
||||
export {
|
||||
DivergedVaultError,
|
||||
SyncError,
|
||||
describeSync,
|
||||
pullVault,
|
||||
pushVault,
|
||||
readSyncState,
|
||||
syncVault,
|
||||
type Put,
|
||||
type RemoteItem,
|
||||
type RemoteVault,
|
||||
type SyncRemote,
|
||||
type SyncReport,
|
||||
type SyncState,
|
||||
} from "./sync.js";
|
||||
|
||||
export { auditEvent, fingerprint, type AuditInput } from "./audit.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
* meta.json vault metadata — key material, all of it wrapped
|
||||
* items/<id>.json one envelope per item
|
||||
* audit.jsonl append-only audit events, values never present
|
||||
* sync.json what was last exchanged with an account (see sync.ts)
|
||||
*
|
||||
* One file per item rather than one file for the vault, for the same reason
|
||||
* storage-backed implementations use one row per item: two writers editing two
|
||||
|
|
@ -48,11 +49,15 @@ export interface VaultStore {
|
|||
listEnvelopes(): Envelope[];
|
||||
readEnvelope(id: string): Envelope | undefined;
|
||||
writeEnvelope(envelope: Envelope): void;
|
||||
/** Write an envelope exactly as given, revision included. For sync only. */
|
||||
putEnvelope(envelope: Envelope): void;
|
||||
deleteEnvelope(id: string): void;
|
||||
readFolders(): Folder[];
|
||||
writeFolders(folders: Folder[]): void;
|
||||
appendAudit(event: AuditEvent): void;
|
||||
readAudit(): AuditEvent[];
|
||||
readSyncState<T>(): T | undefined;
|
||||
writeSyncState<T>(state: T): void;
|
||||
}
|
||||
|
||||
export function createVaultStore(baseDir = opencredsHome()): VaultStore {
|
||||
|
|
@ -60,6 +65,7 @@ export function createVaultStore(baseDir = opencredsHome()): VaultStore {
|
|||
const metaPath = join(baseDir, "meta.json");
|
||||
const foldersPath = join(baseDir, "folders.json");
|
||||
const auditPath = join(baseDir, "audit.jsonl");
|
||||
const syncPath = join(baseDir, "sync.json");
|
||||
|
||||
function ensureDirs(): void {
|
||||
mkdirSync(itemsDir, { recursive: true, mode: 0o700 });
|
||||
|
|
@ -120,6 +126,11 @@ export function createVaultStore(baseDir = opencredsHome()): VaultStore {
|
|||
writePrivate(join(itemsDir, `${envelope.id}.json`), `${JSON.stringify(next, null, 2)}\n`);
|
||||
},
|
||||
|
||||
putEnvelope(envelope: Envelope): void {
|
||||
ensureDirs();
|
||||
writePrivate(join(itemsDir, `${envelope.id}.json`), `${JSON.stringify(envelope, null, 2)}\n`);
|
||||
},
|
||||
|
||||
deleteEnvelope(id: string): void {
|
||||
rmSync(join(itemsDir, `${id}.json`), { force: true });
|
||||
},
|
||||
|
|
@ -141,6 +152,15 @@ export function createVaultStore(baseDir = opencredsHome()): VaultStore {
|
|||
writeFileSync(auditPath, line, { encoding: "utf8", flag: "a", mode: 0o600 });
|
||||
},
|
||||
|
||||
readSyncState<T>(): T | undefined {
|
||||
return readJson<T>(syncPath);
|
||||
},
|
||||
|
||||
writeSyncState<T>(state: T): void {
|
||||
ensureDirs();
|
||||
writePrivate(syncPath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
},
|
||||
|
||||
readAudit(): AuditEvent[] {
|
||||
if (!existsSync(auditPath)) return [];
|
||||
return readFileSync(auditPath, "utf8")
|
||||
|
|
|
|||
277
packages/opencreds/src/sync.test.ts
Normal file
277
packages/opencreds/src/sync.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Sync between two machines through one account.
|
||||
*
|
||||
* The remote here is an in-memory copy of the server's rules (apps/pwa
|
||||
* routes/opencreds.mjs): optimistic revisions per row, tombstones for purges,
|
||||
* a 409 carrying the current row. Each "machine" is its own vault directory.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createItem, decryptItem, encryptItem, updateItem } from "./items.js";
|
||||
import { createVaultStore, type VaultStore } from "./store.js";
|
||||
import { DivergedVaultError, pullVault, pushVault, syncVault, type RemoteItem, type RemoteVault, type SyncRemote } from "./sync.js";
|
||||
import type { Envelope, Item, VaultMeta } from "./types.js";
|
||||
import { createVault } from "./vault-key.js";
|
||||
|
||||
const ITERATIONS = { kdf: "pbkdf2-sha256" as const, iterations: 600_000 };
|
||||
|
||||
function fakeAccount() {
|
||||
let vault: { meta: VaultMeta; metaRevision: number; folders: { ciphertext: string; iv: string; revision: number } | null } | null = null;
|
||||
const rows = new Map<string, { envelope: string | null; revision: number; seq: number }>();
|
||||
let seq = 0;
|
||||
const snapshot = (): RemoteVault | null => (vault ? JSON.parse(JSON.stringify(vault)) : null);
|
||||
const item = (id: string): RemoteItem => {
|
||||
const r = rows.get(id);
|
||||
if (!r) return { id, envelope: null, revision: 0, seq: 0 };
|
||||
return { id, envelope: r.envelope ? { ...JSON.parse(r.envelope), revision: r.revision } : null, revision: r.revision, seq: r.seq };
|
||||
};
|
||||
const remote: SyncRemote = {
|
||||
label: "test-account",
|
||||
async getVault() {
|
||||
return snapshot();
|
||||
},
|
||||
async putMeta(meta, baseRevision) {
|
||||
const current = vault?.metaRevision ?? 0;
|
||||
if (current !== baseRevision) return { ok: false, vault: snapshot() };
|
||||
vault = { meta: JSON.parse(JSON.stringify(meta)), metaRevision: current + 1, folders: vault?.folders ?? null };
|
||||
return { ok: true, revision: current + 1 };
|
||||
},
|
||||
async putFolders(blob, baseRevision) {
|
||||
if (!vault) return { ok: false, vault: null };
|
||||
const current = vault.folders?.revision ?? 0;
|
||||
if (current !== baseRevision) return { ok: false, vault: snapshot() };
|
||||
vault.folders = { ...blob, revision: current + 1 };
|
||||
return { ok: true, revision: current + 1 };
|
||||
},
|
||||
async listItems(since) {
|
||||
const items = [...rows.keys()].map(item).filter((i) => i.seq >= since).sort((a, b) => a.seq - b.seq);
|
||||
return { items, cursor: items.reduce((m, i) => Math.max(m, i.seq), since) };
|
||||
},
|
||||
async putItems(changes) {
|
||||
const applied: Array<{ id: string; revision: number; seq: number }> = [];
|
||||
const conflicts: RemoteItem[] = [];
|
||||
for (const c of changes) {
|
||||
const current = rows.get(c.id)?.revision ?? 0;
|
||||
if (current !== c.baseRevision || (c.baseRevision > 0 && !rows.has(c.id))) {
|
||||
conflicts.push(item(c.id));
|
||||
continue;
|
||||
}
|
||||
const { revision: _r, ...rest } = c.envelope ?? ({} as Envelope);
|
||||
rows.set(c.id, { envelope: c.envelope ? JSON.stringify(rest) : null, revision: current + 1, seq: ++seq });
|
||||
applied.push({ id: c.id, revision: current + 1, seq });
|
||||
}
|
||||
return { applied, conflicts };
|
||||
},
|
||||
async reset() {
|
||||
vault = null;
|
||||
rows.clear();
|
||||
},
|
||||
};
|
||||
return { remote, rows, vault: () => vault };
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
function machine(): VaultStore {
|
||||
const dir = mkdtempSync(join(tmpdir(), "opencreds-sync-"));
|
||||
dirs.push(dir);
|
||||
return createVaultStore(join(dir, "vault"));
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function newVault(store: VaultStore) {
|
||||
const { meta, userKey } = await createVault("pw", { params: ITERATIONS });
|
||||
store.writeMeta(meta);
|
||||
return { meta, userKey };
|
||||
}
|
||||
|
||||
async function add(store: VaultStore, key: Uint8Array, name: string, password = "s3cret"): Promise<Item> {
|
||||
const item = createItem("login", { name, login: { username: "me", password, uris: [] } } as Partial<Item>);
|
||||
store.writeEnvelope(await encryptItem(key, item));
|
||||
return item;
|
||||
}
|
||||
|
||||
async function names(store: VaultStore, key: Uint8Array): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const env of store.listEnvelopes()) out.push((await decryptItem(key, env)).name);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
describe("vault sync", () => {
|
||||
it("uploads a vault, and a second machine downloads it with no key", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
await add(a, userKey, "GitHub");
|
||||
await add(a, userKey, "Bank");
|
||||
|
||||
const up = await syncVault(a, acct.remote, userKey);
|
||||
expect(up.created).toBe(true);
|
||||
expect(up.pushed).toBe(2);
|
||||
|
||||
const b = machine();
|
||||
const down = await pullVault(b, acct.remote);
|
||||
expect(down.downloaded).toBe(true);
|
||||
expect(down.pulled).toBe(2);
|
||||
expect(b.readMeta()).toEqual(a.readMeta());
|
||||
expect(await names(b, userKey)).toEqual(["Bank", "GitHub"]);
|
||||
});
|
||||
|
||||
it("the account holds only ciphertext", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
await add(a, userKey, "VerySecretName", "hunter2-password");
|
||||
a.writeFolders([{ id: "f1", name: "PrivateFolderName" }]);
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
|
||||
const everything = JSON.stringify({ rows: [...acct.rows.values()], vault: acct.vault() });
|
||||
expect(everything).not.toMatch(/VerySecretName|hunter2-password|PrivateFolderName/);
|
||||
expect(acct.vault()?.folders).not.toBeNull();
|
||||
});
|
||||
|
||||
it("an edit on one machine reaches the other; a quiet second sync moves nothing", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
const item = await add(a, userKey, "GitHub");
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const b = machine();
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
|
||||
a.writeEnvelope(await encryptItem(userKey, updateItem(item, { name: "GitHub (work)" })));
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const r = await syncVault(b, acct.remote, userKey);
|
||||
expect(r.pulled).toBe(1);
|
||||
expect(await names(b, userKey)).toEqual(["GitHub (work)"]);
|
||||
|
||||
const again = await syncVault(b, acct.remote, userKey);
|
||||
expect([again.pulled, again.pushed, again.conflicts]).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("the same item edited on both machines keeps both edits", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
const item = await add(a, userKey, "Email");
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const b = machine();
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
|
||||
a.writeEnvelope(await encryptItem(userKey, updateItem(item, { name: "Email A" })));
|
||||
b.writeEnvelope(await encryptItem(userKey, updateItem(item, { name: "Email B" })));
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const r = await syncVault(b, acct.remote, userKey);
|
||||
expect(r.conflicts).toBe(1);
|
||||
expect(await names(b, userKey)).toEqual(["Email A", "Email B (conflict copy)"]);
|
||||
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
expect(await names(a, userKey)).toEqual(["Email A", "Email B (conflict copy)"]);
|
||||
});
|
||||
|
||||
it("without the key, a conflict waits instead of guessing", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
const item = await add(a, userKey, "Email");
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const b = machine();
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
|
||||
a.writeEnvelope(await encryptItem(userKey, updateItem(item, { name: "Email A" })));
|
||||
b.writeEnvelope(await encryptItem(userKey, updateItem(item, { name: "Email B" })));
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const r = await pushVault(b, acct.remote);
|
||||
expect(r.notes.join(" ")).toMatch(/next time the vault is unlocked/);
|
||||
expect(await names(b, userKey)).toEqual(["Email B"]);
|
||||
const later = await syncVault(b, acct.remote, userKey);
|
||||
expect(later.conflicts).toBe(1);
|
||||
expect(await names(b, userKey)).toEqual(["Email A", "Email B (conflict copy)"]);
|
||||
});
|
||||
|
||||
it("a purge reaches the other machine; an edit beats a purge", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
const gone = await add(a, userKey, "Old");
|
||||
const kept = await add(a, userKey, "Kept");
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const b = machine();
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
|
||||
a.deleteEnvelope(gone.id);
|
||||
a.deleteEnvelope(kept.id);
|
||||
b.writeEnvelope(await encryptItem(userKey, updateItem(kept, { name: "Kept, edited" })));
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
|
||||
expect(await names(a, userKey)).toEqual(["Kept, edited"]);
|
||||
expect(await names(b, userKey)).toEqual(["Kept, edited"]);
|
||||
});
|
||||
|
||||
it("a lost sync.json does not turn every item into a conflict copy", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
await add(a, userKey, "One");
|
||||
await add(a, userKey, "Two");
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
rmSync(join(a.baseDir, "sync.json"));
|
||||
|
||||
const r = await syncVault(a, acct.remote, userKey);
|
||||
expect(r.conflicts).toBe(0);
|
||||
expect(await names(a, userKey)).toEqual(["One", "Two"]);
|
||||
expect(acct.rows.size).toBe(2);
|
||||
});
|
||||
|
||||
it("two different vaults are never merged", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const ka = await newVault(a);
|
||||
await add(a, ka.userKey, "A's");
|
||||
await syncVault(a, acct.remote, ka.userKey);
|
||||
|
||||
const b = machine();
|
||||
const kb = await newVault(b);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
b.writeMeta({ ...b.readMeta()!, createdAt: new Date(Date.now() + 1000).toISOString() });
|
||||
await add(b, kb.userKey, "B's");
|
||||
await expect(syncVault(b, acct.remote, kb.userKey)).rejects.toBeInstanceOf(DivergedVaultError);
|
||||
expect(acct.rows.size).toBe(1);
|
||||
});
|
||||
|
||||
it("folders merge across machines, encrypted", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
a.writeFolders([{ id: "fa", name: "Work" }]);
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
const b = machine();
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
expect(b.readFolders()).toEqual([{ id: "fa", name: "Work" }]);
|
||||
|
||||
b.writeFolders([...b.readFolders(), { id: "fb", name: "Home" }]);
|
||||
await syncVault(b, acct.remote, userKey);
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
expect(a.readFolders().map((f) => f.name).sort()).toEqual(["Home", "Work"]);
|
||||
});
|
||||
|
||||
it("a remote id that is not a plain id never becomes a file", async () => {
|
||||
const acct = fakeAccount();
|
||||
const a = machine();
|
||||
const { userKey } = await newVault(a);
|
||||
await syncVault(a, acct.remote, userKey);
|
||||
acct.rows.set("../../escape", { envelope: JSON.stringify({ id: "../../escape", type: 1, ciphertext: "x", iv: "y" }), revision: 1, seq: 99 });
|
||||
|
||||
const r = await pullVault(a, acct.remote);
|
||||
expect(r.notes.join(" ")).toMatch(/unusable id/);
|
||||
expect(readdirSync(join(a.baseDir, "items"))).toEqual([]);
|
||||
expect(() => readFileSync(join(a.baseDir, "..", "..", "escape.json"))).toThrow();
|
||||
});
|
||||
});
|
||||
382
packages/opencreds/src/sync.ts
Normal file
382
packages/opencreds/src/sync.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
/**
|
||||
* Two-way sync between a file vault and a remote copy of the same vault.
|
||||
*
|
||||
* Everything exchanged is already ciphertext: the meta carries key material
|
||||
* wrapped under the master password, items are envelopes under the user key,
|
||||
* and the folder list is encrypted here before it leaves. The remote only
|
||||
* stores and orders blobs; it never needs a key, and sync never needs one
|
||||
* either except to encrypt folders and to split a conflicting edit into two
|
||||
* items. Without the key those two steps wait for the next unlocked run.
|
||||
*
|
||||
* Model: optimistic concurrency per row. `sync.json` records, per item, the
|
||||
* remote revision this machine last agreed with. An item whose local revision
|
||||
* differs from it changed here; a pulled item whose remote revision differs
|
||||
* from it changed there. Both at once is a conflict, resolved without losing
|
||||
* either side: the remote edit keeps the id, the local edit becomes a copy.
|
||||
*
|
||||
* A vault is identified by `meta.createdAt`, which a password change keeps and
|
||||
* `init` replaces. Two different vaults never merge: that is refused, with the
|
||||
* commands that pick one.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { decryptItem, encryptItem } from "./items.js";
|
||||
import { aesGcmDecrypt, aesGcmEncrypt, fromBase64, toBase64, utf8Decode, utf8Encode, uuid } from "./primitives.js";
|
||||
import type { VaultStore } from "./store.js";
|
||||
import type { Envelope, Folder, VaultMeta } from "./types.js";
|
||||
|
||||
export interface RemoteItem {
|
||||
id: string;
|
||||
/** null is a tombstone: the item was purged. */
|
||||
envelope: Envelope | null;
|
||||
revision: number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface RemoteVault {
|
||||
meta: VaultMeta;
|
||||
metaRevision: number;
|
||||
folders: { ciphertext: string; iv: string; revision: number } | null;
|
||||
}
|
||||
|
||||
export type Put<T> = { ok: true; revision: number } | { ok: false; vault: T | null };
|
||||
|
||||
/** Where a vault syncs to. The logicsrc CLI provides one backed by the account. */
|
||||
export interface SyncRemote {
|
||||
/** Shown to people: "app.logicsrc.com (you@example.com)". */
|
||||
label: string;
|
||||
getVault(): Promise<RemoteVault | null>;
|
||||
putMeta(meta: VaultMeta, baseRevision: number): Promise<Put<RemoteVault>>;
|
||||
putFolders(blob: { ciphertext: string; iv: string }, baseRevision: number): Promise<Put<RemoteVault>>;
|
||||
listItems(since: number): Promise<{ items: RemoteItem[]; cursor: number }>;
|
||||
putItems(
|
||||
changes: Array<{ id: string; envelope: Envelope | null; baseRevision: number }>,
|
||||
): Promise<{ applied: Array<{ id: string; revision: number; seq: number }>; conflicts: RemoteItem[] }>;
|
||||
/** Drop the remote vault entirely (`sync --use-local`). */
|
||||
reset(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SyncState {
|
||||
/** createdAt of the vault this state belongs to. */
|
||||
vaultCreatedAt?: string;
|
||||
cursor: number;
|
||||
metaRevision: number;
|
||||
metaHash?: string;
|
||||
foldersRevision: number;
|
||||
foldersHash?: string;
|
||||
/** item id -> the remote revision this machine last agreed with */
|
||||
items: Record<string, number>;
|
||||
lastSyncAt?: string;
|
||||
}
|
||||
|
||||
export interface SyncReport {
|
||||
pulled: number;
|
||||
pushed: number;
|
||||
deleted: number;
|
||||
conflicts: number;
|
||||
created: boolean;
|
||||
downloaded: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export class SyncError extends Error {}
|
||||
|
||||
/** Two different vaults: the account's and this machine's. Never merged. */
|
||||
export class DivergedVaultError extends SyncError {}
|
||||
|
||||
// An id becomes a filename. A remote that sends "../meta" must not get to
|
||||
// write outside items/, so anything but a plain id is refused outright.
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
const PUSH_BATCH = 200;
|
||||
|
||||
const emptyState = (): SyncState => ({ cursor: 0, metaRevision: 0, foldersRevision: 0, items: {} });
|
||||
|
||||
function hash(value: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function sameCiphertext(a: Envelope, b: Envelope | null): boolean {
|
||||
return b !== null && a.ciphertext === b.ciphertext && a.iv === b.iv;
|
||||
}
|
||||
|
||||
function foldersAad(namespace: string): Uint8Array {
|
||||
return utf8Encode(`${namespace}:vault:folders:1`);
|
||||
}
|
||||
|
||||
export function readSyncState(store: VaultStore): SyncState {
|
||||
return { ...emptyState(), ...(store.readSyncState<SyncState>() ?? {}) };
|
||||
}
|
||||
|
||||
function newReport(): SyncReport {
|
||||
return { pulled: 0, pushed: 0, deleted: 0, conflicts: 0, created: false, downloaded: false, notes: [] };
|
||||
}
|
||||
|
||||
function assertSameVault(local: VaultMeta, remote: VaultMeta, label: string): void {
|
||||
if (local.createdAt !== remote.createdAt) {
|
||||
throw new DivergedVaultError(
|
||||
`This machine's vault and the one on ${label} are different vaults (created ${local.createdAt ?? "?"} and ${remote.createdAt ?? "?"}). ` +
|
||||
"Nothing was synced. Keep one: `sync --use-remote` replaces this machine's (backed up first), " +
|
||||
"`sync --use-local` replaces the account's.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring remote changes down. Needs no key. On a machine with no vault yet and
|
||||
* an account that has one, this is what downloads it.
|
||||
*/
|
||||
export async function pullVault(store: VaultStore, remote: SyncRemote, report = newReport()): Promise<SyncReport> {
|
||||
const rv = await remote.getVault();
|
||||
if (!rv) return report;
|
||||
let state = readSyncState(store);
|
||||
const local = store.readMeta();
|
||||
|
||||
if (!local) {
|
||||
store.writeMeta(rv.meta);
|
||||
state = { ...emptyState(), vaultCreatedAt: rv.meta.createdAt, metaRevision: rv.metaRevision, metaHash: hash(rv.meta) };
|
||||
report.downloaded = true;
|
||||
} else {
|
||||
assertSameVault(local, rv.meta, remote.label);
|
||||
if (state.vaultCreatedAt !== local.createdAt) state = { ...emptyState(), vaultCreatedAt: local.createdAt };
|
||||
const localChanged = hash(local) !== state.metaHash;
|
||||
if (rv.metaRevision !== state.metaRevision) {
|
||||
// A password change elsewhere. The user key is the same, so items are
|
||||
// unaffected; if this machine changed it too, the account's wins.
|
||||
if (localChanged && state.metaHash) {
|
||||
report.notes.push(`The master password was changed both here and on ${remote.label}; keeping the account's.`);
|
||||
}
|
||||
if (!localChanged || state.metaHash) {
|
||||
store.writeMeta(rv.meta);
|
||||
state.metaHash = hash(rv.meta);
|
||||
state.metaRevision = rv.metaRevision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { items, cursor } = await remote.listItems(state.cursor);
|
||||
for (const ri of items) {
|
||||
if (!SAFE_ID.test(ri.id) || (ri.envelope && ri.envelope.id !== ri.id)) {
|
||||
report.notes.push(`Ignored a remote item with an unusable id (${JSON.stringify(ri.id).slice(0, 40)}).`);
|
||||
continue;
|
||||
}
|
||||
const synced = state.items[ri.id];
|
||||
if (synced === ri.revision) continue; // already have it
|
||||
const localEnv = store.readEnvelope(ri.id);
|
||||
const changedHere = localEnv ? localEnv.revision !== synced : synced !== undefined;
|
||||
if (changedHere) {
|
||||
// Byte-identical is the same item (a lost sync.json, a copied vault): adopt it.
|
||||
if (localEnv && sameCiphertext(localEnv, ri.envelope)) {
|
||||
store.putEnvelope({ ...localEnv, revision: ri.revision });
|
||||
state.items[ri.id] = ri.revision;
|
||||
}
|
||||
continue; // both sides moved; push resolves it
|
||||
}
|
||||
if (ri.envelope) {
|
||||
store.putEnvelope({ ...ri.envelope, revision: ri.revision });
|
||||
state.items[ri.id] = ri.revision;
|
||||
report.pulled++;
|
||||
} else {
|
||||
if (localEnv) report.deleted++;
|
||||
store.deleteEnvelope(ri.id);
|
||||
delete state.items[ri.id];
|
||||
}
|
||||
}
|
||||
state.cursor = Math.max(state.cursor, cursor);
|
||||
state.lastSyncAt = new Date().toISOString();
|
||||
store.writeSyncState(state);
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send local changes up, resolving conflicts. `userKey` is optional: without
|
||||
* it, folders wait and a conflicting edit stays pending until a run that has it.
|
||||
*/
|
||||
export async function pushVault(
|
||||
store: VaultStore,
|
||||
remote: SyncRemote,
|
||||
userKey?: Uint8Array,
|
||||
report = newReport(),
|
||||
): Promise<SyncReport> {
|
||||
const local = store.readMeta();
|
||||
if (!local) return report;
|
||||
let state = readSyncState(store);
|
||||
if (state.vaultCreatedAt !== local.createdAt) {
|
||||
// First sync of this vault from this machine. If the account already has
|
||||
// a different vault, stop before uploading anything.
|
||||
const rv = await remote.getVault();
|
||||
if (rv) assertSameVault(local, rv.meta, remote.label);
|
||||
state = { ...emptyState(), vaultCreatedAt: local.createdAt };
|
||||
}
|
||||
|
||||
// ---- meta ----
|
||||
if (hash(local) !== state.metaHash) {
|
||||
const put = await remote.putMeta(local, state.metaRevision);
|
||||
if (put.ok) {
|
||||
state.metaRevision = put.revision;
|
||||
if (put.revision === 1) report.created = true;
|
||||
} else if (put.vault) {
|
||||
assertSameVault(local, put.vault.meta, remote.label);
|
||||
store.writeMeta(put.vault.meta);
|
||||
state.metaRevision = put.vault.metaRevision;
|
||||
report.notes.push(`The account's vault settings were newer; kept them.`);
|
||||
}
|
||||
state.metaHash = hash(store.readMeta());
|
||||
}
|
||||
|
||||
// ---- items ----
|
||||
const pending = (): Array<{ id: string; envelope: Envelope | null; baseRevision: number }> => {
|
||||
const out: Array<{ id: string; envelope: Envelope | null; baseRevision: number }> = [];
|
||||
const present = new Set<string>();
|
||||
for (const env of store.listEnvelopes()) {
|
||||
present.add(env.id);
|
||||
if (!SAFE_ID.test(env.id)) continue;
|
||||
if (env.revision !== state.items[env.id]) out.push({ id: env.id, envelope: env, baseRevision: state.items[env.id] ?? 0 });
|
||||
}
|
||||
for (const id of Object.keys(state.items)) {
|
||||
if (!present.has(id)) out.push({ id, envelope: null, baseRevision: state.items[id] });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Two rounds: the second sends what conflict resolution produced (a copy,
|
||||
// a resurrected edit). Anything still conflicting after that waits.
|
||||
for (let round = 0; round < 2; round++) {
|
||||
const changes = pending();
|
||||
if (changes.length === 0) break;
|
||||
for (let i = 0; i < changes.length; i += PUSH_BATCH) {
|
||||
const batch = changes.slice(i, i + PUSH_BATCH);
|
||||
const { applied, conflicts } = await remote.putItems(batch);
|
||||
const byId = new Map(batch.map((c) => [c.id, c]));
|
||||
for (const a of applied) {
|
||||
const sent = byId.get(a.id);
|
||||
if (sent?.envelope) {
|
||||
store.putEnvelope({ ...sent.envelope, revision: a.revision });
|
||||
state.items[a.id] = a.revision;
|
||||
report.pushed++;
|
||||
} else {
|
||||
delete state.items[a.id];
|
||||
report.deleted++;
|
||||
}
|
||||
}
|
||||
for (const theirs of conflicts) {
|
||||
await resolveConflict(store, state, theirs, byId.get(theirs.id)!, userKey, local.namespace, report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- folders ----
|
||||
if (userKey) await syncFolders(store, remote, state, userKey, local.namespace, report);
|
||||
state.lastSyncAt = new Date().toISOString();
|
||||
|
||||
store.writeSyncState(state);
|
||||
return report;
|
||||
}
|
||||
|
||||
async function resolveConflict(
|
||||
store: VaultStore,
|
||||
state: SyncState,
|
||||
theirs: RemoteItem,
|
||||
mine: { id: string; envelope: Envelope | null; baseRevision: number },
|
||||
userKey: Uint8Array | undefined,
|
||||
namespace: string,
|
||||
report: SyncReport,
|
||||
): Promise<void> {
|
||||
if (mine.envelope && sameCiphertext(mine.envelope, theirs.envelope)) {
|
||||
store.putEnvelope({ ...mine.envelope, revision: theirs.revision });
|
||||
state.items[theirs.id] = theirs.revision;
|
||||
return;
|
||||
}
|
||||
report.conflicts++;
|
||||
if (theirs.revision === 0) {
|
||||
// The account has no such row: it was never there, or the vault was reset.
|
||||
delete state.items[mine.id];
|
||||
return;
|
||||
}
|
||||
if (!mine.envelope) {
|
||||
// Purged here, edited there: the edit wins and comes back.
|
||||
if (theirs.envelope) {
|
||||
store.putEnvelope({ ...theirs.envelope, revision: theirs.revision });
|
||||
state.items[theirs.id] = theirs.revision;
|
||||
report.notes.push(`${theirs.id} was purged here but edited elsewhere; kept the edit.`);
|
||||
} else {
|
||||
delete state.items[theirs.id];
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!theirs.envelope) {
|
||||
// Purged there, edited here: keep the edit by re-sending it over the tombstone.
|
||||
// Its local revision is set one past the tombstone so it reads as unsent
|
||||
// (a local counter can happen to equal the remote one).
|
||||
store.putEnvelope({ ...mine.envelope, revision: theirs.revision + 1 });
|
||||
state.items[mine.id] = theirs.revision;
|
||||
report.notes.push(`${mine.id} was purged elsewhere but edited here; kept the edit.`);
|
||||
return;
|
||||
}
|
||||
if (!userKey) {
|
||||
report.notes.push(`${mine.id} was edited here and elsewhere; it will be split into two items the next time the vault is unlocked.`);
|
||||
return;
|
||||
}
|
||||
// Both edited. The account's version keeps the id; this machine's becomes a copy.
|
||||
const item = await decryptItem(userKey, mine.envelope, namespace);
|
||||
const copy = { ...item, id: uuid(), name: `${item.name} (conflict copy)`, updatedAt: new Date().toISOString() };
|
||||
store.writeEnvelope(await encryptItem(userKey, copy, namespace));
|
||||
store.putEnvelope({ ...theirs.envelope, revision: theirs.revision });
|
||||
state.items[theirs.id] = theirs.revision;
|
||||
report.notes.push(`"${item.name}" was edited here and elsewhere; kept both (yours is "${copy.name}").`);
|
||||
}
|
||||
|
||||
async function syncFolders(
|
||||
store: VaultStore,
|
||||
remote: SyncRemote,
|
||||
state: SyncState,
|
||||
userKey: Uint8Array,
|
||||
namespace: string,
|
||||
report: SyncReport,
|
||||
): Promise<void> {
|
||||
const aad = foldersAad(namespace);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const rv = await remote.getVault();
|
||||
let folders = store.readFolders();
|
||||
if (rv?.folders && rv.folders.revision !== state.foldersRevision) {
|
||||
const plain = await aesGcmDecrypt(userKey, fromBase64(rv.folders.iv), fromBase64(rv.folders.ciphertext), aad);
|
||||
const theirs = JSON.parse(utf8Decode(plain)) as Folder[];
|
||||
// Union by id; a name edited on both sides keeps this machine's.
|
||||
const merged = new Map(theirs.map((f) => [f.id, f]));
|
||||
for (const f of folders) merged.set(f.id, f);
|
||||
folders = [...merged.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
store.writeFolders(folders);
|
||||
state.foldersRevision = rv.folders.revision;
|
||||
state.foldersHash = hash(theirs.slice().sort((a, b) => a.id.localeCompare(b.id)));
|
||||
}
|
||||
const sorted = folders.slice().sort((a, b) => a.id.localeCompare(b.id));
|
||||
if (hash(sorted) === state.foldersHash || (sorted.length === 0 && !state.foldersHash)) return;
|
||||
const { iv, ciphertext } = await aesGcmEncrypt(userKey, utf8Encode(JSON.stringify(sorted)), aad);
|
||||
const put = await remote.putFolders({ iv: toBase64(iv), ciphertext: toBase64(ciphertext) }, state.foldersRevision);
|
||||
if (put.ok) {
|
||||
state.foldersRevision = put.revision;
|
||||
state.foldersHash = hash(sorted);
|
||||
return;
|
||||
}
|
||||
report.notes.push("Folders changed elsewhere at the same time; merged and retried.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull then push: what `sync` runs, and what the hooks run around a command. */
|
||||
export async function syncVault(store: VaultStore, remote: SyncRemote, userKey?: Uint8Array): Promise<SyncReport> {
|
||||
const report = await pullVault(store, remote);
|
||||
return pushVault(store, remote, userKey, report);
|
||||
}
|
||||
|
||||
/** One line for a person, or "" when nothing moved. */
|
||||
export function describeSync(report: SyncReport, label: string): string {
|
||||
const parts: string[] = [];
|
||||
if (report.downloaded) parts.push("downloaded the vault");
|
||||
if (report.created) parts.push("uploaded the vault");
|
||||
if (report.pulled) parts.push(`${report.pulled} in`);
|
||||
if (report.pushed) parts.push(`${report.pushed} out`);
|
||||
if (report.deleted) parts.push(`${report.deleted} purged`);
|
||||
if (report.conflicts) parts.push(`${report.conflicts} conflict(s)`);
|
||||
const head = parts.length ? `vault sync (${label}): ${parts.join(", ")}` : "";
|
||||
return [head, ...report.notes.map((n) => `vault sync: ${n}`)].filter(Boolean).join("\n");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue