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>
40 KiB
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.
<!-- 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:
{{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:
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; renderhandleSSHCASignUser/handleSSHCASignHost— parse form, call gRPC, render success inline (show signed cert in readonly textarea) or re-render with errorhandleSSHCACertDetail— fetch cert by serial, render detail pagehandleSSHCACertRevoke/handleSSHCACertDelete— admin action, redirect back to main pagehandleSSHCACreateProfile— admin form, redirect to profile detailhandleSSHCAProfileDetail— fetch profile, render detail pagehandleSSHCAUpdateProfile— admin form, redirect to profile detailhandleSSHCADeleteProfile— admin action, redirect to main page
gRPC Client Methods
// 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
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 pagehandleTransitKeyDetail— fetch key metadata + versions, render detailhandleTransitCreateKey— parse form, call gRPC, redirect to key detailhandleTransitRotateKey— admin, redirect to key detailhandleTransitUpdateConfig— admin, redirect to key detailhandleTransitTrimKey— admin, redirect to key detailhandleTransitDeleteKey— admin, redirect to main pagehandleTransitEncrypt— parse form, call gRPC, re-render main page with result inEncryptResultfieldhandleTransitDecrypt— same pattern,DecryptResultfieldhandleTransitRewrap— same pattern,RewrapResultfieldhandleTransitSign— same pattern,SignResultfieldhandleTransitVerify— same pattern,VerifyResultfield (boolean)handleTransitHMAC— same pattern,HMACResultfield
gRPC Client Methods
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
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 pagehandleUserRegister— call gRPC, redirect to main page with successhandleUserProvision— admin, call gRPC, redirect to main pagehandleUserKeyDetail— fetch public key for username, render detailhandleUserEncrypt— parse form, call gRPC, re-render with envelope resulthandleUserDecrypt— parse form, call gRPC, re-render with plaintext resulthandleUserReEncrypt— parse form, call gRPC, re-render with new envelopehandleUserRotateKey— confirm + call gRPC, redirect to main pagehandleUserDeleteUser— admin, call gRPC, redirect to main page
gRPC Client Methods
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
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
- Add gRPC service clients to
VaultClientstruct - Add
findSSHCAMount(),findTransitMount(),findUserMount()helpers - Update
layout.htmlnavigation with conditional engine links - Update
dashboard.htmlmount table links and mount forms - Extend
requireAuthmiddleware to populate mount availability flags
Phase 2: SSH CA (closest to existing PKI patterns)
- Implement SSH CA wrapper types in
client.go - Implement SSH CA gRPC client methods in
client.go - Add SSH CA methods to
vaultBackendinterface - Create
sshca.html,sshca_cert_detail.html,sshca_profile_detail.html - Implement
internal/webserver/sshca.gohandlers - Register SSH CA routes
Phase 3: Transit
- Implement Transit wrapper types and gRPC client methods
- Add Transit methods to
vaultBackendinterface - Create
transit.html,transit_key_detail.html - Implement
internal/webserver/transit.gohandlers - Register Transit routes
Phase 4: User
- Implement User wrapper types and gRPC client methods
- Add User methods to
vaultBackendinterface - Create
user.html,user_key_detail.html - Implement
internal/webserver/user.gohandlers - Register User routes
Phase 5: Polish
- Test all engine UIs end-to-end against a running instance
- Update
ARCHITECTURE.mdweb routes table - Update
CLAUDE.mdproject 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.