vault: autosync the personal vault to the logicsrc account (#226)
Some checks are pending
CI / build (push) Waiting to run
Deploy to dev2 / deploy (push) Waiting to run
test / test (push) Waiting to run

`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:
Anthony Ettinger 2026-10-04 03:45:25 -07:00 • committed by GitHub
parent 5bebc50beb
commit d38db62e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1293 additions and 7 deletions

View file

@ -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`);
}
});
});
}

View file

@ -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 {

View file

@ -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")

View 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();
});
});

View 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");
}