Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs stored in a new barrier_keys table, rather than encrypting all barrier entries directly. A v2 ciphertext format (0x02) embeds the key ID so the barrier can resolve which DEK to use on decryption. v1 ciphertext remains supported for backward compatibility. Key changes: - crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs - barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys) - seal: RotateMEK re-wraps DEKs without re-encrypting data - engine: Mount auto-creates per-engine DEK - REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate - proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate - db: migration v2 adds barrier_keys table Also includes: security audit report, CSRF protection, engine design specs (sshca, transit, user), path-bound AAD migration tool, policy engine enhancements, and ARCHITECTURE.md updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
772 lines
23 KiB
Go
772 lines
23 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
|
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
|
)
|
|
|
|
func (s *Server) registerRoutes(r chi.Router) {
|
|
// REST API routes — web UI served by metacrypt-web.
|
|
r.Get("/v1/status", s.handleStatus)
|
|
r.Post("/v1/init", s.handleInit)
|
|
r.Post("/v1/unseal", s.handleUnseal)
|
|
r.Post("/v1/seal", s.requireAdmin(s.handleSeal))
|
|
|
|
r.Post("/v1/auth/login", s.handleLogin)
|
|
r.Post("/v1/auth/logout", s.requireAuth(s.handleLogout))
|
|
r.Get("/v1/auth/tokeninfo", s.requireAuth(s.handleTokenInfo))
|
|
|
|
r.Get("/v1/engine/mounts", s.requireAuth(s.handleEngineMounts))
|
|
r.Post("/v1/engine/mount", s.requireAdmin(s.handleEngineMount))
|
|
r.Post("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
|
|
r.Post("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
|
|
|
// CA certificate routes (auth required).
|
|
r.Get("/v1/ca/{mount}/cert/{serial}", s.requireAuth(s.handleGetCert))
|
|
r.Post("/v1/ca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleRevokeCert))
|
|
r.Delete("/v1/ca/{mount}/cert/{serial}", s.requireAdmin(s.handleDeleteCert))
|
|
|
|
// Public PKI routes (no auth required, but must be unsealed).
|
|
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
|
|
r.Get("/v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
|
|
r.Get("/v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
|
|
r.Get("/v1/pki/{mount}/issuer/{name}/crl", s.requireUnseal(s.handlePKICRL))
|
|
|
|
r.Get("/v1/barrier/keys", s.requireAdmin(s.handleBarrierKeys))
|
|
r.Post("/v1/barrier/rotate-mek", s.requireAdmin(s.handleRotateMEK))
|
|
r.Post("/v1/barrier/rotate-key", s.requireAdmin(s.handleRotateKey))
|
|
r.Post("/v1/barrier/migrate", s.requireAdmin(s.handleBarrierMigrate))
|
|
|
|
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
|
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
|
s.registerACMERoutes(r)
|
|
}
|
|
|
|
// --- API Handlers ---
|
|
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"state": s.seal.State().String(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleInit(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Password == "" {
|
|
http.Error(w, `{"error":"password is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
params := crypto.Argon2Params{
|
|
Time: s.cfg.Seal.Argon2Time,
|
|
Memory: s.cfg.Seal.Argon2Memory,
|
|
Threads: s.cfg.Seal.Argon2Threads,
|
|
}
|
|
if err := s.seal.Initialize(r.Context(), []byte(req.Password), params); err != nil {
|
|
if errors.Is(err, seal.ErrAlreadyInitialized) {
|
|
http.Error(w, `{"error":"already initialized"}`, http.StatusConflict)
|
|
return
|
|
}
|
|
s.logger.Error("init failed", "error", err)
|
|
http.Error(w, `{"error":"initialization failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"state": s.seal.State().String(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := s.seal.Unseal([]byte(req.Password)); err != nil {
|
|
if errors.Is(err, seal.ErrNotInitialized) {
|
|
s.logger.Warn("unseal attempt on uninitialized service", "remote_addr", r.RemoteAddr)
|
|
http.Error(w, `{"error":"not initialized"}`, http.StatusPreconditionFailed)
|
|
} else if errors.Is(err, seal.ErrInvalidPassword) {
|
|
s.logger.Warn("unseal attempt with invalid password", "remote_addr", r.RemoteAddr)
|
|
http.Error(w, `{"error":"invalid password"}`, http.StatusUnauthorized)
|
|
} else if errors.Is(err, seal.ErrRateLimited) {
|
|
s.logger.Warn("unseal attempt rate limited", "remote_addr", r.RemoteAddr)
|
|
http.Error(w, `{"error":"too many attempts, try again later"}`, http.StatusTooManyRequests)
|
|
} else if errors.Is(err, seal.ErrNotSealed) {
|
|
s.logger.Warn("unseal attempt on already-unsealed service", "remote_addr", r.RemoteAddr)
|
|
http.Error(w, `{"error":"already unsealed"}`, http.StatusConflict)
|
|
} else {
|
|
s.logger.Error("unseal failed", "remote_addr", r.RemoteAddr, "error", err)
|
|
http.Error(w, `{"error":"unseal failed"}`, http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := s.engines.UnsealAll(r.Context()); err != nil {
|
|
s.logger.Error("engine unseal failed", "error", err)
|
|
http.Error(w, `{"error":"engine unseal failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.logger.Info("service unsealed", "remote_addr", r.RemoteAddr)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"state": s.seal.State().String(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.engines.SealAll(); err != nil {
|
|
s.logger.Error("seal engines failed", "error", err)
|
|
}
|
|
|
|
if err := s.seal.Seal(); err != nil {
|
|
s.logger.Error("seal failed", "error", err)
|
|
http.Error(w, `{"error":"seal failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.auth.ClearCache()
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"state": s.seal.State().String(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if s.seal.State() != seal.StateUnsealed {
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
TOTPCode string `json:"totp_code"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
token, expiresAt, err := s.auth.Login(req.Username, req.Password, req.TOTPCode)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"token": token,
|
|
"expires_at": expiresAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
token := extractToken(r)
|
|
client, err := mcias.New(s.cfg.MCIAS.ServerURL, mcias.Options{
|
|
CACertPath: s.cfg.MCIAS.CACert,
|
|
Token: token,
|
|
})
|
|
if err == nil {
|
|
_ = s.auth.Logout(client)
|
|
}
|
|
|
|
// Clear cookie.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "metacrypt_token",
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
|
info := TokenInfoFromContext(r.Context())
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"username": info.Username,
|
|
"roles": info.Roles,
|
|
"is_admin": info.IsAdmin,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleEngineMounts(w http.ResponseWriter, r *http.Request) {
|
|
mounts := s.engines.ListMounts()
|
|
writeJSON(w, http.StatusOK, mounts)
|
|
}
|
|
|
|
func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Config map[string]interface{} `json:"config"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == "" || req.Type == "" {
|
|
http.Error(w, `{"error":"name and type are required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.engines.Unmount(r.Context(), req.Name); err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
// adminOnlyOperations lists engine operations that require admin role.
|
|
// This enforces the same gates as the typed REST routes, ensuring the
|
|
// generic endpoint cannot bypass admin requirements.
|
|
var adminOnlyOperations = map[string]bool{
|
|
// CA engine.
|
|
"import-root": true,
|
|
"create-issuer": true,
|
|
"delete-issuer": true,
|
|
"revoke-cert": true,
|
|
"delete-cert": true,
|
|
// Transit engine.
|
|
"create-key": true,
|
|
"delete-key": true,
|
|
"rotate-key": true,
|
|
"update-key-config": true,
|
|
"trim-key": true,
|
|
// SSH CA engine.
|
|
"create-profile": true,
|
|
"update-profile": true,
|
|
"delete-profile": true,
|
|
// User engine.
|
|
"provision": true,
|
|
"delete-user": true,
|
|
}
|
|
|
|
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Data map[string]interface{} `json:"data"`
|
|
Mount string `json:"mount"`
|
|
Operation string `json:"operation"`
|
|
Path string `json:"path"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Mount == "" || req.Operation == "" {
|
|
http.Error(w, `{"error":"mount and operation are required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
info := TokenInfoFromContext(r.Context())
|
|
|
|
// Enforce admin requirement for operations that have admin-only typed routes.
|
|
if adminOnlyOperations[req.Operation] && !info.IsAdmin {
|
|
http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Evaluate policy before dispatching to the engine.
|
|
policyReq := &policy.Request{
|
|
Username: info.Username,
|
|
Roles: info.Roles,
|
|
Resource: "engine/" + req.Mount + "/" + req.Operation,
|
|
Action: operationAction(req.Operation),
|
|
}
|
|
effect, err := s.policy.Evaluate(r.Context(), policyReq)
|
|
if err != nil {
|
|
s.logger.Error("policy evaluation failed", "error", err)
|
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if effect != policy.EffectAllow {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
policyChecker := 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
|
|
}
|
|
|
|
engReq := &engine.Request{
|
|
Operation: req.Operation,
|
|
Path: req.Path,
|
|
Data: req.Data,
|
|
CallerInfo: &engine.CallerInfo{
|
|
Username: info.Username,
|
|
Roles: info.Roles,
|
|
IsAdmin: info.IsAdmin,
|
|
},
|
|
CheckPolicy: policyChecker,
|
|
}
|
|
|
|
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
switch {
|
|
case errors.Is(err, engine.ErrMountNotFound):
|
|
status = http.StatusNotFound
|
|
case errors.Is(err, ca.ErrIdentifierInUse):
|
|
status = http.StatusConflict
|
|
case strings.Contains(err.Error(), "forbidden"):
|
|
status = http.StatusForbidden
|
|
case strings.Contains(err.Error(), "authentication required"):
|
|
status = http.StatusUnauthorized
|
|
case strings.Contains(err.Error(), "not found"):
|
|
status = http.StatusNotFound
|
|
}
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp.Data)
|
|
}
|
|
|
|
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
|
|
info := TokenInfoFromContext(r.Context())
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if !info.IsAdmin {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
rules, err := s.policy.ListRules(r.Context())
|
|
if err != nil {
|
|
s.logger.Error("list policies", "error", err)
|
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if rules == nil {
|
|
rules = []policy.Rule{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rules)
|
|
case http.MethodPost:
|
|
if !info.IsAdmin {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
var rule policy.Rule
|
|
if err := readJSON(r, &rule); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if rule.ID == "" {
|
|
http.Error(w, `{"error":"id is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.policy.CreateRule(r.Context(), &rule); err != nil {
|
|
s.logger.Error("create policy", "error", err)
|
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, rule)
|
|
default:
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) {
|
|
info := TokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
id := r.URL.Query().Get("id")
|
|
if id == "" {
|
|
http.Error(w, `{"error":"id parameter required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
rule, err := s.policy.GetRule(r.Context(), id)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rule)
|
|
case http.MethodDelete:
|
|
if err := s.policy.DeleteRule(r.Context(), id); err != nil {
|
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// --- Barrier Key Management Handlers ---
|
|
|
|
func (s *Server) handleBarrierKeys(w http.ResponseWriter, r *http.Request) {
|
|
keys, err := s.seal.Barrier().ListKeys(r.Context())
|
|
if err != nil {
|
|
s.logger.Error("list barrier keys", "error", err)
|
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if keys == nil {
|
|
keys = []barrier.KeyInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, keys)
|
|
}
|
|
|
|
func (s *Server) handleRotateMEK(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Password == "" {
|
|
http.Error(w, `{"error":"password is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := s.seal.RotateMEK(r.Context(), []byte(req.Password)); err != nil {
|
|
if errors.Is(err, seal.ErrInvalidPassword) {
|
|
http.Error(w, `{"error":"invalid password"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if errors.Is(err, seal.ErrSealed) {
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
s.logger.Error("rotate MEK", "error", err)
|
|
http.Error(w, `{"error":"rotation failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
KeyID string `json:"key_id"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.KeyID == "" {
|
|
http.Error(w, `{"error":"key_id is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := s.seal.Barrier().RotateKey(r.Context(), req.KeyID); err != nil {
|
|
if errors.Is(err, barrier.ErrKeyNotFound) {
|
|
http.Error(w, `{"error":"key not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
s.logger.Error("rotate key", "key_id", req.KeyID, "error", err)
|
|
http.Error(w, `{"error":"rotation failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleBarrierMigrate(w http.ResponseWriter, r *http.Request) {
|
|
migrated, err := s.seal.Barrier().MigrateToV2(r.Context())
|
|
if err != nil {
|
|
s.logger.Error("barrier migration", "error", err)
|
|
http.Error(w, `{"error":"migration failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"migrated": migrated,
|
|
})
|
|
}
|
|
|
|
// --- CA Certificate Handlers ---
|
|
|
|
func (s *Server) handleGetCert(w http.ResponseWriter, r *http.Request) {
|
|
mountName := chi.URLParam(r, "mount")
|
|
serial := chi.URLParam(r, "serial")
|
|
|
|
info := TokenInfoFromContext(r.Context())
|
|
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
|
|
Operation: "get-cert",
|
|
Data: map[string]interface{}{"serial": serial},
|
|
CallerInfo: &engine.CallerInfo{
|
|
Username: info.Username,
|
|
Roles: info.Roles,
|
|
IsAdmin: info.IsAdmin,
|
|
},
|
|
})
|
|
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)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp.Data)
|
|
}
|
|
|
|
func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
|
|
mountName := chi.URLParam(r, "mount")
|
|
serial := chi.URLParam(r, "serial")
|
|
|
|
info := TokenInfoFromContext(r.Context())
|
|
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
|
|
Operation: "revoke-cert",
|
|
Data: map[string]interface{}{"serial": serial},
|
|
CallerInfo: &engine.CallerInfo{
|
|
Username: info.Username,
|
|
Roles: info.Roles,
|
|
IsAdmin: info.IsAdmin,
|
|
},
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrCertNotFound) {
|
|
http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, ca.ErrForbidden) {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp.Data)
|
|
}
|
|
|
|
func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
|
|
mountName := chi.URLParam(r, "mount")
|
|
serial := chi.URLParam(r, "serial")
|
|
|
|
info := TokenInfoFromContext(r.Context())
|
|
_, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
|
|
Operation: "delete-cert",
|
|
Data: map[string]interface{}{"serial": serial},
|
|
CallerInfo: &engine.CallerInfo{
|
|
Username: info.Username,
|
|
Roles: info.Roles,
|
|
IsAdmin: info.IsAdmin,
|
|
},
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrCertNotFound) {
|
|
http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
if errors.Is(err, ca.ErrForbidden) {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// --- Public PKI Handlers ---
|
|
|
|
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)
|
|
return
|
|
}
|
|
|
|
certPEM, err := caEng.GetRootCertPEM()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
_, _ = w.Write(certPEM) //nolint:gosec
|
|
}
|
|
|
|
func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
|
|
mountName := chi.URLParam(r, "mount")
|
|
issuerName := r.URL.Query().Get("issuer")
|
|
if issuerName == "" {
|
|
http.Error(w, `{"error":"issuer query parameter required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
caEng, err := s.getCAEngine(mountName)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
chainPEM, err := caEng.GetChainPEM(issuerName)
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrIssuerNotFound) {
|
|
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
_, _ = w.Write(chainPEM) //nolint:gosec
|
|
}
|
|
|
|
func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
|
|
mountName := chi.URLParam(r, "mount")
|
|
issuerName := chi.URLParam(r, "name")
|
|
|
|
caEng, err := s.getCAEngine(mountName)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
certPEM, err := caEng.GetIssuerCertPEM(issuerName)
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrIssuerNotFound) {
|
|
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
_, _ = w.Write(certPEM) //nolint:gosec
|
|
}
|
|
|
|
func (s *Server) handlePKICRL(w http.ResponseWriter, r *http.Request) {
|
|
mountName := chi.URLParam(r, "mount")
|
|
issuerName := chi.URLParam(r, "name")
|
|
|
|
caEng, err := s.getCAEngine(mountName)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
crlDER, err := caEng.GetCRLDER(r.Context(), issuerName)
|
|
if err != nil {
|
|
if errors.Is(err, ca.ErrIssuerNotFound) {
|
|
http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/pkix-crl")
|
|
_, _ = w.Write(crlDER) //nolint:gosec
|
|
}
|
|
|
|
func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
|
|
mount, err := s.engines.GetMount(mountName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mount.Type != engine.EngineTypeCA {
|
|
return nil, errors.New("mount is not a CA engine")
|
|
}
|
|
caEng, ok := mount.Engine.(*ca.CAEngine)
|
|
if !ok {
|
|
return nil, errors.New("mount is not a CA engine")
|
|
}
|
|
return caEng, nil
|
|
}
|
|
|
|
// operationAction maps an engine operation name to a policy action.
|
|
func operationAction(op string) string {
|
|
switch op {
|
|
// Read operations.
|
|
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer",
|
|
"list-keys", "get-key", "get-public-key", "list-users", "get-profile", "list-profiles":
|
|
return policy.ActionRead
|
|
|
|
// Granular cryptographic operations (including batch variants).
|
|
case "encrypt", "batch-encrypt":
|
|
return policy.ActionEncrypt
|
|
case "decrypt", "batch-decrypt":
|
|
return policy.ActionDecrypt
|
|
case "sign", "sign-host", "sign-user":
|
|
return policy.ActionSign
|
|
case "verify":
|
|
return policy.ActionVerify
|
|
case "hmac":
|
|
return policy.ActionHMAC
|
|
|
|
// Everything else is a write.
|
|
default:
|
|
return policy.ActionWrite
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
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
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(body, v)
|
|
}
|