mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
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:
1 parent
31bc23386b
commit
585bb04ab2
52 files changed
+3929
-142
No files matched your search
@@ -0,0 +1,247 @@
|
||||
// Local encrypted vault for CLI-held secrets (API keys, deployment .env values).
|
||||
// Clean-room format — inspired by arca-core's design goals, not its code (AGPL).
|
||||
//
|
||||
// File layout (all multi-byte ints little-endian):
|
||||
// 8B magic "PRMVLT01"
|
||||
// 1B kdf id (0x01 = argon2id)
|
||||
// 4B m (KiB) 4B t 4B p
|
||||
// 1B aead id (0x01 = xchacha20poly1305)
|
||||
// 16B salt 24B nonce
|
||||
// … ciphertext+tag of JSON payload; the full header is the AEAD AAD,
|
||||
// so no parameter can be altered without failing decryption.
|
||||
//
|
||||
// Session: `vault unlock` writes vault.session (0600, TTL'd) holding the
|
||||
// derived key, so agents/scripts use the vault without the password.
|
||||
// `vault lock` or expiry removes it. The session file is the same-user risk
|
||||
// equivalent of a plaintext .env for its lifetime — bounded by the TTL.
|
||||
//
|
||||
// ponytail: single credential slot, no duress/decoy, no Shamir — those are
|
||||
// coercion-resistance features, not a CLI secrets problem.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
import { argon2id } from "@noble/hashes/argon2.js";
|
||||
|
||||
import { CliError } from "./http.js";
|
||||
|
||||
export interface SecretEntry {
|
||||
value: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface VaultData {
|
||||
v: 1;
|
||||
secrets: Record<string, SecretEntry>;
|
||||
}
|
||||
|
||||
const MAGIC = "PRMVLT01";
|
||||
const KDF_ARGON2ID = 0x01;
|
||||
const AEAD_XCHACHA = 0x01;
|
||||
const HEADER_LEN = 8 + 1 + 4 + 4 + 4 + 1 + 16 + 24; // 62
|
||||
|
||||
// Floors, not targets — matching argon2id's current recommended memory cost.
|
||||
const KDF = { m: 65536, t: 3, p: 4 } as const;
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
export function configDir(): string {
|
||||
return (
|
||||
process.env.PRIMORA_CONFIG_DIR ??
|
||||
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "primora")
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultPath(): string {
|
||||
return process.env.PRIMORA_VAULT ?? join(configDir(), "vault.bin");
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return existsSync(vaultPath());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* session */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SESSION_MAX_TTL = 86400; // 24 h ceiling
|
||||
|
||||
interface SessionFile {
|
||||
key: string; // base64 data key
|
||||
exp: number; // epoch seconds
|
||||
}
|
||||
|
||||
function sessionPath(): string {
|
||||
return process.env.PRIMORA_VAULT_SESSION ?? join(configDir(), "vault.session");
|
||||
}
|
||||
|
||||
/** Session key if one exists and is unexpired; expired files are removed. */
|
||||
export function loadSessionKey(): Uint8Array | undefined {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
if (typeof s.key !== "string" || typeof s.exp !== "number") throw new Error("shape");
|
||||
if (s.exp <= Date.now() / 1000) {
|
||||
unlinkSync(path);
|
||||
return undefined;
|
||||
}
|
||||
const key = Buffer.from(s.key, "base64");
|
||||
if (key.length !== 32) throw new Error("bad key length");
|
||||
return new Uint8Array(key);
|
||||
} catch {
|
||||
unlinkSync(path);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionKey(key: Uint8Array, ttlSeconds: number): number {
|
||||
const ttl = Math.min(Math.max(60, ttlSeconds), SESSION_MAX_TTL);
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
mkdirSync(dirname(sessionPath()), { recursive: true });
|
||||
writeFileSync(sessionPath(), JSON.stringify({ key: Buffer.from(key).toString("base64"), exp }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
return ttl;
|
||||
}
|
||||
|
||||
export function clearSession(): boolean {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return false;
|
||||
unlinkSync(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sessionExpiry(): number | undefined {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
return s.exp > Date.now() / 1000 ? s.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Header fields readable without the password (for `vault status`). */
|
||||
export function vaultHeader(): { m: number; t: number; p: number } | undefined {
|
||||
const path = vaultPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < HEADER_LEN || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
|
||||
throw new CliError(`Not a primora vault: ${path}`);
|
||||
}
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
return { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
|
||||
}
|
||||
|
||||
interface ParsedFile {
|
||||
buf: Buffer;
|
||||
header: Buffer;
|
||||
salt: Buffer;
|
||||
nonce: Buffer;
|
||||
params: { m: number; t: number; p: number };
|
||||
}
|
||||
|
||||
function parseFile(): ParsedFile {
|
||||
const path = vaultPath();
|
||||
if (!existsSync(path)) {
|
||||
throw new CliError("No vault found.", "Run `primora vault init` first.");
|
||||
}
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < HEADER_LEN + 16 || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
|
||||
throw new CliError(`Not a primora vault: ${path}`);
|
||||
}
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
if (dv.getUint8(8) !== KDF_ARGON2ID || dv.getUint8(21) !== AEAD_XCHACHA) {
|
||||
throw new CliError("Unsupported vault parameters — newer format version?");
|
||||
}
|
||||
const params = { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
|
||||
if (params.m < 8192 || params.t < 1 || params.p < 1) {
|
||||
throw new CliError("Vault KDF parameters below floor — file is suspect.");
|
||||
}
|
||||
return {
|
||||
buf,
|
||||
header: buf.subarray(0, HEADER_LEN),
|
||||
salt: buf.subarray(22, 38),
|
||||
nonce: buf.subarray(38, 62),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenVault {
|
||||
data: VaultData;
|
||||
key: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
params: { m: number; t: number; p: number };
|
||||
}
|
||||
|
||||
function decrypt(f: ParsedFile, key: Uint8Array): VaultData {
|
||||
try {
|
||||
const plain = xchacha20poly1305(key, f.nonce, f.header).decrypt(f.buf.subarray(HEADER_LEN));
|
||||
const data = JSON.parse(dec.decode(plain)) as VaultData;
|
||||
if (data.v !== 1 || typeof data.secrets !== "object") throw new Error("shape");
|
||||
return data;
|
||||
} catch {
|
||||
throw new CliError("Wrong password or corrupt vault.");
|
||||
}
|
||||
}
|
||||
|
||||
export function openVault(password: Uint8Array): OpenVault {
|
||||
const f = parseFile();
|
||||
const key = argon2id(password, f.salt, { ...f.params, dkLen: 32 });
|
||||
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
|
||||
}
|
||||
|
||||
/** Open with a session key — skips Argon2 entirely. */
|
||||
export function openVaultWithKey(key: Uint8Array): OpenVault {
|
||||
const f = parseFile();
|
||||
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
|
||||
}
|
||||
|
||||
/** Re-encrypt with the same salt/params and a fresh nonce. Atomic via tmp+rename. */
|
||||
export function saveVault(open: OpenVault): void {
|
||||
const path = vaultPath();
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(24));
|
||||
|
||||
const header = new Uint8Array(HEADER_LEN);
|
||||
header.set(enc.encode(MAGIC), 0);
|
||||
const dv = new DataView(header.buffer);
|
||||
dv.setUint8(8, KDF_ARGON2ID);
|
||||
dv.setUint32(9, open.params.m, true);
|
||||
dv.setUint32(13, open.params.t, true);
|
||||
dv.setUint32(17, open.params.p, true);
|
||||
dv.setUint8(21, AEAD_XCHACHA);
|
||||
header.set(open.salt, 22);
|
||||
header.set(nonce, 38);
|
||||
|
||||
const plain = enc.encode(JSON.stringify(open.data));
|
||||
const ct = xchacha20poly1305(open.key, nonce, header).encrypt(plain);
|
||||
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(tmp, Buffer.concat([header, Buffer.from(ct)]), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
export function createVault(password: Uint8Array): void {
|
||||
const path = vaultPath();
|
||||
if (existsSync(path)) {
|
||||
throw new CliError(
|
||||
`Vault already exists: ${path}`,
|
||||
"Use `primora secrets:*` — or remove the file to start over.",
|
||||
);
|
||||
}
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = argon2id(password, salt, { ...KDF, dkLen: 32 });
|
||||
saveVault({ data: { v: 1, secrets: {} }, key, salt, params: KDF });
|
||||
}
|
||||
Reference in new issue
Block a user