mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:31:37 +00:00
a579a84c7f
Add certificate profiles as named enrollment templates that control allowed key algorithms, max TTL, permitted EKUs, required SAN patterns, and optional SPIFFE URI SANs. CSR submissions are validated against profile rules at signing time (key type + minimum size). Short-lived certs (TTL < 1 hour) auto-expire via a new scheduler loop — expiry acts as revocation, no CRL/OCSP needed. New files: - Migration 000003: certificate_profiles table, FK columns on managed_certificates/renewal_policies, key metadata on certificate_versions - domain/profile.go: CertificateProfile + KeyAlgorithmRule structs - repository/postgres/profile.go: full CRUD with JSONB marshaling - service/profile.go: ProfileService with validation + audit logging - service/crypto_validation.go: CSR-against-profile validation (RSA/ECDSA/Ed25519) - handler/profiles.go: 5 HTTP endpoints under /api/v1/profiles - web/src/pages/ProfilesPage.tsx: profiles management page Modified: - renewal.go: CSR validation in CompleteAgentCSRRenewal, ExpireShortLivedCertificates - scheduler.go: 30s short-lived expiry check loop - certificate.go (repo): nullable profile FK, key metadata on versions - main.go: profile repo/service/handler wiring, 8-param NewRenewalService - router.go: 12-param RegisterHandlers with profile routes - seed_demo.sql: 4 demo profiles (standard, mtls, short-lived, high-security) - Frontend: types, API client, routing, sidebar nav Tests: 40 new tests across handler (15), service (13), crypto validation (12) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
6.1 KiB
Go
245 lines
6.1 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// generateTestCSR creates a valid CSR PEM for testing purposes.
|
|
func generateTestCSR(t *testing.T, keyType string, keySize int) string {
|
|
t.Helper()
|
|
|
|
var privKey interface{}
|
|
var err error
|
|
|
|
switch keyType {
|
|
case "RSA":
|
|
privKey, err = rsa.GenerateKey(rand.Reader, keySize)
|
|
case "ECDSA":
|
|
var curve elliptic.Curve
|
|
switch keySize {
|
|
case 256:
|
|
curve = elliptic.P256()
|
|
case 384:
|
|
curve = elliptic.P384()
|
|
default:
|
|
t.Fatalf("unsupported ECDSA key size: %d", keySize)
|
|
}
|
|
privKey, err = ecdsa.GenerateKey(curve, rand.Reader)
|
|
default:
|
|
t.Fatalf("unsupported key type: %s", keyType)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("failed to generate key: %v", err)
|
|
}
|
|
|
|
template := &x509.CertificateRequest{
|
|
Subject: pkix.Name{
|
|
CommonName: "test.example.com",
|
|
},
|
|
DNSNames: []string{"test.example.com", "www.example.com"},
|
|
}
|
|
|
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, privKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to create CSR: %v", err)
|
|
}
|
|
|
|
csrPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrDER,
|
|
})
|
|
|
|
return string(csrPEM)
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_NilProfile(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "ECDSA", 256)
|
|
|
|
result, err := ValidateCSRAgainstProfile(csrPEM, nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeyAlgorithm != "ECDSA" {
|
|
t.Errorf("expected ECDSA, got %s", result.KeyAlgorithm)
|
|
}
|
|
if result.KeySize != 256 {
|
|
t.Errorf("expected 256, got %d", result.KeySize)
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_ECDSA256_Allowed(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "ECDSA", 256)
|
|
|
|
profile := &domain.CertificateProfile{
|
|
Name: "Standard TLS",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "ECDSA", MinSize: 256},
|
|
{Algorithm: "RSA", MinSize: 2048},
|
|
},
|
|
}
|
|
|
|
result, err := ValidateCSRAgainstProfile(csrPEM, profile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeyAlgorithm != "ECDSA" {
|
|
t.Errorf("expected ECDSA, got %s", result.KeyAlgorithm)
|
|
}
|
|
if result.KeySize != 256 {
|
|
t.Errorf("expected 256, got %d", result.KeySize)
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_ECDSA384_Allowed(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "ECDSA", 384)
|
|
|
|
profile := &domain.CertificateProfile{
|
|
Name: "High Security",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "ECDSA", MinSize: 384},
|
|
},
|
|
}
|
|
|
|
result, err := ValidateCSRAgainstProfile(csrPEM, profile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeySize != 384 {
|
|
t.Errorf("expected 384, got %d", result.KeySize)
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_RSA2048_Allowed(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "RSA", 2048)
|
|
|
|
profile := &domain.CertificateProfile{
|
|
Name: "Standard TLS",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "RSA", MinSize: 2048},
|
|
},
|
|
}
|
|
|
|
result, err := ValidateCSRAgainstProfile(csrPEM, profile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeyAlgorithm != "RSA" {
|
|
t.Errorf("expected RSA, got %s", result.KeyAlgorithm)
|
|
}
|
|
if result.KeySize != 2048 {
|
|
t.Errorf("expected 2048, got %d", result.KeySize)
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_ECDSA256_RejectedByHighSecurity(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "ECDSA", 256)
|
|
|
|
profile := &domain.CertificateProfile{
|
|
Name: "High Security",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "ECDSA", MinSize: 384},
|
|
{Algorithm: "RSA", MinSize: 4096},
|
|
},
|
|
}
|
|
|
|
_, err := ValidateCSRAgainstProfile(csrPEM, profile)
|
|
if err == nil {
|
|
t.Fatal("expected rejection, got nil error")
|
|
}
|
|
if !containsSubstring(err.Error(), "does not match any allowed algorithm") {
|
|
t.Errorf("unexpected error message: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_RSA_RejectedByECDSAOnly(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "RSA", 2048)
|
|
|
|
profile := &domain.CertificateProfile{
|
|
Name: "ECDSA Only",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "ECDSA", MinSize: 256},
|
|
},
|
|
}
|
|
|
|
_, err := ValidateCSRAgainstProfile(csrPEM, profile)
|
|
if err == nil {
|
|
t.Fatal("expected rejection, got nil error")
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_EmptyAlgorithmRules(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "ECDSA", 256)
|
|
|
|
profile := &domain.CertificateProfile{
|
|
Name: "Permissive",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{}, // empty = allow anything
|
|
}
|
|
|
|
result, err := ValidateCSRAgainstProfile(csrPEM, profile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeyAlgorithm != "ECDSA" {
|
|
t.Errorf("expected ECDSA, got %s", result.KeyAlgorithm)
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_InvalidPEM(t *testing.T) {
|
|
_, err := ValidateCSRAgainstProfile("not a pem", nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid PEM, got nil")
|
|
}
|
|
if !containsSubstring(err.Error(), "failed to decode CSR PEM") {
|
|
t.Errorf("unexpected error: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestValidateCSRAgainstProfile_InvalidCSRContent(t *testing.T) {
|
|
// Valid PEM block but garbage content
|
|
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nTm90IGEgcmVhbCBDU1I=\n-----END CERTIFICATE REQUEST-----"
|
|
|
|
_, err := ValidateCSRAgainstProfile(csrPEM, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid CSR content, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractCSRKeyInfo_ECDSA(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "ECDSA", 256)
|
|
|
|
result, err := extractCSRKeyInfo(csrPEM)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeyAlgorithm != "ECDSA" {
|
|
t.Errorf("expected ECDSA, got %s", result.KeyAlgorithm)
|
|
}
|
|
if result.KeySize != 256 {
|
|
t.Errorf("expected 256, got %d", result.KeySize)
|
|
}
|
|
}
|
|
|
|
func TestExtractCSRKeyInfo_RSA(t *testing.T) {
|
|
csrPEM := generateTestCSR(t, "RSA", 2048)
|
|
|
|
result, err := extractCSRKeyInfo(csrPEM)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.KeyAlgorithm != "RSA" {
|
|
t.Errorf("expected RSA, got %s", result.KeyAlgorithm)
|
|
}
|
|
if result.KeySize != 2048 {
|
|
t.Errorf("expected 2048, got %d", result.KeySize)
|
|
}
|
|
}
|