From 585bb04ab26f45b6bdc8f508eb9a5dbad632b61a Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 18:46:37 +0200 Subject: [PATCH 1/2] feat: encrypted secrets vault across CLI, desktop, dashboard, and backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 + DEPLOYMENT_GUIDE.md | 2 +- QUICK_START.md | 1 + README.md | 4 +- ROADMAP.md | 18 + .../db/migrations/00011_project_secrets.sql | 19 + apps/backend/db/queries/project_secrets.sql | 36 ++ apps/backend/internal/database/db/models.go | 11 + .../database/db/project_secrets.sql.go | 181 ++++++++ apps/backend/internal/database/db/querier.go | 5 + apps/backend/internal/handlers/http.go | 4 + apps/backend/internal/handlers/secrets.go | 87 ++++ apps/backend/internal/services/jobs.go | 7 +- .../internal/services/project_secrets.go | 234 ++++++++++ .../internal/services/project_secrets_test.go | 125 ++++++ apps/backend/openapi/openapi.yaml | 139 ++++++ apps/cli/README.md | 124 +++++- apps/cli/package.json | 4 +- apps/cli/src/cli.ts | 153 ++++++- apps/cli/src/commands/agent.ts | 236 ++++++++++ apps/cli/src/commands/inject.ts | 61 +++ apps/cli/src/commands/secrets.ts | 317 ++++++++++++++ apps/cli/src/commands/stack.ts | 142 ++++++ apps/cli/src/commands/vault.ts | 166 +++++++ apps/cli/src/out.ts | 16 + apps/cli/src/vault.ts | 247 +++++++++++ apps/desktop/README.md | 15 + .../src-tauri/capabilities/default.json | 2 +- apps/desktop/src-tauri/src/lib.rs | 152 ++++++- apps/desktop/ui/index.html | 6 + apps/desktop/ui/style.css | 219 ++++++++++ apps/desktop/ui/vault.html | 30 ++ apps/desktop/ui/vault.js | 247 +++++++++++ apps/frontend/src/App.tsx | 10 + apps/frontend/src/components/AppShell.tsx | 4 + apps/frontend/src/pages/VaultPage.tsx | 404 ++++++++++++++++++ apps/frontend/src/pages/index.ts | 1 + apps/mcp/README.md | 11 + apps/mcp/src/server.ts | 147 +++++++ package-lock.json | 199 +++------ packages/api-client/src/generated/index.ts | 11 + .../src/generated/models/ProjectSecret.ts | 16 + .../models/ProjectSecretListResponse.ts | 9 + .../models/RevealProjectSecretResponse.ts | 9 + .../src/generated/models/SecretName.ts | 5 + .../models/SetProjectSecretRequest.ts | 13 + .../src/generated/schemas/$ProjectSecret.ts | 36 ++ .../schemas/$ProjectSecretListResponse.ts | 15 + .../schemas/$RevealProjectSecretResponse.ts | 16 + .../src/generated/schemas/$SecretName.ts | 8 + .../schemas/$SetProjectSecretRequest.ts | 19 + .../src/generated/services/SecretsService.ts | 112 +++++ 52 files changed, 3929 insertions(+), 142 deletions(-) create mode 100644 apps/backend/db/migrations/00011_project_secrets.sql create mode 100644 apps/backend/db/queries/project_secrets.sql create mode 100644 apps/backend/internal/database/db/project_secrets.sql.go create mode 100644 apps/backend/internal/handlers/secrets.go create mode 100644 apps/backend/internal/services/project_secrets.go create mode 100644 apps/backend/internal/services/project_secrets_test.go create mode 100644 apps/cli/src/commands/agent.ts create mode 100644 apps/cli/src/commands/inject.ts create mode 100644 apps/cli/src/commands/secrets.ts create mode 100644 apps/cli/src/commands/stack.ts create mode 100644 apps/cli/src/commands/vault.ts create mode 100644 apps/cli/src/vault.ts create mode 100644 apps/desktop/ui/vault.html create mode 100644 apps/desktop/ui/vault.js create mode 100644 apps/frontend/src/pages/VaultPage.tsx create mode 100644 packages/api-client/src/generated/models/ProjectSecret.ts create mode 100644 packages/api-client/src/generated/models/ProjectSecretListResponse.ts create mode 100644 packages/api-client/src/generated/models/RevealProjectSecretResponse.ts create mode 100644 packages/api-client/src/generated/models/SecretName.ts create mode 100644 packages/api-client/src/generated/models/SetProjectSecretRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$ProjectSecret.ts create mode 100644 packages/api-client/src/generated/schemas/$ProjectSecretListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$RevealProjectSecretResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$SecretName.ts create mode 100644 packages/api-client/src/generated/schemas/$SetProjectSecretRequest.ts create mode 100644 packages/api-client/src/generated/services/SecretsService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7505d19..926047b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). +## [Unreleased] + +### Added + +- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility. +- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately. +- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override. +- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv. +- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`. +- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. +- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen. +- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly. +- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window. +- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values. +- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext. + ## [0.7.1] - 2026-09-23 ### Fixed diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index e5387df..5dc1e93 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -37,7 +37,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d | `DRAGONFLY_URL` | backend → cache DSN | yes | | `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes | | `BETTER_AUTH_SECRET` | auth session/signing secret | yes | -| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production | +| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for project secrets (Vault page, `secret://` job refs), integration credentials + webhook secrets at rest | production | | `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes | | `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes | | `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes | diff --git a/QUICK_START.md b/QUICK_START.md index eb1c077..7dc12c9 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -61,6 +61,7 @@ The important ones: | `POSTGRES_*` / `DATABASE_URL` | Postgres connection | | `DRAGONFLY_URL` | Cache/rate-limit store | | `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) | +| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars, `openssl rand -hex 32`) — encrypts the project vault, integration credentials and webhook secrets at rest. Without it the Vault page and `secret://` job refs are unavailable | | `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app | | `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app | diff --git a/README.md b/README.md index 10614ca..d17b963 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,9 @@ and [QUICK_START.md](QUICK_START.md#configuration) for the important ones. ## Ecosystem -- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, and audit from the terminal. +- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, jobs, and audit — plus an encrypted local secrets vault (`vault:*`, `secrets:*`, `inject`), the shared project vault (`secrets:* --remote`, `secret://NAME` refs in job payloads), an agent credential broker (`agent`), and compose-stack operator commands (`stack:*`). - **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients. -- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence. +- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence and a local Secrets Vault window (drives the CLI's vault). - **PWA** - the dashboard is installable on mobile and desktop browsers. ## Documentation diff --git a/ROADMAP.md b/ROADMAP.md index 410fbfb..3073900 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -263,6 +263,24 @@ dashboard's live feed off the same stream. `auth-client` now resolves `/auth` against `window.location.origin`, preserving same-origin defaults without breaking split-origin dev. +## Secrets vault (unreleased) + +Arca-inspired secrets management, clean-room — every surface covered. + +- **Local vault** (`apps/cli`) — `PRMVLT01` format: Argon2id (64 MiB/3/4) → + XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes. `vault:*`, + `secrets:*`, `inject` (env injection + `primora://` refs), TTL sessions, + and the `agent` credential broker (dummy tokens in the child, real secret + attached on the wire per grant). +- **Project vault** (`core.project_secrets`, migration 00011) — AES-256-GCM + via the platform encryptor; metadata-only list, audit-logged reveal; + `secret://NAME` refs in job payloads resolve at delivery and fail loudly + when unresolved. +- **Surfaces** — dashboard Vault page (+ local-vault card under the desktop + shell), desktop vault window via Tauri IPC → `primora` binary, + `secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only + so agents never hold plaintext. + ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. diff --git a/apps/backend/db/migrations/00011_project_secrets.sql b/apps/backend/db/migrations/00011_project_secrets.sql new file mode 100644 index 0000000..e2d647d --- /dev/null +++ b/apps/backend/db/migrations/00011_project_secrets.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Project-level secrets: AES-256-GCM ciphertext produced by the backend's +-- Encryptor. Values are write-once — list/read expose metadata only; a +-- dedicated reveal endpoint decrypts and is audit-logged. secret://NAME +-- references in job payloads resolve at delivery time. +CREATE TABLE core.project_secrets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + ciphertext BYTEA NOT NULL, + url TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, name) +); + +-- +goose Down +DROP TABLE IF EXISTS core.project_secrets; diff --git a/apps/backend/db/queries/project_secrets.sql b/apps/backend/db/queries/project_secrets.sql new file mode 100644 index 0000000..8937a8b --- /dev/null +++ b/apps/backend/db/queries/project_secrets.sql @@ -0,0 +1,36 @@ +-- name: ListProjectSecrets :many +SELECT id, project_id, name, url, notes, created_at, updated_at +FROM core.project_secrets +WHERE project_id = $1 +ORDER BY name ASC; + +-- name: GetProjectSecret :one +SELECT * FROM core.project_secrets +WHERE project_id = $1 + AND name = $2; + +-- name: UpsertProjectSecret :one +INSERT INTO core.project_secrets ( + project_id, + name, + ciphertext, + url, + notes +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (project_id, name) DO UPDATE SET + ciphertext = EXCLUDED.ciphertext, + url = EXCLUDED.url, + notes = EXCLUDED.notes, + updated_at = NOW() +RETURNING *; + +-- name: DeleteProjectSecret :one +DELETE FROM core.project_secrets +WHERE project_id = $1 + AND name = $2 +RETURNING id; + +-- name: ListProjectSecretValues :many +SELECT name, ciphertext +FROM core.project_secrets +WHERE project_id = $1; diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 1b56e6c..9f97578 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -307,6 +307,17 @@ type CoreProjectMember struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type CoreProjectSecret struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Ciphertext []byte `json:"ciphertext"` + Url string `json:"url"` + Notes string `json:"notes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreScheduledJob struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/project_secrets.sql.go b/apps/backend/internal/database/db/project_secrets.sql.go new file mode 100644 index 0000000..ee8d4d6 --- /dev/null +++ b/apps/backend/internal/database/db/project_secrets.sql.go @@ -0,0 +1,181 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: project_secrets.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteProjectSecret = `-- name: DeleteProjectSecret :one +DELETE FROM core.project_secrets +WHERE project_id = $1 + AND name = $2 +RETURNING id +` + +type DeleteProjectSecretParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` +} + +func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const getProjectSecret = `-- name: GetProjectSecret :one +SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets +WHERE project_id = $1 + AND name = $2 +` + +type GetProjectSecretParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` +} + +func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) { + row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name) + var i CoreProjectSecret + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Ciphertext, + &i.Url, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listProjectSecretValues = `-- name: ListProjectSecretValues :many +SELECT name, ciphertext +FROM core.project_secrets +WHERE project_id = $1 +` + +type ListProjectSecretValuesRow struct { + Name string `json:"name"` + Ciphertext []byte `json:"ciphertext"` +} + +func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) { + rows, err := q.db.Query(ctx, listProjectSecretValues, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProjectSecretValuesRow + for rows.Next() { + var i ListProjectSecretValuesRow + if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjectSecrets = `-- name: ListProjectSecrets :many +SELECT id, project_id, name, url, notes, created_at, updated_at +FROM core.project_secrets +WHERE project_id = $1 +ORDER BY name ASC +` + +type ListProjectSecretsRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Url string `json:"url"` + Notes string `json:"notes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) { + rows, err := q.db.Query(ctx, listProjectSecrets, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProjectSecretsRow + for rows.Next() { + var i ListProjectSecretsRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Url, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertProjectSecret = `-- name: UpsertProjectSecret :one +INSERT INTO core.project_secrets ( + project_id, + name, + ciphertext, + url, + notes +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (project_id, name) DO UPDATE SET + ciphertext = EXCLUDED.ciphertext, + url = EXCLUDED.url, + notes = EXCLUDED.notes, + updated_at = NOW() +RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at +` + +type UpsertProjectSecretParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Ciphertext []byte `json:"ciphertext"` + Url string `json:"url"` + Notes string `json:"notes"` +} + +func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) { + row := q.db.QueryRow(ctx, upsertProjectSecret, + arg.ProjectID, + arg.Name, + arg.Ciphertext, + arg.Url, + arg.Notes, + ) + var i CoreProjectSecret + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Ciphertext, + &i.Url, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index ec3a9f1..902086a 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -45,6 +45,7 @@ type Querier interface { DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) + DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) DeleteSetting(ctx context.Context, key string) error DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error) @@ -68,6 +69,7 @@ type Querier interface { GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) + GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) GetSetting(ctx context.Context, key string) (GetSettingRow, error) GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error) @@ -96,6 +98,8 @@ type Querier interface { ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) + ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) + ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) @@ -135,6 +139,7 @@ type Querier interface { UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) + UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) } diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index dcb4de6..6b3e78c 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -116,6 +116,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob) api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns) api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob) + api.GET("/projects/:projectID/secrets", h.listProjectSecrets) + api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret) + api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret) + api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret) api.GET("/projects/:projectID/realtime/stream", h.realtimeStream) api.GET("/buckets/:bucketID/objects", h.listObjects) api.POST("/buckets/:bucketID/objects", h.uploadObject) diff --git a/apps/backend/internal/handlers/secrets.go b/apps/backend/internal/handlers/secrets.go new file mode 100644 index 0000000..3f212b1 --- /dev/null +++ b/apps/backend/internal/handlers/secrets.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + + "github.com/tdvorak/primora/apps/backend/internal/middleware" + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +func (h *HTTPHandler) secretError(c *gin.Context, err error) { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found") + return + } + msg := err.Error() + if strings.Contains(msg, "invalid secret name") { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg) + return + } + if strings.Contains(msg, "encryption is not configured") { + apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg) + return + } + h.handleError(c, err) +} + +func (h *HTTPHandler) listProjectSecrets(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID) + if err != nil { + h.secretError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + +func (h *HTTPHandler) setProjectSecret(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.SetProjectSecretInput + if !h.bindAndValidate(c, &body) { + return + } + body.Name = c.Param("name") + result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.secretError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil { + h.secretError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *HTTPHandler) revealProjectSecret(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)) + if err != nil { + h.secretError(c, err) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/apps/backend/internal/services/jobs.go b/apps/backend/internal/services/jobs.go index 3c84ac3..8421fd9 100644 --- a/apps/backend/internal/services/jobs.go +++ b/apps/backend/internal/services/jobs.go @@ -393,6 +393,7 @@ type jobQueries interface { FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error) MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error) PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error + ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error) } type JobScheduler struct { @@ -552,6 +553,10 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta // deliver POSTs the job payload to its URL, signed like a webhook delivery. func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) { + data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload) + if err != nil { + return nil, err + } payload, _ := json.Marshal(map[string]any{ "event": WebhookEventJobRun, "project_id": job.ProjectID.String(), @@ -560,7 +565,7 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run "run_id": run.ID.String(), "triggered_by": run.TriggeredBy, "occurred_at": time.Now().UTC().Format(time.RFC3339), - "data": json.RawMessage(job.Payload), + "data": data, }) secret := "" if len(job.Secret) > 0 && j.enc != nil { diff --git a/apps/backend/internal/services/project_secrets.go b/apps/backend/internal/services/project_secrets.go new file mode 100644 index 0000000..c7de25c --- /dev/null +++ b/apps/backend/internal/services/project_secrets.go @@ -0,0 +1,234 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/models" + "github.com/tdvorak/primora/apps/backend/internal/secrets" +) + +// Project secrets are the server-side counterpart of the CLI's local vault: +// AES-256-GCM at rest, metadata-only listings, audited reveal. Job payloads +// reference them as secret://NAME — resolved at delivery, never stored in +// plaintext config. + +var projectSecretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +type ProjectSecretSummary struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SetProjectSecretInput struct { + Name string `json:"name"` // from the path param, not the body + Value string `json:"value" validate:"required"` + URL *string `json:"url"` + Notes *string `json:"notes"` +} + +type RevealProjectSecretResponse struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func toProjectSecretSummary(row db.ListProjectSecretsRow) ProjectSecretSummary { + return ProjectSecretSummary{ + ID: row.ID, + Name: row.Name, + URL: row.Url, + Notes: row.Notes, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func (s *PlatformService) ListProjectSecrets(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectSecretSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + rows, err := s.repo.Queries().ListProjectSecrets(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]ProjectSecretSummary, 0, len(rows)) + for _, row := range rows { + out = append(out, toProjectSecretSummary(row)) + } + return out, nil +} + +func (s *PlatformService) SetProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectSecretInput, requestID string) (ProjectSecretSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return ProjectSecretSummary{}, err + } + name := strings.TrimSpace(input.Name) + if !projectSecretNameRe.MatchString(name) { + return ProjectSecretSummary{}, fmt.Errorf("invalid secret name — use env-var-safe names [A-Za-z_][A-Za-z0-9_]*") + } + if s.enc == nil { + return ProjectSecretSummary{}, fmt.Errorf("encryption is not configured on this deployment") + } + ciphertext, err := s.enc.Encrypt([]byte(input.Value)) + if err != nil { + return ProjectSecretSummary{}, err + } + // Merge url/notes over the existing row when the caller omits them — + // `secrets set NAME` should not wipe metadata it never saw. + var url, notes string + if existing, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}); err == nil { + url, notes = existing.Url, existing.Notes + } else if err != pgx.ErrNoRows { + return ProjectSecretSummary{}, err + } + if input.URL != nil { + url = strings.TrimSpace(*input.URL) + } + if input.Notes != nil { + notes = strings.TrimSpace(*input.Notes) + } + row, err := s.repo.Queries().UpsertProjectSecret(ctx, db.UpsertProjectSecretParams{ + ProjectID: projectID, + Name: name, + Ciphertext: ciphertext, + Url: url, + Notes: notes, + }) + if err != nil { + return ProjectSecretSummary{}, err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.set", "project_secret", row.ID.String(), map[string]any{ + "name": row.Name, + })) + } + return ProjectSecretSummary{ + ID: row.ID, + Name: row.Name, + URL: row.Url, + Notes: row.Notes, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + }, nil +} + +func (s *PlatformService) DeleteProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) error { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return err + } + row, err := s.repo.Queries().DeleteProjectSecret(ctx, db.DeleteProjectSecretParams{ProjectID: projectID, Name: name}) + if err != nil { + return err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.deleted", "project_secret", row.String(), map[string]any{ + "name": name, + })) + } + return nil +} + +// Reveal decrypts a single secret. Deliberately loud: audit-logged every time. +func (s *PlatformService) RevealProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) (RevealProjectSecretResponse, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return RevealProjectSecretResponse{}, err + } + row, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}) + if err != nil { + return RevealProjectSecretResponse{}, err + } + plain, err := s.enc.Decrypt(row.Ciphertext) + if err != nil { + return RevealProjectSecretResponse{}, err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.revealed", "project_secret", row.ID.String(), map[string]any{ + "name": row.Name, + })) + } + return RevealProjectSecretResponse{Name: row.Name, Value: string(plain)}, nil +} + +// resolveSecretRefs replaces "secret://NAME" string values in a payload with +// decrypted values. Names that do not resolve fail loudly — a literal +// secret:// string reaching the endpoint is worse than a failed run. +// Package-level so the JobScheduler can reuse it through its narrow repo iface. +func resolveSecretRefs( + ctx context.Context, + list func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error), + enc *secrets.Encryptor, + projectID uuid.UUID, + payload json.RawMessage, +) (json.RawMessage, error) { + if len(payload) == 0 || !strings.Contains(string(payload), "secret://") { + return payload, nil + } + if enc == nil { + return nil, fmt.Errorf("payload references project secrets but encryption is not configured on this deployment") + } + rows, err := list(ctx, projectID) + if err != nil { + return nil, err + } + values := make(map[string]string, len(rows)) + for _, row := range rows { + plain, err := enc.Decrypt(row.Ciphertext) + if err != nil { + return nil, fmt.Errorf("project secret %q failed to decrypt", row.Name) + } + values[row.Name] = string(plain) + } + var doc any + if err := json.Unmarshal(payload, &doc); err != nil { + return nil, fmt.Errorf("payload is not valid JSON: %w", err) + } + var missing []string + resolved := walkSecretRefs(doc, values, &missing) + if len(missing) > 0 { + return nil, fmt.Errorf("unresolved secret references: %s", strings.Join(missing, ", ")) + } + out, err := json.Marshal(resolved) + if err != nil { + return nil, err + } + return out, nil +} + +func walkSecretRefs(node any, values map[string]string, missing *[]string) any { + switch v := node.(type) { + case string: + if strings.HasPrefix(v, "secret://") { + name := strings.TrimPrefix(v, "secret://") + if resolved, ok := values[name]; ok { + return resolved + } + *missing = append(*missing, name) + return v + } + return v + case map[string]any: + for k, item := range v { + v[k] = walkSecretRefs(item, values, missing) + } + return v + case []any: + for i, item := range v { + v[i] = walkSecretRefs(item, values, missing) + } + return v + default: + return node + } +} diff --git a/apps/backend/internal/services/project_secrets_test.go b/apps/backend/internal/services/project_secrets_test.go new file mode 100644 index 0000000..483d4f2 --- /dev/null +++ b/apps/backend/internal/services/project_secrets_test.go @@ -0,0 +1,125 @@ +package services + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/google/uuid" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/secrets" +) + +func testEncryptor(t *testing.T) *secrets.Encryptor { + t.Helper() + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + enc, err := secrets.NewEncryptor(key) + if err != nil { + t.Fatalf("NewEncryptor: %v", err) + } + return enc +} + +func secretLister(t *testing.T, enc *secrets.Encryptor, kv map[string]string) func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) { + t.Helper() + return func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) { + rows := make([]db.ListProjectSecretValuesRow, 0, len(kv)) + for name, value := range kv { + ct, err := enc.Encrypt([]byte(value)) + if err != nil { + t.Fatalf("encrypt %s: %v", name, err) + } + rows = append(rows, db.ListProjectSecretValuesRow{Name: name, Ciphertext: ct}) + } + return rows, nil + } +} + +func TestResolveSecretRefs(t *testing.T) { + enc := testEncryptor(t) + list := secretLister(t, enc, map[string]string{ + "API_KEY": "sk_live_99", + "RECURSIVE": "secret://API_KEY", // must not resolve twice + }) + pid := uuid.New() + + t.Run("replaces nested and array refs", func(t *testing.T) { + payload := json.RawMessage(`{ + "token": "secret://API_KEY", + "nested": {"deep": ["a", "secret://API_KEY", {"x": "secret://API_KEY"}]}, + "plain": "not a secret", + "num": 42 + }`) + out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload) + if err != nil { + t.Fatalf("resolve: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc["token"] != "sk_live_99" { + t.Errorf("token = %v", doc["token"]) + } + deep := doc["nested"].(map[string]any)["deep"].([]any) + if deep[1] != "sk_live_99" || deep[2].(map[string]any)["x"] != "sk_live_99" { + t.Errorf("nested refs not resolved: %v", deep) + } + if doc["plain"] != "not a secret" || doc["num"] != float64(42) { + t.Errorf("non-ref values changed: %v", doc) + } + }) + + t.Run("missing ref fails the job", func(t *testing.T) { + _, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://NOPE"}`)) + if err == nil || !strings.Contains(err.Error(), "NOPE") { + t.Fatalf("expected unresolved error naming NOPE, got %v", err) + } + }) + + t.Run("embedded ref in a larger string is not resolved", func(t *testing.T) { + out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"prefix secret://API_KEY suffix"}`)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !strings.Contains(string(out), "secret://API_KEY") { + t.Fatalf("embedded ref must pass through unchanged: %s", out) + } + }) + + t.Run("resolved value is not re-scanned", func(t *testing.T) { + out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://RECURSIVE"}`)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + var doc map[string]any + _ = json.Unmarshal(out, &doc) + if doc["a"] != "secret://API_KEY" { + t.Fatalf("single-pass resolution expected, got %v", doc["a"]) + } + }) + + t.Run("invalid JSON fails loudly when a ref is present", func(t *testing.T) { + _, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{secret://API_KEY`)) + if err == nil { + t.Fatal("expected JSON error") + } + }) + + t.Run("payloads without refs pass through untouched", func(t *testing.T) { + payload := json.RawMessage(`{"a":1}`) + out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload) + if err != nil || string(out) != string(payload) { + t.Fatalf("passthrough failed: %v %s", err, out) + } + // Nil encryptor must fail loudly — a literal secret:// reaching the + // endpoint is worse than a failed run. + _, err = resolveSecretRefs(context.Background(), list, nil, pid, json.RawMessage(`{"a":"secret://API_KEY"}`)) + if err == nil { + t.Fatal("expected error when encryption is not configured") + } + }) +} diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index 5417520..3eb612b 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -1838,6 +1838,91 @@ paths: description: text/event-stream of {type, occurred_at, data} messages "401": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/secrets: + get: + tags: [Secrets] + operationId: listProjectSecrets + summary: Lists project secrets — metadata only, values are never included. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Project secrets (metadata only) + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectSecretListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/secrets/{name}: + put: + tags: [Secrets] + operationId: setProjectSecret + summary: Creates or replaces a project secret (AES-256-GCM at rest). Omitted url/notes keep existing values. Job payloads can reference it as secret://NAME, resolved at delivery. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/SecretName" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetProjectSecretRequest" + responses: + "200": + description: Secret stored + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectSecret" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Secrets] + operationId: deleteProjectSecret + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/SecretName" + responses: + "204": + description: Secret deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/secrets/{name}/reveal: + post: + tags: [Secrets] + operationId: revealProjectSecret + summary: Returns the decrypted value. Every call is audit-logged. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/SecretName" + responses: + "200": + description: Decrypted value + content: + application/json: + schema: + $ref: "#/components/schemas/RevealProjectSecretResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -1863,6 +1948,13 @@ components: schema: type: string format: uuid + SecretName: + name: name + in: path + required: true + schema: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" BucketID: name: bucketID in: path @@ -3664,3 +3756,50 @@ components: items: $ref: "#/components/schemas/ScheduledJobRun" required: [items] + ProjectSecret: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + description: Env-var-safe name; referenced as secret://NAME in job payloads. + url: + type: string + notes: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, name, url, notes, created_at, updated_at] + ProjectSecretListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ProjectSecret" + required: [items] + SetProjectSecretRequest: + type: object + properties: + value: + type: string + description: Plaintext — stored AES-256-GCM encrypted, never returned by list. + url: + type: string + notes: + type: string + required: [value] + RevealProjectSecretResponse: + type: object + properties: + name: + type: string + value: + type: string + required: [name, value] diff --git a/apps/cli/README.md b/apps/cli/README.md index 127757e..63b7bcc 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,7 @@ # @primora/cli -`primora` - command-line client for a Primora deployment. +`primora` - command-line client for a Primora deployment, plus a local +encrypted secrets vault and compose-stack operator commands. ## Build @@ -20,4 +21,123 @@ project interactively. ## Commands `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, -`audit list --follow`. Every command accepts `--json`. +`jobs`, `audit list --follow`. Every command accepts `--json`. + +## Vault + +A local encrypted vault for secrets the CLI holds - API keys, deployment `.env` +values, anything you would otherwise leave in a dotfile or shell history. + +```bash +primora vault init # create ~/.config/primora/vault.bin +primora secrets set STRIPE_KEY # value prompted, or --value / stdin +primora secrets set OPENAI --value sk-… --url https://platform.openai.com --notes "prod" +primora secrets import .env # import KEY=value pairs +primora secrets list # names + urls + timestamps, never values +primora secrets get STRIPE_KEY # prints the value (scriptable) +primora secrets rm STRIPE_KEY --yes +``` + +Format: Argon2id (64 MiB / 3 / 4) → XChaCha20-Poly1305 over a single JSON +payload; the parameter header is the AEAD's AAD, atomic tmp+rename writes. +Clean-room implementation - compatible with nothing but itself. + +Password source order: live session → `PRIMORA_VAULT_PASSWORD` → +`--password-file` → hidden prompt. `PRIMORA_VAULT` overrides the file +location. + +## Project vault (server) + +`--remote` targets the project's server-side vault instead of the local file — +AES-256-GCM at rest in Postgres, shared with the team and the web dashboard's +Vault page. No vault password; it uses your `primora login` session or +`PRIMORA_API_KEY`. + +```bash +primora secrets set STRIPE_KEY --value sk-… --remote +primora secrets list --remote # metadata only — names, urls, notes +primora secrets get STRIPE_KEY --remote # explicit reveal, audit-logged +primora secrets rm STRIPE_KEY --remote --yes +primora secrets import .env --remote --overwrite +``` + +Job payloads reference secrets as `secret://NAME`; the scheduler resolves them +at delivery, so payloads on disk never contain plaintext: + +```json +{"auth": "secret://STRIPE_KEY"} +``` + +## Sessions + +`primora vault unlock --ttl 900` proves the password once, then writes a +TTL'd session key (`vault.session`, 0600) so agents and scripts use the +vault without ever seeing the password. `primora vault lock` revokes +immediately; expiry does it on its own. The session file is same-user +readable for its lifetime — that is the trade, bounded by the TTL. + +## Agent broker + +`primora agent` wraps one process: the child gets dummy tokens and a +loopback base URL; the broker attaches the real secret on the wire. + +```bash +primora agent --anthropic CLAUDE_KEY --openai OAI_KEY --ttl 900 -- claude +primora agent --upstream stripe=https://api.stripe.com -- npm test +``` + +Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, +`github`, `npm`. Each flag takes a vault secret name; `--upstream +name=https://…` adds any Bearer API (vault key `_KEY`). + +The child environment contains `*_API_KEY=primora-brokered` and a +`*_BASE_URL` pointing at the broker — so the tool must honor a base-URL +override. `gh` hardcodes api.github.com and cannot be brokered this way. +This is a base-URL broker, not a TLS MITM; no CA trust needed. + +## Inject + +Run one process with vault secrets in its environment - nothing on disk, +nothing in argv, parent shell unchanged: + +```bash +primora inject -- npm run dev # all vault secrets +primora inject --env-file .env -- npm start # resolve primora:// refs +``` + +An env-file can mix literals and refs: + +```dotenv +DATABASE_URL=postgres://localhost/app +STRIPE_KEY=primora://STRIPE_KEY +``` + +## Stack + +Operator surface for a compose deployment (`--dir`, defaults to cwd): + +```bash +primora stack up # docker compose up -d +primora stack up --vault # materialize .env from the vault, remove after +primora stack status | logs [svc] | down | pull +primora stack backup # pg_dump → ./backups (storage: scripts/backup.sh) +``` + +`stack up --vault` refuses to overwrite an existing `.env` without `--force` +(the original is restored after the run). For a full backup including the +storage volume, `scripts/backup.sh` remains the tool. + +## For AI agents + +Two safe paths, no secret ever in the agent's context: + +- **Wrapped** — launch the agent under `primora agent … -- claude`. It holds + dummy tokens; the broker attaches real ones on the wire. +- **MCP** — `primora_vault_status`, `primora_vault_list` (metadata only), + `primora_vault_exec` (runs `primora` commands with a secret injected into + the process env; vault/secrets/inject/agent/login and `keys:create` are + denied, so output can never carry a credential). Requires an unlocked + vault session on the host. +- **MCP project vault** — `primora_secrets_list` (metadata only) and + `primora_secrets_set` (write-only). No reveal, no delete: agents reference + `secret://NAME` in job payloads without ever holding the value. diff --git a/apps/cli/package.json b/apps/cli/package.json index ab34e7e..507a1b0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@primora/cli", - "version": "0.5.0", + "version": "0.6.0", "description": "Primora command-line client", "type": "module", "bin": { @@ -19,6 +19,8 @@ }, "dependencies": { "@clack/prompts": "^1.8.1", + "@noble/ciphers": "^2.4.0", + "@noble/hashes": "^2.4.0", "cac": "^7.0.0", "picocolors": "^1.1.1" }, diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 6ffb6e0..f25b149 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -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 ", "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 ", "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 ", "Session lifetime in seconds (default 900, cap 86400)") + .option("--password-file ", "Read the vault password from a file") + .action(cmdVaultUnlock); +cli + .command("vault:lock", "Revoke the session immediately") + .action(cmdVaultLock); + +cli + .command("secrets:set ", "Store a secret in the vault") + .option("--value ", "Secret value (otherwise prompted or read from stdin)") + .option("--url ", "Associated URL (dashboard, docs, endpoint)") + .option("--notes ", "Free-text notes") + .option("--remote", "Target the project vault on the server instead of the local vault") + .option("--project ", "Project override (with --remote)") + .option("--password-file ", "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 ", "Project override (with --remote)") + .option("--password-file ", "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 ", "Project override (with --remote)") + .option("--password-file ", "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 ", "Project override (with --remote)") + .option("--password-file ", "Read the vault password from a file") + .option("--yes", "Skip the confirmation prompt") + .action(cmdSecretsRemove); +cli + .command("secrets:import ", "Import a .env file into the vault") + .option("--remote", "Import into the project vault on the server") + .option("--project ", "Project override (with --remote)") + .option("--password-file ", "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 ", "env template; primora://NAME values resolve from the vault") + .option("--password-file ", "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 ", "Grant lifetime in seconds (default 900, cap 3600)") + .option("--upstream ", "Custom Bearer upstream, repeatable (vault key: _KEY)") + .option("--password-file ", "Read the vault password from a file"); +for (const preset of PRESET_NAMES) { + agentCmd.option(`--${preset} `, `Grant ${preset} API access from vault secret `); +} +agentCmd.action(cmdAgent); + +// Stack — operator surface for the compose deployment in --dir (default cwd). +cli + .command("stack:up", "docker compose up -d") + .option("--dir ", "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 ", "Read the vault password from a file") + .action(cmdStackUp); +cli + .command("stack:down", "docker compose down") + .option("--dir ", "Deployment directory (default: cwd)") + .action(cmdStackDown); +cli + .command("stack:status", "docker compose ps") + .option("--dir ", "Deployment directory (default: cwd)") + .action(cmdStackStatus); +cli + .command("stack:logs [service]", "docker compose logs") + .option("--dir ", "Deployment directory (default: cwd)") + .option("--follow", "Follow log output") + .option("--tail ", "Lines per service (default 200)") + .action(cmdStackLogs); +cli + .command("stack:pull", "Pull newer images") + .option("--dir ", "Deployment directory (default: cwd)") + .action(cmdStackPull); +cli + .command("stack:backup", "pg_dump the platform database into ./backups") + .option("--dir ", "Deployment directory (default: cwd)") + .option("--out ", "Backup directory (default: /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() { diff --git a/apps/cli/src/commands/agent.ts b/apps/cli/src/commands/agent.ts new file mode 100644 index 0000000..0a9dafc --- /dev/null +++ b/apps/cli/src/commands/agent.ts @@ -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; // child env; base = http://127.0.0.1:port/ +} + +const DUMMY = "primora-brokered"; +const bearer = (s: string): [string, string] => ["authorization", `Bearer ${s}`]; + +const PRESETS: Record = { + 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, +): Promise { + 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 = {}; + 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 = {}; + 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 { + 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] -- ", + ); + } + + // Collect grants: -- 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} `).join(" ")} or --upstream name=https://…`, + ); + } + + const vault = await unlockVault(options); + const grants = new Map(); + 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((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((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); +} diff --git a/apps/cli/src/commands/inject.ts b/apps/cli/src/commands/inject.ts new file mode 100644 index 0000000..da2d2cc --- /dev/null +++ b/apps/cli/src/commands/inject.ts @@ -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] -- ` — 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 { + // 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] -- "); + } + + const vault = await unlockVault(options); + const env: Record = {}; + + // 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> { + const vault = await unlockVault(options); + return Object.fromEntries(Object.entries(vault.data.secrets).map(([k, e]) => [k, e.value])); +} diff --git a/apps/cli/src/commands/secrets.ts b/apps/cli/src/commands/secrets.ts new file mode 100644 index 0000000..1b28a4c --- /dev/null +++ b/apps/cli/src/commands/secrets.ts @@ -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 { + 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 { + 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 { + 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 "); + 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 { + 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 { + 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 "); + 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 { + const out: Record = {}; + 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 { + 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."); + } +} diff --git a/apps/cli/src/commands/stack.ts b/apps/cli/src/commands/stack.ts new file mode 100644 index 0000000..31dd05c --- /dev/null +++ b/apps/cli/src/commands/stack.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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."); +} diff --git a/apps/cli/src/commands/vault.ts b/apps/cli/src/commands/vault.ts new file mode 100644 index 0000000..4ab8f53 --- /dev/null +++ b/apps/cli/src/commands/vault.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (clearSession()) success("Vault locked — session revoked."); + else dim("No active session."); +} + +export async function cmdVaultStatus(options: { json?: boolean }): Promise { + 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.`); +} diff --git a/apps/cli/src/out.ts b/apps/cli/src/out.ts index 2830dab..fe77d7e 100644 --- a/apps/cli/src/out.ts +++ b/apps/cli/src/out.ts @@ -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(message: string, work: () => Promise): Promise { + 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; diff --git a/apps/cli/src/vault.ts b/apps/cli/src/vault.ts new file mode 100644 index 0000000..81be9c0 --- /dev/null +++ b/apps/cli/src/vault.ts @@ -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; +} + +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 }); +} diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 240afd4..1926479 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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. diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index e27a472..15bd662 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -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"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cac3cab..1acab76 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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 { + 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 { + 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 { + 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 { + 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, + notes: Option, +) -> 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 { + 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(); diff --git a/apps/desktop/ui/index.html b/apps/desktop/ui/index.html index 6694587..03b5edc 100644 --- a/apps/desktop/ui/index.html +++ b/apps/desktop/ui/index.html @@ -27,6 +27,7 @@

The desktop app renders the console served by your own server. Nothing is hosted by us.

+

Open the local Secrets Vault

+ + diff --git a/apps/desktop/ui/vault.js b/apps/desktop/ui/vault.js new file mode 100644 index 0000000..f0ce22b --- /dev/null +++ b/apps/desktop/ui/vault.js @@ -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(); diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index f199d61..b1d65ae 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -47,6 +47,7 @@ import { DatabasesPage, TelemetryPage, IntegrationsPage, + VaultPage, LoginPage, MembersPage, ProjectsPage, @@ -1531,6 +1532,7 @@ export default function App() { { id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: , keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") }, { id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: , keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") }, { id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: , keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") }, + { id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: , keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: , keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: , keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: , keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, @@ -1772,6 +1774,14 @@ export default function App() { /> + + + + = { telemetry: "Telemetry", integrations: "Integrations", automation: "Automation", + vault: "Vault", auth: "Authentication", audit: "Audit log", settings: "Settings", diff --git a/apps/frontend/src/pages/VaultPage.tsx b/apps/frontend/src/pages/VaultPage.tsx new file mode 100644 index 0000000..a1cd758 --- /dev/null +++ b/apps/frontend/src/pages/VaultPage.tsx @@ -0,0 +1,404 @@ +import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"; +import { + SecretsService, + type ProjectSecret, +} from "@primora/api-client"; +import { Modal } from "../components/Modal"; +import { Input, Select, Textarea } from "../components/Input"; +import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons"; + +interface VaultPageProps { + projectID?: string; + canManage: boolean; + demoMode: boolean; +} + +const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—"); + +const DEMO_SECRETS: ProjectSecret[] = [ + { id: "d1", name: "STRIPE_SECRET", url: "https://dashboard.stripe.com", notes: "prod", created_at: "", updated_at: "2026-09-18T10:00:00Z" }, + { id: "d2", name: "SLACK_WEBHOOK", url: "https://hooks.slack.com", notes: "alerts channel", created_at: "", updated_at: "2026-09-10T08:00:00Z" }, +]; + +// Inside the Tauri shell, __TAURI__ is injected into every webview — the same +// IPC commands the dedicated vault window uses. Absent in a plain browser. +const tauri = () => + (window as unknown as { + __TAURI__?: { core: { invoke: (cmd: string, args?: Record) => Promise } }; + }).__TAURI__; + +interface LocalSecret { + name: string; + url: string | null; + notes: string | null; + updated_at: string; +} +interface LocalStatus { + exists: boolean; + unlocked: boolean; + expires_at: number | null; + secrets: number | null; + path: string; +} + +export function VaultPage(props: VaultPageProps) { + const [secrets, setSecrets] = createSignal([]); + const [message, setMessage] = createSignal(""); + const [error, setError] = createSignal(""); + const [busy, setBusy] = createSignal(); + const [showCreate, setShowCreate] = createSignal(false); + const [showImport, setShowImport] = createSignal(false); + const [form, setForm] = createSignal({ name: "", value: "", url: "", notes: "" }); + const [importText, setImportText] = createSignal(""); + + const [localStatus, setLocalStatus] = createSignal(); + const [localSecrets, setLocalSecrets] = createSignal([]); + const [localErr, setLocalErr] = createSignal(""); + const [localPw, setLocalPw] = createSignal(""); + const [localTtl, setLocalTtl] = createSignal("900"); + const [now, setNow] = createSignal(Math.floor(Date.now() / 1000)); + const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); + onCleanup(() => clearInterval(timer)); + + const refresh = async () => { + if (!props.projectID) return; + if (props.demoMode) { + setSecrets(DEMO_SECRETS); + return; + } + try { + const res = await SecretsService.listProjectSecrets({ projectId: props.projectID }); + setSecrets(res.items); + } catch (e) { + setError(err(e)); + } + }; + + const refreshLocal = async () => { + const t = tauri(); + if (!t) return; + try { + const st = (await t.core.invoke("vault_status")) as LocalStatus; + setLocalStatus(st); + setLocalSecrets(st.unlocked ? ((await t.core.invoke("vault_secrets")) as LocalSecret[]) : []); + } catch (e) { + setLocalErr(err(e)); + } + }; + + onMount(() => { + void refresh(); + void refreshLocal(); + }); + createEffect(() => void refresh()); + + const run = async (key: string, task: () => Promise, done?: string) => { + setBusy(key); + setError(""); + setMessage(""); + try { + await task(); + if (done) setMessage(done); + await refresh(); + } catch (e) { + setError(err(e)); + } finally { + setBusy(undefined); + } + }; + + const runLocal = async (task: () => Promise) => { + setLocalErr(""); + try { + await task(); + } catch (e) { + setLocalErr(err(e)); + } finally { + await refreshLocal(); + } + }; + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setMessage(`${label} copied to clipboard`); + } catch { + setError("Clipboard unavailable — check browser permissions."); + } + }; + + const reveal = (name: string) => + run(`reveal-${name}`, async () => { + if (props.demoMode) { + await copy(`demo-${name.toLowerCase()}`, name); + return; + } + const res = await SecretsService.revealProjectSecret({ projectId: props.projectID!, name }); + await copy(res.value, name); + }); + + const revealLocal = (name: string) => + runLocal(async () => { + const value = (await tauri()!.core.invoke("vault_reveal", { name })) as string; + await copy(value.trimEnd(), name); + }); + + const createSecret = (e: Event) => { + e.preventDefault(); + const f = form(); + void run("create", async () => { + if (props.demoMode) { + setSecrets([...secrets(), { id: `d${Date.now()}`, name: f.name, url: f.url, notes: f.notes, created_at: "", updated_at: new Date().toISOString() }]); + return; + } + await SecretsService.setProjectSecret({ + projectId: props.projectID!, + name: f.name.trim(), + requestBody: { value: f.value, url: f.url.trim() || undefined, notes: f.notes.trim() || undefined }, + }); + setShowCreate(false); + setForm({ name: "", value: "", url: "", notes: "" }); + }, `Secret ${f.name} stored`); + }; + + const removeSecret = (name: string) => + run(`del-${name}`, async () => { + if (props.demoMode) { + setSecrets(secrets().filter((s) => s.name !== name)); + return; + } + await SecretsService.deleteProjectSecret({ projectId: props.projectID!, name }); + }, `Secret ${name} deleted`); + + const importEnv = (e: Event) => { + e.preventDefault(); + const pairs = importText() + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#") && l.includes("=")) + .map((l) => { + const i = l.indexOf("="); + return [l.slice(0, i).trim().replace(/^export\s+/, ""), l.slice(i + 1).trim().replace(/^["']|["']$/g, "")] as const; + }) + .filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k)); + if (!pairs.length) { + setError("No valid KEY=value lines found."); + return; + } + void run("import", async () => { + for (const [name, value] of pairs) { + await SecretsService.setProjectSecret({ projectId: props.projectID!, name, requestBody: { value } }); + } + setShowImport(false); + setImportText(""); + }, `Imported ${pairs.length} secret${pairs.length === 1 ? "" : "s"}`); + }; + + const localRemaining = () => { + const st = localStatus(); + return st?.expires_at ? Math.max(0, st.expires_at - now()) : 0; + }; + + return ( +
+ + +
{message()}
+
{error()}
+ +
+
+
+
Project secrets
+
+ Stored AES-256-GCM encrypted in this deployment. Copy goes to your clipboard and is audit-logged. +
+
+
+ 0} + fallback={

No secrets yet — create one or import a .env file.

} + > +
+ + + + + + + + + + + + + {(s) => ( + + + + + + + + )} + + +
NameURLNotesUpdatedActions
{s.name}{s.url ? {s.url.replace(/^https?:\/\//, "")} : "—"}{s.notes || "—"}{fmtTime(s.updated_at)} +
+ + + + +
+
+
+
+
+ + +
+
+
+
This machine
+
+ Local vault on this device — the same one the CLI manages. These secrets never touch the server. +
+
+ + + unlocked — {Math.floor(localRemaining() / 60)}m {String(localRemaining() % 60).padStart(2, "0")}s + + +
+ +
+
{localErr()}
+ + + No vault on this device — create one with primora vault init or from the tray's + Secrets Vault window. +

+ } + > +
{ + e.preventDefault(); + void runLocal(() => tauri()!.core.invoke("vault_unlock", { password: localPw(), ttl: Number(localTtl()) })); + setLocalPw(""); + }} + > +
+ setLocalPw(e.currentTarget.value)} /> +
+ + +
+
+ } + > + 0} + fallback={

Local vault is empty — manage it from the tray's Secrets Vault window or the CLI.

} + > +
+ + + + + + + {(s) => ( + + + + + + + )} + + +
NameURLNotesActions
{s.name}{s.url ?? "—"}{s.notes ?? "—"} +
+ + +
+
+
+
+
+

+ Injection and the credential broker stay in the CLI: primora inject / primora agent. +

+ +
+ +
+
+
+ + setShowCreate(false)} title="New secret" size="md"> +
+ setForm({ ...form(), name: e.currentTarget.value })} required /> + setForm({ ...form(), value: e.currentTarget.value })} required /> + setForm({ ...form(), url: e.currentTarget.value })} /> + setForm({ ...form(), notes: e.currentTarget.value })} /> +
+ + +
+
+
+ + setShowImport(false)} title="Import .env" size="md"> +
+