Added certificate revocation and CRL generation code.

This commit is contained in:
2025-07-28 01:34:21 +02:00
parent bd9547ff70
commit 6c5842826e
6 changed files with 425 additions and 138 deletions

5
.gitignore vendored
View File

@@ -6,7 +6,12 @@ lab-ca*
*.pem *.pem
# Ignore CA configuration and certificate definitions. # Ignore CA configuration and certificate definitions.
*.hcl *.hcl
# Ignore state files
*.json
# Include example files # Include example files
!/examples/*.hcl !/examples/*.hcl
# Exclude MacOS Finder metadata files # Exclude MacOS Finder metadata files
.DS_Store .DS_Store
# Exclude default certificate and private key files directories
/certs/
/private/

238
ca.go
View File

@@ -12,6 +12,7 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"text/template" "text/template"
"time" "time"
@@ -40,6 +41,10 @@ type _CAConfig struct {
Paths Paths `hcl:"paths,block"` Paths Paths `hcl:"paths,block"`
} }
func (c *_CAConfig) StateName() string {
return c.Label + "_state.json"
}
type Configuration struct { type Configuration struct {
Current _CAConfig `hcl:"ca,block"` Current _CAConfig `hcl:"ca,block"`
} }
@@ -70,10 +75,11 @@ var CAConfig *_CAConfig
var CAKey *rsa.PrivateKey var CAKey *rsa.PrivateKey
var CACert *x509.Certificate var CACert *x509.Certificate
// LoadCA loads the CA config and sets the global CAConfig variable // LoadCAConfig parses and validates the CA config from the given path and stores it in the CAConfig global variable
func LoadCA(path string) error { func LoadCAConfig() error {
fmt.Printf("Loading CA config from %s\n", configPath)
parser := hclparse.NewParser() parser := hclparse.NewParser()
file, diags := parser.ParseHCLFile(path) file, diags := parser.ParseHCLFile(configPath)
if diags.HasErrors() { if diags.HasErrors() {
return fmt.Errorf("failed to parse HCL: %s", diags.Error()) return fmt.Errorf("failed to parse HCL: %s", diags.Error())
} }
@@ -91,9 +97,18 @@ func LoadCA(path string) error {
if err := config.Current.Validate(); err != nil { if err := config.Current.Validate(); err != nil {
return err return err
} }
CAConfig = &config.Current CAConfig = &config.Current
err := error(nil) return nil
}
// LoadCA loads the CA config, certificate, key, and state
func LoadCA() error {
var err error
err = LoadCAConfig()
if err != nil {
return err
}
// Load CA key and certificate // Load CA key and certificate
caCertPath := filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem") caCertPath := filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem")
@@ -125,11 +140,7 @@ func LoadCA(path string) error {
return fmt.Errorf("failed to parse CA private key: %v", err) return fmt.Errorf("failed to parse CA private key: %v", err)
} }
// Derive caStatePath from caConfig label and config file path err = LoadCAState()
caDir := filepath.Dir(path)
caLabel := config.Current.Label
caStatePath := filepath.Join(caDir, caLabel+"_state.json")
CAState, err = LoadCAState(caStatePath)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to load CA state: %w", err) return fmt.Errorf("failed to load CA state: %w", err)
} }
@@ -178,58 +189,6 @@ func parseValidity(validity string) (time.Duration, error) {
} }
} }
func GenerateCA() ([]byte, []byte, error) {
// Use global CAConfig directly
keySize := CAConfig.KeySize
if keySize == 0 {
keySize = 4096
}
priv, err := rsa.GenerateKey(rand.Reader, keySize)
if err != nil {
return nil, nil, err
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate serial number: %v", err)
}
validity, err := parseValidity(CAConfig.Validity)
if err != nil {
return nil, nil, err
}
now := time.Now()
tmpl := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Country: []string{CAConfig.Country},
Organization: []string{CAConfig.Organization},
OrganizationalUnit: optionalSlice(CAConfig.OrganizationalUnit),
Locality: optionalSlice(CAConfig.Locality),
Province: optionalSlice(CAConfig.Province),
CommonName: CAConfig.Name,
},
NotBefore: now,
NotAfter: now.Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
// Add email if present
if CAConfig.Email != "" {
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
Value: CAConfig.Email,
})
}
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM, nil
}
func SavePEM(filename string, data []byte, secure bool, overwrite bool) error { func SavePEM(filename string, data []byte, secure bool, overwrite bool) error {
if !overwrite { if !overwrite {
if _, err := os.Stat(filename); err == nil { if _, err := os.Stat(filename); err == nil {
@@ -280,43 +239,123 @@ func (c *_CAConfig) Validate() error {
return nil return nil
} }
func InitCA(configPath string, overwrite bool) { func InitCA(overwrite bool) error {
if err := LoadCA(configPath); err != nil {
fmt.Println("Error loading config:", err) var err error
return
err = LoadCAConfig()
if err != nil {
return err
} }
// Create certificates directory with 0755, private keys with 0700 // Create certificates directory with 0755, private keys with 0700
if CAConfig.Paths.Certificates != "" { if CAConfig.Paths.Certificates != "" {
if err := os.MkdirAll(CAConfig.Paths.Certificates, 0755); err != nil { if err := os.MkdirAll(CAConfig.Paths.Certificates, 0755); err != nil {
fmt.Printf("Error creating certificates directory '%s': %v\n", CAConfig.Paths.Certificates, err) fmt.Printf("Error creating certificates directory '%s': %v\n", CAConfig.Paths.Certificates, err)
return return err
} }
} }
if CAConfig.Paths.PrivateKeys != "" { if CAConfig.Paths.PrivateKeys != "" {
if err := os.MkdirAll(CAConfig.Paths.PrivateKeys, 0700); err != nil { if err := os.MkdirAll(CAConfig.Paths.PrivateKeys, 0700); err != nil {
fmt.Printf("Error creating private keys directory '%s': %v\n", CAConfig.Paths.PrivateKeys, err) fmt.Printf("Error creating private keys directory '%s': %v\n", CAConfig.Paths.PrivateKeys, err)
return return err
} }
} }
certPEM, keyPEM, err := GenerateCA()
// Initialize CAState empty state with serial starting from 1
CAState = &_CAState{
Serial: 1, // Start serial from 1
CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Certificates: []CertificateRecord{},
}
keySize := CAConfig.KeySize
if keySize == 0 {
keySize = 4096
}
priv, err := rsa.GenerateKey(rand.Reader, keySize)
if err != nil { if err != nil {
fmt.Println("Error generating CA:", err) return err
return
} }
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return fmt.Errorf("failed to generate serial number: %v", err)
}
validity, err := parseValidity(CAConfig.Validity)
if err != nil {
return err
}
now := time.Now()
// Store CA certificate creation time
CAState.CreatedAt = now.UTC().Format(time.RFC3339)
tmpl := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Country: []string{CAConfig.Country},
Organization: []string{CAConfig.Organization},
OrganizationalUnit: optionalSlice(CAConfig.OrganizationalUnit),
Locality: optionalSlice(CAConfig.Locality),
Province: optionalSlice(CAConfig.Province),
CommonName: CAConfig.Name,
},
NotBefore: now,
NotAfter: now.Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
// Add email if present
if CAConfig.Email != "" {
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
Value: CAConfig.Email,
})
}
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
if err := SavePEM(filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem"), certPEM, false, overwrite); err != nil { if err := SavePEM(filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem"), certPEM, false, overwrite); err != nil {
fmt.Println("Error saving CA certificate:", err) fmt.Println("Error saving CA certificate:", err)
return return err
} }
if err := SavePEM(filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true, overwrite); err != nil { if err := SavePEM(filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true, overwrite); err != nil {
fmt.Println("Error saving CA key:", err) fmt.Println("Error saving CA key:", err)
return return err
}
// set last updated time in the CAState
CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
// Save the state
err = SaveCAState()
if err != nil {
fmt.Println("Error saving CA state:", err)
return err
} }
fmt.Println("CA certificate and key generated.") fmt.Println("CA certificate and key generated.")
return nil
} }
// Helper: issue a single certificate and key, save to files, return error if any // Helper: issue a single certificate and key, save to files, return error if any
func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool) error { func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool) error {
// Use global CAConfig directly // Validate Name
if !isValidName(def.Name) {
return fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore")
}
// Initialize Subject if not specified
if def.Subject == "" {
def.Subject = def.Name
}
// Add default dns SAN for server/server-only if none specified // Add default dns SAN for server/server-only if none specified
if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 { if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 {
def.SAN = append(def.SAN, "dns:"+def.Subject) def.SAN = append(def.SAN, "dns:"+def.Subject)
@@ -349,11 +388,14 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
subjectPKIX = pkix.Name{CommonName: def.Subject} subjectPKIX = pkix.Name{CommonName: def.Subject}
} }
dateIssued := time.Now()
expires := dateIssued.Add(validityDur)
certTmpl := x509.Certificate{ certTmpl := x509.Certificate{
SerialNumber: serialNumber, SerialNumber: serialNumber,
Subject: subjectPKIX, Subject: subjectPKIX,
NotBefore: time.Now(), NotBefore: dateIssued,
NotAfter: time.Now().Add(validityDur), NotAfter: expires,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
} }
@@ -397,8 +439,8 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
if basename == "" { if basename == "" {
basename = def.Subject basename = def.Subject
} }
certFile := filepath.Join(CAConfig.Paths.Certificates, basename+"."+def.Type+".crt.pem") certFile := filepath.Join(CAConfig.Paths.Certificates, basename+".crt.pem")
keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+"."+def.Type+".key.pem") keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+".key.pem")
if err := SavePEM(certFile, certPEM, false, overwrite); err != nil { if err := SavePEM(certFile, certPEM, false, overwrite); err != nil {
return fmt.Errorf("error saving certificate: %v", err) return fmt.Errorf("error saving certificate: %v", err)
} }
@@ -421,15 +463,20 @@ Certificate:
def.SAN, def.SAN,
) )
} }
CAState.UpdateCAStateAfterIssue(
CAConfig.SerialType,
basename,
serialNumber,
validityDur,
)
return nil return nil
} }
func IssueCertificate(configPath, subject, certType, validity string, san []string, name, fromFile string, overwrite, dryRun, verbose bool) { func IssueCertificate(configPath, subject, certType, validity string, san []string, name, fromFile string, overwrite, dryRun, verbose bool) error {
if fromFile != "" { if fromFile != "" {
certDefs, defaults, err := LoadCertificatesFile(fromFile) certDefs, defaults, err := LoadCertificatesFile(fromFile)
if err != nil { if err != nil {
fmt.Printf("Error loading certificates file: %v\n", err) return fmt.Errorf("Error loading certificates file: %v", err)
return
} }
successes := 0 successes := 0
errors := 0 errors := 0
@@ -464,34 +511,29 @@ func IssueCertificate(configPath, subject, certType, validity string, san []stri
} }
} }
fmt.Printf("Batch complete: %d succeeded, %d failed.\n", successes, errors) fmt.Printf("Batch complete: %d succeeded, %d failed.\n", successes, errors)
// Save CA state after batch issuance if err := SaveCAState(); err != nil {
caDir := filepath.Dir(configPath)
caLabel := CAConfig.Label
caStatePath := filepath.Join(caDir, caLabel+"_state.json")
if err := SaveCAState(caStatePath, CAState); err != nil {
fmt.Printf("Error saving CA state: %v\n", err) fmt.Printf("Error saving CA state: %v\n", err)
} }
return if errors > 0 {
return fmt.Errorf("%d certificate(s) failed to issue", errors)
}
return nil
} }
// Single mode // Single mode
finalDef := renderCertificateDefTemplates(CertificateDefinition{Name: name, Subject: subject, Type: certType, Validity: validity, SAN: san}, nil) finalDef := renderCertificateDefTemplates(CertificateDefinition{Name: name, Subject: subject, Type: certType, Validity: validity, SAN: san}, nil)
if dryRun { if dryRun {
fmt.Printf("Would issue %s certificate for '%s' (dry run)\n", finalDef.Type, finalDef.Subject) fmt.Printf("Would issue %s certificate for '%s' (dry run)\n", finalDef.Type, finalDef.Subject)
return return nil
} }
err := issueSingleCertificate(finalDef, overwrite, verbose) err := issueSingleCertificate(finalDef, overwrite, verbose)
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) return err
return
} }
fmt.Printf("%s certificate and key for '%s' generated.\n", finalDef.Type, finalDef.Subject) fmt.Printf("%s certificate and key for '%s' generated.\n", finalDef.Type, finalDef.Subject)
// Save CA state after single issuance if err := SaveCAState(); err != nil {
caDir := filepath.Dir(configPath)
caLabel := CAConfig.Label
caStatePath := filepath.Join(caDir, caLabel+"_state.json")
if err := SaveCAState(caStatePath, CAState); err != nil {
fmt.Printf("Error saving CA state: %v\n", err) fmt.Printf("Error saving CA state: %v\n", err)
} }
return nil
} }
// Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature) // Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature)
@@ -616,3 +658,9 @@ func optionalSlice(s string) []string {
} }
return []string{s} return []string{s}
} }
// Helper: validate certificate name using regex
func isValidName(name string) bool {
matched, _ := regexp.MatchString(`^[A-Za-z0-9_-]+$`, name)
return matched
}

147
certdb.go
View File

@@ -2,9 +2,15 @@
package main package main
import ( import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/json" "encoding/json"
"encoding/pem"
"fmt" "fmt"
"math/big"
"os" "os"
"path/filepath"
"time" "time"
) )
@@ -14,6 +20,7 @@ type _CAState struct {
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
Serial int `json:"serial,omitempty"` Serial int `json:"serial,omitempty"`
CRLNumber int `json:"crlNumber"`
Certificates []CertificateRecord `json:"certificates"` Certificates []CertificateRecord `json:"certificates"`
} }
@@ -22,46 +29,46 @@ type CertificateRecord struct {
Issued string `json:"issued"` Issued string `json:"issued"`
Expires string `json:"expires"` Expires string `json:"expires"`
Serial string `json:"serial"` Serial string `json:"serial"`
Valid bool `json:"valid"` RevokedAt string `json:"revokedAt,omitempty"`
RevokeReason int `json:"revokeReason,omitempty"`
}
func caStatePath() string {
return filepath.Join(filepath.Dir(configPath), CAConfig.StateName())
} }
// LoadCAState loads the CA state from a JSON file // LoadCAState loads the CA state from a JSON file
func LoadCAState(filename string) (*_CAState, error) { func LoadCAState() error {
f, err := os.Open(filename) path := caStatePath()
fmt.Printf("Loading CA state from %s\n", path)
f, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return err
} }
defer f.Close() defer f.Close()
var state _CAState CAState = &_CAState{}
if err := json.NewDecoder(f).Decode(&state); err != nil { if err := json.NewDecoder(f).Decode(CAState); err != nil {
return nil, err return err
} }
return &state, nil return nil
} }
// SaveCAState saves the CA state to a JSON file // SaveCAState saves the CA state to a JSON file
func SaveCAState(filename string, state *_CAState) error { func SaveCAState() error {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
f, err := os.Create(filename) f, err := os.Create(caStatePath())
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer f.Close()
enc := json.NewEncoder(f) enc := json.NewEncoder(f)
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(state) return enc.Encode(CAState)
} }
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate // UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate
func UpdateCAStateAfterIssue(jsonFile, serialType, basename string, serialNumber any, validity time.Duration) error { func (s *_CAState) UpdateCAStateAfterIssue(serialType, basename string, serialNumber any, validity time.Duration) error {
var err error if s == nil {
if CAState == nil {
CAState, err = LoadCAState(jsonFile)
if err != nil {
CAState = nil
}
}
if CAState == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n") fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n")
os.Exit(1) os.Exit(1)
} }
@@ -77,13 +84,12 @@ func UpdateCAStateAfterIssue(jsonFile, serialType, basename string, serialNumber
default: default:
serialStr = fmt.Sprintf("%v", serialNumber) serialStr = fmt.Sprintf("%v", serialNumber)
} }
AddCertificate(basename, issued, expires, serialStr, true) s.AddCertificate(basename, issued, expires, serialStr)
return nil return nil
} }
// AddCertificate appends a new CertificateRecord to the CAState func (s *_CAState) AddCertificate(name, issued, expires, serial string) {
func AddCertificate(name, issued, expires, serial string, valid bool) { if s == nil {
if CAState == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n") fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n")
os.Exit(1) os.Exit(1)
} }
@@ -92,9 +98,94 @@ func AddCertificate(name, issued, expires, serial string, valid bool) {
Issued: issued, Issued: issued,
Expires: expires, Expires: expires,
Serial: serial, Serial: serial,
Valid: valid,
} }
CAState.Certificates = append(CAState.Certificates, rec) s.Certificates = append(s.Certificates, rec)
} }
// No CAConfig references to update in this file // RevokeCertificate revokes a certificate by serial number and reason code, updates state, and saves to disk
func (s *_CAState) RevokeCertificate(serial string, reason int) error {
if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n")
os.Exit(1)
}
revoked := false
revokedAt := time.Now().UTC().Format(time.RFC3339)
for i, rec := range s.Certificates {
if rec.Serial == serial && rec.RevokedAt == "" {
s.Certificates[i].RevokedAt = revokedAt
s.Certificates[i].RevokeReason = reason
revoked = true
}
}
if !revoked {
return fmt.Errorf("certificate with serial %s not found or already revoked", serial)
}
s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if err := SaveCAState(); err != nil {
return fmt.Errorf("failed to save CA state after revocation: %v", err)
}
return nil
}
// GenerateCRL generates a CRL file from revoked certificates and writes it to the given path
// validityDays defines the number of days for which the CRL is valid (NextUpdate - ThisUpdate)
func (s *_CAState) GenerateCRL(crlPath string, validityDays int) error {
if s == nil {
return fmt.Errorf("CAState is nil in GenerateCRL")
}
if CACert == nil || CAKey == nil {
return fmt.Errorf("CA certificate or key not loaded")
}
var revokedCerts []pkix.RevokedCertificate
for _, rec := range s.Certificates {
if rec.RevokedAt != "" {
serial := new(big.Int)
serial.SetString(rec.Serial, 16) // Parse serial as hex
revokedTime, err := time.Parse(time.RFC3339, rec.RevokedAt)
if err != nil {
return fmt.Errorf("invalid revocation time for serial %s: %v", rec.Serial, err)
}
reasonCode := rec.RevokeReason
// RFC 5280: Reason code must be encoded as ASN.1 ENUMERATED, not a raw byte
// Use ASN.1 encoding for ENUMERATED
asn1Reason, err := asn1.Marshal(asn1.Enumerated(reasonCode))
if err != nil {
return fmt.Errorf("failed to ASN.1 encode reason code: %v", err)
}
revokedCerts = append(revokedCerts, pkix.RevokedCertificate{
SerialNumber: serial,
RevocationTime: revokedTime,
Extensions: []pkix.Extension{{Id: []int{2, 5, 29, 21}, Value: asn1Reason}},
})
}
}
now := time.Now().UTC()
nextUpdate := now.Add(time.Duration(validityDays) * 24 * time.Hour) // validityDays * 24 * 60 * 60 * 1000 milliseconds
template := &x509.RevocationList{
SignatureAlgorithm: CACert.SignatureAlgorithm,
RevokedCertificates: revokedCerts,
Number: big.NewInt(int64(s.CRLNumber + 1)),
ThisUpdate: now,
NextUpdate: nextUpdate,
Issuer: CACert.Subject,
}
crlBytes, err := x509.CreateRevocationList(nil, template, CACert, CAKey)
if err != nil {
return fmt.Errorf("failed to create CRL: %v", err)
}
f, err := os.Create(crlPath)
if err != nil {
return fmt.Errorf("failed to create CRL file: %v", err)
}
defer f.Close()
if err := pem.Encode(f, &pem.Block{Type: "X509 CRL", Bytes: crlBytes}); err != nil {
return fmt.Errorf("failed to write CRL PEM: %v", err)
}
// Update CRL number and save state
s.CRLNumber++
s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if err := SaveCAState(); err != nil {
return fmt.Errorf("failed to update CA state after CRL generation: %v", err)
}
return nil
}

View File

@@ -18,3 +18,5 @@ certificate "loki" {
# from default: validity = "1y" # from default: validity = "1y"
san = ["DNS:{{ .Name }}.koszewscy.email"] # result: [ "DNS:loki.koszewscy.email" ] san = ["DNS:{{ .Name }}.koszewscy.email"] # result: [ "DNS:loki.koszewscy.email" ]
} }
certificate "alloy" {}

118
main.go
View File

@@ -8,9 +8,10 @@ import (
) )
var Version = "dev" var Version = "dev"
var configPath string
func main() { func main() {
var configPath string
var overwrite bool var overwrite bool
var subject string var subject string
var certType string var certType string
@@ -20,6 +21,11 @@ func main() {
var fromFile string var fromFile string
var dryRun bool var dryRun bool
var verbose bool var verbose bool
var crlFile string
var crlValidityDays int
var revokeName string
var revokeSerial string
var revokeReasonStr string
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "lab-ca", Use: "lab-ca",
@@ -34,11 +40,7 @@ func main() {
Use: "initca", Use: "initca",
Short: "Generate a new CA certificate and key", Short: "Generate a new CA certificate and key",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if err := LoadCA(configPath); err != nil { InitCA(overwrite)
fmt.Printf("Error loading CA config: %v\n", err)
os.Exit(1)
}
InitCA(configPath, overwrite)
}, },
} }
@@ -49,11 +51,23 @@ func main() {
Use: "issue", Use: "issue",
Short: "Issue a new certificate (client, server, server-only, code-signing, email)", Short: "Issue a new certificate (client, server, server-only, code-signing, email)",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if err := LoadCA(configPath); err != nil { if err := LoadCA(); err != nil {
fmt.Printf("Error loading CA config: %v\n", err) fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
if err := IssueCertificate(configPath,
subject,
certType,
validity,
san,
name,
fromFile,
overwrite,
dryRun,
verbose); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1) os.Exit(1)
} }
IssueCertificate(configPath, subject, certType, validity, san, name, fromFile, overwrite, dryRun, verbose)
}, },
} }
@@ -83,9 +97,93 @@ func main() {
fmt.Printf("lab-ca version: %s\n", Version) fmt.Printf("lab-ca version: %s\n", Version)
}, },
} }
var crlCmd = &cobra.Command{
Use: "crl",
Short: "Generate a Certificate Revocation List (CRL)",
Run: func(cmd *cobra.Command, args []string) {
if err := LoadCA(); err != nil {
fmt.Printf("ERROR: %v\n", err)
os.Exit(1)
}
if crlValidityDays <= 0 {
crlValidityDays = 30 // default to 30 days
}
err := CAState.GenerateCRL(crlFile, crlValidityDays)
if err != nil {
fmt.Printf("ERROR generating CRL: %v\n", err)
os.Exit(1)
}
fmt.Printf("CRL written to %s (valid for %d days)\n", crlFile, crlValidityDays)
},
}
crlCmd.Flags().StringVar(&crlFile, "crl-file", "crl.pem", "Output path for CRL file (default: crl.pem)")
crlCmd.Flags().IntVar(&crlValidityDays, "validity-days", 30, "CRL validity in days (default: 30)")
var revokeCmd = &cobra.Command{
Use: "revoke",
Short: "Revoke a certificate by name or serial number",
Run: func(cmd *cobra.Command, args []string) {
if err := LoadCA(); err != nil {
fmt.Printf("ERROR: %v\n", err)
os.Exit(1)
}
if (revokeName == "" && revokeSerial == "") || (revokeName != "" && revokeSerial != "") {
fmt.Println("ERROR: You must specify either --name or --serial (but not both)")
os.Exit(1)
}
serial := ""
if revokeName != "" {
found := false
for _, rec := range CAState.Certificates {
if rec.Name == revokeName {
serial = rec.Serial
found = true
break
}
}
if !found {
fmt.Printf("ERROR: Certificate with name '%s' not found\n", revokeName)
os.Exit(1)
}
} else {
serial = revokeSerial
}
reasonMap := map[string]int{
"unspecified": 0,
"keyCompromise": 1,
"caCompromise": 2,
"affiliationChanged": 3,
"superseded": 4,
"cessationOfOperation": 5,
"certificateHold": 6,
"removeFromCRL": 8,
}
reasonCode, ok := reasonMap[revokeReasonStr]
if !ok {
fmt.Printf("ERROR: Unknown revocation reason '%s'. Valid reasons: ", revokeReasonStr)
for k := range reasonMap {
fmt.Printf("%s ", k)
}
fmt.Println()
os.Exit(1)
}
if err := CAState.RevokeCertificate(serial, reasonCode); err != nil {
fmt.Printf("ERROR: %v\n", err)
os.Exit(1)
}
fmt.Printf("Certificate with serial %s revoked (reason: %s, code %d)\n", serial, revokeReasonStr, reasonCode)
},
}
revokeCmd.Flags().StringVar(&revokeName, "name", "", "Certificate name to revoke (mutually exclusive with --serial)")
revokeCmd.Flags().StringVar(&revokeSerial, "serial", "", "Certificate serial number to revoke (mutually exclusive with --name)")
revokeCmd.Flags().StringVar(&revokeReasonStr, "reason", "cessationOfOperation", "Revocation reason (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL)")
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(crlCmd)
rootCmd.AddCommand(revokeCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
os.Exit(1) os.Exit(1)
@@ -102,6 +200,8 @@ func printMainHelp() {
fmt.Println(" initca Generate a new CA certificate and key") fmt.Println(" initca Generate a new CA certificate and key")
fmt.Println(" issue Issue a new client/server certificate") fmt.Println(" issue Issue a new client/server certificate")
fmt.Println(" version Show version information") fmt.Println(" version Show version information")
fmt.Println(" crl Generate a Certificate Revocation List (CRL)")
fmt.Println(" revoke Revoke a certificate by name or serial number")
fmt.Println() fmt.Println()
fmt.Println("Use 'lab-ca <command> --help' for more information about a command.") fmt.Println("Use 'lab-ca <command> --help' for more information about a command.")
} }

41
run-test.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
GREEN='\033[0;32m'
NC='\033[0m' # No Color
go build
rm -rf certs private *.json crl*.pem
echo -e "\n${GREEN}Initializing CA...${NC}"
./lab-ca initca || exit 1
echo -e "\n${GREEN}Issuing single certificate with incorrect argument..${NC}"
./lab-ca issue --name "blackpanther2.koszewscy.waw.pl"
if [ $? -ne 0 ]; then
echo -e "${GREEN}Failed to issue certificate.${NC} - that's fine it was intended."
else
echo -e "${GREEN}FATAL: The command should fail, but it didn't!${NC}"
exit 1
fi
echo -e "\n${GREEN}Issuing single certificate..${NC}"
./lab-ca issue --name "blackpanther2" --subject "blackpanther2.koszewscy.waw.pl" || exit 1
echo -e "\n${GREEN}Issuing multiple certificates from file...${NC}"
./lab-ca issue --from-file examples/example-certificates.hcl --verbose || exit 1
echo -e "\n${GREEN}Revoking a certificate by name...${NC}"
./lab-ca revoke --name "loki" || exit 1
echo -e "\n${GREEN}Generating CRL...${NC}"
./lab-ca crl --validity-days 7 --crl-file crl-1.pem || exit 1
openssl crl -noout -text -in crl-1.pem
echo -e "\n${GREEN}Revoking a second certificate by name...${NC}"
./lab-ca revoke --name "alloy" || exit 1
echo -e "\n${GREEN}Generating a second CRL...${NC}"
./lab-ca crl --validity-days 7 --crl-file crl-2.pem || exit 1
openssl crl -noout -text -in crl-2.pem
echo -e "\n${GREEN}Dumping CA state...${NC}"
jq '.' example_ca_state.json