Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)

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>
This commit is contained in:
2026-03-16 18:27:44 -07:00
parent ac4577f778
commit 64d921827e
44 changed files with 5184 additions and 90 deletions

View File

@@ -11,6 +11,7 @@ import (
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"
@@ -45,6 +46,11 @@ func (s *Server) registerRoutes(r chi.Router) {
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)
@@ -253,6 +259,31 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
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"`
@@ -271,6 +302,12 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
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,
@@ -412,6 +449,90 @@ func (s *Server) handlePolicyRule(w http.ResponseWriter, r *http.Request) {
}
}
// --- 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) {
@@ -608,13 +729,29 @@ func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) {
return caEng, nil
}
// operationAction maps an engine operation name to a policy action ("read" or "write").
// operationAction maps an engine operation name to a policy action.
func operationAction(op string) string {
switch op {
case "list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer":
return "read"
// 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 "write"
return policy.ActionWrite
}
}