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:
844
engines/webui.md
Normal file
844
engines/webui.md
Normal 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.
|
||||
Reference in New Issue
Block a user