7 Commits

5 changed files with 217 additions and 121 deletions

179
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)
} }
@@ -271,7 +290,7 @@ func parseValidity(validity string) (time.Duration, error) {
} }
} }
func SavePEM(filename string, data []byte, secure bool, overwrite bool) error { func SavePEM(filename string, data []byte, secure bool) error {
if !overwrite { if !overwrite {
if _, err := os.Stat(filename); err == nil { if _, err := os.Stat(filename); err == nil {
return fmt.Errorf("file %s already exists (overwrite not allowed)", filename) return fmt.Errorf("file %s already exists (overwrite not allowed)", filename)
@@ -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")
} }
@@ -321,38 +340,39 @@ func (c *_CAConfig) Validate() error {
return nil return nil
} }
func InitCA(overwrite bool) error { func InitCA() error {
var err error var err 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(overwrite bool) 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(overwrite bool) 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(overwrite bool) 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, overwrite); 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, overwrite); 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()
@@ -433,7 +453,7 @@ func InitCA(overwrite bool) error {
} }
// 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) error {
// Validate Name // Validate Name
isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name) isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name)
@@ -445,6 +465,11 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
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,38 +533,39 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
} }
} }
switch def.Type { // Split usage types by comma
types := strings.SplitSeq(def.Type, ",")
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{}
// Collect selected usage types
for certType := range types {
switch certType {
case "client": case "client":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
case "server": case "server":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
case "server-only":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
case "code-signing": case "code-signing":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning} certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageCodeSigning)
case "email": case "email":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection} certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
default: default:
return fmt.Errorf("unknown certificate type. Use one of: client, server, server-only, code-signing, email") 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 if err := SavePEM(certFile, certPEM, false); err != nil {
}
certFile := filepath.Join(CAConfig.Paths.Certificates, basename+".crt.pem")
keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+".key.pem")
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)
} }
if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil { if err := SavePEM(keyFile, keyPEM, true); err != nil {
return fmt.Errorf("error saving key: %v", err) return fmt.Errorf("error saving key: %v", err)
} }
if verbose { if verbose {
@@ -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], overwrite, verbose) err = issueSingleCertificate(def)
if err != nil { if err != nil {
fmt.Printf("error: %v\n", err) fmt.Printf("error: %v\n", err)
errors++ errors++
@@ -664,7 +693,7 @@ func IssueCertificate(certDef CertificateDefinition, overwrite bool, dryRun bool
return nil return nil
} }
err = issueSingleCertificate(certDef, overwrite, verbose) err = issueSingleCertificate(certDef)
if err != nil { if err != nil {
return err return err

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

@@ -9,5 +9,6 @@ ca "example_ca" {
paths { paths {
certificates = "certs" certificates = "certs"
private_keys = "private" private_keys = "private"
state_file = "ca_state.json"
} }
} }

2
go.mod
View File

@@ -1,4 +1,4 @@
module koszewscy.waw.pl/slawek/lab-ca module gitea.koszewscy.waw.pl/slawek/lab-ca
go 1.24.5 go 1.24.5

58
main.go
View File

@@ -9,22 +9,34 @@ import (
var Version = "dev" var Version = "dev"
// Global flags available to all commands
var overwrite bool
var dryRun bool
var verbose bool
func main() { func main() {
var overwrite bool // list command flags
var listRevoked bool
// issue command flags
var name string
var subject string var subject string
var certType string var certType string
var validity string var validity string
var san []string var san []string
var name string
var dryRun bool // provision command flags
var verbose bool var provisionFile string
// crl command flags
var crlFile string var crlFile string
var crlValidityDays int var crlValidityDays int
// revoke command flags
var revokeName string var revokeName string
var revokeSerial string var revokeSerial string
var revokeReasonStr string var revokeReasonStr string
var provisionFile string
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "lab-ca", Use: "lab-ca",
@@ -39,18 +51,41 @@ 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{
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) {
InitCA(overwrite) InitCA()
}, },
} }
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",
@@ -73,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.")
@@ -115,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
@@ -148,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)
} }
@@ -172,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)