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,26 @@
-- Postgres copy of migrations/004_opencreds_sync.sql (INTEGER -> bigint).
-- The personal vault (`logicsrc vault`, OpenCreds), synced to the account;
-- ciphertext and wrapped key material only.
create table if not exists opencreds_vaults (
user_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
meta text NOT NULL,
meta_revision bigint NOT NULL,
folders text,
folders_revision bigint NOT NULL DEFAULT 0,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
create table if not exists opencreds_items (
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
id text NOT NULL,
type bigint,
envelope text,
revision bigint NOT NULL,
seq bigint NOT NULL,
updated_at bigint NOT NULL,
PRIMARY KEY (user_id, id)
);
CREATE INDEX IF NOT EXISTS idx_opencreds_items_seq ON opencreds_items(user_id, seq);

View file

@ -0,0 +1,32 @@
-- The personal vault (`logicsrc vault`, OpenCreds), synced to the account.
-- Zero-knowledge like credshare: meta holds only key material wrapped under
-- the master password, folders and items are AES-GCM ciphertext under the
-- vault's user key. The server can count items and read their type code
-- (OpenCreds security.md accepts that), and nothing else.
-- One vault per user. meta_revision / folders_revision are optimistic locks:
-- a write names the revision it was based on and loses if someone moved it.
CREATE TABLE IF NOT EXISTS opencreds_vaults (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
meta TEXT NOT NULL,
meta_revision INTEGER NOT NULL,
folders TEXT,
folders_revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- One row per item, as the spec asks. envelope NULL is a tombstone (purged),
-- kept so the purge reaches every other machine. seq orders changes per user
-- for incremental pulls.
CREATE TABLE IF NOT EXISTS opencreds_items (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
id TEXT NOT NULL,
type INTEGER,
envelope TEXT,
revision INTEGER NOT NULL,
seq INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, id)
);
CREATE INDEX IF NOT EXISTS idx_opencreds_items_seq ON opencreds_items(user_id, seq);

View file

@ -0,0 +1,154 @@
// Sync for the personal vault (`logicsrc vault`, OpenCreds) — one per account.
//
// Zero-knowledge: the CLI uploads the vault's meta (key material wrapped under
// the master password), an encrypted folder list, and item envelopes. Nothing
// here can decrypt any of it, and nothing here needs to.
//
// Concurrency is optimistic. Every write names the revision it was based on;
// a write against a revision someone else already moved is answered with the
// current row instead of being applied, and the client resolves it. That is
// the reason the spec stores one row per item: two machines editing two
// different passwords both win, and two editing the same one find out.
//
// Auth: browser session or `Bearer lsk_…` (the logicsrc CLI). Mounted at /api/opencreds.
import { Router } from "express";
import { get, all, run } from "../db.mjs";
import { bearer, userForApiKey } from "../lib/apikey.mjs";
export const opencredsRouter = Router();
/** Upper bound on one push, in items. The client batches below this. */
export const MAX_ITEMS_PER_PUSH = 500;
function api(handler) {
return async (req, res) => {
const user = req.user || (await userForApiKey(bearer(req)));
if (!user) return res.status(401).json({ error: "Not authenticated. Run: logicsrc login" });
try {
await handler(req, res, user);
} catch (e) {
console.error("opencreds:", e);
res.status(500).json({ error: e.message || String(e) });
}
};
}
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const nonNegInt = (v) => Number.isInteger(v) && v >= 0;
function vaultJson(row) {
return {
meta: JSON.parse(row.meta),
metaRevision: Number(row.meta_revision),
folders: row.folders ? { ...JSON.parse(row.folders), revision: Number(row.folders_revision) } : null,
updatedAt: Number(row.updated_at)
};
}
function itemJson(row) {
const envelope = row.envelope ? JSON.parse(row.envelope) : null;
const revision = Number(row.revision);
return { id: row.id, envelope: envelope && { ...envelope, revision }, revision, seq: Number(row.seq) };
}
const vaultRow = (userId) => get(`SELECT * FROM opencreds_vaults WHERE user_id = ?`, [userId]);
const itemRow = (userId, id) => get(`SELECT * FROM opencreds_items WHERE user_id = ? AND id = ?`, [userId, id]);
const NEXT_SEQ = `(SELECT COALESCE(MAX(seq), 0) + 1 FROM opencreds_items WHERE user_id = ?)`;
// ---- the vault: meta + folders ----
opencredsRouter.get("/api/opencreds/vault", api(async (_req, res, user) => {
const row = await vaultRow(user.id);
if (!row) return res.status(404).json({ vault: null });
const counts = await get(
`SELECT COUNT(*) AS n, MAX(seq) AS seq FROM opencreds_items WHERE user_id = ? AND envelope IS NOT NULL`, [user.id]);
res.json({ vault: { ...vaultJson(row), itemCount: Number(counts?.n || 0) } });
}));
opencredsRouter.put("/api/opencreds/vault/meta", api(async (req, res, user) => {
const { meta, baseRevision } = req.body || {};
if (!isObject(meta) || typeof meta.protectedUserKey !== "string" || !nonNegInt(baseRevision)) {
return res.status(422).json({ error: "Expected { meta, baseRevision }." });
}
const text = JSON.stringify(meta), now = Date.now();
if (baseRevision === 0) {
await run(`INSERT INTO opencreds_vaults (user_id, meta, meta_revision, created_at, updated_at) VALUES (?,?,1,?,?) ON CONFLICT (user_id) DO NOTHING`,
[user.id, text, now, now]);
} else {
await run(`UPDATE opencreds_vaults SET meta = ?, meta_revision = meta_revision + 1, updated_at = ? WHERE user_id = ? AND meta_revision = ?`,
[text, now, user.id, baseRevision]);
}
// Read back: our exact meta at base+1 means this write is the one that landed.
const row = await vaultRow(user.id);
if (row && row.meta === text && Number(row.meta_revision) === baseRevision + 1) {
return res.json({ ok: true, revision: baseRevision + 1 });
}
res.status(409).json({ ok: false, vault: row ? vaultJson(row) : null });
}));
opencredsRouter.put("/api/opencreds/vault/folders", api(async (req, res, user) => {
const { ciphertext, iv, baseRevision } = req.body || {};
if (typeof ciphertext !== "string" || typeof iv !== "string" || !nonNegInt(baseRevision)) {
return res.status(422).json({ error: "Expected { ciphertext, iv, baseRevision }." });
}
if (!(await vaultRow(user.id))) return res.status(409).json({ ok: false, error: "Push the vault meta first." });
const text = JSON.stringify({ ciphertext, iv });
await run(`UPDATE opencreds_vaults SET folders = ?, folders_revision = folders_revision + 1, updated_at = ? WHERE user_id = ? AND folders_revision = ?`,
[text, Date.now(), user.id, baseRevision]);
const row = await vaultRow(user.id);
if (row.folders === text && Number(row.folders_revision) === baseRevision + 1) {
return res.json({ ok: true, revision: baseRevision + 1 });
}
res.status(409).json({ ok: false, vault: vaultJson(row) });
}));
// Replace the account's vault (`logicsrc vault sync --use-local`): drop it and
// every item so the next push starts from revision 0.
opencredsRouter.delete("/api/opencreds/vault", api(async (_req, res, user) => {
await run(`DELETE FROM opencreds_items WHERE user_id = ?`, [user.id]);
await run(`DELETE FROM opencreds_vaults WHERE user_id = ?`, [user.id]);
res.json({ ok: true });
}));
// ---- items ----
// `since` is inclusive: a seq can repeat under concurrent pushes, and an item
// seen twice at the same revision is a no-op on the client.
opencredsRouter.get("/api/opencreds/items", api(async (req, res, user) => {
const since = Number.parseInt(String(req.query.since ?? "0"), 10) || 0;
const rows = await all(`SELECT * FROM opencreds_items WHERE user_id = ? AND seq >= ? ORDER BY seq`, [user.id, since]);
const items = rows.map(itemJson);
res.json({ items, cursor: items.reduce((max, i) => Math.max(max, i.seq), since) });
}));
opencredsRouter.put("/api/opencreds/items", api(async (req, res, user) => {
const changes = Array.isArray(req.body?.changes) ? req.body.changes : null;
if (!changes) return res.status(422).json({ error: "Expected { changes: [{ id, envelope|null, baseRevision }] }." });
if (changes.length > MAX_ITEMS_PER_PUSH) return res.status(413).json({ error: `At most ${MAX_ITEMS_PER_PUSH} items per push.` });
for (const c of changes) {
const ok = isObject(c) && typeof c.id === "string" && c.id.length > 0 && c.id.length <= 128 && nonNegInt(c.baseRevision) &&
(c.envelope === null || (isObject(c.envelope) && c.envelope.id === c.id && typeof c.envelope.ciphertext === "string" && typeof c.envelope.iv === "string"));
if (!ok) return res.status(422).json({ error: `Malformed change${isObject(c) && c.id ? ` for ${c.id}` : ""}.` });
}
if (!(await vaultRow(user.id))) return res.status(409).json({ error: "Push the vault meta first." });
const applied = [], conflicts = [];
for (const c of changes) {
// The server's revision is authoritative; the client's local counter is not stored.
const envelope = c.envelope ? JSON.stringify((({ revision: _r, ...rest }) => rest)(c.envelope)) : null;
const type = c.envelope && Number.isInteger(c.envelope.type) ? c.envelope.type : null;
const now = Date.now();
if (c.baseRevision === 0) {
await run(`INSERT INTO opencreds_items (user_id, id, type, envelope, revision, seq, updated_at) VALUES (?,?,?,?,1,${NEXT_SEQ},?) ON CONFLICT (user_id, id) DO NOTHING`,
[user.id, c.id, type, envelope, user.id, now]);
} else {
await run(`UPDATE opencreds_items SET type = ?, envelope = ?, revision = revision + 1, seq = ${NEXT_SEQ}, updated_at = ? WHERE user_id = ? AND id = ? AND revision = ?`,
[type, envelope, user.id, now, user.id, c.id, c.baseRevision]);
}
const row = await itemRow(user.id, c.id);
if (row && (row.envelope ?? null) === envelope && Number(row.revision) === c.baseRevision + 1) {
applied.push({ id: c.id, revision: Number(row.revision), seq: Number(row.seq) });
} else {
conflicts.push(row ? itemJson(row) : { id: c.id, envelope: null, revision: 0, seq: 0 });
}
}
res.json({ applied, conflicts });
}));

View file

@ -10,6 +10,7 @@ import { authRouter } from "./routes/auth.mjs";
import { passkeyRouter } from "./routes/passkey.mjs";
import { coinpayRouter } from "./routes/coinpay.mjs";
import { credshareRouter } from "./routes/credshare.mjs";
import { opencredsRouter } from "./routes/opencreds.mjs";
import { cliRouter } from "./routes/cli.mjs";
import { pagesRouter } from "./routes/pages.mjs";
@ -18,6 +19,9 @@ app.disable("x-powered-by");
if (config.secure) app.set("trust proxy", 1); // Railway terminates TLS
// body parsing — keep the raw body for HMAC signature verification
// A vault push carries hundreds of encrypted items; parse it under a larger cap
// first. The global parser below skips a body that is already parsed.
app.use("/api/opencreds", express.json({ limit: "10mb" }));
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
@ -46,6 +50,7 @@ app.use(authRouter); // GET / (+ /auth/login|register|logout)
app.use(passkeyRouter);
app.use(coinpayRouter);
app.use(credshareRouter); // /api/credshare/* (session or lsk_ Bearer)
app.use(opencredsRouter); // /api/opencreds/* — personal vault sync (session or lsk_ Bearer)
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
app.use(pagesRouter); // /dashboard, /teams/*, /settings

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, []);
});