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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,7 @@ func (s *Server) Start() error {
|
||||
s.registerRoutes(r)
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
|
||||
@@ -241,18 +241,84 @@ func TestEngineRequestPolicyAllowsWithRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationAction verifies the read/write classification of operations.
|
||||
func TestOperationAction(t *testing.T) {
|
||||
readOps := []string{"list-issuers", "list-certs", "get-cert", "get-root", "get-chain", "get-issuer"}
|
||||
for _, op := range readOps {
|
||||
if got := operationAction(op); got != "read" {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, "read")
|
||||
// TestEngineRequestAdminOnlyBlocksNonAdmin verifies that admin-only operations
|
||||
// via the generic endpoint are rejected for non-admin users.
|
||||
func TestEngineRequestAdminOnlyBlocksNonAdmin(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
for _, op := range []string{"create-issuer", "delete-cert", "create-key", "rotate-key", "create-profile", "provision"} {
|
||||
body := makeEngineRequest("test-mount", op)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("operation %q: expected 403 for non-admin, got %d", op, w.Code)
|
||||
}
|
||||
}
|
||||
writeOps := []string{"issue", "renew", "create-issuer", "delete-issuer", "sign-csr", "revoke"}
|
||||
for _, op := range writeOps {
|
||||
if got := operationAction(op); got != "write" {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, "write")
|
||||
}
|
||||
|
||||
// TestEngineRequestAdminOnlyAllowsAdmin verifies that admin-only operations
|
||||
// via the generic endpoint are allowed for admin users.
|
||||
func TestEngineRequestAdminOnlyAllowsAdmin(t *testing.T) {
|
||||
srv, sealMgr, _ := setupTestServer(t)
|
||||
unsealServer(t, sealMgr, nil)
|
||||
|
||||
for _, op := range []string{"create-issuer", "delete-cert", "create-key", "rotate-key", "create-profile", "provision"} {
|
||||
body := makeEngineRequest("test-mount", op)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
|
||||
req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true})
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleEngineRequest(w, req)
|
||||
|
||||
// Admin passes the admin check; will get 404 (mount not found) not 403.
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Errorf("operation %q: admin should not be forbidden, got 403", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationAction verifies the action classification of operations.
|
||||
func TestOperationAction(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
// Read operations.
|
||||
"list-issuers": policy.ActionRead,
|
||||
"list-certs": policy.ActionRead,
|
||||
"get-cert": policy.ActionRead,
|
||||
"get-root": policy.ActionRead,
|
||||
"get-chain": policy.ActionRead,
|
||||
"get-issuer": policy.ActionRead,
|
||||
"list-keys": policy.ActionRead,
|
||||
"get-key": policy.ActionRead,
|
||||
"get-public-key": policy.ActionRead,
|
||||
"list-users": policy.ActionRead,
|
||||
"get-profile": policy.ActionRead,
|
||||
"list-profiles": policy.ActionRead,
|
||||
|
||||
// Granular crypto operations (including batch variants).
|
||||
"encrypt": policy.ActionEncrypt,
|
||||
"batch-encrypt": policy.ActionEncrypt,
|
||||
"decrypt": policy.ActionDecrypt,
|
||||
"batch-decrypt": policy.ActionDecrypt,
|
||||
"sign": policy.ActionSign,
|
||||
"sign-host": policy.ActionSign,
|
||||
"sign-user": policy.ActionSign,
|
||||
"verify": policy.ActionVerify,
|
||||
"hmac": policy.ActionHMAC,
|
||||
|
||||
// Write operations.
|
||||
"issue": policy.ActionWrite,
|
||||
"renew": policy.ActionWrite,
|
||||
"create-issuer": policy.ActionWrite,
|
||||
"delete-issuer": policy.ActionWrite,
|
||||
"sign-csr": policy.ActionWrite,
|
||||
"revoke": policy.ActionWrite,
|
||||
}
|
||||
for op, want := range tests {
|
||||
if got := operationAction(op); got != want {
|
||||
t.Errorf("operationAction(%q) = %q, want %q", op, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user