Files
metacrypt/internal/engine/sshca/sshca.go
Kyle Isom a80323e320 Add web UI for SSH CA, Transit, and User engines; full security audit and remediation
Web UI: Added browser-based management for all three remaining engines
(SSH CA, Transit, User E2E). Includes gRPC client wiring, handler files,
7 HTML templates, dashboard mount forms, and conditional navigation links.
Fixed REST API routes to match design specs (SSH CA cert singular paths,
Transit PATCH for update-key-config).

Security audit: Conducted full-system audit covering crypto core, all
engine implementations, API servers, policy engine, auth, deployment,
and documentation. Identified 42 new findings (#39-#80) across all
severity levels.

Remediation of all 8 High findings:
- #68: Replaced 14 JSON-injection-vulnerable error responses with safe
  json.Encoder via writeJSONError helper
- #48: Added two-layer path traversal defense (barrier validatePath
  rejects ".." segments; engine ValidateName enforces safe name pattern)
- #39: Extended RLock through entire crypto operations in barrier
  Get/Put/Delete/List to eliminate TOCTOU race with Seal
- #40: Unified ReWrapKeys and seal_config UPDATE into single SQLite
  transaction to prevent irrecoverable data loss on crash during MEK
  rotation
- #49: Added resolveTTL to CA engine enforcing issuer MaxTTL ceiling
  on handleIssue and handleSignCSR
- #61: Store raw ECDH private key bytes in userState for effective
  zeroization on Seal
- #62: Fixed user engine policy resource path from mountPath to
  mountName() so policy rules match correctly
- #69: Added newPolicyChecker helper and passed service-level policy
  evaluation to all 25 typed REST handler engine.Request structs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:02:06 -07:00

1188 lines
30 KiB
Go

// Package sshca implements the SSH CA engine for SSH certificate issuance.
package sshca
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/binary"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
mcrypto "git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
)
var (
ErrSealed = errors.New("sshca: engine is sealed")
ErrCertNotFound = errors.New("sshca: certificate not found")
ErrProfileExists = errors.New("sshca: profile already exists")
ErrProfileNotFound = errors.New("sshca: profile not found")
ErrForbidden = errors.New("sshca: forbidden")
ErrUnauthorized = errors.New("sshca: authentication required")
)
// SSHCAEngine implements the SSH CA engine.
type SSHCAEngine struct {
barrier barrier.Barrier
config *SSHCAConfig
caKey crypto.PrivateKey
caSigner ssh.Signer
mountPath string
krlVersion uint64
krlData []byte
mu sync.RWMutex
}
// NewSSHCAEngine creates a new SSH CA engine instance.
func NewSSHCAEngine() engine.Engine {
return &SSHCAEngine{}
}
func (e *SSHCAEngine) Type() engine.EngineType {
return engine.EngineTypeSSHCA
}
func (e *SSHCAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error {
e.mu.Lock()
defer e.mu.Unlock()
e.barrier = b
e.mountPath = mountPath
cfg := &SSHCAConfig{
KeyAlgorithm: "ed25519",
MaxTTL: "87600h",
DefaultTTL: "24h",
}
if v, ok := config["key_algorithm"].(string); ok && v != "" {
cfg.KeyAlgorithm = v
}
if v, ok := config["max_ttl"].(string); ok && v != "" {
cfg.MaxTTL = v
}
if v, ok := config["default_ttl"].(string); ok && v != "" {
cfg.DefaultTTL = v
}
// Validate config.
if _, err := time.ParseDuration(cfg.MaxTTL); err != nil {
return fmt.Errorf("sshca: invalid max_ttl: %w", err)
}
if _, err := time.ParseDuration(cfg.DefaultTTL); err != nil {
return fmt.Errorf("sshca: invalid default_ttl: %w", err)
}
// Generate CA key.
privKey, err := generateKey(cfg.KeyAlgorithm)
if err != nil {
return fmt.Errorf("sshca: generate CA key: %w", err)
}
// Store CA key as PKCS8 PEM.
keyBytes, err := x509.MarshalPKCS8PrivateKey(privKey)
if err != nil {
return fmt.Errorf("sshca: marshal CA key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
if err := b.Put(ctx, mountPath+"ca/key.pem", keyPEM); err != nil {
return fmt.Errorf("sshca: store CA key: %w", err)
}
// Store CA public key in SSH authorized_keys format.
sshPub, err := ssh.NewPublicKey(publicKey(privKey))
if err != nil {
return fmt.Errorf("sshca: create SSH public key: %w", err)
}
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPub)
if err := b.Put(ctx, mountPath+"ca/pubkey.pub", pubKeyBytes); err != nil {
return fmt.Errorf("sshca: store CA public key: %w", err)
}
// Store config.
cfgData, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("sshca: marshal config: %w", err)
}
if err := b.Put(ctx, mountPath+"config.json", cfgData); err != nil {
return fmt.Errorf("sshca: store config: %w", err)
}
// Initialize KRL version.
if err := e.storeKRLVersion(ctx, 0); err != nil {
return fmt.Errorf("sshca: store KRL version: %w", err)
}
// Set in-memory state.
e.config = cfg
e.caKey = privKey
signer, err := ssh.NewSignerFromKey(privKey)
if err != nil {
return fmt.Errorf("sshca: create signer: %w", err)
}
e.caSigner = signer
e.krlVersion = 0
e.krlData = e.buildKRL(nil)
return nil
}
func (e *SSHCAEngine) Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.barrier = b
e.mountPath = mountPath
// Load config.
cfgData, err := b.Get(ctx, mountPath+"config.json")
if err != nil {
return fmt.Errorf("sshca: load config: %w", err)
}
var cfg SSHCAConfig
if err := json.Unmarshal(cfgData, &cfg); err != nil {
return fmt.Errorf("sshca: parse config: %w", err)
}
e.config = &cfg
// Load CA key.
keyPEM, err := b.Get(ctx, mountPath+"ca/key.pem")
if err != nil {
return fmt.Errorf("sshca: load CA key: %w", err)
}
block, _ := pem.Decode(keyPEM)
if block == nil {
return fmt.Errorf("sshca: decode CA key PEM")
}
privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("sshca: parse CA key: %w", err)
}
e.caKey = privKey
signer, err := ssh.NewSignerFromKey(privKey)
if err != nil {
return fmt.Errorf("sshca: create signer: %w", err)
}
e.caSigner = signer
// Load KRL version.
e.krlVersion, err = e.loadKRLVersion(ctx)
if err != nil {
// Default to 0 if not found.
e.krlVersion = 0
}
// Rebuild KRL from revoked certs.
revokedSerials, err := e.collectRevokedSerials(ctx)
if err != nil {
return fmt.Errorf("sshca: rebuild KRL: %w", err)
}
e.krlData = e.buildKRL(revokedSerials)
return nil
}
func (e *SSHCAEngine) Seal() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.caKey != nil {
engine.ZeroizeKey(e.caKey)
}
e.caKey = nil
e.caSigner = nil
e.config = nil
e.krlData = nil
return nil
}
func (e *SSHCAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.config == nil {
return nil, ErrSealed
}
switch req.Operation {
case "get-ca-pubkey":
return e.handleGetCAPubkey(ctx)
case "sign-host":
return e.handleSignHost(ctx, req)
case "sign-user":
return e.handleSignUser(ctx, req)
case "create-profile":
return e.handleCreateProfile(ctx, req)
case "update-profile":
return e.handleUpdateProfile(ctx, req)
case "get-profile":
return e.handleGetProfile(ctx, req)
case "list-profiles":
return e.handleListProfiles(ctx, req)
case "delete-profile":
return e.handleDeleteProfile(ctx, req)
case "get-cert":
return e.handleGetCert(ctx, req)
case "list-certs":
return e.handleListCerts(ctx, req)
case "revoke-cert":
return e.handleRevokeCert(ctx, req)
case "delete-cert":
return e.handleDeleteCert(ctx, req)
case "get-krl":
return e.handleGetKRL(ctx)
default:
return nil, fmt.Errorf("sshca: unknown operation %q", req.Operation)
}
}
// GetCAPubkey returns the CA public key. Thread-safe for use by route handlers.
func (e *SSHCAEngine) GetCAPubkey(ctx context.Context) ([]byte, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.config == nil {
return nil, ErrSealed
}
return e.barrier.Get(ctx, e.mountPath+"ca/pubkey.pub")
}
// GetKRL returns the current KRL data. Thread-safe for use by route handlers.
func (e *SSHCAEngine) GetKRL() ([]byte, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.config == nil {
return nil, ErrSealed
}
if e.krlData == nil {
return e.buildKRL(nil), nil
}
cp := make([]byte, len(e.krlData))
copy(cp, e.krlData)
return cp, nil
}
func (e *SSHCAEngine) handleGetCAPubkey(ctx context.Context) (*engine.Response, error) {
pubKeyBytes, err := e.barrier.Get(ctx, e.mountPath+"ca/pubkey.pub")
if err != nil {
return nil, fmt.Errorf("sshca: load CA public key: %w", err)
}
return &engine.Response{
Data: map[string]interface{}{
"public_key": string(pubKeyBytes),
},
}, nil
}
func (e *SSHCAEngine) handleSignHost(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
pubKeyStr, _ := req.Data["public_key"].(string)
if pubKeyStr == "" {
return nil, fmt.Errorf("sshca: public_key is required")
}
hostname, _ := req.Data["hostname"].(string)
if hostname == "" {
return nil, fmt.Errorf("sshca: hostname is required")
}
ttlStr, _ := req.Data["ttl"].(string)
// Policy check: sshca/{mount}/id/{hostname} action sign.
mountName := e.mountName()
resource := fmt.Sprintf("sshca/%s/id/%s", mountName, hostname)
if req.CheckPolicy != nil {
effect, matched := req.CheckPolicy(resource, "sign")
if matched && effect == "deny" {
return nil, ErrForbidden
}
}
// Parse public key.
sshPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKeyStr))
if err != nil {
return nil, fmt.Errorf("sshca: parse public key: %w", err)
}
// Determine TTL.
ttl, err := e.resolveTTL(ttlStr, "")
if err != nil {
return nil, err
}
// Generate serial.
serial, err := generateSerial()
if err != nil {
return nil, fmt.Errorf("sshca: generate serial: %w", err)
}
now := time.Now()
cert := &ssh.Certificate{
CertType: ssh.HostCert,
Key: sshPubKey,
Serial: serial,
KeyId: fmt.Sprintf("host:%s", hostname),
ValidAfter: uint64(now.Add(-5 * time.Minute).Unix()),
ValidBefore: uint64(now.Add(ttl).Unix()),
ValidPrincipals: []string{hostname},
}
if err := cert.SignCert(rand.Reader, e.caSigner); err != nil {
return nil, fmt.Errorf("sshca: sign certificate: %w", err)
}
certData := ssh.MarshalAuthorizedKey(cert)
// Store cert record.
record := CertRecord{
Serial: serial,
CertType: "host",
Principals: []string{hostname},
CertData: string(certData),
KeyID: cert.KeyId,
IssuedBy: req.CallerInfo.Username,
IssuedAt: now,
ExpiresAt: now.Add(ttl),
}
if err := e.storeCertRecord(ctx, &record); err != nil {
return nil, err
}
return &engine.Response{
Data: map[string]interface{}{
"serial": strconv.FormatUint(serial, 10),
"cert_type": "host",
"principals": []interface{}{hostname},
"cert_data": string(certData),
"key_id": cert.KeyId,
"issued_by": req.CallerInfo.Username,
"issued_at": now.Format(time.RFC3339),
"expires_at": now.Add(ttl).Format(time.RFC3339),
},
}, nil
}
func (e *SSHCAEngine) handleSignUser(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
pubKeyStr, _ := req.Data["public_key"].(string)
if pubKeyStr == "" {
return nil, fmt.Errorf("sshca: public_key is required")
}
ttlStr, _ := req.Data["ttl"].(string)
profileName, _ := req.Data["profile"].(string)
// Parse principals.
principals := extractStringSlice(req.Data, "principals")
// Default: user can only sign for own username as principal.
if len(principals) == 0 {
principals = []string{req.CallerInfo.Username}
}
// Load profile if specified.
var profile *SigningProfile
if profileName != "" {
p, err := e.loadProfile(ctx, profileName)
if err != nil {
return nil, fmt.Errorf("sshca: profile %q: %w", profileName, err)
}
profile = p
}
// Check principals.
if profile != nil && len(profile.AllowedPrincipals) > 0 {
for _, p := range principals {
if !contains(profile.AllowedPrincipals, p) {
return nil, fmt.Errorf("sshca: principal %q not allowed by profile %q", p, profileName)
}
}
} else if !req.CallerInfo.IsAdmin {
// Non-admin without profile can only sign for own username.
for _, p := range principals {
if p != req.CallerInfo.Username {
// Check policy.
if req.CheckPolicy != nil {
mountName := e.mountName()
resource := fmt.Sprintf("sshca/%s/id/%s", mountName, p)
effect, matched := req.CheckPolicy(resource, "sign")
if matched && effect == "allow" {
continue
}
}
return nil, fmt.Errorf("sshca: forbidden: non-admin cannot sign for principal %q", p)
}
}
}
// Parse public key.
sshPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKeyStr))
if err != nil {
return nil, fmt.Errorf("sshca: parse public key: %w", err)
}
// Determine TTL.
profileMaxTTL := ""
if profile != nil {
profileMaxTTL = profile.MaxTTL
}
ttl, err := e.resolveTTL(ttlStr, profileMaxTTL)
if err != nil {
return nil, err
}
// Build extensions.
extensions := map[string]string{"permit-pty": ""}
if profile != nil && len(profile.Extensions) > 0 {
// Start with defaults, profile wins on conflict.
for k, v := range profile.Extensions {
extensions[k] = v
}
}
// Build critical options.
var criticalOptions map[string]string
if profile != nil && len(profile.CriticalOptions) > 0 {
criticalOptions = make(map[string]string)
for k, v := range profile.CriticalOptions {
criticalOptions[k] = v
}
}
// Generate serial.
serial, err := generateSerial()
if err != nil {
return nil, fmt.Errorf("sshca: generate serial: %w", err)
}
now := time.Now()
cert := &ssh.Certificate{
CertType: ssh.UserCert,
Key: sshPubKey,
Serial: serial,
KeyId: fmt.Sprintf("user:%s", principals[0]),
ValidAfter: uint64(now.Add(-5 * time.Minute).Unix()),
ValidBefore: uint64(now.Add(ttl).Unix()),
ValidPrincipals: principals,
Permissions: ssh.Permissions{
CriticalOptions: criticalOptions,
Extensions: extensions,
},
}
if err := cert.SignCert(rand.Reader, e.caSigner); err != nil {
return nil, fmt.Errorf("sshca: sign certificate: %w", err)
}
certData := ssh.MarshalAuthorizedKey(cert)
// Store cert record.
record := CertRecord{
Serial: serial,
CertType: "user",
Principals: principals,
CertData: string(certData),
KeyID: cert.KeyId,
Profile: profileName,
IssuedBy: req.CallerInfo.Username,
IssuedAt: now,
ExpiresAt: now.Add(ttl),
}
if err := e.storeCertRecord(ctx, &record); err != nil {
return nil, err
}
principalsIface := make([]interface{}, len(principals))
for i, p := range principals {
principalsIface[i] = p
}
return &engine.Response{
Data: map[string]interface{}{
"serial": strconv.FormatUint(serial, 10),
"cert_type": "user",
"principals": principalsIface,
"cert_data": string(certData),
"key_id": cert.KeyId,
"profile": profileName,
"issued_by": req.CallerInfo.Username,
"issued_at": now.Format(time.RFC3339),
"expires_at": now.Add(ttl).Format(time.RFC3339),
},
}, nil
}
func (e *SSHCAEngine) handleCreateProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
name, _ := req.Data["name"].(string)
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
if err := engine.ValidateName(name); err != nil {
return nil, fmt.Errorf("sshca: %w", err)
}
// Check if profile already exists.
_, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json")
if err == nil {
return nil, ErrProfileExists
}
profile := SigningProfile{
Name: name,
CriticalOptions: extractStringMap(req.Data, "critical_options"),
Extensions: extractStringMap(req.Data, "extensions"),
MaxTTL: stringFromData(req.Data, "max_ttl"),
AllowedPrincipals: extractStringSlice(req.Data, "allowed_principals"),
}
if err := e.storeProfile(ctx, &profile); err != nil {
return nil, err
}
return &engine.Response{
Data: map[string]interface{}{
"name": name,
},
}, nil
}
func (e *SSHCAEngine) handleUpdateProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
name, _ := req.Data["name"].(string)
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
// Load existing profile.
profile, err := e.loadProfile(ctx, name)
if err != nil {
return nil, ErrProfileNotFound
}
if v := extractStringMap(req.Data, "critical_options"); v != nil {
profile.CriticalOptions = v
}
if v := extractStringMap(req.Data, "extensions"); v != nil {
profile.Extensions = v
}
if v := stringFromData(req.Data, "max_ttl"); v != "" {
profile.MaxTTL = v
}
if v := extractStringSlice(req.Data, "allowed_principals"); v != nil {
profile.AllowedPrincipals = v
}
if err := e.storeProfile(ctx, profile); err != nil {
return nil, err
}
return &engine.Response{
Data: map[string]interface{}{
"name": name,
},
}, nil
}
func (e *SSHCAEngine) handleGetProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
name, _ := req.Data["name"].(string)
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
profile, err := e.loadProfile(ctx, name)
if err != nil {
return nil, ErrProfileNotFound
}
principalsIface := make([]interface{}, len(profile.AllowedPrincipals))
for i, p := range profile.AllowedPrincipals {
principalsIface[i] = p
}
return &engine.Response{
Data: map[string]interface{}{
"name": profile.Name,
"critical_options": profile.CriticalOptions,
"extensions": profile.Extensions,
"max_ttl": profile.MaxTTL,
"allowed_principals": principalsIface,
},
}, nil
}
func (e *SSHCAEngine) handleListProfiles(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
paths, err := e.barrier.List(ctx, e.mountPath+"profiles/")
if err != nil {
return &engine.Response{
Data: map[string]interface{}{
"profiles": []interface{}{},
},
}, nil
}
profiles := make([]interface{}, 0, len(paths))
for _, p := range paths {
if strings.HasSuffix(p, ".json") {
name := strings.TrimSuffix(p, ".json")
profiles = append(profiles, name)
}
}
return &engine.Response{
Data: map[string]interface{}{
"profiles": profiles,
},
}, nil
}
func (e *SSHCAEngine) handleDeleteProfile(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
name, _ := req.Data["name"].(string)
if name == "" {
return nil, fmt.Errorf("sshca: name is required")
}
// Check existence.
if _, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json"); err != nil {
return nil, ErrProfileNotFound
}
if err := e.barrier.Delete(ctx, e.mountPath+"profiles/"+name+".json"); err != nil {
return nil, fmt.Errorf("sshca: delete profile: %w", err)
}
return &engine.Response{
Data: map[string]interface{}{"ok": true},
}, nil
}
func (e *SSHCAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
serialStr, _ := req.Data["serial"].(string)
if serialStr == "" {
return nil, fmt.Errorf("sshca: serial is required")
}
record, err := e.loadCertRecord(ctx, serialStr)
if err != nil {
return nil, ErrCertNotFound
}
return &engine.Response{
Data: certRecordToData(record),
}, nil
}
func (e *SSHCAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsUser() {
return nil, ErrForbidden
}
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
if err != nil {
return &engine.Response{
Data: map[string]interface{}{
"certs": []interface{}{},
},
}, nil
}
certs := make([]interface{}, 0, len(paths))
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
serialStr := strings.TrimSuffix(p, ".json")
record, err := e.loadCertRecord(ctx, serialStr)
if err != nil {
continue
}
certs = append(certs, certRecordToData(record))
}
return &engine.Response{
Data: map[string]interface{}{
"certs": certs,
},
}, nil
}
func (e *SSHCAEngine) handleRevokeCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
serialStr, _ := req.Data["serial"].(string)
if serialStr == "" {
return nil, fmt.Errorf("sshca: serial is required")
}
record, err := e.loadCertRecord(ctx, serialStr)
if err != nil {
return nil, ErrCertNotFound
}
now := time.Now()
record.Revoked = true
record.RevokedAt = now
record.RevokedBy = req.CallerInfo.Username
if err := e.storeCertRecord(ctx, record); err != nil {
return nil, err
}
// Rebuild KRL.
e.krlVersion++
if err := e.storeKRLVersion(ctx, e.krlVersion); err != nil {
return nil, err
}
revokedSerials, _ := e.collectRevokedSerials(ctx)
e.krlData = e.buildKRL(revokedSerials)
return &engine.Response{
Data: map[string]interface{}{
"serial": serialStr,
"revoked_at": now.Format(time.RFC3339),
},
}, nil
}
func (e *SSHCAEngine) handleDeleteCert(ctx context.Context, req *engine.Request) (*engine.Response, error) {
if req.CallerInfo == nil {
return nil, ErrUnauthorized
}
if !req.CallerInfo.IsAdmin {
return nil, ErrForbidden
}
serialStr, _ := req.Data["serial"].(string)
if serialStr == "" {
return nil, fmt.Errorf("sshca: serial is required")
}
// Check existence.
if _, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serialStr+".json"); err != nil {
return nil, ErrCertNotFound
}
if err := e.barrier.Delete(ctx, e.mountPath+"certs/"+serialStr+".json"); err != nil {
return nil, fmt.Errorf("sshca: delete cert: %w", err)
}
// Rebuild KRL (the deleted cert may have been revoked).
e.krlVersion++
if err := e.storeKRLVersion(ctx, e.krlVersion); err != nil {
return nil, err
}
revokedSerials, _ := e.collectRevokedSerials(ctx)
e.krlData = e.buildKRL(revokedSerials)
return &engine.Response{
Data: map[string]interface{}{"ok": true},
}, nil
}
func (e *SSHCAEngine) handleGetKRL(_ context.Context) (*engine.Response, error) {
if e.krlData == nil {
return &engine.Response{
Data: map[string]interface{}{
"krl": string(e.buildKRL(nil)),
},
}, nil
}
cp := make([]byte, len(e.krlData))
copy(cp, e.krlData)
return &engine.Response{
Data: map[string]interface{}{
"krl": string(cp),
},
}, nil
}
// --- Helpers ---
func generateKey(algorithm string) (crypto.PrivateKey, error) {
switch algorithm {
case "ed25519":
_, priv, err := ed25519.GenerateKey(rand.Reader)
return priv, err
case "ecdsa-p256":
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case "ecdsa-p384":
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
default:
return nil, fmt.Errorf("unsupported key algorithm: %s", algorithm)
}
}
func publicKey(priv crypto.PrivateKey) crypto.PublicKey {
switch k := priv.(type) {
case ed25519.PrivateKey:
return k.Public()
case *ecdsa.PrivateKey:
return &k.PublicKey
default:
return nil
}
}
func generateSerial() (uint64, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, err
}
return binary.BigEndian.Uint64(b[:]), nil
}
func (e *SSHCAEngine) resolveTTL(requestedTTL, profileMaxTTL string) (time.Duration, error) {
maxTTL, err := time.ParseDuration(e.config.MaxTTL)
if err != nil {
return 0, fmt.Errorf("sshca: invalid engine max_ttl: %w", err)
}
// Profile max_ttl overrides engine max_ttl if more restrictive.
if profileMaxTTL != "" {
profileMax, err := time.ParseDuration(profileMaxTTL)
if err == nil && profileMax < maxTTL {
maxTTL = profileMax
}
}
if requestedTTL != "" {
ttl, err := time.ParseDuration(requestedTTL)
if err != nil {
return 0, fmt.Errorf("sshca: invalid ttl: %w", err)
}
if ttl > maxTTL {
return 0, fmt.Errorf("sshca: requested TTL %s exceeds maximum %s", ttl, maxTTL)
}
return ttl, nil
}
defaultTTL, err := time.ParseDuration(e.config.DefaultTTL)
if err != nil {
return 0, fmt.Errorf("sshca: invalid default_ttl: %w", err)
}
if defaultTTL > maxTTL {
return maxTTL, nil
}
return defaultTTL, nil
}
func (e *SSHCAEngine) mountName() string {
// mountPath is "engine/sshca/{name}/" — extract name.
parts := strings.Split(strings.TrimSuffix(e.mountPath, "/"), "/")
if len(parts) >= 3 {
return parts[2]
}
return ""
}
func (e *SSHCAEngine) storeProfile(ctx context.Context, profile *SigningProfile) error {
data, err := json.Marshal(profile)
if err != nil {
return fmt.Errorf("sshca: marshal profile: %w", err)
}
return e.barrier.Put(ctx, e.mountPath+"profiles/"+profile.Name+".json", data)
}
func (e *SSHCAEngine) loadProfile(ctx context.Context, name string) (*SigningProfile, error) {
data, err := e.barrier.Get(ctx, e.mountPath+"profiles/"+name+".json")
if err != nil {
return nil, err
}
var profile SigningProfile
if err := json.Unmarshal(data, &profile); err != nil {
return nil, err
}
return &profile, nil
}
func (e *SSHCAEngine) storeCertRecord(ctx context.Context, record *CertRecord) error {
data, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("sshca: marshal cert record: %w", err)
}
serialStr := strconv.FormatUint(record.Serial, 10)
return e.barrier.Put(ctx, e.mountPath+"certs/"+serialStr+".json", data)
}
func (e *SSHCAEngine) loadCertRecord(ctx context.Context, serialStr string) (*CertRecord, error) {
data, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serialStr+".json")
if err != nil {
return nil, err
}
var record CertRecord
if err := json.Unmarshal(data, &record); err != nil {
return nil, err
}
return &record, nil
}
func (e *SSHCAEngine) storeKRLVersion(ctx context.Context, version uint64) error {
data, err := json.Marshal(map[string]uint64{"version": version})
if err != nil {
return err
}
return e.barrier.Put(ctx, e.mountPath+"krl_version.json", data)
}
func (e *SSHCAEngine) loadKRLVersion(ctx context.Context) (uint64, error) {
data, err := e.barrier.Get(ctx, e.mountPath+"krl_version.json")
if err != nil {
return 0, err
}
var v map[string]uint64
if err := json.Unmarshal(data, &v); err != nil {
return 0, err
}
return v["version"], nil
}
func (e *SSHCAEngine) collectRevokedSerials(ctx context.Context) ([]uint64, error) {
paths, err := e.barrier.List(ctx, e.mountPath+"certs/")
if err != nil {
return nil, nil
}
var serials []uint64
for _, p := range paths {
if !strings.HasSuffix(p, ".json") {
continue
}
serialStr := strings.TrimSuffix(p, ".json")
record, err := e.loadCertRecord(ctx, serialStr)
if err != nil {
continue
}
if record.Revoked {
serials = append(serials, record.Serial)
}
}
return serials, nil
}
// buildKRL builds an OpenSSH KRL binary blob.
//
// Format:
//
// MAGIC = "OPENSSH_KRL\x00" (12 bytes)
// VERSION = uint32(1)
// KRL_VERSION = uint64
// GENERATED_DATE = uint64(unix timestamp)
// FLAGS = uint64(0)
// RESERVED = string (empty, length-prefixed)
// COMMENT = string (empty, length-prefixed)
// [Section: type=0x01 (KRL_SECTION_CERTIFICATES)]
// CA key blob (length-prefixed)
// [Subsection: type=0x20 (KRL_SECTION_CERT_SERIAL_LIST)]
// Sorted uint64 serials
func (e *SSHCAEngine) buildKRL(revokedSerials []uint64) []byte {
var buf []byte
// Magic.
buf = append(buf, []byte("OPENSSH_KRL\x00")...)
// Format version.
buf = binary.BigEndian.AppendUint32(buf, 1)
// KRL version.
buf = binary.BigEndian.AppendUint64(buf, e.krlVersion)
// Generated date.
buf = binary.BigEndian.AppendUint64(buf, uint64(time.Now().Unix()))
// Flags.
buf = binary.BigEndian.AppendUint64(buf, 0)
// Reserved (empty string).
buf = binary.BigEndian.AppendUint32(buf, 0)
// Comment (empty string).
buf = binary.BigEndian.AppendUint32(buf, 0)
if len(revokedSerials) > 0 && e.caSigner != nil {
// Sort serials.
sort.Slice(revokedSerials, func(i, j int) bool {
return revokedSerials[i] < revokedSerials[j]
})
// Build serial list subsection.
var subsection []byte
// Subsection type: 0x20 = KRL_SECTION_CERT_SERIAL_LIST.
subsection = append(subsection, 0x20)
// Subsection data: list of uint64 serials.
var serialData []byte
for _, s := range revokedSerials {
serialData = binary.BigEndian.AppendUint64(serialData, s)
}
// Length-prefixed subsection data.
subsection = binary.BigEndian.AppendUint32(subsection, uint32(len(serialData)))
subsection = append(subsection, serialData...)
// Build section.
// Section type: 0x01 = KRL_SECTION_CERTIFICATES.
buf = append(buf, 0x01)
// Section data: CA key blob + subsections.
var sectionData []byte
// CA key blob (length-prefixed).
caKeyBlob := e.caSigner.PublicKey().Marshal()
sectionData = binary.BigEndian.AppendUint32(sectionData, uint32(len(caKeyBlob)))
sectionData = append(sectionData, caKeyBlob...)
sectionData = append(sectionData, subsection...)
// Length-prefixed section data.
buf = binary.BigEndian.AppendUint32(buf, uint32(len(sectionData)))
buf = append(buf, sectionData...)
}
return buf
}
func certRecordToData(record *CertRecord) map[string]interface{} {
principalsIface := make([]interface{}, len(record.Principals))
for i, p := range record.Principals {
principalsIface[i] = p
}
data := map[string]interface{}{
"serial": strconv.FormatUint(record.Serial, 10),
"cert_type": record.CertType,
"principals": principalsIface,
"cert_data": record.CertData,
"key_id": record.KeyID,
"issued_by": record.IssuedBy,
"issued_at": record.IssuedAt.Format(time.RFC3339),
"expires_at": record.ExpiresAt.Format(time.RFC3339),
}
if record.Profile != "" {
data["profile"] = record.Profile
}
if record.Revoked {
data["revoked"] = true
data["revoked_at"] = record.RevokedAt.Format(time.RFC3339)
data["revoked_by"] = record.RevokedBy
}
return data
}
func extractStringSlice(data map[string]interface{}, key string) []string {
raw, ok := data[key]
if !ok {
return nil
}
switch v := raw.(type) {
case []interface{}:
result := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return v
default:
return nil
}
}
func extractStringMap(data map[string]interface{}, key string) map[string]string {
raw, ok := data[key]
if !ok {
return nil
}
switch v := raw.(type) {
case map[string]interface{}:
result := make(map[string]string, len(v))
for k, val := range v {
if s, ok := val.(string); ok {
result[k] = s
}
}
return result
case map[string]string:
return v
default:
return nil
}
}
func stringFromData(data map[string]interface{}, key string) string {
v, _ := data[key].(string)
return v
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// Ensure mcrypto import is used (for zeroize if needed in future).
var _ = mcrypto.Zeroize