feat: encrypted secrets vault across CLI, desktop, dashboard, and backend

Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id →
XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:*
/ inject (primora:// refs) / agent credential broker (dummy tokens in the
child, real secret on the wire) / TTL sessions / stack:* operator group.

Project vault (backend): core.project_secrets, AES-256-GCM via the
platform encryptor. Metadata-only list, audit-logged reveal, and
secret://NAME refs resolved at job delivery — refs stored, values never.

Surfaces: dashboard Vault page (+ local-vault card under the Tauri
shell), desktop vault window via IPC to the primora binary,
secrets:* --remote CLI mode, metadata/write-only MCP tools so agents
consume secrets via secret:// refs without holding plaintext.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 18:46:37 +02:00
1 parent 31bc23386b
commit 585bb04ab2
52 files changed
+3929 -142

No files matched your search

+6
View File
@@ -27,6 +27,7 @@
</form>
<p id="err" class="err" hidden></p>
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
</main>
<script type="module">
@@ -51,6 +52,11 @@
if (saved) location.replace(saved);
}
document.getElementById("vault-link").addEventListener("click", (e) => {
e.preventDefault();
tauri?.event.emit("open-vault");
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
err.hidden = true;
+219
View File
@@ -99,3 +99,222 @@ button:disabled {
font-size: 0.78rem;
line-height: 1.5;
}
/* ---------- secrets vault window ---------- */
body.vault {
display: block;
padding: 0;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #26292f;
background: #101216;
position: sticky;
top: 0;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.brand img {
width: 32px;
height: 32px;
border-radius: 8px;
}
.brand h1 {
font-size: 0.95rem;
font-weight: 600;
}
.bar-sub {
font-size: 0.72rem;
color: #6d6d76;
font-family: ui-monospace, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 24rem;
}
.bar-right {
display: flex;
align-items: center;
gap: 0.6rem;
}
.pill {
font-size: 0.75rem;
font-family: ui-monospace, monospace;
color: #6d6d76;
padding: 0.25rem 0.6rem;
border: 1px solid #26292f;
border-radius: 999px;
white-space: nowrap;
}
.pill.ok {
color: #34d399;
border-color: #1e3a2f;
}
.pill.warn {
color: #9a9aa3;
}
.view {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.vcard {
background: #14161b;
border: 1px solid #26292f;
border-radius: 14px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.vcard h2 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.vcard select {
padding: 0.65rem 0.8rem;
background: #0b0d11;
border: 1px solid #2e3239;
border-radius: 8px;
color: inherit;
font-size: 0.9rem;
outline: none;
}
.vtable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.vtable th {
text-align: left;
color: #6d6d76;
font-weight: 500;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #26292f;
}
.vtable td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid #1e2025;
vertical-align: middle;
}
.vtable tr:last-child td {
border-bottom: none;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 0.82rem;
}
.dimcell {
color: #9a9aa3;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.acts {
display: flex;
gap: 0.35rem;
justify-content: flex-end;
}
.ghost {
background: transparent;
border: 1px solid #2e3239;
color: #c9c9cf;
font-weight: 500;
}
.ghost:hover {
border-color: #f4693a;
color: #fff;
}
.ghost.sm {
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
border-radius: 6px;
}
.danger:hover {
border-color: #ef6b6b;
color: #ef6b6b;
}
.vform {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.vform input {
flex: 1 1 8rem;
min-width: 0;
}
.vform input.grow {
flex: 3 1 14rem;
}
.vform button {
flex: 0 0 auto;
padding: 0.65rem 1rem;
}
.codeblock {
background: #0b0d11;
border: 1px solid #26292f;
border-radius: 8px;
padding: 0.8rem 1rem;
font-family: ui-monospace, monospace;
font-size: 0.82rem;
color: #e7e7ea;
line-height: 1.6;
}
.vlink {
color: #f4693a;
cursor: pointer;
font-size: 0.8rem;
}
.vlink:hover {
text-decoration: underline;
}
.dim {
color: #6d6d76;
font-size: 0.85rem;
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Primora — Secrets Vault</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
</head>
<body class="vault">
<header class="bar">
<div class="brand">
<img src="icon.svg" alt="" />
<div>
<h1>Secrets Vault</h1>
<p id="path" class="bar-sub"></p>
</div>
</div>
<div class="bar-right">
<span id="pill" class="pill"></span>
<button id="lock-btn" class="ghost" hidden>Lock</button>
</div>
</header>
<main id="view" class="view">
<p class="dim">Loading…</p>
</main>
<script type="module" src="vault.js"></script>
</body>
</html>
+247
View File
@@ -0,0 +1,247 @@
const tauri = window.__TAURI__;
const view = document.getElementById("view");
const pill = document.getElementById("pill");
const lockBtn = document.getElementById("lock-btn");
const pathEl = document.getElementById("path");
let countdownTimer = null;
const el = (tag, attrs = {}, ...children) => {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
}
for (const c of children) node.append(c);
return node;
};
const errBox = (msg) => el("p", { class: "err" }, msg);
const dimNote = (msg) => el("p", { class: "hint" }, msg);
function setPill(state, expiresAt) {
clearInterval(countdownTimer);
pill.className = "pill";
lockBtn.hidden = true;
if (state === "unlocked" && expiresAt) {
const tick = () => {
const left = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
const m = Math.floor(left / 60);
const s = left % 60;
pill.textContent = `● unlocked — ${m}m ${String(s).padStart(2, "0")}s`;
if (!left) refresh();
};
pill.classList.add("ok");
tick();
countdownTimer = setInterval(tick, 1000);
lockBtn.hidden = false;
} else {
pill.textContent = state === "none" ? "no vault" : "● locked";
if (state !== "none") pill.classList.add("warn");
}
}
function card(title, sub, ...children) {
return el("section", { class: "vcard" },
el("h2", {}, title),
sub ? el("p", { class: "sub" }, sub) : "",
...children);
}
function busy(btn, label) {
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = label;
}
const unbusy = (btn) => {
btn.disabled = false;
btn.textContent = btn.dataset.label || btn.textContent;
};
/* ---------- views ---------- */
function renderMissing() {
setPill("none");
pathEl.textContent = "";
view.replaceChildren(card(
"CLI required",
"The vault lives in the primora CLI — the desktop app drives the same vault file, so terminal and desktop always agree.",
el("pre", { class: "codeblock" }, "npm i -g @primora/cli\nprimora vault init"),
dimNote("Reopen this window after installing.")
));
}
function renderInit() {
setPill("none");
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "new-password" });
const pw2 = el("input", { type: "password", placeholder: "Confirm password", autocomplete: "new-password" });
const err = el("p", { class: "err", hidden: true });
const btn = el("button", {
onclick: async () => {
err.hidden = true;
if (pw.value.length < 8) { err.textContent = "Use at least 8 characters."; err.hidden = false; return; }
if (pw.value !== pw2.value) { err.textContent = "Passwords do not match."; err.hidden = false; return; }
busy(btn, "Creating…");
try { await tauri.core.invoke("vault_init", { password: pw.value }); }
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
refresh();
},
}, "Create vault");
view.replaceChildren(card(
"Create your vault",
"Local, encrypted, Argon2id + XChaCha20-Poly1305. The password is not recoverable — lose it and the contents are gone.",
pw, pw2, btn, err
));
}
function renderLocked() {
setPill("locked");
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "current-password", autofocus: true });
const ttl = el("select", {},
el("option", { value: "900" }, "Unlock for 15 minutes"),
el("option", { value: "3600" }, "Unlock for 1 hour"),
el("option", { value: "28800" }, "Unlock for 8 hours"),
el("option", { value: "86400" }, "Unlock for 24 hours"));
const err = el("p", { class: "err", hidden: true });
const btn = el("button", {
onclick: async () => {
err.hidden = true;
busy(btn, "Unlocking…");
try { await tauri.core.invoke("vault_unlock", { password: pw.value, ttl: Number(ttl.value) }); }
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
refresh();
},
}, "Unlock");
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") btn.click(); });
view.replaceChildren(card(
"Vault is locked",
"Unlocks a timed session — agents and scripts can use secrets without the password until it expires.",
pw, ttl, btn, err
));
}
function secretRow(s) {
const copy = el("button", { class: "ghost sm", title: "Copy value" }, "copy");
copy.addEventListener("click", async () => {
try {
const value = await tauri.core.invoke("vault_reveal", { name: s.name });
await navigator.clipboard.writeText(value.trimEnd());
copy.textContent = "copied";
setTimeout(() => (copy.textContent = "copy"), 1200);
} catch (e) { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); }
});
const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete");
del.addEventListener("click", async () => {
if (del.dataset.armed) {
try { await tauri.core.invoke("vault_rm", { name: s.name }); } catch {}
refresh();
return;
}
del.dataset.armed = "1";
del.textContent = "sure?";
setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500);
});
return el("tr", {},
el("td", { class: "mono" }, s.name),
el("td", {}, s.url ? el("span", {
class: "vlink",
title: `${s.url} — click to copy`,
onclick: async () => { await navigator.clipboard.writeText(s.url).catch(() => {}); },
}, s.url.replace(/^https?:\/\//, "").slice(0, 34)) : "—"),
el("td", { class: "dimcell", title: s.notes ?? "" }, (s.notes ?? "—").slice(0, 34)),
el("td", { class: "dimcell" }, (s.updated_at ?? "").slice(0, 10)),
el("td", { class: "acts" }, copy, del));
}
async function renderUnlocked(st) {
setPill("unlocked", st.expires_at);
const secrets = await tauri.core.invoke("vault_secrets").catch(() => []);
const table = secrets.length
? el("table", { class: "vtable" },
el("thead", {}, el("tr", {},
el("th", {}, "name"), el("th", {}, "url"), el("th", {}, "notes"),
el("th", {}, "updated"), el("th", {}, ""))),
el("tbody", {}, ...secrets.map(secretRow)))
: dimNote("Empty — add a secret below or import a .env file.");
// inline add form
const name = el("input", { placeholder: "NAME", class: "mono", spellcheck: "false" });
const value = el("input", { type: "password", placeholder: "value", autocomplete: "off" });
const url = el("input", { placeholder: "url (optional)", inputmode: "url" });
const notes = el("input", { placeholder: "notes (optional)" });
const err = el("p", { class: "err", hidden: true });
const addBtn = el("button", {
onclick: async () => {
err.hidden = true;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.value)) {
err.textContent = "Name must be env-safe: [A-Za-z_][A-Za-z0-9_]*";
err.hidden = false; return;
}
if (!value.value) { err.textContent = "Value required."; err.hidden = false; return; }
busy(addBtn, "Encrypting…");
try {
await tauri.core.invoke("vault_set", {
name: name.value, value: value.value, url: url.value || null, notes: notes.value || null,
});
} catch (e) { err.textContent = String(e); err.hidden = false; unbusy(addBtn); return; }
refresh();
},
}, "Add secret");
const importPath = el("input", { placeholder: "/path/to/.env", class: "mono grow" });
const importBtn = el("button", {
class: "ghost",
onclick: async () => {
err.hidden = true;
if (!importPath.value.trim()) return;
busy(importBtn, "Importing…");
try { await tauri.core.invoke("vault_import", { path: importPath.value.trim(), overwrite: false }); }
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(importBtn); return; }
refresh();
},
}, "Import .env");
view.replaceChildren(
el("section", { class: "vcard" },
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
table,
el("div", { class: "vform" }, name, value, url, notes, addBtn),
el("div", { class: "vform" }, importPath, importBtn),
err),
el("p", { class: "hint" },
"Values are never shown here — copy goes straight to the clipboard. " +
"Injection and the credential broker stay in the CLI: primora inject / primora agent.")
);
}
/* ---------- boot ---------- */
async function refresh() {
if (!tauri) {
view.replaceChildren(errBox("This page is meant to run inside the Primora desktop app."));
return;
}
const ok = await tauri.core.invoke("vault_probe").catch(() => false);
if (!ok) return renderMissing();
const st = await tauri.core.invoke("vault_status").catch((e) => ({ error: String(e) }));
if (st.error) { view.replaceChildren(errBox(st.error)); return; }
pathEl.textContent = st.path ?? "";
if (!st.exists) return renderInit();
if (!st.unlocked) return renderLocked();
renderUnlocked(st);
}
lockBtn.addEventListener("click", async () => {
await tauri.core.invoke("vault_lock").catch(() => {});
refresh();
});
// Keep the pill/table honest if the session expires while the window sits open.
setInterval(async () => {
const st = await tauri?.core.invoke("vault_status").catch(() => null);
if (st && !st.unlocked && !lockBtn.hidden) refresh();
}, 30000);
refresh();