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

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

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

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

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

View File

@@ -16,8 +16,19 @@ var (
ErrSealed = errors.New("barrier: sealed")
ErrNotFound = errors.New("barrier: entry not found")
ErrKeyNotFound = errors.New("barrier: key not found")
ErrInvalidPath = errors.New("barrier: invalid path")
)
// validatePath rejects paths containing ".." segments to prevent path traversal.
func validatePath(p string) error {
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return fmt.Errorf("%w: %q", ErrInvalidPath, p)
}
}
return nil
}
// Barrier is the encrypted storage barrier interface.
type Barrier interface {
// Unseal opens the barrier with the given master encryption key.
@@ -137,11 +148,12 @@ func resolveKeyID(path string) string {
}
func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
if err := validatePath(path); err != nil {
return nil, err
}
b.mu.RLock()
mek := b.mek
keys := b.keys
b.mu.RUnlock()
if mek == nil {
defer b.mu.RUnlock()
if b.mek == nil {
return nil, ErrSealed
}
@@ -161,7 +173,7 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("barrier: extract key ID %q: %w", path, err)
}
dek, ok := keys[keyID]
dek, ok := b.keys[keyID]
if !ok {
return nil, fmt.Errorf("barrier: %w: %q for path %q", ErrKeyNotFound, keyID, path)
}
@@ -173,7 +185,7 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
}
// v1 ciphertext — use MEK directly (backward compat).
pt, err := crypto.Decrypt(mek, encrypted, []byte(path))
pt, err := crypto.Decrypt(b.mek, encrypted, []byte(path))
if err != nil {
return nil, fmt.Errorf("barrier: decrypt %q: %w", path, err)
}
@@ -181,11 +193,12 @@ func (b *AESGCMBarrier) Get(ctx context.Context, path string) ([]byte, error) {
}
func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) error {
if err := validatePath(path); err != nil {
return err
}
b.mu.RLock()
mek := b.mek
keys := b.keys
b.mu.RUnlock()
if mek == nil {
defer b.mu.RUnlock()
if b.mek == nil {
return ErrSealed
}
@@ -194,12 +207,12 @@ func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) erro
var encrypted []byte
var err error
if dek, ok := keys[keyID]; ok {
if dek, ok := b.keys[keyID]; ok {
// Use v2 format with the appropriate DEK.
encrypted, err = crypto.EncryptV2(dek, keyID, value, []byte(path))
} else {
// No DEK registered for this key ID — fall back to MEK with v1 format.
encrypted, err = crypto.Encrypt(mek, value, []byte(path))
encrypted, err = crypto.Encrypt(b.mek, value, []byte(path))
}
if err != nil {
return fmt.Errorf("barrier: encrypt %q: %w", path, err)
@@ -216,10 +229,12 @@ func (b *AESGCMBarrier) Put(ctx context.Context, path string, value []byte) erro
}
func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error {
if err := validatePath(path); err != nil {
return err
}
b.mu.RLock()
mek := b.mek
b.mu.RUnlock()
if mek == nil {
defer b.mu.RUnlock()
if b.mek == nil {
return ErrSealed
}
@@ -232,10 +247,12 @@ func (b *AESGCMBarrier) Delete(ctx context.Context, path string) error {
}
func (b *AESGCMBarrier) List(ctx context.Context, prefix string) ([]string, error) {
if err := validatePath(prefix); err != nil {
return nil, err
}
b.mu.RLock()
mek := b.mek
b.mu.RUnlock()
if mek == nil {
defer b.mu.RUnlock()
if b.mek == nil {
return nil, ErrSealed
}
@@ -605,7 +622,8 @@ func (b *AESGCMBarrier) createKeyLockedTx(ctx context.Context, tx *sql.Tx, keyID
}
// ReWrapKeys re-encrypts all DEKs with a new MEK. Called during MEK rotation.
// The new MEK is already set in b.mek by the caller.
// This method manages its own transaction. For atomic MEK rotation where
// the seal_config update must be in the same transaction, use ReWrapKeysTx.
func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
b.mu.Lock()
defer b.mu.Unlock()
@@ -620,6 +638,30 @@ func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
}
defer func() { _ = tx.Rollback() }()
if err := b.reWrapKeysLocked(ctx, tx, newMEK); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("barrier: commit re-wrap: %w", err)
}
b.swapMEKLocked(newMEK)
return nil
}
// ReWrapKeysTx re-encrypts all DEKs with a new MEK within the given
// transaction. The caller is responsible for committing the transaction
// and then calling SwapMEK to update the in-memory state.
// The barrier mutex must be held by the caller.
func (b *AESGCMBarrier) ReWrapKeysTx(ctx context.Context, tx *sql.Tx, newMEK []byte) error {
if b.mek == nil {
return ErrSealed
}
return b.reWrapKeysLocked(ctx, tx, newMEK)
}
func (b *AESGCMBarrier) reWrapKeysLocked(ctx context.Context, tx *sql.Tx, newMEK []byte) error {
for keyID, dek := range b.keys {
encDEK, err := crypto.Encrypt(newMEK, dek, []byte(keyID))
if err != nil {
@@ -632,15 +674,19 @@ func (b *AESGCMBarrier) ReWrapKeys(ctx context.Context, newMEK []byte) error {
return fmt.Errorf("barrier: update key %q: %w", keyID, err)
}
}
return nil
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("barrier: commit re-wrap: %w", err)
}
// SwapMEK updates the in-memory MEK after a committed transaction.
func (b *AESGCMBarrier) SwapMEK(newMEK []byte) {
b.mu.Lock()
defer b.mu.Unlock()
b.swapMEKLocked(newMEK)
}
// Update the MEK in memory.
func (b *AESGCMBarrier) swapMEKLocked(newMEK []byte) {
crypto.Zeroize(b.mek)
k := make([]byte, len(newMEK))
copy(k, newMEK)
b.mek = k
return nil
}

View File

@@ -634,6 +634,9 @@ func (e *CAEngine) handleCreateIssuer(ctx context.Context, req *engine.Request)
if name == "" {
return nil, fmt.Errorf("ca: issuer name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("ca: %w", err)
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -873,10 +876,13 @@ func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engin
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
}
// Apply user overrides.
if v, ok := req.Data["ttl"].(string); ok && v != "" {
profile.Expiry = v
// Validate and apply TTL against issuer MaxTTL.
requestedTTL, _ := req.Data["ttl"].(string)
ttl, err := resolveTTL(requestedTTL, is.config.MaxTTL)
if err != nil {
return nil, err
}
profile.Expiry = ttl.String()
if v, ok := req.Data["key_usages"].([]interface{}); ok {
profile.KeyUse = toStringSlice(v)
}
@@ -1259,10 +1265,6 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
}
if v, ok := req.Data["ttl"].(string); ok && v != "" {
profile.Expiry = v
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -1275,6 +1277,14 @@ func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*eng
return nil, ErrIssuerNotFound
}
// Validate and apply TTL against issuer MaxTTL.
requestedTTL, _ := req.Data["ttl"].(string)
ttl, err := resolveTTL(requestedTTL, is.config.MaxTTL)
if err != nil {
return nil, err
}
profile.Expiry = ttl.String()
// Authorization: admins bypass; otherwise check identifiers from the CSR.
if !req.CallerInfo.IsAdmin {
sans := append(csr.DNSNames, ipStrings(csr.IPAddresses)...)
@@ -1497,6 +1507,25 @@ func zeroizeKey(key crypto.PrivateKey) {
}
}
// resolveTTL parses and validates a requested TTL against the issuer's MaxTTL.
func resolveTTL(requested, issuerMaxTTL string) (time.Duration, error) {
maxTTL, err := time.ParseDuration(issuerMaxTTL)
if err != nil || maxTTL <= 0 {
maxTTL = 2160 * time.Hour // 90 days fallback
}
if requested != "" {
ttl, err := time.ParseDuration(requested)
if err != nil {
return 0, fmt.Errorf("ca: invalid TTL %q: %w", requested, err)
}
if ttl > maxTTL {
return 0, fmt.Errorf("ca: requested TTL %s exceeds issuer maximum %s", ttl, maxTTL)
}
return ttl, nil
}
return maxTTL, nil
}
func toStringSlice(v []interface{}) []string {
s := make([]string, 0, len(v))
for _, item := range v {

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"sync"
@@ -28,8 +29,21 @@ var (
ErrMountExists = errors.New("engine: mount already exists")
ErrMountNotFound = errors.New("engine: mount not found")
ErrUnknownType = errors.New("engine: unknown engine type")
ErrInvalidName = errors.New("engine: invalid name")
)
var validName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// ValidateName checks that a user-provided name is safe for use in barrier
// paths. Names must be 1-128 characters, start with an alphanumeric, and
// contain only alphanumerics, dots, hyphens, and underscores.
func ValidateName(name string) error {
if name == "" || len(name) > 128 || !validName.MatchString(name) {
return fmt.Errorf("%w: %q", ErrInvalidName, name)
}
return nil
}
// CallerInfo carries authentication context into engines.
type CallerInfo struct {
Username string
@@ -131,6 +145,10 @@ const mountsPrefix = "engine/_mounts/"
// Mount creates and initializes a new engine mount.
func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error {
if err := ValidateName(name); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()

View File

@@ -547,6 +547,9 @@ func (e *SSHCAEngine) handleCreateProfile(ctx context.Context, req *engine.Reque
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("sshca: %w", err)
}
// Check if profile already exists.
_, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json")

View File

@@ -382,6 +382,9 @@ func (e *TransitEngine) handleCreateKey(ctx context.Context, req *engine.Request
if name == "" {
return nil, fmt.Errorf("transit: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("transit: %w", err)
}
if keyType == "" {
keyType = "aes256-gcm"
}

View File

@@ -47,9 +47,10 @@ var (
// userState holds in-memory state for a loaded user.
type userState struct {
privKey *ecdh.PrivateKey
pubKey *ecdh.PublicKey
config *UserKeyConfig
privKey *ecdh.PrivateKey
privBytes []byte // raw private key bytes, retained for zeroization
pubKey *ecdh.PublicKey
config *UserKeyConfig
}
// UserEngine implements the user-to-user encryption engine.
@@ -68,6 +69,16 @@ func NewUserEngine() engine.Engine {
}
}
// mountName extracts the mount name from the full mount path.
// mountPath is "engine/user/{name}/".
func (e *UserEngine) mountName() string {
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
if len(parts) >= 3 {
return parts[2]
}
return e.mountPath
}
func (e *UserEngine) Type() engine.EngineType {
return engine.EngineTypeUser
}
@@ -154,12 +165,13 @@ func (e *UserEngine) Seal() error {
e.mu.Lock()
defer e.mu.Unlock()
// Zeroize all private keys.
// Zeroize all private key material.
for _, u := range e.users {
if u.privKey != nil {
raw := u.privKey.Bytes()
crypto.Zeroize(raw)
if u.privBytes != nil {
crypto.Zeroize(u.privBytes)
u.privBytes = nil
}
u.privKey = nil
}
e.users = nil
e.config = nil
@@ -249,6 +261,9 @@ func (e *UserEngine) handleProvision(ctx context.Context, req *engine.Request) (
if username == "" {
return nil, fmt.Errorf("user: username is required")
}
if err := engine.ValidateName(username); err != nil {
return nil, fmt.Errorf("user: %w", err)
}
e.mu.Lock()
defer e.mu.Unlock()
@@ -349,13 +364,18 @@ func (e *UserEngine) handleEncrypt(ctx context.Context, req *engine.Request) (*e
if len(recipientNames) > maxRecipients {
return nil, ErrTooMany
}
for _, r := range recipientNames {
if err := engine.ValidateName(r); err != nil {
return nil, fmt.Errorf("user: invalid recipient: %w", err)
}
}
sender := req.CallerInfo.Username
// Policy check for each recipient.
if req.CheckPolicy != nil {
for _, r := range recipientNames {
resource := fmt.Sprintf("user/%s/recipient/%s", e.mountPath, r)
resource := fmt.Sprintf("user/%s/recipient/%s", e.mountName(), r)
effect, matched := req.CheckPolicy(resource, "write")
if matched && effect == "deny" {
return nil, fmt.Errorf("user: forbidden: policy denies encryption to recipient %s", r)
@@ -715,8 +735,9 @@ func (e *UserEngine) createUser(ctx context.Context, username string, autoProvis
}
u := &userState{
privKey: priv,
pubKey: priv.PublicKey(),
privKey: priv,
privBytes: priv.Bytes(), // retain copy for zeroization on Seal
pubKey: priv.PublicKey(),
config: &UserKeyConfig{
Algorithm: e.config.KeyAlgorithm,
CreatedAt: time.Now().UTC(),
@@ -789,9 +810,10 @@ func (e *UserEngine) loadUser(ctx context.Context, username string) error {
}
e.users[username] = &userState{
privKey: priv,
pubKey: priv.PublicKey(),
config: &cfg,
privKey: priv,
privBytes: privBytes, // retained for zeroization on Seal
pubKey: priv.PublicKey(),
config: &cfg,
}
return nil
}

View File

@@ -128,6 +128,8 @@ func LintRule(rule *Rule) []string {
if rule.ID == "" {
problems = append(problems, "rule ID is required")
} else if strings.Contains(rule.ID, "/") || strings.Contains(rule.ID, "..") {
problems = append(problems, "rule ID must not contain '/' or '..'")
}
if !validEffects[rule.Effect] {

View File

@@ -284,28 +284,40 @@ func (m *Manager) RotateMEK(ctx context.Context, password []byte) error {
return fmt.Errorf("seal: generate new mek: %w", err)
}
// Re-wrap all DEKs with new MEK.
if err := m.barrier.ReWrapKeys(ctx, newMEK); err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: re-wrap keys: %w", err)
}
// Encrypt new MEK with KWK.
// Encrypt new MEK with KWK before starting the transaction.
newEncMEK, err := crypto.Encrypt(kwk, newMEK, nil)
if err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: encrypt new mek: %w", err)
}
// Update seal_config.
_, err = m.db.ExecContext(ctx,
// Re-wrap DEKs and update seal_config in a single atomic transaction.
tx, err := m.db.BeginTx(ctx, nil)
if err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := m.barrier.ReWrapKeysTx(ctx, tx, newMEK); err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: re-wrap keys: %w", err)
}
_, err = tx.ExecContext(ctx,
"UPDATE seal_config SET encrypted_mek = ? WHERE id = 1", newEncMEK)
if err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: update seal config: %w", err)
}
// Swap in-memory MEK.
if err := tx.Commit(); err != nil {
crypto.Zeroize(newMEK)
return fmt.Errorf("seal: commit mek rotation: %w", err)
}
// Only after commit: swap in-memory state.
m.barrier.SwapMEK(newMEK)
crypto.Zeroize(m.mek)
m.mek = newMEK
m.logger.Info("MEK rotated successfully")

View File

@@ -11,6 +11,7 @@ import (
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
@@ -53,10 +54,10 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
r.Get("/v1/sshca/{mount}/certs/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Get("/v1/sshca/{mount}/cert/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
r.Post("/v1/sshca/{mount}/certs/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/certs/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
r.Post("/v1/sshca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/cert/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
// Public PKI routes (no auth required, but must be unsealed).
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
@@ -89,7 +90,7 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/transit/{mount}/keys/{name}", s.requireAuth(s.handleTransitGetKey))
r.Delete("/v1/transit/{mount}/keys/{name}", s.requireAdmin(s.handleTransitDeleteKey))
r.Post("/v1/transit/{mount}/keys/{name}/rotate", s.requireAdmin(s.handleTransitRotateKey))
r.Post("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig))
r.Patch("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig))
r.Post("/v1/transit/{mount}/keys/{name}/trim", s.requireAdmin(s.handleTransitTrimKey))
r.Post("/v1/transit/{mount}/encrypt/{key}", s.requireAuth(s.handleTransitEncrypt))
r.Post("/v1/transit/{mount}/decrypt/{key}", s.requireAuth(s.handleTransitDecrypt))
@@ -287,7 +288,7 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil {
s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err)
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
writeJSONError(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
@@ -302,7 +303,7 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.engines.Unmount(r.Context(), req.Name); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
@@ -434,7 +435,7 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
writeJSONError(w, err.Error(), status)
return
}
@@ -615,13 +616,14 @@ func (s *Server) handleGetCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
if errors.Is(err, ca.ErrCertNotFound) {
http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, resp.Data)
@@ -640,6 +642,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
if errors.Is(err, ca.ErrCertNotFound) {
@@ -650,7 +653,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, resp.Data)
@@ -669,6 +672,7 @@ func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
if errors.Is(err, ca.ErrCertNotFound) {
@@ -679,7 +683,7 @@ func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusNoContent, nil)
@@ -691,7 +695,7 @@ func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -715,7 +719,7 @@ func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -739,7 +743,7 @@ func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -763,7 +767,7 @@ func (s *Server) handlePKICRL(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -1047,6 +1051,7 @@ func (s *Server) handleUserRegister(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1076,7 +1081,8 @@ func (s *Server) handleUserProvision(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": req.Username},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": req.Username},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1095,6 +1101,7 @@ func (s *Server) handleUserListUsers(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1114,7 +1121,8 @@ func (s *Server) handleUserGetPublicKey(w http.ResponseWriter, r *http.Request)
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1134,7 +1142,8 @@ func (s *Server) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1215,7 +1224,8 @@ func (s *Server) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1241,7 +1251,8 @@ func (s *Server) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1260,6 +1271,7 @@ func (s *Server) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1301,6 +1313,28 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
writeJSON(w, status, map[string]string{"error": msg})
}
// newPolicyChecker builds a PolicyChecker closure for a caller, used by typed
// REST handlers to pass service-level policy evaluation into the engine.
func (s *Server) newPolicyChecker(r *http.Request, info *auth.TokenInfo) engine.PolicyChecker {
return func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
}
func readJSON(r *http.Request, v interface{}) error {
defer func() { _ = r.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
@@ -1331,7 +1365,7 @@ func (s *Server) handleSSHCAPubkey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
pubKey, err := eng.GetCAPubkey(r.Context())
@@ -1347,7 +1381,7 @@ func (s *Server) handleSSHCAKRL(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
krlData, err := eng.GetKRL()
@@ -1386,6 +1420,7 @@ func (s *Server) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1502,6 +1537,7 @@ func (s *Server) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1557,6 +1593,7 @@ func (s *Server) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1577,6 +1614,7 @@ func (s *Server) handleSSHCAGetProfile(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1595,6 +1633,7 @@ func (s *Server) handleSSHCAListProfiles(w http.ResponseWriter, r *http.Request)
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1615,6 +1654,7 @@ func (s *Server) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1635,6 +1675,7 @@ func (s *Server) handleSSHCAGetCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1653,6 +1694,7 @@ func (s *Server) handleSSHCAListCerts(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1673,6 +1715,7 @@ func (s *Server) handleSSHCARevokeCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1693,6 +1736,7 @@ func (s *Server) handleSSHCADeleteCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1728,5 +1772,5 @@ func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
strings.Contains(err.Error(), "too many"):
status = http.StatusBadRequest
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
writeJSONError(w, err.Error(), status)
}

View File

@@ -124,6 +124,120 @@ func (m *mockVault) CreatePolicy(ctx context.Context, token string, rule PolicyR
func (m *mockVault) DeletePolicy(ctx context.Context, token, id string) error { return nil }
// SSH CA stubs
func (m *mockVault) GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error) {
return nil, nil
}
func (m *mockVault) GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error {
return nil
}
func (m *mockVault) UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error {
return nil
}
func (m *mockVault) DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error {
return nil
}
func (m *mockVault) ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error) {
return nil, nil
}
func (m *mockVault) GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) RevokeSSHCACert(ctx context.Context, token, mount, serial string) error {
return nil
}
func (m *mockVault) DeleteSSHCACert(ctx context.Context, token, mount, serial string) error {
return nil
}
func (m *mockVault) GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error) {
return nil, fmt.Errorf("not implemented")
}
// Transit stubs
func (m *mockVault) ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error) {
return nil, nil
}
func (m *mockVault) GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error {
return nil
}
func (m *mockVault) DeleteTransitKey(ctx context.Context, token, mount, name string) error {
return nil
}
func (m *mockVault) RotateTransitKey(ctx context.Context, token, mount, name string) error {
return nil
}
func (m *mockVault) UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error {
return nil
}
func (m *mockVault) TrimTransitKey(ctx context.Context, token, mount, name string) (int, error) {
return 0, nil
}
func (m *mockVault) TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitSign(ctx context.Context, token, mount, key, input string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error) {
return false, fmt.Errorf("not implemented")
}
func (m *mockVault) TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error) {
return "", fmt.Errorf("not implemented")
}
// User stubs
func (m *mockVault) UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) ListUsers(ctx context.Context, token, mount string) ([]string, error) {
return nil, nil
}
func (m *mockVault) UserEncrypt(ctx context.Context, token, mount, plaintext, metadata string, recipients []string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error) {
return "", fmt.Errorf("not implemented")
}
func (m *mockVault) UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
return nil, fmt.Errorf("not implemented")
}
func (m *mockVault) UserDeleteUser(ctx context.Context, token, mount, username string) error {
return nil
}
func (m *mockVault) Close() error { return nil }
// ---- handleTGZDownload tests ----

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@@ -17,13 +18,16 @@ import (
// VaultClient wraps the gRPC stubs for communicating with the vault.
type VaultClient struct {
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
ca pb.CAServiceClient
policy pb.PolicyServiceClient
conn *grpc.ClientConn
system pb.SystemServiceClient
auth pb.AuthServiceClient
engine pb.EngineServiceClient
pki pb.PKIServiceClient
ca pb.CAServiceClient
policy pb.PolicyServiceClient
sshca pb.SSHCAServiceClient
transit pb.TransitServiceClient
user pb.UserServiceClient
}
// NewVaultClient dials the vault gRPC server and returns a client.
@@ -55,13 +59,16 @@ func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient,
logger.Debug("vault gRPC connection established", "addr", addr)
return &VaultClient{
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
ca: pb.NewCAServiceClient(conn),
policy: pb.NewPolicyServiceClient(conn),
conn: conn,
system: pb.NewSystemServiceClient(conn),
auth: pb.NewAuthServiceClient(conn),
engine: pb.NewEngineServiceClient(conn),
pki: pb.NewPKIServiceClient(conn),
ca: pb.NewCAServiceClient(conn),
policy: pb.NewPolicyServiceClient(conn),
sshca: pb.NewSSHCAServiceClient(conn),
transit: pb.NewTransitServiceClient(conn),
user: pb.NewUserServiceClient(conn),
}, nil
}
@@ -496,3 +503,610 @@ func (c *VaultClient) ListCerts(ctx context.Context, token, mount string) ([]Cer
}
return certs, nil
}
// ---------------------------------------------------------------------------
// SSH CA
// ---------------------------------------------------------------------------
// SSHCAPublicKey holds the CA public key details for display.
type SSHCAPublicKey struct {
PublicKey string // authorized_keys format
}
// GetSSHCAPublicKey returns the SSH CA public key for a mount.
func (c *VaultClient) GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error) {
resp, err := c.sshca.GetCAPublicKey(ctx, &pb.SSHGetCAPublicKeyRequest{Mount: mount})
if err != nil {
return nil, err
}
return &SSHCAPublicKey{PublicKey: resp.PublicKey}, nil
}
// SSHCASignRequest holds parameters for signing an SSH certificate.
type SSHCASignRequest struct {
PublicKey string
Principals []string
Profile string
TTL string
}
// SSHCASignResult holds the result of signing an SSH certificate.
type SSHCASignResult struct {
Serial string
CertType string
Principals []string
CertData string
KeyID string
IssuedBy string
IssuedAt string
ExpiresAt string
}
func sshSignResultFromHost(resp *pb.SSHSignHostResponse) *SSHCASignResult {
r := &SSHCASignResult{
Serial: resp.Serial,
CertType: resp.CertType,
Principals: resp.Principals,
CertData: resp.CertData,
KeyID: resp.KeyId,
IssuedBy: resp.IssuedBy,
}
if resp.IssuedAt != nil {
r.IssuedAt = resp.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if resp.ExpiresAt != nil {
r.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return r
}
func sshSignResultFromUser(resp *pb.SSHSignUserResponse) *SSHCASignResult {
r := &SSHCASignResult{
Serial: resp.Serial,
CertType: resp.CertType,
Principals: resp.Principals,
CertData: resp.CertData,
KeyID: resp.KeyId,
IssuedBy: resp.IssuedBy,
}
if resp.IssuedAt != nil {
r.IssuedAt = resp.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if resp.ExpiresAt != nil {
r.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return r
}
// SSHCASignHost signs an SSH host certificate.
func (c *VaultClient) SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
hostname := ""
if len(req.Principals) > 0 {
hostname = req.Principals[0]
}
resp, err := c.sshca.SignHost(withToken(ctx, token), &pb.SSHSignHostRequest{
Mount: mount,
PublicKey: req.PublicKey,
Hostname: hostname,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
return sshSignResultFromHost(resp), nil
}
// SSHCASignUser signs an SSH user certificate.
func (c *VaultClient) SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error) {
resp, err := c.sshca.SignUser(withToken(ctx, token), &pb.SSHSignUserRequest{
Mount: mount,
PublicKey: req.PublicKey,
Principals: req.Principals,
Profile: req.Profile,
Ttl: req.TTL,
})
if err != nil {
return nil, err
}
return sshSignResultFromUser(resp), nil
}
// SSHCAProfileSummary holds lightweight profile data for list views.
type SSHCAProfileSummary struct {
Name string
}
// ListSSHCAProfiles returns all signing profile names for a mount.
func (c *VaultClient) ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error) {
resp, err := c.sshca.ListProfiles(withToken(ctx, token), &pb.SSHListProfilesRequest{Mount: mount})
if err != nil {
return nil, err
}
profiles := make([]SSHCAProfileSummary, 0, len(resp.Profiles))
for _, name := range resp.Profiles {
profiles = append(profiles, SSHCAProfileSummary{Name: name})
}
return profiles, nil
}
// SSHCAProfile holds full profile data for the detail view.
type SSHCAProfile struct {
Name string
CriticalOptions map[string]string
Extensions map[string]string
MaxTTL string
AllowedPrincipals []string
}
// GetSSHCAProfile retrieves a signing profile by name.
func (c *VaultClient) GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error) {
resp, err := c.sshca.GetProfile(withToken(ctx, token), &pb.SSHGetProfileRequest{Mount: mount, Name: name})
if err != nil {
return nil, err
}
return &SSHCAProfile{
Name: resp.Name,
CriticalOptions: resp.CriticalOptions,
Extensions: resp.Extensions,
MaxTTL: resp.MaxTtl,
AllowedPrincipals: resp.AllowedPrincipals,
}, nil
}
// SSHCAProfileRequest holds parameters for creating or updating a profile.
type SSHCAProfileRequest struct {
Name string
CriticalOptions map[string]string
Extensions map[string]string
MaxTTL string
AllowedPrincipals []string
}
// CreateSSHCAProfile creates a new signing profile.
func (c *VaultClient) CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error {
_, err := c.sshca.CreateProfile(withToken(ctx, token), &pb.SSHCreateProfileRequest{
Mount: mount,
Name: req.Name,
CriticalOptions: req.CriticalOptions,
Extensions: req.Extensions,
MaxTtl: req.MaxTTL,
AllowedPrincipals: req.AllowedPrincipals,
})
return err
}
// UpdateSSHCAProfile updates an existing signing profile.
func (c *VaultClient) UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error {
_, err := c.sshca.UpdateProfile(withToken(ctx, token), &pb.SSHUpdateProfileRequest{
Mount: mount,
Name: name,
CriticalOptions: req.CriticalOptions,
Extensions: req.Extensions,
MaxTtl: req.MaxTTL,
AllowedPrincipals: req.AllowedPrincipals,
})
return err
}
// DeleteSSHCAProfile removes a signing profile.
func (c *VaultClient) DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error {
_, err := c.sshca.DeleteProfile(withToken(ctx, token), &pb.SSHDeleteProfileRequest{Mount: mount, Name: name})
return err
}
// SSHCACertSummary holds lightweight cert data for list views.
type SSHCACertSummary struct {
Serial string
CertType string
Principals string
KeyID string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
Revoked bool
}
// ListSSHCACerts returns all SSH certificate summaries for a mount.
func (c *VaultClient) ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error) {
resp, err := c.sshca.ListCerts(withToken(ctx, token), &pb.SSHListCertsRequest{Mount: mount})
if err != nil {
return nil, err
}
certs := make([]SSHCACertSummary, 0, len(resp.Certs))
for _, s := range resp.Certs {
cs := SSHCACertSummary{
Serial: s.Serial,
CertType: s.CertType,
Principals: strings.Join(s.Principals, ", "),
KeyID: s.KeyId,
Profile: s.Profile,
IssuedBy: s.IssuedBy,
Revoked: s.Revoked,
}
if s.IssuedAt != nil {
cs.IssuedAt = s.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if s.ExpiresAt != nil {
cs.ExpiresAt = s.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
certs = append(certs, cs)
}
return certs, nil
}
// SSHCACertDetail holds full SSH certificate data for the detail view.
type SSHCACertDetail struct {
Serial string
CertType string
Principals []string
CertData string
KeyID string
Profile string
IssuedBy string
IssuedAt string
ExpiresAt string
Revoked bool
RevokedAt string
RevokedBy string
}
// GetSSHCACert retrieves a full SSH certificate record by serial.
func (c *VaultClient) GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error) {
resp, err := c.sshca.GetCert(withToken(ctx, token), &pb.SSHGetCertRequest{Mount: mount, Serial: serial})
if err != nil {
return nil, err
}
rec := resp.GetCert()
if rec == nil {
return nil, fmt.Errorf("cert not found")
}
cd := &SSHCACertDetail{
Serial: rec.Serial,
CertType: rec.CertType,
Principals: rec.Principals,
CertData: rec.CertData,
KeyID: rec.KeyId,
Profile: rec.Profile,
IssuedBy: rec.IssuedBy,
Revoked: rec.Revoked,
RevokedBy: rec.RevokedBy,
}
if rec.IssuedAt != nil {
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.ExpiresAt != nil {
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
}
if rec.RevokedAt != nil {
cd.RevokedAt = rec.RevokedAt.AsTime().Format("2006-01-02T15:04:05Z")
}
return cd, nil
}
// RevokeSSHCACert marks an SSH certificate as revoked.
func (c *VaultClient) RevokeSSHCACert(ctx context.Context, token, mount, serial string) error {
_, err := c.sshca.RevokeCert(withToken(ctx, token), &pb.SSHRevokeCertRequest{Mount: mount, Serial: serial})
return err
}
// DeleteSSHCACert permanently removes an SSH certificate record.
func (c *VaultClient) DeleteSSHCACert(ctx context.Context, token, mount, serial string) error {
_, err := c.sshca.DeleteCert(withToken(ctx, token), &pb.SSHDeleteCertRequest{Mount: mount, Serial: serial})
return err
}
// GetSSHCAKRL returns the binary KRL data for a mount.
func (c *VaultClient) GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error) {
resp, err := c.sshca.GetKRL(ctx, &pb.SSHGetKRLRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.Krl, nil
}
// ---------------------------------------------------------------------------
// Transit
// ---------------------------------------------------------------------------
// TransitKeySummary holds lightweight key data for list views.
type TransitKeySummary struct {
Name string
}
// ListTransitKeys returns all key names for a mount.
func (c *VaultClient) ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error) {
resp, err := c.transit.ListKeys(withToken(ctx, token), &pb.ListTransitKeysRequest{Mount: mount})
if err != nil {
return nil, err
}
keys := make([]TransitKeySummary, 0, len(resp.Keys))
for _, name := range resp.Keys {
keys = append(keys, TransitKeySummary{Name: name})
}
return keys, nil
}
// TransitKeyDetail holds full key metadata for the detail view.
type TransitKeyDetail struct {
Name string
Type string
CurrentVersion int
MinDecryptionVersion int
AllowDeletion bool
Versions []int
}
// GetTransitKey retrieves key metadata.
func (c *VaultClient) GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error) {
resp, err := c.transit.GetKey(withToken(ctx, token), &pb.GetTransitKeyRequest{Mount: mount, Name: name})
if err != nil {
return nil, err
}
versions := make([]int, 0, len(resp.Versions))
for _, v := range resp.Versions {
versions = append(versions, int(v))
}
return &TransitKeyDetail{
Name: resp.Name,
Type: resp.Type,
CurrentVersion: int(resp.CurrentVersion),
MinDecryptionVersion: int(resp.MinDecryptionVersion),
AllowDeletion: resp.AllowDeletion,
Versions: versions,
}, nil
}
// CreateTransitKey creates a new named key.
func (c *VaultClient) CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error {
_, err := c.transit.CreateKey(withToken(ctx, token), &pb.CreateTransitKeyRequest{
Mount: mount,
Name: name,
Type: keyType,
})
return err
}
// DeleteTransitKey permanently removes a named key.
func (c *VaultClient) DeleteTransitKey(ctx context.Context, token, mount, name string) error {
_, err := c.transit.DeleteKey(withToken(ctx, token), &pb.DeleteTransitKeyRequest{Mount: mount, Name: name})
return err
}
// RotateTransitKey creates a new version of the named key.
func (c *VaultClient) RotateTransitKey(ctx context.Context, token, mount, name string) error {
_, err := c.transit.RotateKey(withToken(ctx, token), &pb.RotateTransitKeyRequest{Mount: mount, Name: name})
return err
}
// UpdateTransitKeyConfig updates key config (min_decryption_version, allow_deletion).
func (c *VaultClient) UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error {
_, err := c.transit.UpdateKeyConfig(withToken(ctx, token), &pb.UpdateTransitKeyConfigRequest{
Mount: mount,
Name: name,
MinDecryptionVersion: int32(minDecryptVersion),
AllowDeletion: allowDeletion,
})
return err
}
// TrimTransitKey deletes old key versions below min_decryption_version.
func (c *VaultClient) TrimTransitKey(ctx context.Context, token, mount, name string) (int, error) {
resp, err := c.transit.TrimKey(withToken(ctx, token), &pb.TrimTransitKeyRequest{Mount: mount, Name: name})
if err != nil {
return 0, err
}
return int(resp.Trimmed), nil
}
// TransitEncrypt encrypts plaintext with a named key.
func (c *VaultClient) TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error) {
resp, err := c.transit.Encrypt(withToken(ctx, token), &pb.TransitEncryptRequest{
Mount: mount,
Key: key,
Plaintext: plaintext,
Context: transitCtx,
})
if err != nil {
return "", err
}
return resp.Ciphertext, nil
}
// TransitDecrypt decrypts ciphertext with a named key.
func (c *VaultClient) TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
resp, err := c.transit.Decrypt(withToken(ctx, token), &pb.TransitDecryptRequest{
Mount: mount,
Key: key,
Ciphertext: ciphertext,
Context: transitCtx,
})
if err != nil {
return "", err
}
return resp.Plaintext, nil
}
// TransitRewrap re-encrypts ciphertext with the latest key version.
func (c *VaultClient) TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error) {
resp, err := c.transit.Rewrap(withToken(ctx, token), &pb.TransitRewrapRequest{
Mount: mount,
Key: key,
Ciphertext: ciphertext,
Context: transitCtx,
})
if err != nil {
return "", err
}
return resp.Ciphertext, nil
}
// TransitSign signs input data with an asymmetric key.
func (c *VaultClient) TransitSign(ctx context.Context, token, mount, key, input string) (string, error) {
resp, err := c.transit.Sign(withToken(ctx, token), &pb.TransitSignRequest{
Mount: mount,
Key: key,
Input: input,
})
if err != nil {
return "", err
}
return resp.Signature, nil
}
// TransitVerify verifies a signature against input data.
func (c *VaultClient) TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error) {
resp, err := c.transit.Verify(withToken(ctx, token), &pb.TransitVerifyRequest{
Mount: mount,
Key: key,
Input: input,
Signature: signature,
})
if err != nil {
return false, err
}
return resp.Valid, nil
}
// TransitHMAC computes an HMAC.
func (c *VaultClient) TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error) {
resp, err := c.transit.Hmac(withToken(ctx, token), &pb.TransitHmacRequest{
Mount: mount,
Key: key,
Input: input,
})
if err != nil {
return "", err
}
return resp.Hmac, nil
}
// GetTransitPublicKey returns the public key for an asymmetric transit key.
func (c *VaultClient) GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error) {
resp, err := c.transit.GetPublicKey(withToken(ctx, token), &pb.GetTransitPublicKeyRequest{Mount: mount, Name: name})
if err != nil {
return "", err
}
return resp.PublicKey, nil
}
// ---------------------------------------------------------------------------
// User (E2E Encryption)
// ---------------------------------------------------------------------------
// UserKeyInfo holds a user's public key details.
type UserKeyInfo struct {
Username string
PublicKey string
Algorithm string
}
// UserRegister self-registers the caller.
func (c *VaultClient) UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
resp, err := c.user.Register(withToken(ctx, token), &pb.UserRegisterRequest{Mount: mount})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// UserProvision creates a keypair for a given username. Admin only.
func (c *VaultClient) UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
resp, err := c.user.Provision(withToken(ctx, token), &pb.UserProvisionRequest{Mount: mount, Username: username})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// GetUserPublicKey returns the public key for a username.
func (c *VaultClient) GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error) {
resp, err := c.user.GetPublicKey(withToken(ctx, token), &pb.UserGetPublicKeyRequest{Mount: mount, Username: username})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// ListUsers returns all registered usernames.
func (c *VaultClient) ListUsers(ctx context.Context, token, mount string) ([]string, error) {
resp, err := c.user.ListUsers(withToken(ctx, token), &pb.UserListUsersRequest{Mount: mount})
if err != nil {
return nil, err
}
return resp.Users, nil
}
// UserEncrypt encrypts plaintext for one or more recipients.
func (c *VaultClient) UserEncrypt(ctx context.Context, token, mount, plaintext, userMetadata string, recipients []string) (string, error) {
resp, err := c.user.Encrypt(withToken(ctx, token), &pb.UserEncryptRequest{
Mount: mount,
Plaintext: plaintext,
Metadata: userMetadata,
Recipients: recipients,
})
if err != nil {
return "", err
}
return resp.Envelope, nil
}
// UserDecryptResult holds the result of decrypting an envelope.
type UserDecryptResult struct {
Plaintext string
Sender string
Metadata string
}
// UserDecrypt decrypts an envelope addressed to the caller.
func (c *VaultClient) UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error) {
resp, err := c.user.Decrypt(withToken(ctx, token), &pb.UserDecryptRequest{Mount: mount, Envelope: envelope})
if err != nil {
return nil, err
}
return &UserDecryptResult{
Plaintext: resp.Plaintext,
Sender: resp.Sender,
Metadata: resp.Metadata,
}, nil
}
// UserReEncrypt re-encrypts an envelope with current keys.
func (c *VaultClient) UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error) {
resp, err := c.user.ReEncrypt(withToken(ctx, token), &pb.UserReEncryptRequest{Mount: mount, Envelope: envelope})
if err != nil {
return "", err
}
return resp.Envelope, nil
}
// UserRotateKey generates a new keypair for the caller.
func (c *VaultClient) UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error) {
resp, err := c.user.RotateKey(withToken(ctx, token), &pb.UserRotateKeyRequest{Mount: mount})
if err != nil {
return nil, err
}
return &UserKeyInfo{
Username: resp.Username,
PublicKey: resp.PublicKey,
Algorithm: resp.Algorithm,
}, nil
}
// UserDeleteUser removes a user's keys. Admin only.
func (c *VaultClient) UserDeleteUser(ctx context.Context, token, mount, username string) error {
_, err := c.user.DeleteUser(withToken(ctx, token), &pb.UserDeleteUserRequest{Mount: mount, Username: username})
return err
}

View File

@@ -38,6 +38,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.HandleFunc("/login", ws.handleLogin)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
r.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine))
r.Route("/policy", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePolicy))
@@ -45,6 +46,47 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete))
})
r.Route("/sshca", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleSSHCA))
r.Post("/sign-user", ws.requireAuth(ws.handleSSHCASignUser))
r.Post("/sign-host", ws.requireAuth(ws.handleSSHCASignHost))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleSSHCACertDetail))
r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleSSHCACertRevoke))
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleSSHCACertDelete))
r.Post("/profile/create", ws.requireAuth(ws.handleSSHCACreateProfile))
r.Get("/profile/{name}", ws.requireAuth(ws.handleSSHCAProfileDetail))
r.Post("/profile/{name}/update", ws.requireAuth(ws.handleSSHCAUpdateProfile))
r.Post("/profile/{name}/delete", ws.requireAuth(ws.handleSSHCADeleteProfile))
})
r.Route("/transit", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleTransit))
r.Get("/key/{name}", ws.requireAuth(ws.handleTransitKeyDetail))
r.Post("/key/create", ws.requireAuth(ws.handleTransitCreateKey))
r.Post("/key/{name}/rotate", ws.requireAuth(ws.handleTransitRotateKey))
r.Post("/key/{name}/config", ws.requireAuth(ws.handleTransitUpdateConfig))
r.Post("/key/{name}/trim", ws.requireAuth(ws.handleTransitTrimKey))
r.Post("/key/{name}/delete", ws.requireAuth(ws.handleTransitDeleteKey))
r.Post("/encrypt", ws.requireAuth(ws.handleTransitEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleTransitDecrypt))
r.Post("/rewrap", ws.requireAuth(ws.handleTransitRewrap))
r.Post("/sign", ws.requireAuth(ws.handleTransitSign))
r.Post("/verify", ws.requireAuth(ws.handleTransitVerify))
r.Post("/hmac", ws.requireAuth(ws.handleTransitHMAC))
})
r.Route("/user", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleUser))
r.Post("/register", ws.requireAuth(ws.handleUserRegister))
r.Post("/provision", ws.requireAuth(ws.handleUserProvision))
r.Get("/key/{username}", ws.requireAuth(ws.handleUserKeyDetail))
r.Post("/encrypt", ws.requireAuth(ws.handleUserEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleUserDecrypt))
r.Post("/re-encrypt", ws.requireAuth(ws.handleUserReEncrypt))
r.Post("/rotate", ws.requireAuth(ws.handleUserRotateKey))
r.Post("/delete/{username}", ws.requireAuth(ws.handleUserDeleteUser))
})
r.Route("/pki", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePKI))
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
@@ -201,13 +243,11 @@ func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
})
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
ws.renderTemplate(w, "dashboard.html", data)
}
func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) {
@@ -258,18 +298,57 @@ func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Reque
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) handleDashboardMountEngine(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
mountName := r.FormValue("name")
engineType := r.FormValue("type")
if mountName == "" || engineType == "" {
ws.renderDashboardWithError(w, r, info, "Mount name and engine type are required")
return
}
cfg := map[string]interface{}{}
if v := r.FormValue("key_algorithm"); v != "" {
cfg["key_algorithm"] = v
}
token := extractCookie(r)
if err := ws.vault.Mount(r.Context(), token, mountName, engineType, cfg); err != nil {
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
return
}
// Redirect to the appropriate engine page.
switch engineType {
case "sshca":
http.Redirect(w, r, "/sshca", http.StatusFound)
case "transit":
http.Redirect(w, r, "/transit", http.StatusFound)
case "user":
http.Redirect(w, r, "/user", http.StatusFound)
default:
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
}
func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
"MountError": errMsg,
})
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
data["MountError"] = errMsg
ws.renderTemplate(w, "dashboard.html", data)
}
func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
@@ -282,11 +361,8 @@ func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
@@ -482,15 +558,12 @@ func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request)
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuerName": issuerName,
"Certs": certs,
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["IssuerName"] = issuerName
data["Certs"] = certs
data["NameFilter"] = r.URL.Query().Get("name")
data["SortBy"] = sortBy
ws.renderTemplate(w, "issuer_detail.html", data)
}
@@ -640,12 +713,10 @@ func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Cert": cert,
})
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Cert"] = cert
ws.renderTemplate(w, "cert_detail.html", data)
}
func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) {
@@ -776,12 +847,9 @@ func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"SignedCert": signed,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["SignedCert"] = signed
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
@@ -800,12 +868,9 @@ func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
@@ -825,16 +890,45 @@ func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request,
}
func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "ca")
}
func (ws *WebServer) findSSHCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "sshca")
}
func (ws *WebServer) findTransitMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "transit")
}
func (ws *WebServer) findUserMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "user")
}
func (ws *WebServer) findMount(r *http.Request, token, engineType string) (string, error) {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return "", err
}
for _, m := range mounts {
if m.Type == "ca" {
if m.Type == engineType {
return m.Name, nil
}
}
return "", fmt.Errorf("no CA engine mounted")
return "", fmt.Errorf("no %s engine mounted", engineType)
}
// mountTypes returns a set of engine types that are currently mounted.
func (ws *WebServer) mountTypes(r *http.Request, token string) map[string]bool {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return nil
}
types := make(map[string]bool, len(mounts))
for _, m := range mounts {
types[m.Type] = true
}
return types
}
func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
@@ -848,11 +942,9 @@ func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
if err != nil {
rules = []PolicyRule{}
}
ws.renderTemplate(w, "policy.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Rules": rules,
})
data := ws.baseData(r, info)
data["Rules"] = rules
ws.renderTemplate(w, "policy.html", data)
}
func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
@@ -927,12 +1019,23 @@ func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request)
func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) {
rules, _ := ws.vault.ListPolicies(r.Context(), token)
ws.renderTemplate(w, "policy.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Rules": rules,
"Error": errMsg,
})
data := ws.baseData(r, info)
data["Rules"] = rules
data["Error"] = errMsg
ws.renderTemplate(w, "policy.html", data)
}
// baseData returns a template data map pre-populated with user info and nav flags.
func (ws *WebServer) baseData(r *http.Request, info *TokenInfo) map[string]interface{} {
token := extractCookie(r)
types := ws.mountTypes(r, token)
return map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"HasSSHCA": types["sshca"],
"HasTransit": types["transit"],
"HasUser": types["user"],
}
}
// grpcMessage extracts a human-readable message from a gRPC error.

View File

@@ -23,6 +23,7 @@ import (
// vaultBackend is the interface used by WebServer to communicate with the vault.
// It is satisfied by *VaultClient and can be replaced with a mock in tests.
type vaultBackend interface {
// System
Status(ctx context.Context) (string, error)
Init(ctx context.Context, password string) error
Unseal(ctx context.Context, password string) error
@@ -30,6 +31,9 @@ type vaultBackend interface {
ValidateToken(ctx context.Context, token string) (*TokenInfo, error)
ListMounts(ctx context.Context, token string) ([]MountInfo, error)
Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error
Close() error
// PKI / CA
GetRootCert(ctx context.Context, mount string) ([]byte, error)
GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error)
ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error
@@ -41,11 +45,54 @@ type vaultBackend interface {
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
RevokeCert(ctx context.Context, token, mount, serial string) error
DeleteCert(ctx context.Context, token, mount, serial string) error
// Policy
ListPolicies(ctx context.Context, token string) ([]PolicyRule, error)
GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error)
CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error)
DeletePolicy(ctx context.Context, token, id string) error
Close() error
// SSH CA
GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error)
SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error)
GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error)
CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error
UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error
ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx context.Context, token, mount, serial string) error
DeleteSSHCACert(ctx context.Context, token, mount, serial string) error
GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error)
// Transit
ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error
DeleteTransitKey(ctx context.Context, token, mount, name string) error
RotateTransitKey(ctx context.Context, token, mount, name string) error
UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error
TrimTransitKey(ctx context.Context, token, mount, name string) (int, error)
TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error)
TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitSign(ctx context.Context, token, mount, key, input string) (string, error)
TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error)
TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error)
GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error)
// User (E2E encryption)
UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
ListUsers(ctx context.Context, token, mount string) ([]string, error)
UserEncrypt(ctx context.Context, token, mount, plaintext, metadata string, recipients []string) (string, error)
UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error)
UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserDeleteUser(ctx context.Context, token, mount, username string) error
}
const userCacheTTL = 5 * time.Minute

400
internal/webserver/sshca.go Normal file
View File

@@ -0,0 +1,400 @@
package webserver
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) handleSSHCA(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName); err == nil {
data["CAPublicKey"] = pubkey.PublicKey
}
if profiles, err := ws.vault.ListSSHCAProfiles(r.Context(), token, mountName); err == nil {
data["Profiles"] = profiles
}
if certs, err := ws.vault.ListSSHCACerts(r.Context(), token, mountName); err == nil {
for i := range certs {
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
}
data["Certs"] = certs
}
ws.renderTemplate(w, "sshca.html", data)
}
func (ws *WebServer) handleSSHCASignUser(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
publicKey := strings.TrimSpace(r.FormValue("public_key"))
if publicKey == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Public key is required")
return
}
var principals []string
for _, line := range strings.Split(r.FormValue("principals"), "\n") {
if v := strings.TrimSpace(line); v != "" {
principals = append(principals, v)
}
}
req := SSHCASignRequest{
PublicKey: publicKey,
Principals: principals,
Profile: r.FormValue("profile"),
TTL: r.FormValue("ttl"),
}
result, err := ws.vault.SSHCASignUser(r.Context(), token, mountName, req)
if err != nil {
ws.renderSSHCAWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderSSHCAWithResult(w, r, mountName, info, "SignUserResult", result)
}
func (ws *WebServer) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
publicKey := strings.TrimSpace(r.FormValue("public_key"))
if publicKey == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Public key is required")
return
}
hostname := strings.TrimSpace(r.FormValue("hostname"))
if hostname == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Hostname is required")
return
}
req := SSHCASignRequest{
PublicKey: publicKey,
Principals: []string{hostname},
TTL: r.FormValue("ttl"),
}
result, err := ws.vault.SSHCASignHost(r.Context(), token, mountName, req)
if err != nil {
ws.renderSSHCAWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderSSHCAWithResult(w, r, mountName, info, "SignHostResult", result)
}
func (ws *WebServer) handleSSHCACertDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
cert, err := ws.vault.GetSSHCACert(r.Context(), token, mountName, serial)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "certificate not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Cert"] = cert
ws.renderTemplate(w, "sshca_cert_detail.html", data)
}
func (ws *WebServer) handleSSHCACertRevoke(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
if err := ws.vault.RevokeSSHCACert(r.Context(), token, mountName, serial); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca/cert/"+serial, http.StatusSeeOther)
}
func (ws *WebServer) handleSSHCACertDelete(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
serial := chi.URLParam(r, "serial")
if err := ws.vault.DeleteSSHCACert(r.Context(), token, mountName, serial); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca", http.StatusSeeOther)
}
func (ws *WebServer) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
ws.renderSSHCAWithError(w, r, mountName, info, "Profile name is required")
return
}
var principals []string
for _, line := range strings.Split(r.FormValue("allowed_principals"), "\n") {
if v := strings.TrimSpace(line); v != "" {
principals = append(principals, v)
}
}
extensions := map[string]string{}
for _, ext := range r.Form["extensions"] {
extensions[ext] = ""
}
criticalOptions := map[string]string{}
if v := strings.TrimSpace(r.FormValue("force_command")); v != "" {
criticalOptions["force-command"] = v
}
if v := strings.TrimSpace(r.FormValue("source_address")); v != "" {
criticalOptions["source-address"] = v
}
req := SSHCAProfileRequest{
Name: name,
CriticalOptions: criticalOptions,
Extensions: extensions,
MaxTTL: r.FormValue("max_ttl"),
AllowedPrincipals: principals,
}
if err := ws.vault.CreateSSHCAProfile(r.Context(), token, mountName, req); err != nil {
ws.renderSSHCAWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/sshca/profile/"+name, http.StatusFound)
}
func (ws *WebServer) handleSSHCAProfileDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
profile, err := ws.vault.GetSSHCAProfile(r.Context(), token, mountName, name)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "profile not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Profile"] = profile
ws.renderTemplate(w, "sshca_profile_detail.html", data)
}
func (ws *WebServer) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
var principals []string
for _, line := range strings.Split(r.FormValue("allowed_principals"), "\n") {
if v := strings.TrimSpace(line); v != "" {
principals = append(principals, v)
}
}
extensions := map[string]string{}
for _, ext := range r.Form["extensions"] {
extensions[ext] = ""
}
criticalOptions := map[string]string{}
if v := strings.TrimSpace(r.FormValue("force_command")); v != "" {
criticalOptions["force-command"] = v
}
if v := strings.TrimSpace(r.FormValue("source_address")); v != "" {
criticalOptions["source-address"] = v
}
req := SSHCAProfileRequest{
CriticalOptions: criticalOptions,
Extensions: extensions,
MaxTTL: r.FormValue("max_ttl"),
AllowedPrincipals: principals,
}
if err := ws.vault.UpdateSSHCAProfile(r.Context(), token, mountName, name, req); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca/profile/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findSSHCAMount(r, token)
if err != nil {
http.Error(w, "no SSH CA engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if err := ws.vault.DeleteSSHCAProfile(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sshca", http.StatusSeeOther)
}
func (ws *WebServer) renderSSHCAWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName); err == nil {
data["CAPublicKey"] = pubkey.PublicKey
}
if profiles, err := ws.vault.ListSSHCAProfiles(r.Context(), token, mountName); err == nil {
data["Profiles"] = profiles
}
if certs, err := ws.vault.ListSSHCACerts(r.Context(), token, mountName); err == nil {
data["Certs"] = certs
}
ws.renderTemplate(w, "sshca.html", data)
}
func (ws *WebServer) renderSSHCAWithResult(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, resultKey string, result *SSHCASignResult) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data[resultKey] = result
if pubkey, err := ws.vault.GetSSHCAPublicKey(r.Context(), mountName); err == nil {
data["CAPublicKey"] = pubkey.PublicKey
}
if profiles, err := ws.vault.ListSSHCAProfiles(r.Context(), token, mountName); err == nil {
data["Profiles"] = profiles
}
if certs, err := ws.vault.ListSSHCACerts(r.Context(), token, mountName); err == nil {
data["Certs"] = certs
}
ws.renderTemplate(w, "sshca.html", data)
}

View File

@@ -0,0 +1,417 @@
package webserver
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) handleTransit(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if keys, err := ws.vault.ListTransitKeys(r.Context(), token, mountName); err == nil {
data["Keys"] = keys
}
ws.renderTemplate(w, "transit.html", data)
}
func (ws *WebServer) handleTransitKeyDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
key, err := ws.vault.GetTransitKey(r.Context(), token, mountName, name)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "key not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Key"] = key
// Fetch public key for asymmetric signing keys.
switch key.Type {
case "ed25519", "ecdsa-p256", "ecdsa-p384":
if pubkey, err := ws.vault.GetTransitPublicKey(r.Context(), token, mountName, name); err == nil {
data["PublicKey"] = pubkey
}
}
ws.renderTemplate(w, "transit_key_detail.html", data)
}
func (ws *WebServer) handleTransitCreateKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key name is required")
return
}
keyType := r.FormValue("type")
if keyType == "" {
keyType = "aes256-gcm"
}
if err := ws.vault.CreateTransitKey(r.Context(), token, mountName, name, keyType); err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusFound)
}
func (ws *WebServer) handleTransitRotateKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if err := ws.vault.RotateTransitKey(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleTransitUpdateConfig(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
minDecrypt := 0
if v := r.FormValue("min_decryption_version"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
minDecrypt = n
}
}
allowDeletion := r.FormValue("allow_deletion") == "on"
if err := ws.vault.UpdateTransitKeyConfig(r.Context(), token, mountName, name, minDecrypt, allowDeletion); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleTransitTrimKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if _, err := ws.vault.TrimTransitKey(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit/key/"+name, http.StatusSeeOther)
}
func (ws *WebServer) handleTransitDeleteKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
name := chi.URLParam(r, "name")
if err := ws.vault.DeleteTransitKey(r.Context(), token, mountName, name); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transit", http.StatusSeeOther)
}
func (ws *WebServer) handleTransitEncrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
plaintext := r.FormValue("plaintext")
transitCtx := r.FormValue("context")
if key == "" || plaintext == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and plaintext are required")
return
}
ciphertext, err := ws.vault.TransitEncrypt(r.Context(), token, mountName, key, plaintext, transitCtx)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "EncryptResult", ciphertext)
}
func (ws *WebServer) handleTransitDecrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
ciphertext := r.FormValue("ciphertext")
transitCtx := r.FormValue("context")
if key == "" || ciphertext == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and ciphertext are required")
return
}
plaintext, err := ws.vault.TransitDecrypt(r.Context(), token, mountName, key, ciphertext, transitCtx)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "DecryptResult", plaintext)
}
func (ws *WebServer) handleTransitRewrap(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
ciphertext := r.FormValue("ciphertext")
transitCtx := r.FormValue("context")
if key == "" || ciphertext == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and ciphertext are required")
return
}
newCiphertext, err := ws.vault.TransitRewrap(r.Context(), token, mountName, key, ciphertext, transitCtx)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "RewrapResult", newCiphertext)
}
func (ws *WebServer) handleTransitSign(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
input := r.FormValue("input")
if key == "" || input == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and input are required")
return
}
signature, err := ws.vault.TransitSign(r.Context(), token, mountName, key, input)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "SignResult", signature)
}
func (ws *WebServer) handleTransitVerify(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
input := r.FormValue("input")
signature := r.FormValue("signature")
if key == "" || input == "" || signature == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key, input, and signature are required")
return
}
valid, err := ws.vault.TransitVerify(r.Context(), token, mountName, key, input, signature)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "VerifyResult", valid)
}
func (ws *WebServer) handleTransitHMAC(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findTransitMount(r, token)
if err != nil {
http.Error(w, "no transit engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
key := r.FormValue("key")
input := r.FormValue("input")
if key == "" || input == "" {
ws.renderTransitWithError(w, r, mountName, info, "Key and input are required")
return
}
hmac, err := ws.vault.TransitHMAC(r.Context(), token, mountName, key, input)
if err != nil {
ws.renderTransitWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderTransitWithResult(w, r, mountName, info, "HMACResult", hmac)
}
func (ws *WebServer) renderTransitWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if keys, err := ws.vault.ListTransitKeys(r.Context(), token, mountName); err == nil {
data["Keys"] = keys
}
ws.renderTemplate(w, "transit.html", data)
}
func (ws *WebServer) renderTransitWithResult(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, resultKey string, result interface{}) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data[resultKey] = result
if keys, err := ws.vault.ListTransitKeys(r.Context(), token, mountName); err == nil {
data["Keys"] = keys
}
ws.renderTemplate(w, "transit.html", data)
}

277
internal/webserver/user.go Normal file
View File

@@ -0,0 +1,277 @@
package webserver
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (ws *WebServer) handleUser(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
// Try to fetch the caller's own key.
if keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, info.Username); err == nil {
data["OwnKey"] = keyInfo
}
if users, err := ws.vault.ListUsers(r.Context(), token, mountName); err == nil {
data["Users"] = users
}
ws.renderTemplate(w, "user.html", data)
}
func (ws *WebServer) handleUserRegister(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
if _, err := ws.vault.UserRegister(r.Context(), token, mountName); err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/user", http.StatusFound)
}
func (ws *WebServer) handleUserProvision(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
username := strings.TrimSpace(r.FormValue("username"))
if username == "" {
ws.renderUserWithError(w, r, mountName, info, "Username is required")
return
}
if _, err := ws.vault.UserProvision(r.Context(), token, mountName, username); err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/user", http.StatusFound)
}
func (ws *WebServer) handleUserKeyDetail(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
username := chi.URLParam(r, "username")
keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, username)
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["KeyInfo"] = keyInfo
ws.renderTemplate(w, "user_key_detail.html", data)
}
func (ws *WebServer) handleUserEncrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
var recipients []string
for _, line := range strings.Split(r.FormValue("recipients"), "\n") {
if v := strings.TrimSpace(line); v != "" {
recipients = append(recipients, v)
}
}
plaintext := r.FormValue("plaintext")
metadata := r.FormValue("metadata")
if len(recipients) == 0 || plaintext == "" {
ws.renderUserWithError(w, r, mountName, info, "Recipients and plaintext are required")
return
}
envelope, err := ws.vault.UserEncrypt(r.Context(), token, mountName, plaintext, metadata, recipients)
if err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderUserWithResult(w, r, mountName, info, "EncryptResult", envelope)
}
func (ws *WebServer) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
envelope := r.FormValue("envelope")
if envelope == "" {
ws.renderUserWithError(w, r, mountName, info, "Envelope is required")
return
}
result, err := ws.vault.UserDecrypt(r.Context(), token, mountName, envelope)
if err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderUserWithResult(w, r, mountName, info, "DecryptResult", result)
}
func (ws *WebServer) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
envelope := r.FormValue("envelope")
if envelope == "" {
ws.renderUserWithError(w, r, mountName, info, "Envelope is required")
return
}
newEnvelope, err := ws.vault.UserReEncrypt(r.Context(), token, mountName, envelope)
if err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
ws.renderUserWithResult(w, r, mountName, info, "ReEncryptResult", newEnvelope)
}
func (ws *WebServer) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
if _, err := ws.vault.UserRotateKey(r.Context(), token, mountName); err != nil {
ws.renderUserWithError(w, r, mountName, info, grpcMessage(err))
return
}
http.Redirect(w, r, "/user", http.StatusSeeOther)
}
func (ws *WebServer) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := extractCookie(r)
mountName, err := ws.findUserMount(r, token)
if err != nil {
http.Error(w, "no user engine mounted", http.StatusNotFound)
return
}
username := chi.URLParam(r, "username")
if err := ws.vault.UserDeleteUser(r.Context(), token, mountName, username); err != nil {
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/user", http.StatusSeeOther)
}
func (ws *WebServer) renderUserWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, info.Username); err == nil {
data["OwnKey"] = keyInfo
}
if users, err := ws.vault.ListUsers(r.Context(), token, mountName); err == nil {
data["Users"] = users
}
ws.renderTemplate(w, "user.html", data)
}
func (ws *WebServer) renderUserWithResult(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, resultKey string, result interface{}) {
token := extractCookie(r)
data := ws.baseData(r, info)
data["MountName"] = mountName
data[resultKey] = result
if keyInfo, err := ws.vault.GetUserPublicKey(r.Context(), token, mountName, info.Username); err == nil {
data["OwnKey"] = keyInfo
}
if users, err := ws.vault.ListUsers(r.Context(), token, mountName); err == nil {
data["Users"] = users
}
ws.renderTemplate(w, "user.html", data)
}