Files
metacrypt/engines/webui.md
Kyle Isom a80323e320 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>
2026-03-16 22:02:06 -07:00

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; 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

// 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 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

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 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

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

  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)

  1. Implement SSH CA wrapper types in client.go
  2. Implement SSH CA gRPC client methods in client.go
  3. Add SSH CA methods to vaultBackend interface
  4. Create sshca.html, sshca_cert_detail.html, sshca_profile_detail.html
  5. Implement internal/webserver/sshca.go handlers
  6. Register SSH CA routes

Phase 3: Transit

  1. Implement Transit wrapper types and gRPC client methods
  2. Add Transit methods to vaultBackend interface
  3. Create transit.html, transit_key_detail.html
  4. Implement internal/webserver/transit.go handlers
  5. Register Transit routes

Phase 4: User

  1. Implement User wrapper types and gRPC client methods
  2. Add User methods to vaultBackend interface
  3. Create user.html, user_key_detail.html
  4. Implement internal/webserver/user.go handlers
  5. Register User routes

Phase 5: Polish

  1. Test all engine UIs end-to-end against a running instance
  2. Update ARCHITECTURE.md web routes table
  3. 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.