feat: encrypted credential vault

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ahmed mohammed
2026-09-22 07:34:01 +03:00
co-authored by Claude Opus 4.7
parent 5f8746cfae
commit dd6b880f49
4 changed files with 123 additions and 2 deletions
+6 -2
View File
@@ -62,10 +62,14 @@ export const getSettings = (userId: string) =>
one<SettingsInput & { onboarded: number }>("SELECT * FROM settings WHERE user_id = ?", userId); one<SettingsInput & { onboarded: number }>("SELECT * FROM settings WHERE user_id = ?", userId);
export function saveSettings(userId: string, s: SettingsInput) { export function saveSettings(userId: string, s: SettingsInput) {
// A blank secret means "keep what is stored" — enforced here, not only in the form layer, so no caller
// (password change, onboarding re-save, a future import) can blank a stored key by omitting it.
db.prepare(`INSERT INTO settings (user_id, analyst_base_url, analyst_key, analyst_model, analyst_extra, db.prepare(`INSERT INTO settings (user_id, analyst_base_url, analyst_key, analyst_model, analyst_extra,
jev_key, jev_model, onboarded, updated_at) VALUES (?,?,?,?,?,?,?,?,?) jev_key, jev_model, onboarded, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url, analyst_key=excluded.analyst_key, ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url,
analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra, jev_key=excluded.jev_key, analyst_key=COALESCE(NULLIF(excluded.analyst_key, ''), settings.analyst_key),
analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra,
jev_key=COALESCE(NULLIF(excluded.jev_key, ''), settings.jev_key),
jev_model=excluded.jev_model, onboarded=excluded.onboarded, updated_at=excluded.updated_at`) jev_model=excluded.jev_model, onboarded=excluded.onboarded, updated_at=excluded.updated_at`)
.run(userId, s.analyst_base_url, s.analyst_key, s.analyst_model, s.analyst_extra, s.jev_key, s.jev_model, .run(userId, s.analyst_base_url, s.analyst_key, s.analyst_model, s.analyst_extra, s.jev_key, s.jev_model,
s.onboarded, now()); s.onboarded, now());
+45
View File
@@ -0,0 +1,45 @@
import { getSettings, saveSettings, type SettingsInput } from "./db.ts";
import { seal, open, mask } from "./vault.ts";
export type AnalystConfig = { baseUrl: string; apiKey: string; model: string; extra: Record<string, unknown> };
const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim();
const kept = (fd: FormData, k: string, existing: string) => { const v = str(fd, k); return v ? seal(v) : existing; };
export function saveSettingsForm(userId: string, fd: FormData, onboarded = 1) {
const cur = getSettings(userId);
let extra = str(fd, "analyst_extra") || "{}";
try { JSON.parse(extra); } catch { extra = "{}"; }
saveSettings(userId, {
analyst_base_url: str(fd, "analyst_base_url").replace(/\/+$/, ""),
analyst_key: kept(fd, "analyst_key", cur?.analyst_key ?? ""),
analyst_model: str(fd, "analyst_model"),
analyst_extra: extra,
jev_key: kept(fd, "jev_key", cur?.jev_key ?? ""),
jev_model: str(fd, "jev_model") || "jev-latest",
onboarded: cur?.onboarded === 1 ? 1 : onboarded,
} satisfies SettingsInput);
}
export function analystConfig(userId: string): AnalystConfig | null {
const s = getSettings(userId);
const baseUrl = s?.analyst_base_url ?? "";
const apiKey = open(s?.analyst_key ?? "");
const model = s?.analyst_model ?? "";
if (!baseUrl || !apiKey || !model) return null;
let extra: Record<string, unknown> = {};
try { extra = JSON.parse(s!.analyst_extra) as Record<string, unknown>; } catch { /* bad JSON means no extra params */ }
return { baseUrl, apiKey, model, extra };
}
export const jevKey = (userId: string) => open(getSettings(userId)?.jev_key ?? "");
export const jevModel = (userId: string) => getSettings(userId)?.jev_model || "jev-latest";
export function settingsView(userId: string) {
const s = getSettings(userId);
const analystKey = open(s?.analyst_key ?? "");
const jev = open(s?.jev_key ?? "");
return {
analyst_base_url: s?.analyst_base_url ?? "", analyst_model: s?.analyst_model ?? "",
analyst_extra: s?.analyst_extra ?? "{}", jev_model: s?.jev_model ?? "jev-latest",
analyst_key_masked: mask(analystKey), jev_key_masked: mask(jev),
has_analyst: !!(s?.analyst_base_url && s?.analyst_model && analystKey), has_jev: !!jev,
};
}
export type SettingsView = ReturnType<typeof settingsView>;
+24
View File
@@ -0,0 +1,24 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
function key() {
const s = process.env.APP_SECRET;
if (!s || s.length < 16) throw new Error("APP_SECRET is missing or shorter than 16 chars — set it in .env");
return scryptSync(s, "qai-vault-v1", 32);
}
export function seal(plain: string): string {
if (!plain) return "";
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const ct = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return `${iv.toString("hex")}.${c.getAuthTag().toString("hex")}.${ct.toString("hex")}`;
}
export function open(sealed: string): string {
if (!sealed) return "";
try {
const [iv, tag, ct] = sealed.split(".");
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "hex"), { authTagLength: 16 });
d.setAuthTag(Buffer.from(tag, "hex"));
return Buffer.concat([d.update(Buffer.from(ct, "hex")), d.final()]).toString("utf8");
} catch { return ""; }
}
export const mask = (v: string) => (v ? `••••${v.slice(-4)}` : "");
+48
View File
@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
process.env.APP_SECRET = "unit-test-secret-value";
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-vault-`);
const { seal, open, mask } = await import("../lib/vault.ts");
const db = await import("../lib/db.ts");
const { saveSettingsForm, analystConfig, settingsView, jevKey } = await import("../lib/settings.ts");
test("seal/open round-trips; junk and tamper yield empty", () => {
assert.equal(open(seal("sk-abc123")), "sk-abc123");
assert.equal(open("deadbeef.00.zzz"), "");
assert.equal(open(""), "");
assert.equal(mask("sk-abcdefgh1234"), "••••1234");
assert.equal(mask(""), "");
});
test("blank secret keeps the stored key", () => {
const u = db.createUser("v@x.y", "h");
const fd = (o: Record<string, string>) => { const f = new FormData();
Object.entries(o).forEach(([k, v]) => f.set(k, v)); return f; };
saveSettingsForm(u, fd({ analyst_base_url: "https://api/x/v1/", analyst_key: "sk-1111", analyst_model: "gpt",
analyst_extra: '{"temperature":0.2}', jev_key: "ts-1", jev_model: "jev-latest" }));
assert.equal(analystConfig(u)?.apiKey, "sk-1111");
saveSettingsForm(u, fd({ analyst_base_url: "https://api/y/v1", analyst_key: "", analyst_model: "gpt",
analyst_extra: "not json", jev_key: "", jev_model: "" }));
const c = analystConfig(u)!;
assert.equal(c.baseUrl, "https://api/y/v1", "trailing slash trimmed");
assert.equal(c.apiKey, "sk-1111", "blank must not wipe the key");
assert.deepEqual(c.extra, {}, "bad JSON falls back to empty params");
assert.equal(jevKey(u), "ts-1");
assert.equal(settingsView(u).has_analyst, true);
assert.equal(settingsView(u).analyst_key_masked, "••••1111");
assert.equal((settingsView(u) as Record<string, unknown>).analyst_key, undefined, "no raw key to the client");
});
test("saveSettings never blanks a stored secret, whoever calls it", () => {
const u = db.createUser("k@x.y", "h");
const base = { analyst_base_url: "b", analyst_key: "sealed", analyst_model: "m", analyst_extra: "{}",
jev_key: "jevsealed", jev_model: "j", onboarded: 1 };
db.saveSettings(u, base);
db.saveSettings(u, { ...base, analyst_key: "", jev_key: "" });
assert.equal(db.getSettings(u)?.analyst_key, "sealed");
assert.equal(db.getSettings(u)?.jev_key, "jevsealed");
assert.equal(db.getSettings(u)?.analyst_base_url, "b", "non-secret fields still update");
});