Add SSH CA engine with host/user cert signing, profiles, and KRL

Implement the complete SSH CA engine following the CA engine pattern:
- Engine core (initialize, unseal, seal, HandleRequest) with ed25519/ecdsa key support
- Host and user certificate signing with TTL enforcement and policy checks
- Signing profiles with extensions, critical options, and principal restrictions
- Certificate CRUD (list, get, revoke, delete) with proper auth enforcement
- OpenSSH KRL generation rebuilt on revoke/delete operations
- gRPC service (SSHCAService) with all RPCs and interceptor registration
- REST routes for public endpoints (CA pubkey, KRL) and authenticated operations
- Comprehensive test suite (15 tests covering lifecycle, signing, profiles, KRL, auth)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 19:43:32 -07:00
parent 64d921827e
commit 5ae37da300
10 changed files with 6007 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ import (
"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/engine/sshca"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
@@ -40,6 +41,23 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Post("/v1/ca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleRevokeCert))
r.Delete("/v1/ca/{mount}/cert/{serial}", s.requireAdmin(s.handleDeleteCert))
// Public SSH CA routes (no auth required, but must be unsealed).
r.Get("/v1/sshca/{mount}/ca", s.requireUnseal(s.handleSSHCAPubkey))
r.Get("/v1/sshca/{mount}/krl", s.requireUnseal(s.handleSSHCAKRL))
// SSH CA auth-required routes.
r.Post("/v1/sshca/{mount}/sign-host", s.requireAuth(s.handleSSHCASignHost))
r.Post("/v1/sshca/{mount}/sign-user", s.requireAuth(s.handleSSHCASignUser))
r.Post("/v1/sshca/{mount}/profiles", s.requireAdmin(s.handleSSHCACreateProfile))
r.Put("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCAUpdateProfile))
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}/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))
// 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))
@@ -260,28 +278,30 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
}
// 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.
// Keys are "engineType:operation" to avoid name collisions across engines
// (e.g. transit "rotate-key" is admin-only but user "rotate-key" is user-self).
var adminOnlyOperations = map[string]bool{
// CA engine.
"import-root": true,
"create-issuer": true,
"delete-issuer": true,
"revoke-cert": true,
"delete-cert": true,
"ca:import-root": true,
"ca:create-issuer": true,
"ca:delete-issuer": true,
"ca:revoke-cert": true,
"ca:delete-cert": true,
// Transit engine.
"create-key": true,
"delete-key": true,
"rotate-key": true,
"update-key-config": true,
"trim-key": true,
"transit:create-key": true,
"transit:delete-key": true,
"transit:rotate-key": true,
"transit:update-key-config": true,
"transit:trim-key": true,
// SSH CA engine.
"create-profile": true,
"update-profile": true,
"delete-profile": true,
"sshca:create-profile": true,
"sshca:update-profile": true,
"sshca:delete-profile": true,
"sshca:revoke-cert": true,
"sshca:delete-cert": true,
// User engine.
"provision": true,
"delete-user": true,
"user:provision": true,
"user:delete-user": true,
}
func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
@@ -302,8 +322,16 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
info := TokenInfoFromContext(r.Context())
// Resolve engine type from mount to qualify the admin-only lookup.
mount, err := s.engines.GetMount(req.Mount)
if err != nil {
http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound)
return
}
// Enforce admin requirement for operations that have admin-only typed routes.
if adminOnlyOperations[req.Operation] && !info.IsAdmin {
// Key is "engineType:operation" to avoid cross-engine name collisions.
if adminOnlyOperations[string(mount.Type)+":"+req.Operation] && !info.IsAdmin {
http.Error(w, `{"error":"forbidden: admin required"}`, http.StatusForbidden)
return
}
@@ -734,13 +762,14 @@ 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":
"list-keys", "get-key", "get-public-key", "list-users", "get-profile", "list-profiles",
"get-ca-pubkey", "get-krl":
return policy.ActionRead
// Granular cryptographic operations (including batch variants).
case "encrypt", "batch-encrypt":
return policy.ActionEncrypt
case "decrypt", "batch-decrypt":
case "decrypt", "batch-decrypt", "rewrap", "batch-rewrap":
return policy.ActionDecrypt
case "sign", "sign-host", "sign-user":
return policy.ActionSign
@@ -769,3 +798,417 @@ func readJSON(r *http.Request, v interface{}) error {
}
return json.Unmarshal(body, v)
}
// --- SSH CA Handlers ---
func (s *Server) getSSHCAEngine(mountName string) (*sshca.SSHCAEngine, error) {
mount, err := s.engines.GetMount(mountName)
if err != nil {
return nil, err
}
if mount.Type != engine.EngineTypeSSHCA {
return nil, errors.New("mount is not an SSH CA engine")
}
eng, ok := mount.Engine.(*sshca.SSHCAEngine)
if !ok {
return nil, errors.New("mount is not an SSH CA engine")
}
return eng, nil
}
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)
return
}
pubKey, err := eng.GetCAPubkey(r.Context())
if err != nil {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(pubKey) //nolint:gosec
}
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)
return
}
krlData, err := eng.GetKRL()
if err != nil {
http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(krlData) //nolint:gosec
}
func (s *Server) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
PublicKey string `json:"public_key"`
Hostname string `json:"hostname"`
TTL string `json:"ttl"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{
"public_key": req.PublicKey,
"hostname": req.Hostname,
}
if req.TTL != "" {
data["ttl"] = req.TTL
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "sign-host",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCASignUser(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
PublicKey string `json:"public_key"`
Principals []string `json:"principals"`
Profile string `json:"profile"`
TTL string `json:"ttl"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{
"public_key": req.PublicKey,
}
if len(req.Principals) > 0 {
principals := make([]interface{}, len(req.Principals))
for i, p := range req.Principals {
principals[i] = p
}
data["principals"] = principals
}
if req.Profile != "" {
data["profile"] = req.Profile
}
if req.TTL != "" {
data["ttl"] = req.TTL
}
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
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "sign-user",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: policyChecker,
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
var req struct {
Name string `json:"name"`
CriticalOptions map[string]string `json:"critical_options"`
Extensions map[string]string `json:"extensions"`
MaxTTL string `json:"max_ttl"`
AllowedPrincipals []string `json:"allowed_principals"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{"name": req.Name}
if req.CriticalOptions != nil {
opts := make(map[string]interface{}, len(req.CriticalOptions))
for k, v := range req.CriticalOptions {
opts[k] = v
}
data["critical_options"] = opts
}
if req.Extensions != nil {
exts := make(map[string]interface{}, len(req.Extensions))
for k, v := range req.Extensions {
exts[k] = v
}
data["extensions"] = exts
}
if req.MaxTTL != "" {
data["max_ttl"] = req.MaxTTL
}
if len(req.AllowedPrincipals) > 0 {
principals := make([]interface{}, len(req.AllowedPrincipals))
for i, p := range req.AllowedPrincipals {
principals[i] = p
}
data["allowed_principals"] = principals
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "create-profile",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusCreated, resp.Data)
}
func (s *Server) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
name := chi.URLParam(r, "name")
var req struct {
CriticalOptions map[string]string `json:"critical_options"`
Extensions map[string]string `json:"extensions"`
MaxTTL string `json:"max_ttl"`
AllowedPrincipals []string `json:"allowed_principals"`
}
if err := readJSON(r, &req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
info := TokenInfoFromContext(r.Context())
data := map[string]interface{}{"name": name}
if req.CriticalOptions != nil {
opts := make(map[string]interface{}, len(req.CriticalOptions))
for k, v := range req.CriticalOptions {
opts[k] = v
}
data["critical_options"] = opts
}
if req.Extensions != nil {
exts := make(map[string]interface{}, len(req.Extensions))
for k, v := range req.Extensions {
exts[k] = v
}
data["extensions"] = exts
}
if req.MaxTTL != "" {
data["max_ttl"] = req.MaxTTL
}
if len(req.AllowedPrincipals) > 0 {
principals := make([]interface{}, len(req.AllowedPrincipals))
for i, p := range req.AllowedPrincipals {
principals[i] = p
}
data["allowed_principals"] = principals
}
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "update-profile",
Data: data,
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCAGetProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
name := chi.URLParam(r, "name")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "get-profile",
Data: map[string]interface{}{"name": name},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCAListProfiles(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-profiles",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
name := chi.URLParam(r, "name")
info := TokenInfoFromContext(r.Context())
_, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "delete-profile",
Data: map[string]interface{}{"name": name},
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
func (s *Server) handleSSHCAGetCert(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 {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCAListCerts(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
info := TokenInfoFromContext(r.Context())
resp, err := s.engines.HandleRequest(r.Context(), mountName, &engine.Request{
Operation: "list-certs",
CallerInfo: &engine.CallerInfo{
Username: info.Username,
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
})
if err != nil {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCARevokeCert(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 {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusOK, resp.Data)
}
func (s *Server) handleSSHCADeleteCert(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 {
s.writeEngineError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, engine.ErrMountNotFound):
status = http.StatusNotFound
case errors.Is(err, sshca.ErrCertNotFound):
status = http.StatusNotFound
case errors.Is(err, sshca.ErrProfileNotFound):
status = http.StatusNotFound
case errors.Is(err, sshca.ErrProfileExists):
status = http.StatusConflict
case errors.Is(err, sshca.ErrForbidden):
status = http.StatusForbidden
case errors.Is(err, sshca.ErrUnauthorized):
status = http.StatusUnauthorized
case strings.Contains(err.Error(), "forbidden"):
status = http.StatusForbidden
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
}