Add web UI for SSH CA, Transit, and User engines; full security audit and remediation

Web UI: Added browser-based management for all three remaining engines
(SSH CA, Transit, User E2E). Includes gRPC client wiring, handler files,
7 HTML templates, dashboard mount forms, and conditional navigation links.
Fixed REST API routes to match design specs (SSH CA cert singular paths,
Transit PATCH for update-key-config).

Security audit: Conducted full-system audit covering crypto core, all
engine implementations, API servers, policy engine, auth, deployment,
and documentation. Identified 42 new findings (#39-#80) across all
severity levels.

Remediation of all 8 High findings:
- #68: Replaced 14 JSON-injection-vulnerable error responses with safe
  json.Encoder via writeJSONError helper
- #48: Added two-layer path traversal defense (barrier validatePath
  rejects ".." segments; engine ValidateName enforces safe name pattern)
- #39: Extended RLock through entire crypto operations in barrier
  Get/Put/Delete/List to eliminate TOCTOU race with Seal
- #40: Unified ReWrapKeys and seal_config UPDATE into single SQLite
  transaction to prevent irrecoverable data loss on crash during MEK
  rotation
- #49: Added resolveTTL to CA engine enforcing issuer MaxTTL ceiling
  on handleIssue and handleSignCSR
- #61: Store raw ECDH private key bytes in userState for effective
  zeroization on Seal
- #62: Fixed user engine policy resource path from mountPath to
  mountName() so policy rules match correctly
- #69: Added newPolicyChecker helper and passed service-level policy
  evaluation to all 25 typed REST handler engine.Request structs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 22:02:06 -07:00
parent 128f5abc4d
commit a80323e320
29 changed files with 5061 additions and 647 deletions

844
engines/webui.md Normal file
View File

@@ -0,0 +1,844 @@
# Web UI Implementation Plan: SSH CA, Transit, and User Engines
## Overview
Three engines (SSH CA, Transit, User) are fully implemented at the core,
gRPC, and REST layers but have no web UI. This plan adds browser-based
management for each, following the patterns established by the PKI engine UI.
## Architecture
The web UI is served by `metacrypt-web`, a separate binary that talks to the
API server over gRPC. All data access flows through the gRPC client — the web
server has no direct database or barrier access. Authorization is enforced by
the API server; the web UI only controls visibility (e.g. hiding admin-only
forms from non-admin users).
### Existing Patterns (from PKI reference)
| Concern | Pattern |
|---------|---------|
| Template composition | `layout.html` defines `"layout"` block; page templates define `"title"` and `"content"` blocks |
| Template rendering | `renderTemplate(w, "page.html", data)` — parses `layout.html` + page template, injects CSRF func |
| gRPC calls | `ws.vault.Method(ctx, token)` via `vaultBackend` interface; token from cookie |
| CSRF | Signed double-submit cookie; `{{csrfField}}` in every form |
| Error display | `{{if .Error}}<div class="error">{{.Error}}</div>{{end}}` at top of content |
| Success display | `<div class="success">...</div>` inline after action |
| Mount discovery | `findCAMount()` pattern — iterate `ListMounts()`, match on `.Type` |
| Tables | `.table-wrapper` > `<table>` with `<thead>`/`<tbody>` |
| Detail views | `.card` with `.card-title` + `.kv-table` for metadata |
| Admin actions | `{{if .IsAdmin}}` guards around admin-only cards |
| Forms | `.form-row` > `.form-group` > `<label>` + `<input>` |
| Navigation | Breadcrumb in `.page-meta`: `← Dashboard` |
---
## Shared Changes
### 1. Navigation (layout.html)
Add engine links to the top nav, conditionally displayed based on mounted
engines. The template data already includes `Username` and `IsAdmin`; extend
it with engine availability flags.
```html
<!-- After PKI link -->
{{if .HasSSHCA}}<a href="/sshca" class="btn btn-ghost btn-sm">SSH CA</a>{{end}}
{{if .HasTransit}}<a href="/transit" class="btn btn-ghost btn-sm">Transit</a>{{end}}
{{if .HasUser}}<a href="/user" class="btn btn-ghost btn-sm">User Crypto</a>{{end}}
```
To populate these flags without an extra gRPC call per page, cache mount
types in the web server after the first `ListMounts()` call per request (the
`requireAuth` middleware already validates the token — extend it to also
populate a `mountTypes` set on the request context).
### 2. Dashboard (dashboard.html)
Extend the mount table to link all engine types, not just CA:
```html
{{if eq (printf "%s" .Type) "ca"}}
<a href="/pki">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "sshca"}}
<a href="/sshca">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "transit"}}
<a href="/transit">{{.Name}}</a>
{{else if eq (printf "%s" .Type) "user"}}
<a href="/user">{{.Name}}</a>
{{else}}
{{.Name}}
{{end}}
```
Add mount forms for the three new engine types (admin only), following the
existing `<details><summary>Mount a CA engine</summary>` pattern.
### 3. gRPC Client (client.go)
Add gRPC service clients to `VaultClient`:
```go
type VaultClient struct {
// ... existing fields ...
sshca pb.SSHCAServiceClient
transit pb.TransitServiceClient
user pb.UserServiceClient
}
```
Add wrapper request/response types for each engine (see per-engine sections
below). Follow the existing pattern: thin structs that translate between
protobuf and template-friendly Go types.
### 4. vaultBackend Interface (server.go)
Add methods for each engine to the `vaultBackend` interface. Group by engine.
### 5. Mount Helpers
Add `findSSHCAMount()`, `findTransitMount()`, `findUserMount()` following
the `findCAMount()` pattern.
---
## SSH CA Engine Web UI
### Route: `/sshca`
File: `internal/webserver/routes.go` — register under `r.Route("/sshca", ...)`
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/sshca` | `handleSSHCA` | User |
| POST | `/sshca/sign-user` | `handleSSHCASignUser` | User |
| POST | `/sshca/sign-host` | `handleSSHCASignHost` | User |
| GET | `/sshca/cert/{serial}` | `handleSSHCACertDetail` | User |
| POST | `/sshca/cert/{serial}/revoke` | `handleSSHCACertRevoke` | Admin |
| POST | `/sshca/cert/{serial}/delete` | `handleSSHCACertDelete` | Admin |
| POST | `/sshca/profile/create` | `handleSSHCACreateProfile` | Admin |
| GET | `/sshca/profile/{name}` | `handleSSHCAProfileDetail` | User |
| POST | `/sshca/profile/{name}/update` | `handleSSHCAUpdateProfile` | Admin |
| POST | `/sshca/profile/{name}/delete` | `handleSSHCADeleteProfile` | Admin |
### Template: `sshca.html`
Main page for the SSH CA engine. Structure:
```
┌─────────────────────────────────────────┐
│ SSH CA: {mount} ← Dashboard│
├─────────────────────────────────────────┤
│ CA Public Key │
│ ┌─────────────────────────────────────┐ │
│ │ Algorithm: ed25519 │ │
│ │ Fingerprint: SHA256:xxxx │ │
│ │ [Download Public Key] │ │
│ │ ssh-ed25519 AAAA... (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign User Certificate │
│ ┌─────────────────────────────────────┐ │
│ │ Profile: [select] │ │
│ │ Public Key: [textarea, ssh format] │ │
│ │ Principals: [textarea, one/line] │ │
│ │ TTL: [input, optional] │ │
│ │ [Sign Certificate] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign Host Certificate │
│ ┌─────────────────────────────────────┐ │
│ │ Profile: [select] │ │
│ │ Public Key: [textarea, ssh format] │ │
│ │ Hostnames: [textarea, one/line] │ │
│ │ TTL: [input, optional] │ │
│ │ [Sign Certificate] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Signing Profiles │
│ ┌─────────────────────────────────────┐ │
│ │ Name │ Type │ Max TTL │ Actions │ │
│ │ ─────┼──────┼─────────┼──────────── │ │
│ │ eng │ user │ 8760h │ [View] │ │
│ │ ops │ host │ 720h │ [View] │ │
│ └─────────────────────────────────────┘ │
│ [Create Profile] (admin, <details>) │
├─────────────────────────────────────────┤
│ Recent Certificates │
│ ┌─────────────────────────────────────┐ │
│ │ Serial │ Type │ Principals │ Issued │ │
│ │ │ │ │ By │ │
│ │ ───────┼──────┼────────────┼─────── │ │
│ │ abc123 │ user │ kyle │ kyle │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ KRL (admin only) │
│ ┌─────────────────────────────────────┐ │
│ │ Version: 7 │ │
│ │ [Download KRL] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### Template: `sshca_cert_detail.html`
Detail view for a single SSH certificate:
```
┌─────────────────────────────────────────┐
│ SSH Certificate: {serial} ← SSH CA │
├─────────────────────────────────────────┤
│ Details │
│ ┌─────────────────────────────────────┐ │
│ │ Type: user │ │
│ │ Serial: abc123 │ │
│ │ Key ID: kyle@host │ │
│ │ Principals: kyle, root │ │
│ │ Profile: eng │ │
│ │ Valid After: 2026-03-16T... │ │
│ │ Valid Before: 2026-03-17T... │ │
│ │ Issued By: kyle │ │
│ │ Issued At: 2026-03-16T... │ │
│ │ Revoked: No │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Certificate (readonly textarea) │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Revoke] [Delete] │
└─────────────────────────────────────────┘
```
### Template: `sshca_profile_detail.html`
Detail view for a signing profile:
```
┌─────────────────────────────────────────┐
│ Profile: {name} ← SSH CA │
├─────────────────────────────────────────┤
│ Configuration │
│ ┌─────────────────────────────────────┐ │
│ │ Name: eng │ │
│ │ Type: user │ │
│ │ Max TTL: 8760h │ │
│ │ Default TTL: 24h │ │
│ │ Allowed Principals: * │ │
│ │ Force Command: (none) │ │
│ │ Source Addresses: (none) │ │
│ │ Allow Agent Fwd: yes │ │
│ │ Allow Port Fwd: yes │ │
│ │ Allow PTY: yes │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Edit Profile (admin only, <details>) │
│ ┌─────────────────────────────────────┐ │
│ │ (form with pre-populated fields) │ │
│ │ [Update Profile] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Delete Profile] │
└─────────────────────────────────────────┘
```
### Handler File: `internal/webserver/sshca.go`
Handlers for all SSH CA web routes. Follow `pki.go` patterns:
- `handleSSHCA` — main page: fetch CA pubkey, profiles, recent certs; render
- `handleSSHCASignUser` / `handleSSHCASignHost` — parse form, call gRPC,
render success inline (show signed cert in readonly textarea) or re-render
with error
- `handleSSHCACertDetail` — fetch cert by serial, render detail page
- `handleSSHCACertRevoke` / `handleSSHCACertDelete` — admin action, redirect
back to main page
- `handleSSHCACreateProfile` — admin form, redirect to profile detail
- `handleSSHCAProfileDetail` — fetch profile, render detail page
- `handleSSHCAUpdateProfile` — admin form, redirect to profile detail
- `handleSSHCADeleteProfile` — admin action, redirect to main page
### gRPC Client Methods
```go
// client.go additions
GetSSHCAPublicKey(ctx, token, mount string) (*SSHCAPublicKey, error)
SSHCASignUser(ctx, token, mount string, req *SSHCASignRequest) (*SSHCACert, error)
SSHCASignHost(ctx, token, mount string, req *SSHCASignRequest) (*SSHCACert, error)
ListSSHCACerts(ctx, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx, token, mount, serial string) error
DeleteSSHCACert(ctx, token, mount, serial string) error
CreateSSHCAProfile(ctx, token, mount string, req *SSHCAProfileRequest) error
GetSSHCAProfile(ctx, token, mount, name string) (*SSHCAProfile, error)
ListSSHCAProfiles(ctx, token, mount string) ([]SSHCAProfileSummary, error)
UpdateSSHCAProfile(ctx, token, mount, name string, req *SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx, token, mount, name string) error
GetSSHCAKRL(ctx, token, mount string) ([]byte, uint64, error)
```
### Wrapper Types
```go
type SSHCAPublicKey struct {
Algorithm string
PublicKey string // authorized_keys format
Fingerprint string
}
type SSHCASignRequest struct {
PublicKey string
Principals []string
TTL string
Profile string
}
type SSHCACert struct {
Serial string
Certificate string // SSH certificate text
}
type SSHCACertSummary struct {
Serial string
Type string // "user" or "host"
Principals string // comma-joined
IssuedBy string
IssuedAt string
ExpiresAt string
Revoked bool
}
type SSHCACertDetail struct {
SSHCACertSummary
KeyID string
Profile string
Certificate string // full cert text
}
type SSHCAProfileSummary struct {
Name string
Type string
MaxTTL string
DefaultTTL string
}
type SSHCAProfile struct {
SSHCAProfileSummary
AllowedPrincipals []string
ForceCommand string
SourceAddresses []string
AllowAgentFwd bool
AllowPortFwd bool
AllowPTY bool
}
type SSHCAProfileRequest struct {
Name string
Type string // "user" or "host"
MaxTTL string
DefaultTTL string
AllowedPrincipals []string
ForceCommand string
SourceAddresses []string
AllowAgentFwd bool
AllowPortFwd bool
AllowPTY bool
}
```
---
## Transit Engine Web UI
### Route: `/transit`
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/transit` | `handleTransit` | User |
| GET | `/transit/key/{name}` | `handleTransitKeyDetail` | User |
| POST | `/transit/key/create` | `handleTransitCreateKey` | Admin |
| POST | `/transit/key/{name}/rotate` | `handleTransitRotateKey` | Admin |
| POST | `/transit/key/{name}/config` | `handleTransitUpdateConfig` | Admin |
| POST | `/transit/key/{name}/trim` | `handleTransitTrimKey` | Admin |
| POST | `/transit/key/{name}/delete` | `handleTransitDeleteKey` | Admin |
| POST | `/transit/encrypt` | `handleTransitEncrypt` | User |
| POST | `/transit/decrypt` | `handleTransitDecrypt` | User |
| POST | `/transit/rewrap` | `handleTransitRewrap` | User |
| POST | `/transit/sign` | `handleTransitSign` | User |
| POST | `/transit/verify` | `handleTransitVerify` | User |
| POST | `/transit/hmac` | `handleTransitHMAC` | User |
### Template: `transit.html`
Main page. Structure:
```
┌─────────────────────────────────────────┐
│ Transit: {mount} ← Dashboard│
├─────────────────────────────────────────┤
│ Named Keys │
│ ┌─────────────────────────────────────┐ │
│ │ Name │ Type │ Versions │ │
│ │ ─────────┼────────────┼─────────── │ │
│ │ payments │ aes256-gcm │ 3 │ │
│ │ signing │ ed25519 │ 1 │ │
│ └─────────────────────────────────────┘ │
│ [Create Key] (admin, <details>) │
│ Name: [input] │
│ Type: [select: aes256-gcm, etc.] │
│ [Create] │
├─────────────────────────────────────────┤
│ Encrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select from named keys] │ │
│ │ Plaintext (base64): [textarea] │ │
│ │ Context (optional): [input] │ │
│ │ [Encrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ metacrypt:v1:xxxxx (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Decrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select] │ │
│ │ Ciphertext: [textarea] │ │
│ │ Context (optional): [input] │ │
│ │ [Decrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ base64 plaintext (readonly area) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Rewrap │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select] │ │
│ │ Ciphertext: [textarea] │ │
│ │ Context (optional): [input] │ │
│ │ [Rewrap] │ │
│ │ │ │
│ │ Result: (new ciphertext) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Sign │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, signing keys only] │ │
│ │ Input (base64): [textarea] │ │
│ │ [Sign] │ │
│ │ │ │
│ │ Result: metacrypt:v1:sig (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Verify │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, signing keys only] │ │
│ │ Input (base64): [textarea] │ │
│ │ Signature: [textarea] │ │
│ │ [Verify] │ │
│ │ │ │
│ │ Result: Valid ✓ / Invalid ✗ │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ HMAC │
│ ┌─────────────────────────────────────┐ │
│ │ Key: [select, HMAC keys only] │ │
│ │ Input (base64): [textarea] │ │
│ │ [Generate HMAC] │ │
│ │ │ │
│ │ Result: metacrypt:v1:hmac (ro) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
Note: Batch operations are API-only. The web UI does not expose batch
encrypt/decrypt/rewrap — those are designed for programmatic use.
### Template: `transit_key_detail.html`
```
┌─────────────────────────────────────────┐
│ Key: {name} ← Transit │
├─────────────────────────────────────────┤
│ Key Details │
│ ┌─────────────────────────────────────┐ │
│ │ Name: payments │ │
│ │ Type: aes256-gcm │ │
│ │ Latest Version: 3 │ │
│ │ Min Decrypt Version: 1 │ │
│ │ Min Encrypt Version: 3 │ │
│ │ Allow Deletion: no │ │
│ │ Exportable: no │ │
│ │ Max Key Versions: 0 (unlimited) │ │
│ │ Created: 2026-03-16... │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Key Versions │
│ ┌─────────────────────────────────────┐ │
│ │ Version │ Created │ │
│ │ ────────┼────────────────────────── │ │
│ │ 3 │ 2026-03-16T10:00:00Z │ │
│ │ 2 │ 2026-03-10T08:00:00Z │ │
│ │ 1 │ 2026-03-01T12:00:00Z │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Public Key (signing keys only) │
│ [textarea, PEM, readonly] │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Rotate Key] │
│ │
│ Update Config (<details>) │
│ Min Decrypt Version: [input] │
│ Allow Deletion: [checkbox] │
│ [Update] │
│ │
│ Trim Versions (<details>) │
│ Keep from version: [input] │
│ [Trim] │
│ │
│ [Delete Key] (danger, requires confirm) │
└─────────────────────────────────────────┘
```
### Handler File: `internal/webserver/transit.go`
- `handleTransit` — list keys, render main page
- `handleTransitKeyDetail` — fetch key metadata + versions, render detail
- `handleTransitCreateKey` — parse form, call gRPC, redirect to key detail
- `handleTransitRotateKey` — admin, redirect to key detail
- `handleTransitUpdateConfig` — admin, redirect to key detail
- `handleTransitTrimKey` — admin, redirect to key detail
- `handleTransitDeleteKey` — admin, redirect to main page
- `handleTransitEncrypt` — parse form, call gRPC, re-render main page with
result in `EncryptResult` field
- `handleTransitDecrypt` — same pattern, `DecryptResult` field
- `handleTransitRewrap` — same pattern, `RewrapResult` field
- `handleTransitSign` — same pattern, `SignResult` field
- `handleTransitVerify` — same pattern, `VerifyResult` field (boolean)
- `handleTransitHMAC` — same pattern, `HMACResult` field
### gRPC Client Methods
```go
ListTransitKeys(ctx, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx, token, mount string, req *CreateTransitKeyRequest) error
DeleteTransitKey(ctx, token, mount, name string) error
RotateTransitKey(ctx, token, mount, name string) error
UpdateTransitKeyConfig(ctx, token, mount, name string, req *UpdateTransitKeyConfigRequest) error
TrimTransitKey(ctx, token, mount, name string, minVersion int) error
TransitEncrypt(ctx, token, mount, key string, req *TransitEncryptRequest) (string, error)
TransitDecrypt(ctx, token, mount, key string, req *TransitDecryptRequest) (string, error)
TransitRewrap(ctx, token, mount, key string, req *TransitRewrapRequest) (string, error)
TransitSign(ctx, token, mount, key string, input string) (string, error)
TransitVerify(ctx, token, mount, key string, input, signature string) (bool, error)
TransitHMAC(ctx, token, mount, key string, input string) (string, error)
GetTransitPublicKey(ctx, token, mount, name string) (string, error)
```
### Wrapper Types
```go
type TransitKeySummary struct {
Name string
Type string
LatestVersion int
}
type TransitKeyVersion struct {
Version int
CreatedAt string
}
type TransitKeyDetail struct {
Name string
Type string
LatestVersion int
MinDecryptVersion int
MinEncryptVersion int
AllowDeletion bool
Exportable bool
MaxKeyVersions int
CreatedAt string
Versions []TransitKeyVersion
PublicKeyPEM string // empty for symmetric keys
}
type CreateTransitKeyRequest struct {
Name string
Type string // aes256-gcm, chacha20-poly, ed25519, etc.
}
type UpdateTransitKeyConfigRequest struct {
MinDecryptVersion int
AllowDeletion *bool // nil = no change
}
type TransitEncryptRequest struct {
Plaintext string // base64
Context string // optional AAD
}
type TransitDecryptRequest struct {
Ciphertext string // metacrypt:v1:...
Context string
}
type TransitRewrapRequest struct {
Ciphertext string
Context string
}
```
---
## User Engine Web UI
### Route: `/user`
| Method | Path | Handler | Auth |
|--------|------|---------|------|
| GET | `/user` | `handleUser` | User |
| POST | `/user/register` | `handleUserRegister` | User |
| POST | `/user/provision` | `handleUserProvision` | Admin |
| GET | `/user/key/{username}` | `handleUserKeyDetail` | User |
| POST | `/user/encrypt` | `handleUserEncrypt` | User |
| POST | `/user/decrypt` | `handleUserDecrypt` | User |
| POST | `/user/re-encrypt` | `handleUserReEncrypt` | User |
| POST | `/user/rotate` | `handleUserRotateKey` | User |
| POST | `/user/delete/{username}` | `handleUserDeleteUser` | Admin |
### Template: `user.html`
Main page. Structure:
```
┌─────────────────────────────────────────┐
│ User Crypto: {mount} ← Dashboard│
├─────────────────────────────────────────┤
│ Your Key │
│ ┌─────────────────────────────────────┐ │
│ │ (if registered) │ │
│ │ Algorithm: x25519 │ │
│ │ Public Key: base64... │ │
│ │ │ │
│ │ (if not registered) │ │
│ │ You have no keypair. │ │
│ │ [Register] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Encrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Recipients: [textarea, one/line] │ │
│ │ Plaintext (base64): [textarea] │ │
│ │ Metadata (optional): [input] │ │
│ │ [Encrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ JSON envelope (readonly textarea) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Decrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Envelope (JSON): [textarea] │ │
│ │ [Decrypt] │ │
│ │ │ │
│ │ Result (if present): │ │
│ │ base64 plaintext (readonly) │ │
│ │ Metadata: ... (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Re-Encrypt │
│ ┌─────────────────────────────────────┐ │
│ │ Envelope (JSON): [textarea] │ │
│ │ [Re-Encrypt] │ │
│ │ │ │
│ │ Result: updated envelope (readonly) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Key Rotation (danger zone) │
│ ┌─────────────────────────────────────┐ │
│ │ ⚠ Rotating your key will make all │ │
│ │ existing envelopes unreadable │ │
│ │ unless re-encrypted first. │ │
│ │ [Rotate Key] (confirm dialog) │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Registered Users │
│ ┌─────────────────────────────────────┐ │
│ │ Username │ Algorithm │ Actions │ │
│ │ ───────────┼───────────┼─────────── │ │
│ │ kyle │ x25519 │ [View Key] │ │
│ │ alice │ x25519 │ [View Key] │ │
│ └─────────────────────────────────────┘ │
│ [Provision User] (admin, <details>) │
│ Username: [input] │
│ [Provision] │
├─────────────────────────────────────────┤
│ Admin: Delete User (admin only) │
│ ┌─────────────────────────────────────┐ │
│ │ Username: [input] │ │
│ │ [Delete User] (danger, confirm) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### Template: `user_key_detail.html`
```
┌─────────────────────────────────────────┐
│ User Key: {username} ← User │
├─────────────────────────────────────────┤
│ Public Key │
│ ┌─────────────────────────────────────┐ │
│ │ Username: kyle │ │
│ │ Algorithm: x25519 │ │
│ │ Public Key: base64... │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Admin Actions (admin only) │
│ [Delete User] (danger, confirm) │
└─────────────────────────────────────────┘
```
### Handler File: `internal/webserver/user.go`
- `handleUser` — fetch own key (if registered), list users, render main page
- `handleUserRegister` — call gRPC, redirect to main page with success
- `handleUserProvision` — admin, call gRPC, redirect to main page
- `handleUserKeyDetail` — fetch public key for username, render detail
- `handleUserEncrypt` — parse form, call gRPC, re-render with envelope result
- `handleUserDecrypt` — parse form, call gRPC, re-render with plaintext result
- `handleUserReEncrypt` — parse form, call gRPC, re-render with new envelope
- `handleUserRotateKey` — confirm + call gRPC, redirect to main page
- `handleUserDeleteUser` — admin, call gRPC, redirect to main page
### gRPC Client Methods
```go
UserRegister(ctx, token, mount string) error
UserProvision(ctx, token, mount, username string) error
GetUserPublicKey(ctx, token, mount, username string) (*UserPublicKey, error)
ListUsers(ctx, token, mount string) ([]UserSummary, error)
UserEncrypt(ctx, token, mount string, req *UserEncryptRequest) (string, error)
UserDecrypt(ctx, token, mount string, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx, token, mount string, envelope string) (string, error)
UserRotateKey(ctx, token, mount string) error
DeleteUser(ctx, token, mount, username string) error
```
### Wrapper Types
```go
type UserPublicKey struct {
Username string
Algorithm string
PublicKey string // base64
}
type UserSummary struct {
Username string
Algorithm string
}
type UserEncryptRequest struct {
Recipients []string
Plaintext string // base64
Metadata string // optional
}
type UserDecryptResult struct {
Plaintext string // base64
Metadata string
Sender string
}
```
---
## Implementation Order
Implement in this order to build incrementally on shared infrastructure:
### Phase 1: Shared Infrastructure
1. Add gRPC service clients to `VaultClient` struct
2. Add `findSSHCAMount()`, `findTransitMount()`, `findUserMount()` helpers
3. Update `layout.html` navigation with conditional engine links
4. Update `dashboard.html` mount table links and mount forms
5. Extend `requireAuth` middleware to populate mount availability flags
### Phase 2: SSH CA (closest to existing PKI patterns)
6. Implement SSH CA wrapper types in `client.go`
7. Implement SSH CA gRPC client methods in `client.go`
8. Add SSH CA methods to `vaultBackend` interface
9. Create `sshca.html`, `sshca_cert_detail.html`, `sshca_profile_detail.html`
10. Implement `internal/webserver/sshca.go` handlers
11. Register SSH CA routes
### Phase 3: Transit
12. Implement Transit wrapper types and gRPC client methods
13. Add Transit methods to `vaultBackend` interface
14. Create `transit.html`, `transit_key_detail.html`
15. Implement `internal/webserver/transit.go` handlers
16. Register Transit routes
### Phase 4: User
17. Implement User wrapper types and gRPC client methods
18. Add User methods to `vaultBackend` interface
19. Create `user.html`, `user_key_detail.html`
20. Implement `internal/webserver/user.go` handlers
21. Register User routes
### Phase 5: Polish
22. Test all engine UIs end-to-end against a running instance
23. Update `ARCHITECTURE.md` web routes table
24. Update `CLAUDE.md` project structure (if template list changed)
---
## Design Decisions
### No batch operations in UI
Batch encrypt/decrypt/rewrap (transit) are for programmatic use. The web UI
exposes single-operation forms only. Users needing batch operations should use
the REST or gRPC API directly.
### Operation results inline, not separate pages
Encrypt/decrypt/sign/verify results are shown inline on the same page (in a
readonly textarea or success div), not on a separate result page. This
follows the PKI "Sign CSR" pattern where the signed cert appears in the same
card after submission. It avoids navigation complexity and keeps the workflow
tight.
### No JavaScript beyond htmx
All forms use standard POST submission. HTMX is used only where it already
is in the codebase (seal button). No client-side validation, key filtering,
or dynamic form behavior.
### Key selection via `<select>`
Transit and User operations that reference a named key use a `<select>`
dropdown populated server-side. The handler fetches the key list on every
page render. For mounts with many keys, this is acceptable — transit engines
typically have tens of keys, not thousands.
### Profile-gated signing
The SSH CA sign forms require selecting a profile. The profile list is
fetched from the backend — the user can only sign with profiles they have
policy access to. If the gRPC call returns a permission error, the web UI
shows it as a form error, not a 403 page.
### Danger zone pattern
Destructive operations (delete key, delete user, rotate user key) use the
existing pattern: red `.btn-danger` button with `onclick="return
confirm('...')"`. No additional confirmation pages.