Implement CA/PKI engine with two-tier X.509 certificate issuance
Add the first concrete engine implementation: a CA (PKI) engine that generates
a self-signed root CA at mount time, issues scoped intermediate CAs ("issuers"),
and signs leaf certificates using configurable profiles (server, client, peer).
Engine framework updates:
- Add CallerInfo struct for auth context in engine requests
- Add config parameter to Engine.Initialize for mount-time configuration
- Export Mount.Engine field; add GetEngine/GetMount on Registry
CA engine (internal/engine/ca/):
- Two-tier PKI: root CA → issuers → leaf certificates
- 10 operations: get-root, get-chain, get-issuer, create/delete/list issuers,
issue, get-cert, list-certs, renew
- Certificate profiles with user-overridable TTL, key usages, and key algorithm
- Private keys never stored in barrier; zeroized from memory on seal
- Supports ECDSA, RSA, and Ed25519 key types via goutils/certlib/certgen
Server routes:
- Wire up engine mount/request handlers (replace Phase 1 stubs)
- Add public PKI routes (/v1/pki/{mount}/ca, /ca/chain, /issuer/{name})
for unauthenticated TLS trust bootstrapping
Also includes: ARCHITECTURE.md, deploy config updates, operational tooling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,18 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -41,6 +45,11 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount))
|
||||
mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
||||
|
||||
// Public PKI routes (no auth required, but must be unsealed).
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain))
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer))
|
||||
|
||||
mux.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
||||
mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
||||
}
|
||||
@@ -239,15 +248,25 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Phase 1: no engine types registered yet.
|
||||
http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented)
|
||||
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) {
|
||||
@@ -274,8 +293,140 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Phase 1 stub.
|
||||
http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented)
|
||||
|
||||
var req struct {
|
||||
Mount string `json:"mount"`
|
||||
Operation string `json:"operation"`
|
||||
Path string `json:"path"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
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())
|
||||
engReq := &engine.Request{
|
||||
Operation: req.Operation,
|
||||
Path: req.Path,
|
||||
Data: req.Data,
|
||||
CallerInfo: &engine.CallerInfo{
|
||||
Username: info.Username,
|
||||
Roles: info.Roles,
|
||||
IsAdmin: info.IsAdmin,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
// Map known errors to appropriate status codes.
|
||||
switch {
|
||||
case errors.Is(err, engine.ErrMountNotFound):
|
||||
status = http.StatusNotFound
|
||||
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)
|
||||
}
|
||||
|
||||
// --- Public PKI Handlers ---
|
||||
|
||||
func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
|
||||
mountName := r.PathValue("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)
|
||||
}
|
||||
|
||||
func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
|
||||
mountName := r.PathValue("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)
|
||||
}
|
||||
|
||||
func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
|
||||
mountName := r.PathValue("mount")
|
||||
issuerName := r.PathValue("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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user