5 Commits

4 changed files with 195 additions and 108 deletions

177
ca.go
View File

@@ -24,9 +24,10 @@ import (
type Paths struct { type Paths struct {
Certificates string `hcl:"certificates"` Certificates string `hcl:"certificates"`
PrivateKeys string `hcl:"private_keys"` PrivateKeys string `hcl:"private_keys"`
StatePath string `hcl:"state_file"`
} }
type _CAConfig struct { type CAConfig struct {
Label string `hcl:",label"` Label string `hcl:",label"`
Name string `hcl:"name"` Name string `hcl:"name"`
Country string `hcl:"country"` Country string `hcl:"country"`
@@ -41,12 +42,12 @@ type _CAConfig struct {
Paths Paths `hcl:"paths,block"` Paths Paths `hcl:"paths,block"`
} }
func (c *_CAConfig) StateName() string { func (c *CAConfig) GetStateFileName() string {
return c.Label + "_state.json" return c.Label + "_state.json"
} }
type Configuration struct { type Configuration struct {
Current _CAConfig `hcl:"ca,block"` CA CAConfig `hcl:"ca,block"`
} }
type CertificateDefinition struct { type CertificateDefinition struct {
@@ -57,7 +58,7 @@ type CertificateDefinition struct {
SAN []string `hcl:"san,optional"` SAN []string `hcl:"san,optional"`
} }
func (def *CertificateDefinition) fillDefaultValues(defaults *CertificateDefaults) { func (def *CertificateDefinition) FillDefaultValues(defaults *CertificateDefaults) {
if defaults == nil { if defaults == nil {
return return
} }
@@ -142,17 +143,27 @@ func (c *Certificates) LoadFromFile(path string) error {
} }
// Global CA configuration and state variables // Global CA configuration and state variables
var CAConfigPath string var caConfigPath string
var CAState *_CAState var caConfig *CAConfig
var CAConfig *_CAConfig
var CAKey *rsa.PrivateKey var caStatePath string
var CACert *x509.Certificate var caState *CAState
var caKey *rsa.PrivateKey
var caCert *x509.Certificate
// LoadCAConfig parses and validates the CA config from the given path and stores it in the CAConfig global variable // LoadCAConfig parses and validates the CA config from the given path and stores it in the CAConfig global variable
func LoadCAConfig() error { func LoadCAConfig() error {
fmt.Printf("Loading CA config from %s\n", CAConfigPath) if verbose {
cwd, err := os.Getwd()
if err != nil {
return err
}
fmt.Printf("The current working dirctory: \"%s\"\n", cwd)
fmt.Printf("Loading CA config from \"%s\"... ", caConfigPath)
}
parser := hclparse.NewParser() parser := hclparse.NewParser()
file, diags := parser.ParseHCLFile(CAConfigPath) file, diags := parser.ParseHCLFile(caConfigPath)
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())
} }
@@ -161,16 +172,24 @@ func LoadCAConfig() error {
if diags.HasErrors() { if diags.HasErrors() {
return fmt.Errorf("failed to decode HCL: %s", diags.Error()) return fmt.Errorf("failed to decode HCL: %s", diags.Error())
} }
if (_CAConfig{}) == config.Current { if (CAConfig{}) == config.CA {
return fmt.Errorf("no 'ca' block found in config file") return fmt.Errorf("no 'ca' block found in config file")
} }
if config.Current.Label == "" { if config.CA.Label == "" {
return fmt.Errorf("the 'ca' block must have a label (e.g., ca \"mylabel\" {...})") return fmt.Errorf("the 'ca' block must have a label (e.g., ca \"mylabel\" {...})")
} }
if err := config.Current.Validate(); err != nil { if err := config.CA.Validate(); err != nil {
return err return err
} }
CAConfig = &config.Current
// If the state file is specified as an absolute path, use it directly.
if filepath.IsAbs(config.CA.Paths.StatePath) {
caStatePath = config.CA.Paths.StatePath
} else {
caStatePath = filepath.Join(filepath.Dir(caConfigPath), config.CA.Paths.StatePath)
}
caConfig = &config.CA
return nil return nil
} }
@@ -184,8 +203,8 @@ func LoadCA() error {
} }
// 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")
caKeyPath := filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem") caKeyPath := filepath.Join(caConfig.Paths.PrivateKeys, "ca_key.pem")
caCertPEM, err := os.ReadFile(caCertPath) caCertPEM, err := os.ReadFile(caCertPath)
if err != nil { if err != nil {
@@ -200,7 +219,7 @@ func LoadCA() error {
if caCertBlock == nil { if caCertBlock == nil {
return fmt.Errorf("failed to parse CA certificate PEM") return fmt.Errorf("failed to parse CA certificate PEM")
} }
CACert, err = x509.ParseCertificate(caCertBlock.Bytes) caCert, err = x509.ParseCertificate(caCertBlock.Bytes)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse CA certificate: %v", err) return fmt.Errorf("failed to parse CA certificate: %v", err)
} }
@@ -208,7 +227,7 @@ func LoadCA() error {
if caKeyBlock == nil { if caKeyBlock == nil {
return fmt.Errorf("failed to parse CA key PEM") return fmt.Errorf("failed to parse CA key PEM")
} }
CAKey, err = x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes) caKey, err = x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse CA private key: %v", err) return fmt.Errorf("failed to parse CA private key: %v", err)
} }
@@ -297,7 +316,7 @@ func (p *Paths) Validate() error {
return nil return nil
} }
func (c *_CAConfig) Validate() error { func (c *CAConfig) Validate() error {
if c.Name == "" { if c.Name == "" {
return fmt.Errorf("CA 'name' is required") return fmt.Errorf("CA 'name' is required")
} }
@@ -327,32 +346,33 @@ func InitCA() error {
err = LoadCAConfig() err = LoadCAConfig()
if err != nil { if err != nil {
fmt.Printf("ERROR: %v\n", err)
return err 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 err 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 err return err
} }
} }
// Initialize CAState empty state with serial starting from 1 // Initialize CAState empty state with serial starting from 1
CAState = &_CAState{ caState = &CAState{
Serial: 1, // Start serial from 1 Serial: 1, // Start serial from 1
CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339), UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Certificates: []CertificateRecord{}, Certificates: []CertificateRecord{},
} }
keySize := CAConfig.KeySize keySize := caConfig.KeySize
if keySize == 0 { if keySize == 0 {
keySize = 4096 keySize = 4096
} }
@@ -366,28 +386,28 @@ func InitCA() error {
return fmt.Errorf("failed to generate serial number: %v", err) return fmt.Errorf("failed to generate serial number: %v", err)
} }
if CAConfig.Validity == "" { if caConfig.Validity == "" {
CAConfig.Validity = "5y" // Use default validity of 5 years caConfig.Validity = "5y" // Use default validity of 5 years
} }
validity, err := parseValidity(CAConfig.Validity) validity, err := parseValidity(caConfig.Validity)
if err != nil { if err != nil {
return err return err
} }
now := time.Now() now := time.Now()
// Store CA certificate creation time // Store CA certificate creation time
CAState.CreatedAt = now.UTC().Format(time.RFC3339) caState.CreatedAt = now.UTC().Format(time.RFC3339)
tmpl := x509.Certificate{ tmpl := x509.Certificate{
SerialNumber: serialNumber, SerialNumber: serialNumber,
Subject: pkix.Name{ Subject: pkix.Name{
Country: []string{CAConfig.Country}, Country: []string{caConfig.Country},
Organization: []string{CAConfig.Organization}, Organization: []string{caConfig.Organization},
OrganizationalUnit: optionalSlice(CAConfig.OrganizationalUnit), OrganizationalUnit: optionalSlice(caConfig.OrganizationalUnit),
Locality: optionalSlice(CAConfig.Locality), Locality: optionalSlice(caConfig.Locality),
Province: optionalSlice(CAConfig.Province), Province: optionalSlice(caConfig.Province),
CommonName: CAConfig.Name, CommonName: caConfig.Name,
}, },
NotBefore: now, NotBefore: now,
NotAfter: now.Add(validity), NotAfter: now.Add(validity),
@@ -396,10 +416,10 @@ func InitCA() error {
IsCA: true, IsCA: true,
} }
// Add email if present // Add email if present
if CAConfig.Email != "" { if caConfig.Email != "" {
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{ tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
Value: CAConfig.Email, Value: caConfig.Email,
}) })
} }
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
@@ -409,17 +429,17 @@ func InitCA() error {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) 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); err != nil { if err := SavePEM(filepath.Join(caConfig.Paths.Certificates, "ca_cert.pem"), certPEM, false); err != nil {
fmt.Println("Error saving CA certificate:", err) fmt.Println("Error saving CA certificate:", err)
return err return err
} }
if err := SavePEM(filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true); err != nil { if err := SavePEM(filepath.Join(caConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true); err != nil {
fmt.Println("Error saving CA key:", err) fmt.Println("Error saving CA key:", err)
return err return err
} }
// set last updated time in the CAState // set last updated time in the CAState
CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339) caState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
// Save the state // Save the state
err = SaveCAState() err = SaveCAState()
@@ -445,6 +465,11 @@ func issueSingleCertificate(def CertificateDefinition) error {
return fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore") return fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore")
} }
// Check if the certificate is in database, fail if it is.
if caState.FindByName(def.Name, false) != nil {
return fmt.Errorf("certificate %s already exists and is valid.", def.Name)
}
// Initialize Subject if not specified // Initialize Subject if not specified
if def.Subject == "" { if def.Subject == "" {
def.Subject = def.Name def.Subject = def.Name
@@ -508,34 +533,35 @@ func issueSingleCertificate(def CertificateDefinition) error {
} }
} }
switch def.Type { // Split usage types by comma
case "client": types := strings.SplitSeq(def.Type, ",")
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{}
case "server":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} // Collect selected usage types
case "server-only": for certType := range types {
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} switch certType {
case "code-signing": case "client":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning} certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
case "email": case "server":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection} certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
default: case "code-signing":
return fmt.Errorf("unknown certificate type. Use one of: client, server, server-only, code-signing, email") certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageCodeSigning)
case "email":
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
default:
return fmt.Errorf("unknown certificate type. Use one of: client, server, code-signing, email")
}
} }
certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, CACert, &priv.PublicKey, CAKey) certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey)
if err != nil { if err != nil {
return fmt.Errorf("failed to create certificate: %v", err) return fmt.Errorf("failed to create certificate: %v", err)
} }
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
basename := def.Name certFile := filepath.Join(caConfig.Paths.Certificates, def.Name+".crt.pem")
if basename == "" { keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem")
basename = def.Subject
}
certFile := filepath.Join(CAConfig.Paths.Certificates, basename+".crt.pem")
keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+".key.pem")
if err := SavePEM(certFile, certPEM, false); err != nil { if err := SavePEM(certFile, certPEM, false); err != nil {
return fmt.Errorf("error saving certificate: %v", err) return fmt.Errorf("error saving certificate: %v", err)
} }
@@ -558,9 +584,11 @@ Certificate:
def.SAN, def.SAN,
) )
} }
CAState.UpdateCAStateAfterIssue( caState.UpdateCAStateAfterIssue(
CAConfig.SerialType, caConfig.SerialType,
basename, def.Name,
def.Subject,
def.Type,
serialNumber, serialNumber,
validityDur, validityDur,
) )
@@ -596,25 +624,26 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
// Loop through all certificate definitions // Loop through all certificate definitions
// to render templates and fill missing fields from defaults // to render templates and fill missing fields from defaults
for i := range certDefs.Certificates { for _, def := range certDefs.Certificates {
// Fill missing fields from defaults, if provided // Fill missing fields from defaults, if provided
certDefs.Certificates[i].fillDefaultValues(certDefs.Defaults) def.FillDefaultValues(certDefs.Defaults)
// Render templates in the definition using the variables map // Render templates in the definition using the variables map
// with added definition name. // with added definition name.
variables := certDefs.Variables variables := certDefs.Variables
if variables == nil { if variables == nil {
variables = make(map[string]string) variables = make(map[string]string)
} }
variables["Name"] = certDefs.Certificates[i].Name variables["Name"] = def.Name
err = certDefs.Certificates[i].RenderTemplates(variables) err = def.RenderTemplates(variables)
if err != nil { if err != nil {
return fmt.Errorf("failed to render templates for certificate %s: %v", certDefs.Certificates[i].Name, err) return fmt.Errorf("failed to render templates for certificate %s: %v", def.Name, err)
} }
} }
n := len(certDefs.Certificates)
// No errors so far, now we can issue certificates // No errors so far, now we can issue certificates
for i := range certDefs.Certificates { for i, def := range certDefs.Certificates {
fmt.Printf("[%d/%d] Issuing %s... ", i+1, len(certDefs.Certificates), certDefs.Certificates[i].Name) fmt.Printf("[%d/%d] Issuing %s... ", i+1, n, def.Name)
if dryRun { if dryRun {
fmt.Printf("(dry run)\n") fmt.Printf("(dry run)\n")
@@ -622,7 +651,7 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
continue continue
} }
err = issueSingleCertificate(certDefs.Certificates[i]) err = issueSingleCertificate(def)
if err != nil { if err != nil {
fmt.Printf("error: %v\n", err) fmt.Printf("error: %v\n", err)
errors++ errors++

View File

@@ -10,12 +10,11 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"os" "os"
"path/filepath"
"time" "time"
) )
// _CAState represents the persisted CA state in JSON // CAState represents the persisted CA state in JSON
type _CAState struct { 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"`
@@ -26,6 +25,8 @@ type _CAState struct {
// CertificateRecord represents a single certificate record in the CA state // CertificateRecord represents a single certificate record in the CA state
type CertificateRecord struct { type CertificateRecord struct {
Name string `json:"name"` Name string `json:"name"`
Subject string `json:"subject"`
Type string `json:"type"`
Issued string `json:"issued"` Issued string `json:"issued"`
Expires string `json:"expires"` Expires string `json:"expires"`
Serial string `json:"serial"` Serial string `json:"serial"`
@@ -33,21 +34,48 @@ type CertificateRecord struct {
RevokeReason int `json:"revokeReason,omitempty"` RevokeReason int `json:"revokeReason,omitempty"`
} }
func caStatePath() string { // Look for a certifcate by its name
return filepath.Join(filepath.Dir(CAConfigPath), CAConfig.StateName()) func (c *CAState) FindByName(name string, all bool) *CertificateRecord {
for _, cert := range c.Certificates {
if cert.RevokedAt != "" && !all {
continue
}
if cert.Name == name {
return &cert
}
}
return nil
} }
// Look for a certificate by its serial
func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord {
for _, cert := range c.Certificates {
if cert.RevokedAt != "" && !all {
continue
}
if cert.Serial == serial {
return &cert
}
}
return nil
}
// func caStatePath() string {
// return filepath.Join(filepath.Dir(caConfigPath), caConfig.GetStateFileName())
// }
// LoadCAState loads the CA state from a JSON file // LoadCAState loads the CA state from a JSON file
func LoadCAState() error { func LoadCAState() error {
path := caStatePath() fmt.Printf("Loading CA state from %s\n", caStatePath)
fmt.Printf("Loading CA state from %s\n", path) f, err := os.Open(caStatePath)
f, err := os.Open(path)
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer f.Close()
CAState = &_CAState{} caState = &CAState{}
if err := json.NewDecoder(f).Decode(CAState); err != nil { if err := json.NewDecoder(f).Decode(caState); err != nil {
return err return err
} }
return nil return nil
@@ -55,19 +83,19 @@ func LoadCAState() error {
// SaveCAState saves the CA state to a JSON file // SaveCAState saves the CA state to a JSON file
func SaveCAState() error { func SaveCAState() error {
CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339) caState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
f, err := os.Create(caStatePath()) 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(CAState) 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 (s *_CAState) UpdateCAStateAfterIssue(serialType, basename string, serialNumber any, validity time.Duration) error { func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject string, certType string, serialNumber any, validity time.Duration) error {
if s == nil { if s == 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,24 +105,26 @@ func (s *_CAState) UpdateCAStateAfterIssue(serialType, basename string, serialNu
serialStr := "" serialStr := ""
switch serialType { switch serialType {
case "sequential": case "sequential":
serialStr = fmt.Sprintf("%d", CAState.Serial) serialStr = fmt.Sprintf("%d", caState.Serial)
CAState.Serial++ caState.Serial++
case "random": case "random":
serialStr = fmt.Sprintf("%x", serialNumber) serialStr = fmt.Sprintf("%x", serialNumber)
default: default:
serialStr = fmt.Sprintf("%v", serialNumber) serialStr = fmt.Sprintf("%v", serialNumber)
} }
s.AddCertificate(basename, issued, expires, serialStr) s.AddCertificate(name, subject, certType, issued, expires, serialStr)
return nil return nil
} }
func (s *_CAState) AddCertificate(name, issued, expires, serial string) { func (s *CAState) AddCertificate(name, subject, certType, issued, expires, serial string) {
if s == nil { if s == 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)
} }
rec := CertificateRecord{ rec := CertificateRecord{
Name: name, Name: name,
Subject: subject,
Type: certType,
Issued: issued, Issued: issued,
Expires: expires, Expires: expires,
Serial: serial, Serial: serial,
@@ -103,7 +133,7 @@ func (s *_CAState) AddCertificate(name, issued, expires, serial string) {
} }
// RevokeCertificate revokes a certificate by serial number and reason code, updates state, and saves to disk // 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 { func (s *CAState) RevokeCertificate(serial string, reason int) error {
if s == nil { if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n") fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n")
os.Exit(1) os.Exit(1)
@@ -129,11 +159,11 @@ func (s *_CAState) RevokeCertificate(serial string, reason int) error {
// GenerateCRL generates a CRL file from revoked certificates and writes it to the given path // 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) // validityDays defines the number of days for which the CRL is valid (NextUpdate - ThisUpdate)
func (s *_CAState) GenerateCRL(crlPath string, validityDays int) error { func (s *CAState) GenerateCRL(crlPath string, validityDays int) error {
if s == nil { if s == nil {
return fmt.Errorf("CAState is nil in GenerateCRL") return fmt.Errorf("CAState is nil in GenerateCRL")
} }
if CACert == nil || CAKey == nil { if caCert == nil || caKey == nil {
return fmt.Errorf("CA certificate or key not loaded") return fmt.Errorf("CA certificate or key not loaded")
} }
var revokedCerts []pkix.RevokedCertificate var revokedCerts []pkix.RevokedCertificate
@@ -162,14 +192,14 @@ func (s *_CAState) GenerateCRL(crlPath string, validityDays int) error {
now := time.Now().UTC() now := time.Now().UTC()
nextUpdate := now.Add(time.Duration(validityDays) * 24 * time.Hour) // validityDays * 24 * 60 * 60 * 1000 milliseconds nextUpdate := now.Add(time.Duration(validityDays) * 24 * time.Hour) // validityDays * 24 * 60 * 60 * 1000 milliseconds
template := &x509.RevocationList{ template := &x509.RevocationList{
SignatureAlgorithm: CACert.SignatureAlgorithm, SignatureAlgorithm: caCert.SignatureAlgorithm,
RevokedCertificates: revokedCerts, RevokedCertificates: revokedCerts,
Number: big.NewInt(int64(s.CRLNumber + 1)), Number: big.NewInt(int64(s.CRLNumber + 1)),
ThisUpdate: now, ThisUpdate: now,
NextUpdate: nextUpdate, NextUpdate: nextUpdate,
Issuer: CACert.Subject, Issuer: caCert.Subject,
} }
crlBytes, err := x509.CreateRevocationList(nil, template, CACert, CAKey) crlBytes, err := x509.CreateRevocationList(nil, template, caCert, caKey)
if err != nil { if err != nil {
return fmt.Errorf("failed to create CRL: %v", err) return fmt.Errorf("failed to create CRL: %v", err)
} }

View File

@@ -1,13 +1,14 @@
ca "example_ca" { ca "example_ca" {
name = "Example CA" name = "Example CA"
country = "PL" country = "PL"
organization = "ACME Corp" organization = "ACME Corp"
serial_type = "random" serial_type = "random"
key_size = 4096 key_size = 4096
validity = "10y" validity = "10y"
paths { paths {
certificates = "certs" certificates = "certs"
private_keys = "private" private_keys = "private"
state_file = "ca_state.json"
} }
} }

37
main.go
View File

@@ -16,6 +16,9 @@ var verbose bool
func main() { func main() {
// list command flags
var listRevoked bool
// issue command flags // issue command flags
var name string var name string
var subject string var subject string
@@ -48,7 +51,7 @@ func main() {
rootCmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files") rootCmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate") rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate")
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)") rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)")
rootCmd.PersistentFlags().StringVar(&CAConfigPath, "config-path", "ca_config.hcl", "Path to CA configuration file") rootCmd.PersistentFlags().StringVar(&caConfigPath, "config", "ca_config.hcl", "Path to CA configuration file")
// lab-ca initca command // lab-ca initca command
var initCmd = &cobra.Command{ var initCmd = &cobra.Command{
@@ -60,6 +63,29 @@ func main() {
} }
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(initCmd)
// lab-ca list command
var listCmd = &cobra.Command{
Use: "list",
Short: "List issued certificates",
Run: func(cmd *cobra.Command, args []string) {
err := LoadCA()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
for _, certDef := range caState.Certificates {
if certDef.RevokedAt != "" {
continue
}
fmt.Printf("Certificate %s\n", certDef.Name)
fmt.Printf("\tSubject: %s\n\tType: %s\n\tIssued at: %s\n",
certDef.Subject, certDef.Type, certDef.Issued)
}
},
}
listCmd.Flags().BoolVar(&listRevoked, "revoked", false, "List all certificates, including revoked ones")
rootCmd.AddCommand(listCmd)
// lab-ca issue command // lab-ca issue command
var issueCmd = &cobra.Command{ var issueCmd = &cobra.Command{
Use: "issue", Use: "issue",
@@ -82,7 +108,8 @@ func main() {
issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)") issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)")
issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (optional, defaults to --name)") issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (optional, defaults to --name)")
issueCmd.Flags().StringVar(&certType, "type", "server", "Certificate type: client, server, server-only, code-signing, email") issueCmd.Flags().StringVar(&certType, "type", "server",
"Certificate type: client, server, code-signing, email.\nCombine by specifying more than one separated by comma.")
issueCmd.Flags().StringArrayVar(&san, "san", nil, issueCmd.Flags().StringArrayVar(&san, "san", nil,
"Subject Alternative Name (SAN). Use multiple times for multiple values.\nFormat: dns:example.com, ip:1.2.3.4, email:user@example.com") "Subject Alternative Name (SAN). Use multiple times for multiple values.\nFormat: dns:example.com, ip:1.2.3.4, email:user@example.com")
issueCmd.Flags().StringVar(&validity, "validity", "1y", "Certificate validity (e.g. 2y, 6m, 30d). Overrides config file for this certificate.") issueCmd.Flags().StringVar(&validity, "validity", "1y", "Certificate validity (e.g. 2y, 6m, 30d). Overrides config file for this certificate.")
@@ -124,7 +151,7 @@ func main() {
serial := "" serial := ""
if revokeName != "" { if revokeName != "" {
found := false found := false
for _, rec := range CAState.Certificates { for _, rec := range caState.Certificates {
if rec.Name == revokeName { if rec.Name == revokeName {
serial = rec.Serial serial = rec.Serial
found = true found = true
@@ -157,7 +184,7 @@ func main() {
fmt.Println() fmt.Println()
os.Exit(1) os.Exit(1)
} }
if err := CAState.RevokeCertificate(serial, reasonCode); err != nil { if err := caState.RevokeCertificate(serial, reasonCode); err != nil {
fmt.Printf("ERROR: %v\n", err) fmt.Printf("ERROR: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -181,7 +208,7 @@ func main() {
if crlValidityDays <= 0 { if crlValidityDays <= 0 {
crlValidityDays = 30 // default to 30 days crlValidityDays = 30 // default to 30 days
} }
err := CAState.GenerateCRL(crlFile, crlValidityDays) err := caState.GenerateCRL(crlFile, crlValidityDays)
if err != nil { if err != nil {
fmt.Printf("ERROR generating CRL: %v\n", err) fmt.Printf("ERROR generating CRL: %v\n", err)
os.Exit(1) os.Exit(1)