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

+15
View File
@@ -28,4 +28,19 @@ npm run bundle --workspace @primora/desktop # release bundles → src-tauri/ta
to be a Primora deployment, use the tray's Change Server.
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
`http://localhost` for a local deployment.
## Secrets Vault
The tray menu's **Secrets Vault…** opens a local window (`ui/vault.html`) for
the CLI's encrypted vault — init, timed unlock/lock, list with copy-to-clipboard,
add/delete, `.env` import. Values are never displayed; copy goes straight to the
clipboard.
The desktop app does not reimplement the vault: IPC commands shell out to the
`primora` binary on PATH (`PRIMORA_CLI` overrides the binary path). One vault
file, one crypto implementation — terminal and desktop always agree. Requires
`npm i -g @primora/cli`; without it the window shows install instructions.
Injection and the credential broker stay CLI-only (`primora inject`,
`primora agent`).
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
@@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the main window",
"windows": ["main"],
"windows": ["main", "vault"],
"permissions": ["core:default"]
}
+149 -3
View File
@@ -1,10 +1,13 @@
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, WindowEvent,
AppHandle, Listener, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
/// Persisted shell state — one field, the deployment to load.
@@ -67,24 +70,163 @@ fn show_main(app: &AppHandle) {
}
}
fn open_vault(app: &AppHandle) {
if let Some(window) = app.get_webview_window("vault") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return;
}
let _ = WebviewWindowBuilder::new(app, "vault", WebviewUrl::App("vault.html".into()))
.title("Primora — Secrets Vault")
.inner_size(780.0, 620.0)
.min_inner_size(560.0, 420.0)
.center()
.build();
}
/// The vault lives in the CLI — the desktop proxies the `primora` binary so
/// both surfaces operate on the same vault file, one crypto implementation.
/// Passwords travel via env, secret values via stdin; never via argv.
fn run_cli(args: &[&str], stdin_data: Option<&str>, password: Option<&str>) -> Result<String, String> {
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
let mut cmd = Command::new(&bin);
cmd.args(args)
.stdin(if stdin_data.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(pw) = password {
cmd.env("PRIMORA_VAULT_PASSWORD", pw);
}
let mut child = cmd.spawn().map_err(|_| {
"primora CLI not found on PATH — install it: npm i -g @primora/cli".to_string()
})?;
if let Some(data) = stdin_data {
child
.stdin
.as_mut()
.ok_or("failed to open child stdin")?
.write_all(data.as_bytes())
.map_err(|e| e.to_string())?;
}
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[tauri::command]
fn vault_probe() -> bool {
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
Command::new(bin)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[tauri::command]
fn vault_status() -> Result<Value, String> {
let out = run_cli(&["vault", "status", "--json"], None, None)?;
serde_json::from_str(&out).map_err(|e| e.to_string())
}
#[tauri::command]
fn vault_secrets() -> Result<Value, String> {
let out = run_cli(&["secrets", "list", "--json"], None, None)?;
serde_json::from_str(&out).map_err(|e| e.to_string())
}
#[tauri::command]
fn vault_reveal(name: String) -> Result<String, String> {
run_cli(&["secrets", "get", &name], None, None)
}
#[tauri::command]
fn vault_init(password: String) -> Result<(), String> {
run_cli(&["vault", "init"], None, Some(&password)).map(|_| ())
}
#[tauri::command]
fn vault_unlock(password: String, ttl: u32) -> Result<(), String> {
let ttl = ttl.clamp(60, 86400).to_string();
run_cli(&["vault", "unlock", "--ttl", &ttl], None, Some(&password)).map(|_| ())
}
#[tauri::command]
fn vault_lock() -> Result<(), String> {
run_cli(&["vault", "lock"], None, None).map(|_| ())
}
#[tauri::command]
fn vault_set(
name: String,
value: String,
url: Option<String>,
notes: Option<String>,
) -> Result<(), String> {
let mut args = vec!["secrets", "set", name.as_str()];
if let Some(u) = url.as_deref().filter(|s| !s.is_empty()) {
args.extend(["--url", u]);
}
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
args.extend(["--notes", n]);
}
run_cli(&args, Some(&value), None).map(|_| ())
}
#[tauri::command]
fn vault_rm(name: String) -> Result<(), String> {
run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ())
}
#[tauri::command]
fn vault_import(path: String, overwrite: bool) -> Result<String, String> {
let mut args = vec!["secrets", "import", path.as_str()];
if overwrite {
args.push("--overwrite");
}
run_cli(&args, None, None)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main(app);
}))
.invoke_handler(tauri::generate_handler![get_server_url, set_server_url])
.invoke_handler(tauri::generate_handler![
get_server_url,
set_server_url,
vault_probe,
vault_status,
vault_secrets,
vault_reveal,
vault_init,
vault_unlock,
vault_lock,
vault_set,
vault_rm,
vault_import,
])
.setup(|app| {
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
let vault_item = MenuItem::with_id(app, "vault", "Secrets Vault…", true, None::<&str>)?;
let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &change, &quit])?;
let menu = Menu::with_items(app, &[&show, &vault_item, &change, &quit])?;
let mut tray = TrayIconBuilder::new()
.menu(&menu)
.tooltip("Primora")
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app),
"vault" => open_vault(app),
"change_server" => {
// Clearing the URL and restarting lands back on the connect screen.
let _ = save_config(app, &DesktopConfig { server_url: None });
@@ -108,6 +250,10 @@ pub fn run() {
}
let _tray = tray.build(app)?;
// The connect screen links here — "open the local Secrets Vault".
let handle = app.handle().clone();
app.listen("open-vault", move |_| open_vault(&handle));
// Close hides to the tray instead of quitting — Quit is on the menu.
if let Some(window) = app.get_webview_window("main") {
let handle = window.clone();
+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();