Skip to content

feat(ui): SOPS secret encrypt/decrypt with local age keys#5134

Open
devantler wants to merge 2 commits into
claude/ui-apply-manifestsfrom
claude/ui-secrets-cipher
Open

feat(ui): SOPS secret encrypt/decrypt with local age keys#5134
devantler wants to merge 2 commits into
claude/ui-apply-manifestsfrom
claude/ui-secrets-cipher

Conversation

@devantler
Copy link
Copy Markdown
Contributor

A2 slice 4: a local-only Secrets view that SOPS-encrypts a plaintext document for an age recipient and decrypts a SOPS document with the local age keys. Stacked on #5133.

Backend

  • Optional api.CipherService interface (EncryptSecret/DecryptSecret/CipherRecipients) + a secretsCipher capability (derived from the interface). Cluster-independent routes GET /api/v1/secrets/recipients, POST …/encrypt, POST …/decrypt — registered only when the backend implements it (local only; the operator has no local keys).
  • Local impl reuses pkg/client/sops in-process: encrypt builds KeyGroups from an explicit recipient (MasterKeyFromRecipient, not .sops.yaml — verified the empty-KeyGroups command path won't populate keys) and stages plaintext in a 0600 temp file (SOPS reads a path); decrypt loads keys via keyservice.NewLocalClient(); recipients are derived from the age key file (GetAgeKeyPath + DerivePublicKey). Empty recipient → first local key.
  • Extracted Server.registerCapabilityRoutes + a shared decodeJSON (kept Handler under funlen).

Frontend

  • SecretsView: side-by-side Encrypt (recipient selector + per-pane format + plaintext) and Decrypt panels with copyable output; a Secrets nav item gated on secretsCipher.

Quality

  • Built, then adversarially reviewed (Go correctness/security · React · simplify): 0 confirmed, low findings addressed (per-pane format selector; documented the read-only/decrypt posture).
  • Tests: real age-key encrypt→decrypt roundtrip, default/empty/invalid-recipient, recipient listing, endpoints, read-only 403. go build/test + golangci-lint clean (G304 nolint is the trusted SOPS key path); web UI tsc+vite build pass.
  • Live: roundtrip via ksail ui with a real age key returns the exact plaintext; /api/v1/config reports secretsCipher:true.

🤖 Generated with Claude Code

devantler and others added 2 commits June 7, 2026 21:45
A2 slice 4: a local-only Secrets view that SOPS-encrypts a plaintext document for
an age recipient and decrypts a SOPS document with the local age keys.

Backend:
- Optional api.CipherService interface (EncryptSecret/DecryptSecret/
  CipherRecipients) + a secretsCipher capability (derived from the interface).
  Cluster-independent routes GET /api/v1/secrets/recipients, POST .../encrypt,
  POST .../decrypt — registered only when the backend implements it (local only;
  the operator has no local keys).
- Local impl reuses pkg/client/sops in-process: encrypt builds KeyGroups from the
  recipient (sopsage.MasterKeyFromRecipient) — explicit, not .sops.yaml — and
  stages plaintext in a 0600 temp file (SOPS reads a path); decrypt loads the
  local keys via keyservice.NewLocalClient(); recipients are derived from the
  age key file (sops.GetAgeKeyPath + DerivePublicKey). An empty recipient
  auto-uses the first local age key.
- Extracted Server.registerCapabilityRoutes + a shared decodeJSON helper.

Frontend:
- SecretsView: side-by-side Encrypt (recipient selector + format + plaintext) and
  Decrypt panels with copyable output; a Secrets nav item gated on secretsCipher.

Tests: capability, endpoints, read-only 403 (api); a real age-key encrypt→decrypt
roundtrip, default-recipient, recipient listing, empty/invalid-recipient
rejection (clusterapi). go build/test + golangci-lint clean; web UI tsc+vite
build pass. Live: roundtrip via `ksail ui` returns the exact plaintext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the adversarial review (0 confirmed, low findings):

- Per-pane format selector: a single shared Format control governed both encrypt
  and decrypt, so decrypting a document whose format differed from the last
  encrypt failed confusingly. Each pane now has its own format selector (extracted
  a FormatSelect helper).
- Document the read-only/decrypt decision: encrypt/decrypt are POST, so the
  read-only guard blocks them in read-only mode — intentional (a read-only
  deployment locks down secret crypto, incl. decrypt's plaintext exposure); the
  local backend runs writable. Added a comment at the route registration.

(Skipped: the empty-recipient first-key default is mitigated by the recipient
selector + documented; the handleScaleResource decodeJSON dedup is pre-existing
code on another branch.)

web UI tsc+vite build + go build pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🫴 Ready

Development

Successfully merging this pull request may close these issues.

1 participant