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

@ -0,0 +1,123 @@
// /api/opencreds: the account copy of a personal vault. The server stores
// ciphertext and enforces one rule, optimistic revisions: a write based on a
// revision someone else already moved is refused with the current row.
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import express from "express";
const here = dirname(fileURLToPath(import.meta.url));
const { db, run, isPostgres } = await import("../src/db.mjs");
const { opencredsRouter } = await import("../src/routes/opencreds.mjs");
if (isPostgres) {
await db.execute("DROP SCHEMA public CASCADE");
await db.execute("CREATE SCHEMA public");
}
for (const file of ["001_auth.sql", "004_opencreds_sync.sql"]) {
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) await db.execute(statement);
}
for (const uid of ["u1", "u2"]) await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [uid, `${uid}@e.test`, Date.now()]);
const app = express();
app.use(express.json({ limit: "10mb" }));
app.use((req, _res, next) => { req.user = req.get("x-user") ? { id: req.get("x-user") } : null; next(); });
app.use(opencredsRouter);
const server = app.listen(0);
await new Promise((r) => server.once("listening", r));
const base = `http://127.0.0.1:${server.address().port}`;
test.after(() => new Promise((r) => server.close(r)));
async function call(user, method, path, body) {
const res = await fetch(base + path, {
method,
headers: { ...(user ? { "x-user": user } : {}), ...(body ? { "content-type": "application/json" } : {}) },
body: body ? JSON.stringify(body) : undefined
});
return { status: res.status, body: await res.json() };
}
const meta = (tag) => ({ opencreds: "0.1", namespace: "opencreds", createdAt: "2026-10-04T00:00:00Z", protectedUserKey: "pk-" + tag, protectedUserKeyIv: "iv" });
const env = (id, c) => ({ id, type: 1, ciphertext: c, iv: "iv-" + c });
test("no session, no key: 401", async () => {
assert.equal((await call(null, "GET", "/api/opencreds/vault")).status, 401);
});
test("meta: create once, update on the current revision, refuse a stale one", async () => {
assert.equal((await call("u1", "GET", "/api/opencreds/vault")).status, 404);
assert.deepEqual((await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("a"), baseRevision: 0 })).body, { ok: true, revision: 1 });
const again = await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("b"), baseRevision: 0 });
assert.equal(again.status, 409);
assert.equal(again.body.vault.meta.protectedUserKey, "pk-a");
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("c"), baseRevision: 1 })).body.revision, 2);
const stale = await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("d"), baseRevision: 1 });
assert.equal(stale.status, 409);
assert.equal(stale.body.vault.metaRevision, 2);
});
test("items: insert, update, conflict returns the current row, tombstone, cursor", async () => {
let r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "c1"), baseRevision: 0 }, { id: "i2", envelope: env("i2", "x"), baseRevision: 0 }] });
assert.equal(r.body.applied.length, 2);
assert.equal(r.body.conflicts.length, 0);
r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "c2"), baseRevision: 1 }] });
assert.deepEqual(r.body.applied.map((a) => a.revision), [2]);
r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "stale"), baseRevision: 1 }] });
assert.equal(r.body.applied.length, 0);
assert.equal(r.body.conflicts[0].revision, 2);
assert.equal(r.body.conflicts[0].envelope.ciphertext, "c2");
r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i2", envelope: null, baseRevision: 1 }] });
assert.equal(r.body.applied[0].revision, 2);
const all = await call("u1", "GET", "/api/opencreds/items?since=0");
const byId = Object.fromEntries(all.body.items.map((i) => [i.id, i]));
assert.equal(byId.i1.envelope.ciphertext, "c2");
assert.equal(byId.i1.envelope.revision, 2);
assert.equal(byId.i2.envelope, null);
const later = await call("u1", "GET", `/api/opencreds/items?since=${byId.i2.seq}`);
assert.deepEqual(later.body.items.map((i) => i.id), ["i2"]);
const v = await call("u1", "GET", "/api/opencreds/vault");
assert.equal(v.body.vault.itemCount, 1);
});
test("an update to a row that does not exist is a conflict at revision 0", async () => {
const r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "ghost", envelope: env("ghost", "g"), baseRevision: 3 }] });
assert.deepEqual(r.body.conflicts, [{ id: "ghost", envelope: null, revision: 0, seq: 0 }]);
});
test("one user's vault is invisible to another", async () => {
assert.equal((await call("u2", "GET", "/api/opencreds/vault")).status, 404);
assert.deepEqual((await call("u2", "GET", "/api/opencreds/items?since=0")).body.items, []);
const r = await call("u2", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "u2"), baseRevision: 0 }] });
assert.equal(r.status, 409, "items need the user's own vault first");
});
test("malformed changes are refused before anything is written", async () => {
const mismatch = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "a", envelope: env("b", "x"), baseRevision: 0 }] });
assert.equal(mismatch.status, 422);
const noBase = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "a", envelope: env("a", "x") }] });
assert.equal(noBase.status, 422);
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: "nope", baseRevision: 0 })).status, 422);
});
test("folders follow the same revision rule", async () => {
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/folders", { ciphertext: "f", iv: "i", baseRevision: 0 })).body.revision, 1);
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/folders", { ciphertext: "g", iv: "i", baseRevision: 0 })).status, 409);
});
test("reset drops the vault and its items", async () => {
await call("u1", "DELETE", "/api/opencreds/vault");
assert.equal((await call("u1", "GET", "/api/opencreds/vault")).status, 404);
assert.deepEqual((await call("u1", "GET", "/api/opencreds/items?since=0")).body.items, []);
});