mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41: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
+151
-2
@@ -4,6 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||
import { cmdAuditList } from "./commands/audit.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||
import { cmdInject } from "./commands/inject.js";
|
||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
|
||||
import {
|
||||
@@ -14,6 +15,28 @@ import {
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
||||
import {
|
||||
cmdSecretsGet,
|
||||
cmdSecretsImport,
|
||||
cmdSecretsList,
|
||||
cmdSecretsRemove,
|
||||
cmdSecretsSet,
|
||||
} from "./commands/secrets.js";
|
||||
import { cmdAgent, PRESET_NAMES } from "./commands/agent.js";
|
||||
import {
|
||||
cmdStackBackup,
|
||||
cmdStackDown,
|
||||
cmdStackLogs,
|
||||
cmdStackPull,
|
||||
cmdStackStatus,
|
||||
cmdStackUp,
|
||||
} from "./commands/stack.js";
|
||||
import {
|
||||
cmdVaultInit,
|
||||
cmdVaultLock,
|
||||
cmdVaultStatus,
|
||||
cmdVaultUnlock,
|
||||
} from "./commands/vault.js";
|
||||
import { fail } from "./out.js";
|
||||
|
||||
const cli = cac("primora");
|
||||
@@ -144,16 +167,142 @@ cli
|
||||
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
|
||||
.action(cmdAuditList);
|
||||
|
||||
// Local vault — secrets on this machine, not on the server. Password source:
|
||||
// live session → PRIMORA_VAULT_PASSWORD → --password-file → hidden prompt.
|
||||
cli
|
||||
.command("vault:init", "Create the local secrets vault")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdVaultInit);
|
||||
cli
|
||||
.command("vault:status", "Show vault path, KDF parameters and lock state")
|
||||
.action(cmdVaultStatus);
|
||||
cli
|
||||
.command("vault:unlock", "Open a TTL'd session — agents use the vault without the password")
|
||||
.option("--ttl <s>", "Session lifetime in seconds (default 900, cap 86400)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdVaultUnlock);
|
||||
cli
|
||||
.command("vault:lock", "Revoke the session immediately")
|
||||
.action(cmdVaultLock);
|
||||
|
||||
cli
|
||||
.command("secrets:set <name>", "Store a secret in the vault")
|
||||
.option("--value <value>", "Secret value (otherwise prompted or read from stdin)")
|
||||
.option("--url <url>", "Associated URL (dashboard, docs, endpoint)")
|
||||
.option("--notes <text>", "Free-text notes")
|
||||
.option("--remote", "Target the project vault on the server instead of the local vault")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsSet);
|
||||
cli
|
||||
.command("secrets:get [name]", "Print a secret's value (interactive picker if omitted)")
|
||||
.option("--remote", "Reveal from the project vault on the server (audit-logged)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsGet);
|
||||
cli
|
||||
.command("secrets:list", "List secret names (never values)")
|
||||
.option("--remote", "List project vault secrets on the server (metadata only)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsList);
|
||||
cli
|
||||
.command("secrets:rm [name]", "Delete a secret (interactive picker if omitted)")
|
||||
.option("--remote", "Delete from the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdSecretsRemove);
|
||||
cli
|
||||
.command("secrets:import <file>", "Import a .env file into the vault")
|
||||
.option("--remote", "Import into the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--overwrite", "Replace existing secrets")
|
||||
.action(cmdSecretsImport);
|
||||
|
||||
cli
|
||||
.command("inject", "Run a command with vault secrets in its environment")
|
||||
.option("--all", "Inject every vault secret (default when no --env-file)")
|
||||
.option("--env-file <path>", "env template; primora://NAME values resolve from the vault")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdInject);
|
||||
|
||||
// Agent broker — the child gets dummy tokens; the loopback proxy attaches the
|
||||
// real secret per grant. One flag per preset takes a vault secret name.
|
||||
const agentCmd = cli
|
||||
.command("agent", "Wrap a command with the credential broker (dummy env, real secret on the wire)")
|
||||
.option("--ttl <s>", "Grant lifetime in seconds (default 900, cap 3600)")
|
||||
.option("--upstream <name=url>", "Custom Bearer upstream, repeatable (vault key: <NAME>_KEY)")
|
||||
.option("--password-file <path>", "Read the vault password from a file");
|
||||
for (const preset of PRESET_NAMES) {
|
||||
agentCmd.option(`--${preset} <name>`, `Grant ${preset} API access from vault secret <name>`);
|
||||
}
|
||||
agentCmd.action(cmdAgent);
|
||||
|
||||
// Stack — operator surface for the compose deployment in --dir (default cwd).
|
||||
cli
|
||||
.command("stack:up", "docker compose up -d")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--vault", "Materialize .env from the vault for this run only")
|
||||
.option("--force", "Allow --vault to temporarily swap an existing .env")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdStackUp);
|
||||
cli
|
||||
.command("stack:down", "docker compose down")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackDown);
|
||||
cli
|
||||
.command("stack:status", "docker compose ps")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackStatus);
|
||||
cli
|
||||
.command("stack:logs [service]", "docker compose logs")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--follow", "Follow log output")
|
||||
.option("--tail <n>", "Lines per service (default 200)")
|
||||
.action(cmdStackLogs);
|
||||
cli
|
||||
.command("stack:pull", "Pull newer images")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackPull);
|
||||
cli
|
||||
.command("stack:backup", "pg_dump the platform database into ./backups")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--out <path>", "Backup directory (default: <dir>/backups)")
|
||||
.action(cmdStackBackup);
|
||||
|
||||
cli.help();
|
||||
cli.version("0.4.0");
|
||||
cli.version("0.6.0");
|
||||
cli.example("primora login --url https://primora.example.com");
|
||||
cli.example("primora vault init");
|
||||
cli.example("primora secrets import .env");
|
||||
cli.example("primora secrets list --remote");
|
||||
cli.example("primora inject --env-file .env -- npm run dev");
|
||||
cli.example("primora agent --anthropic CLAUDE_KEY -- claude");
|
||||
|
||||
// cac only matches the first positional token, so "orgs list" cannot be a
|
||||
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
||||
// `primora orgs list` and `primora orgs:list` both work.
|
||||
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]);
|
||||
const GROUPS = new Set([
|
||||
"orgs",
|
||||
"projects",
|
||||
"buckets",
|
||||
"objects",
|
||||
"keys",
|
||||
"jobs",
|
||||
"audit",
|
||||
"vault",
|
||||
"secrets",
|
||||
"stack",
|
||||
]);
|
||||
const argv = process.argv.slice(2);
|
||||
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
|
||||
} else if (argv[0] === "vault" && !argv[1]) {
|
||||
argv[0] = "vault:status";
|
||||
} else if (argv[0] === "secrets" && !argv[1]) {
|
||||
argv[0] = "secrets:list";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim } from "../out.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
/**
|
||||
* `primora agent` — loopback credential broker. Wraps one child process with
|
||||
* dummy tokens; the proxy attaches the real secret on the wire per grant.
|
||||
* The child (an AI agent, a script) never holds a real key.
|
||||
*
|
||||
* Transport note: this is a base-URL broker, not a TLS MITM — tools must honor
|
||||
* their *_BASE_URL / registry / api-url override (OpenAI, Anthropic, Groq,
|
||||
* DeepSeek, xAI, OpenRouter, npm do; `gh` hardcodes api.github.com). That is
|
||||
* the documented ceiling — a CA-trusted MITM is a much larger build.
|
||||
* ponytail: no standalone daemon (`agent start`) — wrap mode only.
|
||||
*/
|
||||
|
||||
interface Grant {
|
||||
upstream: string; // e.g. https://api.anthropic.com
|
||||
header: (secret: string) => [string, string];
|
||||
}
|
||||
|
||||
interface Preset extends Grant {
|
||||
env: (base: string) => Record<string, string>; // child env; base = http://127.0.0.1:port/<name>
|
||||
}
|
||||
|
||||
const DUMMY = "primora-brokered";
|
||||
const bearer = (s: string): [string, string] => ["authorization", `Bearer ${s}`];
|
||||
|
||||
const PRESETS: Record<string, Preset> = {
|
||||
anthropic: {
|
||||
upstream: "https://api.anthropic.com",
|
||||
header: (s) => ["x-api-key", s],
|
||||
env: (b) => ({ ANTHROPIC_BASE_URL: b, ANTHROPIC_API_KEY: DUMMY }),
|
||||
},
|
||||
openai: {
|
||||
upstream: "https://api.openai.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ OPENAI_BASE_URL: `${b}/v1`, OPENAI_API_KEY: DUMMY }),
|
||||
},
|
||||
openrouter: {
|
||||
upstream: "https://openrouter.ai",
|
||||
header: bearer,
|
||||
env: (b) => ({ OPENROUTER_BASE_URL: `${b}/api`, OPENROUTER_API_KEY: DUMMY }),
|
||||
},
|
||||
groq: {
|
||||
upstream: "https://api.groq.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ GROQ_BASE_URL: `${b}/openai`, GROQ_API_KEY: DUMMY }),
|
||||
},
|
||||
deepseek: {
|
||||
upstream: "https://api.deepseek.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ DEEPSEEK_BASE_URL: b, DEEPSEEK_API_KEY: DUMMY }),
|
||||
},
|
||||
xai: {
|
||||
upstream: "https://api.x.ai",
|
||||
header: bearer,
|
||||
env: (b) => ({ XAI_BASE_URL: `${b}/v1`, XAI_API_KEY: DUMMY }),
|
||||
},
|
||||
github: {
|
||||
upstream: "https://api.github.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ GITHUB_API_URL: b, GH_TOKEN: DUMMY, GITHUB_TOKEN: DUMMY }),
|
||||
},
|
||||
npm: {
|
||||
upstream: "https://registry.npmjs.org",
|
||||
header: bearer,
|
||||
env: (b) => ({ NPM_CONFIG_REGISTRY: b, NPM_TOKEN: DUMMY }),
|
||||
},
|
||||
};
|
||||
|
||||
export const PRESET_NAMES = Object.keys(PRESETS);
|
||||
|
||||
export interface AgentOptions extends VaultPasswordOptions {
|
||||
ttl?: string;
|
||||
upstream?: string | string[];
|
||||
[preset: string]: unknown; // cac preset flags land here
|
||||
}
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
async function proxy(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
grants: Map<string, { grant: Grant; secret: string }>,
|
||||
): Promise<void> {
|
||||
const match = /^\/([a-z0-9_-]+)(\/.*)?$/i.exec(req.url ?? "");
|
||||
const entry = match ? grants.get(match[1].toLowerCase()) : undefined;
|
||||
if (!match || !entry) {
|
||||
res.writeHead(404, { "content-type": "text/plain" }).end("primora agent: no grant for this route");
|
||||
return;
|
||||
}
|
||||
const suffix = match[2] ?? "/";
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
const lk = k.toLowerCase();
|
||||
if (HOP_BY_HOP.has(lk) || Array.isArray(v) || v === undefined) continue;
|
||||
headers[lk] = v;
|
||||
}
|
||||
const [hk, hv] = entry.grant.header(entry.secret);
|
||||
headers[hk] = hv; // real secret attached here — the child sent a dummy
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
||||
|
||||
try {
|
||||
const up = await fetch(entry.grant.upstream + suffix, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
const out: Record<string, string> = {};
|
||||
up.headers.forEach((v, k) => {
|
||||
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
||||
});
|
||||
res.writeHead(up.status, out);
|
||||
if (up.body) Readable.fromWeb(up.body as never).pipe(res);
|
||||
else res.end();
|
||||
} catch (e) {
|
||||
res.writeHead(502).end(`primora agent: upstream error — ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdAgent(options: AgentOptions): Promise<void> {
|
||||
const sep = process.argv.indexOf("--");
|
||||
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
|
||||
if (!child.length) {
|
||||
throw new CliError(
|
||||
"No command given.",
|
||||
"Usage: primora agent --anthropic VAULT_KEY [--ttl 900] -- <cmd>",
|
||||
);
|
||||
}
|
||||
|
||||
// Collect grants: --<preset> <vault-name> plus custom --upstream name=url.
|
||||
const wanted: { name: string; grant: Grant; env?: Preset["env"]; secretName: string }[] = [];
|
||||
for (const name of PRESET_NAMES) {
|
||||
const secretName = options[name];
|
||||
if (typeof secretName === "string" && secretName) {
|
||||
wanted.push({ name, grant: PRESETS[name], env: PRESETS[name].env, secretName });
|
||||
}
|
||||
}
|
||||
const customs = ([] as string[]).concat(options.upstream ?? []);
|
||||
for (const spec of customs) {
|
||||
const eq = spec.indexOf("=");
|
||||
const url = spec.slice(eq + 1);
|
||||
// HTTPS for real upstreams; HTTP only for loopback dev servers.
|
||||
const urlOk =
|
||||
/^https:\/\//.test(url) || /^http:\/\/(127\.|localhost|\[::1\])/.test(url);
|
||||
if (eq < 1 || !urlOk) {
|
||||
throw new CliError(
|
||||
`Bad --upstream "${spec}".`,
|
||||
"Format: name=https://api.host (http allowed for 127.0.0.1/localhost only) — Authorization: Bearer is attached.",
|
||||
);
|
||||
}
|
||||
const name = spec.slice(0, eq).toLowerCase();
|
||||
const upstream = spec.slice(eq + 1).replace(/\/+$/, "");
|
||||
wanted.push({
|
||||
name,
|
||||
grant: { upstream, header: bearer },
|
||||
env: (b) => ({
|
||||
[`${name.toUpperCase().replace(/-/g, "_")}_BASE_URL`]: b,
|
||||
[`${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]: DUMMY,
|
||||
}),
|
||||
secretName: name.toUpperCase().replace(/-/g, "_") + "_KEY",
|
||||
});
|
||||
}
|
||||
if (!wanted.length) {
|
||||
throw new CliError(
|
||||
"No grants.",
|
||||
`Pass at least one: ${PRESET_NAMES.map((n) => `--${n} <vault-key>`).join(" ")} or --upstream name=https://…`,
|
||||
);
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const grants = new Map<string, { grant: Grant; secret: string }>();
|
||||
for (const w of wanted) {
|
||||
const entry = vault.data.secrets[w.secretName];
|
||||
if (!entry) {
|
||||
throw new CliError(
|
||||
`Secret "${w.secretName}" not in vault for grant "${w.name}".`,
|
||||
`Run \`primora secrets set ${w.secretName}\` first.`,
|
||||
);
|
||||
}
|
||||
grants.set(w.name, { grant: w.grant, secret: entry.value });
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => void proxy(req, res, grants));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
const ttl = Math.min(Math.max(60, Number(options.ttl ?? 900)), 3600);
|
||||
const timer = setTimeout(() => {
|
||||
server.close();
|
||||
process.stderr.write("primora agent: grant TTL expired — broker stopped\n");
|
||||
process.exit(0);
|
||||
}, ttl * 1000);
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const w of wanted) Object.assign(env, w.env?.(`${base}/${w.name}`));
|
||||
dim(`broker on ${base} — grants: ${wanted.map((w) => w.name).join(", ")} (ttl ${ttl}s)`);
|
||||
|
||||
// Async spawn — spawnSync would block the event loop and starve the proxy.
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
const proc = spawn(child[0], child.slice(1), { env, stdio: "inherit" });
|
||||
proc.on("error", (e) => {
|
||||
server.close();
|
||||
reject(new CliError(`Failed to start ${child[0]}: ${e.message}`));
|
||||
});
|
||||
proc.on("exit", (c) => resolve(c ?? 1));
|
||||
});
|
||||
clearTimeout(timer);
|
||||
server.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim } from "../out.js";
|
||||
import { parseEnvFile } from "./secrets.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
const REF_RE = /^primora:\/\/(.+)$/;
|
||||
|
||||
/**
|
||||
* `primora inject [--all] [--env-file PATH] -- <cmd>` — run one child process
|
||||
* with vault secrets in its environment. Nothing written to disk, nothing in
|
||||
* argv; the parent shell is unchanged. `primora://NAME` values inside the
|
||||
* env-file resolve against the vault; literals stay literals.
|
||||
*/
|
||||
export async function cmdInject(
|
||||
options: VaultPasswordOptions & { all?: boolean; envFile?: string },
|
||||
): Promise<void> {
|
||||
// cac does not model `--` passthrough — slice it out of argv directly.
|
||||
const sep = process.argv.indexOf("--");
|
||||
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
|
||||
if (!child.length) {
|
||||
throw new CliError("No command given.", "Usage: primora inject [--all] [--env-file .env] -- <cmd>");
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Default: whole vault. --all exists for readability when --env-file is also used.
|
||||
if (options.all || !options.envFile) {
|
||||
for (const [k, e] of Object.entries(vault.data.secrets)) env[k] = e.value;
|
||||
}
|
||||
if (options.envFile) {
|
||||
for (const [key, raw] of Object.entries(parseEnvFile(options.envFile))) {
|
||||
const ref = REF_RE.exec(raw);
|
||||
if (!ref) {
|
||||
env[key] = raw;
|
||||
continue;
|
||||
}
|
||||
const entry = vault.data.secrets[ref[1]];
|
||||
if (!entry) throw new CliError(`Unresolved ref: primora://${ref[1]} in ${options.envFile}`);
|
||||
env[key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
dim(`injecting ${Object.keys(env).length} var${Object.keys(env).length === 1 ? "" : "s"} → ${child[0]}`);
|
||||
const res = spawnSync(child[0], child.slice(1), {
|
||||
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (res.error) throw new CliError(`Failed to start ${child[0]}: ${res.error.message}`);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
/** Used by `stack up --vault`: render all vault entries as .env lines. */
|
||||
export async function vaultAsEnvFile(
|
||||
options: VaultPasswordOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const vault = await unlockVault(options);
|
||||
return Object.fromEntries(Object.entries(vault.data.secrets).map(([k, e]) => [k, e.value]));
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { SecretsService, type ProjectSecret } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
|
||||
import { saveVault } from "../vault.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
// Env-safe names only: `inject` and `stack --vault` place entries into a child
|
||||
// environment, so a name that is not a valid env var would fail there anyway.
|
||||
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function checkName(name: string): void {
|
||||
if (!NAME_RE.test(name)) {
|
||||
throw new CliError(
|
||||
`Invalid secret name "${name}".`,
|
||||
"Use env-var-safe names: [A-Za-z_][A-Za-z0-9_]*",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote secrets live in the project's server-side store (AES-256-GCM at rest).
|
||||
// `--remote` switches every secrets:* verb to it; no vault password is needed.
|
||||
export interface RemoteOptions {
|
||||
remote?: boolean;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
function remoteProject(options: RemoteOptions): string {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
return requireProject(cfg, options.project);
|
||||
}
|
||||
|
||||
function remoteRow(s: ProjectSecret): string[] {
|
||||
return [s.name, s.url || "—", s.notes || "—", formatDate(s.updated_at)];
|
||||
}
|
||||
|
||||
export interface SetOptions extends VaultPasswordOptions, RemoteOptions {
|
||||
value?: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function cmdSecretsSet(name: string, options: SetOptions): Promise<void> {
|
||||
checkName(name);
|
||||
let value = options.value;
|
||||
if (value === undefined && !process.stdin.isTTY) {
|
||||
value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
|
||||
}
|
||||
if (value === undefined) {
|
||||
const entered = await p.password({ message: `Value for ${name}` });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
value = entered;
|
||||
}
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
await SecretsService.setProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
requestBody: { value, url: options.url, notes: options.notes },
|
||||
});
|
||||
success(`Set ${name} in the project vault`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
const existing = vault.data.secrets[name];
|
||||
vault.data.secrets[name] = {
|
||||
value,
|
||||
url: options.url ?? existing?.url,
|
||||
notes: options.notes ?? existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`${existing ? "Updated" : "Set"} ${name}`);
|
||||
}
|
||||
|
||||
async function pickRemote(projectId: string, message: string): Promise<string> {
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
process.exit(0);
|
||||
}
|
||||
const picked = await p.select({
|
||||
message,
|
||||
options: items.map((s) => ({ value: s.name, label: s.name, hint: s.url || s.notes || undefined })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export async function cmdSecretsGet(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to reveal");
|
||||
const res = await SecretsService.revealProjectSecret({ projectId, name });
|
||||
if (isJson(options)) {
|
||||
printJson({ name, value: res.value });
|
||||
return;
|
||||
}
|
||||
process.stdout.write(res.value + "\n");
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets get <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME`.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to print",
|
||||
options: names.map((n) => ({
|
||||
value: n,
|
||||
label: n,
|
||||
hint: vault.data.secrets[n].url ?? vault.data.secrets[n].notes ?? undefined,
|
||||
})),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
const entry = vault.data.secrets[name];
|
||||
if (!entry) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
name,
|
||||
value: entry.value,
|
||||
url: entry.url ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.stdout.write(entry.value + "\n");
|
||||
}
|
||||
|
||||
export async function cmdSecretsList(
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson(items);
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
return;
|
||||
}
|
||||
printTable(["NAME", "URL", "NOTES", "UPDATED"], items.map(remoteRow));
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const rows = Object.entries(vault.data.secrets).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (isJson(options)) {
|
||||
printJson(
|
||||
rows.map(([name, e]) => ({
|
||||
name,
|
||||
url: e.url ?? null,
|
||||
notes: e.notes ?? null,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME` or `primora secrets import .env`.");
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
["NAME", "URL", "UPDATED"],
|
||||
rows.map(([name, e]) => [name, e.url ?? "—", formatDate(e.updated_at)]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdSecretsRemove(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to delete");
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete project secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
await SecretsService.deleteProjectSecret({ projectId, name });
|
||||
success(`Deleted ${name}`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets rm <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to delete",
|
||||
options: names.map((n) => ({ value: n, label: n })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
if (!vault.data.secrets[name]) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
delete vault.data.secrets[name];
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Deleted ${name}`);
|
||||
}
|
||||
|
||||
/** Parse a .env file: KEY=value lines, optional `export`, quotes stripped. */
|
||||
export function parseEnvFile(path: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
||||
let val = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function cmdSecretsImport(
|
||||
file: string,
|
||||
options: VaultPasswordOptions & RemoteOptions & { overwrite?: boolean },
|
||||
): Promise<void> {
|
||||
const parsed = parseEnvFile(file);
|
||||
const entries = Object.entries(parsed).filter(([k]) => NAME_RE.test(k));
|
||||
if (!entries.length) throw new CliError(`No valid KEY=value pairs in ${file}.`);
|
||||
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
const existing = new Set(items.map((s) => s.name));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
if (existing.has(key) && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await SecretsService.setProjectSecret({ projectId, name: key, requestBody: { value } });
|
||||
added++;
|
||||
}
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} into the project vault`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
const existing = vault.data.secrets[key];
|
||||
if (existing && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
vault.data.secrets[key] = {
|
||||
value,
|
||||
url: existing?.url,
|
||||
notes: existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
added++;
|
||||
}
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} from ${file}`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim, success } from "../out.js";
|
||||
import { vaultAsEnvFile } from "./inject.js";
|
||||
import { parseEnvFile } from "./secrets.js";
|
||||
import type { VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
interface StackOptions extends VaultPasswordOptions {
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
function projectDir(options: StackOptions): string {
|
||||
const dir = options.dir ?? process.cwd();
|
||||
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
||||
if (existsSync(join(dir, name))) return dir;
|
||||
}
|
||||
throw new CliError(
|
||||
`No compose file in ${dir}.`,
|
||||
"Run from a Primora deployment directory or pass --dir.",
|
||||
);
|
||||
}
|
||||
|
||||
function compose(dir: string, args: string[], env?: NodeJS.ProcessEnv): number {
|
||||
const res = spawnSync("docker", ["compose", ...args], {
|
||||
cwd: dir,
|
||||
stdio: "inherit",
|
||||
env: env ?? process.env,
|
||||
});
|
||||
if (res.error) {
|
||||
throw new CliError(
|
||||
`Failed to run docker compose: ${res.error.message}`,
|
||||
"Docker with the Compose plugin is required.",
|
||||
);
|
||||
}
|
||||
return res.status ?? 1;
|
||||
}
|
||||
|
||||
export async function cmdStackUp(
|
||||
options: StackOptions & { vault?: boolean; force?: boolean; detach?: boolean },
|
||||
): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const envPath = join(dir, ".env");
|
||||
|
||||
if (!options.vault) {
|
||||
const code = compose(dir, ["up", "-d"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack is up.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault mode: materialize .env for the duration of compose, then remove it.
|
||||
// `env_file:` directives make compose require the file on disk — there is no
|
||||
// flag to feed it via the process environment alone.
|
||||
const secrets = await vaultAsEnvFile(options);
|
||||
const hadEnv = existsSync(envPath);
|
||||
if (hadEnv && !options.force) {
|
||||
throw new CliError(
|
||||
`${envPath} already exists.`,
|
||||
"Import it (`primora secrets import .env --overwrite`), remove it, or pass --force to swap it for the vault during this run.",
|
||||
);
|
||||
}
|
||||
const prior = hadEnv ? readFileSync(envPath) : undefined;
|
||||
// compose env_file parsing is literal — write raw KEY=value lines.
|
||||
const lines = Object.entries(secrets).map(([k, v]) => {
|
||||
if (v.includes("\n")) throw new CliError(`Secret ${k} contains a newline — cannot be an env var.`);
|
||||
return `${k}=${v}`;
|
||||
});
|
||||
try {
|
||||
writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
|
||||
const code = compose(dir, ["up", "-d"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack is up — .env restored/removed; secrets live in the vault only.");
|
||||
} finally {
|
||||
if (prior) writeFileSync(envPath, prior, { mode: 0o600 });
|
||||
else if (existsSync(envPath)) unlinkSync(envPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdStackDown(options: StackOptions): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const code = compose(dir, ["down"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack stopped.");
|
||||
}
|
||||
|
||||
export async function cmdStackStatus(options: StackOptions): Promise<void> {
|
||||
const code = compose(projectDir(options), ["ps"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
}
|
||||
|
||||
export async function cmdStackLogs(
|
||||
service: string | undefined,
|
||||
options: StackOptions & { follow?: boolean; tail?: string },
|
||||
): Promise<void> {
|
||||
const args = ["logs", "--tail", options.tail ?? "200"];
|
||||
if (options.follow) args.push("--follow");
|
||||
if (service) args.push(service);
|
||||
const code = compose(projectDir(options), args);
|
||||
if (code !== 0) process.exit(code);
|
||||
}
|
||||
|
||||
export async function cmdStackPull(options: StackOptions): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const code = compose(dir, ["pull"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Images updated — run `primora stack up` to apply.");
|
||||
}
|
||||
|
||||
/** Postgres dump via `compose exec`; for a full backup (DB + storage volume)
|
||||
* use scripts/backup.sh — it covers both. ponytail: no storage tar here. */
|
||||
export async function cmdStackBackup(
|
||||
options: StackOptions & { out?: string },
|
||||
): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const envFile = join(dir, ".env");
|
||||
const env = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
||||
const user = env.POSTGRES_USER ?? "primora";
|
||||
const db = env.POSTGRES_DB ?? "primora";
|
||||
|
||||
const outDir = options.out ?? join(dir, "backups");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const outPath = join(outDir, `primora-${stamp}.dump`);
|
||||
|
||||
const res = spawnSync(
|
||||
"docker",
|
||||
["compose", "exec", "-T", "postgres", "pg_dump", "-U", user, "-Fc", db],
|
||||
{ cwd: dir, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024 },
|
||||
);
|
||||
if (res.error || res.status !== 0) {
|
||||
throw new CliError(
|
||||
`pg_dump failed: ${res.error?.message ?? res.stderr?.toString().slice(0, 300) ?? `exit ${res.status}`}`,
|
||||
"Is the stack running? `primora stack status`",
|
||||
);
|
||||
}
|
||||
writeFileSync(outPath, res.stdout as Buffer, { mode: 0o600 });
|
||||
success(`Backup written to ${outPath}`);
|
||||
dim("Postgres only — scripts/backup.sh also archives the storage volume.");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as p from "@clack/prompts";
|
||||
|
||||
import pc from "picocolors";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim, isJson, printJson, success, withSpinner } from "../out.js";
|
||||
import {
|
||||
clearSession,
|
||||
createVault,
|
||||
loadSessionKey,
|
||||
openVault,
|
||||
openVaultWithKey,
|
||||
saveSessionKey,
|
||||
sessionExpiry,
|
||||
vaultExists,
|
||||
vaultHeader,
|
||||
vaultPath,
|
||||
type OpenVault,
|
||||
} from "../vault.js";
|
||||
|
||||
export interface VaultPasswordOptions {
|
||||
passwordFile?: string;
|
||||
}
|
||||
|
||||
async function promptPassword(confirm: boolean): Promise<Uint8Array> {
|
||||
const entered = await p.password({ message: "Vault password" });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (confirm) {
|
||||
const again = await p.password({ message: "Confirm vault password" });
|
||||
if (p.isCancel(again)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (again !== entered) throw new CliError("Passwords do not match.");
|
||||
}
|
||||
return new TextEncoder().encode(entered);
|
||||
}
|
||||
|
||||
async function readPassword(options: VaultPasswordOptions): Promise<Uint8Array | undefined> {
|
||||
const env = process.env.PRIMORA_VAULT_PASSWORD;
|
||||
if (env) return new TextEncoder().encode(env);
|
||||
if (options.passwordFile) {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const pw = readFileSync(options.passwordFile, "utf8").replace(/\r?\n$/, "");
|
||||
return new TextEncoder().encode(pw);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the vault: live session key → PRIMORA_VAULT_PASSWORD → --password-file →
|
||||
* hidden prompt. The session is how agents and scripts use the vault without
|
||||
* ever seeing the password; everything else still proves the password.
|
||||
*/
|
||||
export async function unlockVault(options: VaultPasswordOptions): Promise<OpenVault> {
|
||||
const session = loadSessionKey();
|
||||
if (session) return openVaultWithKey(session);
|
||||
|
||||
const password = await readPassword(options);
|
||||
if (password) return withSpinner("Unlocking vault…", async () => openVault(password));
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError(
|
||||
"Vault is locked.",
|
||||
"Run `primora vault unlock`, or set PRIMORA_VAULT_PASSWORD / pass --password-file.",
|
||||
);
|
||||
}
|
||||
const prompted = await promptPassword(false);
|
||||
return withSpinner("Unlocking vault…", async () => openVault(prompted));
|
||||
}
|
||||
|
||||
export async function cmdVaultInit(options: VaultPasswordOptions): Promise<void> {
|
||||
let password = await readPassword(options);
|
||||
if (!password) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError(
|
||||
"Vault password required.",
|
||||
"Set PRIMORA_VAULT_PASSWORD or pass --password-file in non-interactive mode.",
|
||||
);
|
||||
}
|
||||
password = await promptPassword(true);
|
||||
}
|
||||
await withSpinner("Creating vault…", async () => createVault(password));
|
||||
success(`Vault created at ${vaultPath()}`);
|
||||
dim("Secrets are encrypted with Argon2id + XChaCha20-Poly1305. The password is not recoverable.");
|
||||
}
|
||||
|
||||
export async function cmdVaultUnlock(
|
||||
options: VaultPasswordOptions & { ttl?: string },
|
||||
): Promise<void> {
|
||||
const session = loadSessionKey();
|
||||
if (session) {
|
||||
const exp = sessionExpiry();
|
||||
dim(`Already unlocked — expires ${exp ? new Date(exp * 1000).toISOString() : "soon"}.`);
|
||||
return;
|
||||
}
|
||||
const password =
|
||||
(await readPassword(options)) ??
|
||||
(process.stdin.isTTY
|
||||
? await promptPassword(false)
|
||||
: (() => {
|
||||
throw new CliError(
|
||||
"Vault password required.",
|
||||
"Set PRIMORA_VAULT_PASSWORD or pass --password-file.",
|
||||
);
|
||||
})());
|
||||
const vault = await withSpinner("Unlocking vault…", async () => openVault(password)); // proves the password before stashing the key
|
||||
const ttl = saveSessionKey(vault.key, Number(options.ttl ?? 900));
|
||||
success(`Vault unlocked for ${ttl}s — secrets usable without the password until expiry.`);
|
||||
dim("`primora vault lock` revokes immediately.");
|
||||
}
|
||||
|
||||
export async function cmdVaultLock(): Promise<void> {
|
||||
if (clearSession()) success("Vault locked — session revoked.");
|
||||
else dim("No active session.");
|
||||
}
|
||||
|
||||
export async function cmdVaultStatus(options: { json?: boolean }): Promise<void> {
|
||||
const exists = vaultExists();
|
||||
const header = exists ? vaultHeader() : undefined;
|
||||
const exp = sessionExpiry();
|
||||
const unlocked = exp !== undefined;
|
||||
let count: number | undefined;
|
||||
if (unlocked) {
|
||||
try {
|
||||
count = Object.keys(openVaultWithKey(loadSessionKey()!).data.secrets).length;
|
||||
} catch {
|
||||
// session key no longer matches (vault re-created) — report locked
|
||||
}
|
||||
}
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
path: vaultPath(),
|
||||
exists,
|
||||
unlocked,
|
||||
expires_at: exp ?? null,
|
||||
secrets: count ?? null,
|
||||
kdf: header ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!exists) {
|
||||
dim(`No vault at ${vaultPath()} — run \`primora vault init\`.`);
|
||||
return;
|
||||
}
|
||||
const remaining = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const state = unlocked
|
||||
? pc.green("● unlocked") +
|
||||
pc.dim(` — ${mins ? `${mins}m ` : ""}${remaining % 60}s remaining`)
|
||||
: pc.dim("● locked");
|
||||
process.stdout.write(`vault ${state}\n\n`);
|
||||
const rows: [string, string][] = [
|
||||
["path", vaultPath()],
|
||||
["kdf", `argon2id · ${header!.m} KiB · ${header!.t} passes · ${header!.p} lanes`],
|
||||
];
|
||||
if (unlocked) rows.push(["secrets", String(count ?? "?")]);
|
||||
const width = Math.max(...rows.map(([k]) => k.length));
|
||||
for (const [k, v] of rows) {
|
||||
process.stdout.write(`${pc.dim(k.padEnd(width))} ${v}\n`);
|
||||
}
|
||||
if (!unlocked) dim(`\n\`primora vault unlock\` opens a session for agents and scripts.`);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
|
||||
export function isJson(options: { json?: boolean }): boolean {
|
||||
@@ -28,6 +29,21 @@ export function fail(error: unknown): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Spinner for multi-hundred-ms work (Argon2 derives). Silent when piped. */
|
||||
export async function withSpinner<T>(message: string, work: () => Promise<T>): Promise<T> {
|
||||
if (!process.stdout.isTTY) return work();
|
||||
const s = p.spinner();
|
||||
s.start(message);
|
||||
try {
|
||||
const result = await work();
|
||||
s.stop(message);
|
||||
return result;
|
||||
} catch (error) {
|
||||
s.stop(pc.red("failed"));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal column printer — left-aligned, two-space gutter. */
|
||||
export function printTable(headers: string[], rows: string[][]): void {
|
||||
const all = headers.length ? [headers, ...rows] : rows;
|
||||
|
||||
@@ -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