All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1197 lines
30 KiB
Go
1197 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/mc/metacrypt/internal/barrier"
|
|
mcrypto "git.wntrmute.dev/mc/metacrypt/internal/crypto"
|
|
"git.wntrmute.dev/mc/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")
|
|
}
|
|
if err := engine.ValidateName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if err := engine.ValidateName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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")
|
|
}
|
|
if err := engine.ValidateName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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
|