10 Commits
v0.1 ... v0.2.1

9 changed files with 868 additions and 494 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/

View File

@@ -23,10 +23,14 @@ The tool is designed to be used from the command line. It has a simple command s
lab-ca <command> [options] lab-ca <command> [options]
``` ```
There are two commands available: The main commands available are:
- `initca` - initialize a new CA - this command creates a new CA and a self-signed CA certificate. - `initca` — Initialize a new CA and create a self-signed CA certificate.
- `issue` - issue a new certificate - this command creates a new certificate signed by the CA. - `issue` — Issue a new certificate signed by the CA.
- `provision` — Provision multiple certificates from a batch file (HCL) in one go.
- `revoke` — Revoke a certificate by name or serial number.
- `crl` — Generate a Certificate Revocation List (CRL) from revoked certificates.
- `version` — Show version information for the tool.
Run the command with `-h` or `--help` or without any arguments to see the usage information. Each command has its own set of options, arguments, and a help message. Run the command with `-h` or `--help` or without any arguments to see the usage information. Each command has its own set of options, arguments, and a help message.
@@ -70,35 +74,47 @@ The `paths` block defines where the command will store the generated certificate
> **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment. > **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment.
## Certificate Issuance ## Certificate Issuance and Provisioning
To issue a new certificate, you can use the `issue` command and specify the certificate definition on the command line, or use batch mode and provide a file with certificate definitions. To issue a new certificate, you can use the `issue` command and specify the certificate definition on the command line, or use the `provision` command to provide a file with multiple certificate definitions for batch processing.
The definition file also uses HCL syntax. Here is an example of a certificate definition: The definition file also uses HCL syntax. Here is an example of a certificate definition file:
```hcl ```hcl
defaults { defaults {
subject = "{{ .Name }}.example.com" subject = "{{ .Name }}.example.org"
type = "server" type = "server"
validity = "1y" validity = "1y"
san = ["DNS:{{ .Name }}.example.com"] san = ["DNS:{{ .Name }}.example.org"]
} }
certificate "grafana" { variables = {
# from default: subject = "{{ .Name }}.example.com" # result: grafana.example.com Domain = "example.net"
# from default: type = "server" Country = "EX"
# from default: validity = "1y"
# from default: san = ["DNS:{{ .Name }}.example.com"] # result: [ "DNS:grafana.example.com" ]
} }
certificate "loki" { certificate "service1" {
subject = "{{ .Name }}.example.net" # result: loki.example.net # from default: subject = "{{ .Name }}.example.org"
# from default: type = "server" # from default: type = "server"
# from default: validity = "1y" # from default: validity = "1y"
san = ["DNS:{{ .Name }}.example.net"] # result: [ "DNS:loki.example.net" ] # from default: san = ["DNS:{{ .Name }}.example.org"]
}
certificate "service2" {
subject = "{{ .Name }}.example.net"
# from default: type = "server"
# from default: validity = "1y"
san = ["DNS:{{ .Name }}.example.net"]
}
certificate "service3" {}
certificate "service4" {
subject = "{{ .Name }}.{{ .Domain }}"
san = ["DNS:{{ .Name }}.{{ .Domain }}"]
} }
``` ```
Values specified in the `defaults` block will be used for all certificates unless overridden in individual certificate definitions. Go-style template syntax is also supported, so you can use `{{ .Name }}` to refer to the certificate name. Values specified in the `defaults` block will be used for all certificates unless overridden in individual certificate definitions. Go-style template syntax is also supported, so you can use `{{ .Name }}` to refer to the certificate name, and variables from the `variables` map can be used in templates as well.
You can use DNS or IP SANs for server certificates (`server` and `server-only`), and email SANs for email certificates (`email`). The command will check if the SAN is valid based on the type of certificate. You can use DNS or IP SANs for server certificates (`server` and `server-only`), and email SANs for email certificates (`email`). The command will check if the SAN is valid based on the type of certificate.

806
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"
@@ -25,7 +26,7 @@ type Paths struct {
PrivateKeys string `hcl:"private_keys"` PrivateKeys string `hcl:"private_keys"`
} }
type CA 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"`
@@ -40,11 +41,15 @@ type CA struct {
Paths Paths `hcl:"paths,block"` Paths Paths `hcl:"paths,block"`
} }
type Configuration struct { func (c *_CAConfig) StateName() string {
CA CA `hcl:"ca,block"` return c.Label + "_state.json"
} }
type CertificateDef struct { type Configuration struct {
Current _CAConfig `hcl:"ca,block"`
}
type CertificateDefinition struct {
Name string `hcl:",label"` Name string `hcl:",label"`
Subject string `hcl:"subject,optional"` Subject string `hcl:"subject,optional"`
Type string `hcl:"type,optional"` Type string `hcl:"type,optional"`
@@ -52,6 +57,62 @@ type CertificateDef struct {
SAN []string `hcl:"san,optional"` SAN []string `hcl:"san,optional"`
} }
func (def *CertificateDefinition) fillDefaultValues(defaults *CertificateDefaults) {
if defaults == nil {
return
}
if def.Subject == "" {
def.Subject = defaults.Subject
}
if def.Type == "" {
def.Type = defaults.Type
}
if def.Validity == "" {
def.Validity = defaults.Validity
}
if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
def.SAN = defaults.SAN
}
}
// Helper: renderTemplates applies Go template to a string
// using the provided variables map. It returns an error if the template execution fails.
func applyTemplateToString(s string, variables map[string]string) (string, error) {
tmpl, err := template.New("").Parse(s)
if err != nil {
return s, err
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, variables)
if err != nil {
return s, err
}
return buf.String(), nil
}
func (c *CertificateDefinition) RenderTemplates(variables map[string]string) error {
// Apply Go templates to Subject and SAN fields using
// the variables map
if c.Subject != "" {
renderedSubject, err := applyTemplateToString(c.Subject, variables)
if err != nil {
return fmt.Errorf("failed to render subject template: %v", err)
}
c.Subject = renderedSubject
}
if len(c.SAN) > 0 {
for i, san := range c.SAN {
renderedSAN, err := applyTemplateToString(san, variables)
if err != nil {
return fmt.Errorf("failed to render SAN template: %v", err)
}
c.SAN[i] = renderedSAN
}
}
return nil
}
type CertificateDefaults struct { type CertificateDefaults struct {
Subject string `hcl:"subject,optional"` Subject string `hcl:"subject,optional"`
Type string `hcl:"type,optional"` Type string `hcl:"type,optional"`
@@ -61,31 +122,106 @@ type CertificateDefaults struct {
type Certificates struct { type Certificates struct {
Defaults *CertificateDefaults `hcl:"defaults,block"` Defaults *CertificateDefaults `hcl:"defaults,block"`
Certificates []CertificateDef `hcl:"certificate,block"` Variables map[string]string `hcl:"variables,optional"`
Certificates []CertificateDefinition `hcl:"certificate,block"`
} }
func LoadCA(path string) (*CA, error) { // Load certificate provisioning configuration from the given path.
func (c *Certificates) LoadFromFile(path string) error {
parser := hclparse.NewParser() parser := hclparse.NewParser()
file, diags := parser.ParseHCLFile(path) file, diags := parser.ParseHCLFile(path)
if diags.HasErrors() { if diags.HasErrors() {
return nil, fmt.Errorf("failed to parse HCL: %s", diags.Error()) return fmt.Errorf("failed to parse HCL: %s", diags.Error())
}
diags = gohcl.DecodeBody(file.Body, nil, c)
if diags.HasErrors() {
return fmt.Errorf("failed to decode HCL: %s", diags.Error())
}
return nil
}
// Global CA configuration and state variables
var CAConfigPath string
var CAState *_CAState
var CAConfig *_CAConfig
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
func LoadCAConfig() error {
fmt.Printf("Loading CA config from %s\n", CAConfigPath)
parser := hclparse.NewParser()
file, diags := parser.ParseHCLFile(CAConfigPath)
if diags.HasErrors() {
return fmt.Errorf("failed to parse HCL: %s", diags.Error())
} }
var config Configuration var config Configuration
diags = gohcl.DecodeBody(file.Body, nil, &config) diags = gohcl.DecodeBody(file.Body, nil, &config)
if diags.HasErrors() { if diags.HasErrors() {
return nil, fmt.Errorf("failed to decode HCL: %s", diags.Error()) return fmt.Errorf("failed to decode HCL: %s", diags.Error())
} }
if (CA{}) == config.CA { if (_CAConfig{}) == config.Current {
return nil, fmt.Errorf("no 'ca' block found in config file") return fmt.Errorf("no 'ca' block found in config file")
} }
if err := config.CA.Validate(); err != nil { if config.Current.Label == "" {
return nil, err return fmt.Errorf("the 'ca' block must have a label (e.g., ca \"mylabel\" {...})")
} }
return &config.CA, nil if err := config.Current.Validate(); err != nil {
return err
}
CAConfig = &config.Current
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
caCertPath := filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem")
caKeyPath := filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem")
caCertPEM, err := os.ReadFile(caCertPath)
if err != nil {
return fmt.Errorf("error reading CA certificate file: %v", err)
}
caKeyPEM, err := os.ReadFile(caKeyPath)
if err != nil {
return fmt.Errorf("error reading CA key file: %v", err)
}
caCertBlock, _ := pem.Decode(caCertPEM)
if caCertBlock == nil {
return fmt.Errorf("failed to parse CA certificate PEM")
}
CACert, err = x509.ParseCertificate(caCertBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse CA certificate: %v", err)
}
caKeyBlock, _ := pem.Decode(caKeyPEM)
if caKeyBlock == nil {
return fmt.Errorf("failed to parse CA key PEM")
}
CAKey, err = x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse CA private key: %v", err)
}
err = LoadCAState()
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to load CA state: %w", err)
}
return nil
} }
// Parse certificates.hcl file with defaults support // Parse certificates.hcl file with defaults support
func LoadCertificatesFile(path string) ([]CertificateDef, *CertificateDefaults, error) { func LoadCertificatesFile(path string) ([]CertificateDefinition, *CertificateDefaults, error) {
parser := hclparse.NewParser() parser := hclparse.NewParser()
file, diags := parser.ParseHCLFile(path) file, diags := parser.ParseHCLFile(path)
if diags.HasErrors() { if diags.HasErrors() {
@@ -99,10 +235,17 @@ func LoadCertificatesFile(path string) ([]CertificateDef, *CertificateDefaults,
return certsFile.Certificates, certsFile.Defaults, nil return certsFile.Certificates, certsFile.Defaults, nil
} }
// Certificate definitions can have validity in various formats:
// - "1y" for 1 year
// - "6m" for 6 months
// - "30d" for 30 days
// Check the syntax and parse validity string into time.Duration
func parseValidity(validity string) (time.Duration, error) { func parseValidity(validity string) (time.Duration, error) {
// Return error is the function is called with an empty validity
if validity == "" { if validity == "" {
return time.Hour * 24 * 365 * 5, nil // default 5 years return 0, fmt.Errorf("validity cannot be empty")
} }
var n int var n int
var unit rune var unit rune
_, err := fmt.Sscanf(validity, "%d%c", &n, &unit) _, err := fmt.Sscanf(validity, "%d%c", &n, &unit)
@@ -110,10 +253,12 @@ func parseValidity(validity string) (time.Duration, error) {
// If no unit, assume years // If no unit, assume years
_, err2 := fmt.Sscanf(validity, "%d", &n) _, err2 := fmt.Sscanf(validity, "%d", &n)
if err2 != nil { if err2 != nil {
// Still no success, return error
return 0, fmt.Errorf("invalid validity format: %s", validity) return 0, fmt.Errorf("invalid validity format: %s", validity)
} }
unit = 'y' unit = 'y'
} }
switch unit { switch unit {
case 'y': case 'y':
return time.Hour * 24 * 365 * time.Duration(n), nil return time.Hour * 24 * 365 * time.Duration(n), nil
@@ -126,57 +271,6 @@ func parseValidity(validity string) (time.Duration, error) {
} }
} }
func GenerateCA(ca *CA) ([]byte, []byte, error) {
keySize := ca.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(ca.Validity)
if err != nil {
return nil, nil, err
}
now := time.Now()
tmpl := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Country: []string{ca.Country},
Organization: []string{ca.Organization},
OrganizationalUnit: optionalSlice(ca.OrganizationalUnit),
Locality: optionalSlice(ca.Locality),
Province: optionalSlice(ca.Province),
CommonName: ca.Name,
},
NotBefore: now,
NotAfter: now.Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
// Add email if present
if ca.Email != "" {
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
Value: ca.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 {
@@ -203,7 +297,7 @@ func (p *Paths) Validate() error {
return nil return nil
} }
func (c *CA) 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")
} }
@@ -213,10 +307,11 @@ func (c *CA) Validate() error {
if c.Organization == "" { if c.Organization == "" {
return fmt.Errorf("CA 'organization' is required") return fmt.Errorf("CA 'organization' is required")
} }
// SerialType is now optional; default to 'random' if empty
if c.SerialType == "" { if c.SerialType == "" {
c.SerialType = "random" c.SerialType = "random"
} }
if c.SerialType != "random" && c.SerialType != "sequential" { if c.SerialType != "random" && c.SerialType != "sequential" {
return fmt.Errorf("CA 'serial_type' must be 'random' or 'sequential'") return fmt.Errorf("CA 'serial_type' must be 'random' or 'sequential'")
} }
@@ -226,131 +321,180 @@ func (c *CA) Validate() error {
return nil return nil
} }
func InitCA(configPath string, overwrite bool) { func InitCA(overwrite bool) error {
ca, err := LoadCA(configPath)
var err error
err = LoadCAConfig()
if err != nil { if err != nil {
fmt.Println("Error loading config:", err) return err
return
} }
// Create certificates directory with 0755, private keys with 0700 // Create certificates directory with 0755, private keys with 0700
if ca.Paths.Certificates != "" { if CAConfig.Paths.Certificates != "" {
if err := os.MkdirAll(ca.Paths.Certificates, 0755); err != nil { if err := os.MkdirAll(CAConfig.Paths.Certificates, 0755); err != nil {
fmt.Printf("Error creating certificates directory '%s': %v\n", ca.Paths.Certificates, err) fmt.Printf("Error creating certificates directory '%s': %v\n", CAConfig.Paths.Certificates, err)
return return err
} }
} }
if ca.Paths.PrivateKeys != "" { if CAConfig.Paths.PrivateKeys != "" {
if err := os.MkdirAll(ca.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", ca.Paths.PrivateKeys, err) fmt.Printf("Error creating private keys directory '%s': %v\n", CAConfig.Paths.PrivateKeys, err)
return return err
} }
} }
certPEM, keyPEM, err := GenerateCA(ca) // 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
} }
if err := SavePEM(filepath.Join(ca.Paths.Certificates, "ca_cert.pem"), certPEM, false, overwrite); err != nil { 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)
}
if CAConfig.Validity == "" {
CAConfig.Validity = "5y" // Use default validity of 5 years
}
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 {
fmt.Println("Error saving CA certificate:", err) fmt.Println("Error saving CA certificate:", err)
return return err
} }
if err := SavePEM(filepath.Join(ca.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
} }
func IssueCertificate(configPath, name string, subject, certType, validityFlag string, san []string, overwrite bool) { // Helper: issue a single certificate and key, save to files, return error if any
func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool) error {
// Validate Name
isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name)
if err != nil {
return fmt.Errorf("error validating certificate name: %v", err)
}
if !isValidName {
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 (certType == "server" || certType == "server-only") && len(san) == 0 { if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 {
san = append(san, "dns:"+subject) def.SAN = append(def.SAN, "dns:"+def.Subject)
}
ca, err := LoadCA(configPath)
if err != nil {
fmt.Println("Error loading config:", err)
return
}
caCertPath := filepath.Join(ca.Paths.Certificates, "ca_cert.pem")
caKeyPath := filepath.Join(ca.Paths.PrivateKeys, "ca_key.pem")
caCertPEM, err := os.ReadFile(caCertPath)
if err != nil {
fmt.Println("Error reading CA certificate file:", err)
return
}
caKeyPEM, err := os.ReadFile(caKeyPath)
if err != nil {
fmt.Println("Error reading CA key file:", err)
return
}
caCertBlock, _ := pem.Decode(caCertPEM)
if caCertBlock == nil {
fmt.Println("Failed to parse CA certificate PEM")
return
}
caCert, err := x509.ParseCertificate(caCertBlock.Bytes)
if err != nil {
fmt.Println("Failed to parse CA certificate:", err)
return
}
caKeyBlock, _ := pem.Decode(caKeyPEM)
if caKeyBlock == nil {
fmt.Println("Failed to parse CA key PEM")
return
}
caKey, err := x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
if err != nil {
fmt.Println("Failed to parse CA private key:", err)
return
} }
priv, err := rsa.GenerateKey(rand.Reader, 4096) priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil { if err != nil {
fmt.Println("Failed to generate private key:", err) return fmt.Errorf("failed to generate private key: %v", err)
return
} }
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil { if err != nil {
fmt.Println("Failed to generate serial number:", err) return fmt.Errorf("failed to generate serial number: %v", err)
return
} }
var validity time.Duration var validityDur time.Duration
if validityFlag != "" { validity := def.Validity
validity, err = parseValidity(validityFlag) if validity == "" {
validity = "1y" // default to 1 year
}
validityDur, err = parseValidity(validity)
if err != nil { if err != nil {
fmt.Println("Invalid validity value:", err) return fmt.Errorf("invalid validity value: %v", err)
return
}
} else {
validity = 365 * 24 * time.Hour // default 1 year
} }
// Parse subject as DN if it looks like a DN, otherwise use as CommonName only
var subjectPKIX pkix.Name var subjectPKIX pkix.Name
if isDNFormat(subject) { if isDNFormat(def.Subject) {
subjectPKIX = parseDistinguishedName(subject) subjectPKIX = parseDistinguishedName(def.Subject)
} else { } else {
subjectPKIX = pkix.Name{CommonName: 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(validity), NotAfter: expires,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
} }
// Handle SANs for _, s := range def.SAN {
for _, s := range san {
sLower := strings.ToLower(s) sLower := strings.ToLower(s)
var val string var val string
if n, _ := fmt.Sscanf(sLower, "dns:%s", &val); n == 1 { if n, _ := fmt.Sscanf(sLower, "dns:%s", &val); n == 1 {
@@ -360,12 +504,11 @@ func IssueCertificate(configPath, name string, subject, certType, validityFlag s
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 { } else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val) certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
} else { } else {
fmt.Printf("Invalid SAN format: %s\n", s) return fmt.Errorf("invalid SAN format: %s", s)
return
} }
} }
switch certType { switch def.Type {
case "client": case "client":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
case "server": case "server":
@@ -377,180 +520,161 @@ func IssueCertificate(configPath, name string, subject, certType, validityFlag s
case "email": case "email":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection} certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}
default: default:
fmt.Println("Unknown certificate type. Use one of: client, server, server-only, code-signing, email.") return fmt.Errorf("unknown certificate type. Use one of: client, server, server-only, code-signing, email")
return
} }
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 {
fmt.Println("Failed to create certificate:", err) return fmt.Errorf("failed to create certificate: %v", err)
return
} }
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 := name basename := def.Name
if basename == "" { if basename == "" {
basename = subject basename = def.Subject
} }
certFile := filepath.Join(ca.Paths.Certificates, basename+"."+certType+".crt.pem") certFile := filepath.Join(CAConfig.Paths.Certificates, basename+".crt.pem")
keyFile := filepath.Join(ca.Paths.PrivateKeys, basename+"."+certType+".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 {
fmt.Println("Error saving certificate:", err) return fmt.Errorf("error saving certificate: %v", err)
return
} }
if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil { if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil {
fmt.Println("Error saving key:", err) return fmt.Errorf("error saving key: %v", err)
return
} }
fmt.Printf("%s certificate and key for '%s' generated.\n", certType, subject) if verbose {
fmt.Printf(`
Certificate:
Name: %s
Subject: %s
Type: %s
Validity: %s
SAN: %v
`,
def.Name,
def.Subject,
def.Type,
def.Validity,
def.SAN,
)
}
CAState.UpdateCAStateAfterIssue(
CAConfig.SerialType,
basename,
serialNumber,
validityDur,
)
return nil
} }
// Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature) // A prototype of certificate provisioning function
func GetCertificateDefaults(path string) CertificateDef { func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose bool) error {
_, defaults, err := LoadCertificatesFile(path) err := LoadCA()
if err != nil || defaults == nil {
return CertificateDef{} if err != nil {
} fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return CertificateDef{ os.Exit(1)
Type: defaults.Type, }
Validity: defaults.Validity,
SAN: defaults.SAN, // Make an empty Certificates struct to hold the definitions
} certDefs := Certificates{}
}
// Load certificates provisioning configuration from the file (HCL syntax)
err = certDefs.LoadFromFile(filePath)
if err != nil {
return fmt.Errorf("Error loading certificates file: %v", err)
}
// The certificate provisioning file must contain at least one certificate definition
if len(certDefs.Certificates) < 1 {
return fmt.Errorf("No certificates defined in %s", filePath)
}
// We will be counting successes and errors
successes := 0
errors := 0
// Loop through all certificate definitions
// to render templates and fill missing fields from defaults
for i := range certDefs.Certificates {
// Fill missing fields from defaults, if provided
certDefs.Certificates[i].fillDefaultValues(certDefs.Defaults)
// Render templates in the definition using the variables map
// with added definition name.
variables := certDefs.Variables
if variables == nil {
variables = make(map[string]string)
}
variables["Name"] = certDefs.Certificates[i].Name
err = certDefs.Certificates[i].RenderTemplates(variables)
if err != nil {
return fmt.Errorf("failed to render templates for certificate %s: %v", certDefs.Certificates[i].Name, err)
}
}
// No errors so far, now we can issue certificates
for i := range certDefs.Certificates {
fmt.Printf("[%d/%d] Issuing %s... ", i+1, len(certDefs.Certificates), certDefs.Certificates[i].Name)
// Issue certificate with custom basename and dry-run support
func IssueCertificateWithBasename(configPath, basename, subject, certType, validityFlag string, san []string, overwrite, dryRun bool) error {
if dryRun { if dryRun {
fmt.Printf("Would issue certificate: name=%s, subject=%s, type=%s, validity=%s, SAN=%v\n", basename, subject, certType, validityFlag, san) fmt.Printf("(dry run)\n")
successes++
continue
}
err = issueSingleCertificate(certDefs.Certificates[i], overwrite, verbose)
if err != nil {
fmt.Printf("error: %v\n", err)
errors++
} else {
if !verbose {
fmt.Printf("done\n")
}
successes++
}
}
fmt.Printf("Provisioning complete: %d succeeded, %d failed.\n", successes, errors)
err = SaveCAState()
if err != nil {
fmt.Printf("Error saving CA state: %v\n", err)
}
return nil
}
func IssueCertificate(certDef CertificateDefinition, overwrite bool, dryRun bool, verbose bool) error {
err := LoadCA()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
if certDef.Subject == "" {
certDef.Subject = certDef.Name
}
// Render templates in the certificae subject and SAN fields
variables := map[string]string{"Name": certDef.Name}
certDef.RenderTemplates(variables)
if dryRun {
fmt.Printf("Would issue %s certificate for '%s' (dry run)\n", certDef.Type, certDef.Subject)
return nil return nil
} }
// Call IssueCertificate but override basename logic
return issueCertificateInternal(configPath, basename, subject, certType, validityFlag, san, overwrite)
}
// Internal: like IssueCertificate but with explicit basename err = issueSingleCertificate(certDef, overwrite, verbose)
func issueCertificateInternal(configPath, basename, subject, certType, validityFlag string, san []string, overwrite bool) error {
// Add default dns SAN for server/server-only if none specified
if (certType == "server" || certType == "server-only") && len(san) == 0 {
san = append(san, "dns:"+subject)
}
ca, err := LoadCA(configPath)
if err != nil { if err != nil {
return fmt.Errorf("Error loading config: %v", err) return err
} }
caCertPath := filepath.Join(ca.Paths.Certificates, "ca_cert.pem") fmt.Printf("%s certificate and key for '%s' generated.\n", certDef.Type, certDef.Subject)
caKeyPath := filepath.Join(ca.Paths.PrivateKeys, "ca_key.pem") if err := SaveCAState(); err != nil {
fmt.Printf("Error saving CA state: %v\n", err)
caCertPEM, err := os.ReadFile(caCertPath)
if err != nil {
return fmt.Errorf("Error reading CA certificate file: %v", err)
}
caKeyPEM, err := os.ReadFile(caKeyPath)
if err != nil {
return fmt.Errorf("Error reading CA key file: %v", err)
} }
caCertBlock, _ := pem.Decode(caCertPEM)
if caCertBlock == nil {
return fmt.Errorf("Failed to parse CA certificate PEM")
}
caCert, err := x509.ParseCertificate(caCertBlock.Bytes)
if err != nil {
return fmt.Errorf("Failed to parse CA certificate: %v", err)
}
caKeyBlock, _ := pem.Decode(caKeyPEM)
if caKeyBlock == nil {
return fmt.Errorf("Failed to parse CA key PEM")
}
caKey, err := x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
if err != nil {
return fmt.Errorf("Failed to parse CA private key: %v", err)
}
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("Failed to generate private key: %v", err)
}
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)
}
var validity time.Duration
if validityFlag != "" {
validity, err = parseValidity(validityFlag)
if err != nil {
return fmt.Errorf("Invalid validity value: %v", err)
}
} else {
validity = 365 * 24 * time.Hour // default 1 year
}
// Parse subject as DN if it looks like a DN, otherwise use as CommonName only
var subjectPKIX pkix.Name
if isDNFormat(subject) {
subjectPKIX = parseDistinguishedName(subject)
} else {
subjectPKIX = pkix.Name{CommonName: subject}
}
certTmpl := x509.Certificate{
SerialNumber: serialNumber,
Subject: subjectPKIX,
NotBefore: time.Now(),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
}
// Handle SANs
for _, s := range san {
sLower := strings.ToLower(s)
var val string
if n, _ := fmt.Sscanf(sLower, "dns:%s", &val); n == 1 {
certTmpl.DNSNames = append(certTmpl.DNSNames, val)
} else if n, _ := fmt.Sscanf(sLower, "ip:%s", &val); n == 1 {
certTmpl.IPAddresses = append(certTmpl.IPAddresses, net.ParseIP(val))
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
} else {
return fmt.Errorf("Invalid SAN format: %s", s)
}
}
switch certType {
case "client":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
case "server":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
case "server-only":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
case "code-signing":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}
case "email":
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}
default:
return fmt.Errorf("Unknown certificate type. Use one of: client, server, server-only, code-signing, email.")
}
certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey)
if err != nil {
return fmt.Errorf("Failed to create certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
certFile := filepath.Join(ca.Paths.Certificates, basename+".crt.pem")
keyFile := filepath.Join(ca.Paths.PrivateKeys, basename+".key.pem")
if err := SavePEM(certFile, certPEM, false, overwrite); err != nil {
return fmt.Errorf("Error saving certificate: %v", err)
}
if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil {
return fmt.Errorf("Error saving key: %v", err)
}
return nil return nil
} }
@@ -592,84 +716,6 @@ func parseDistinguishedName(dn string) pkix.Name {
return name return name
} }
// Helper: apply Go template to a string using CertificateDef and CertificateDefaults as data
func applyTemplate(s string, def CertificateDef, defaults *CertificateDefaults) (string, error) {
data := struct {
CertificateDef
Defaults *CertificateDefaults
}{
CertificateDef: def,
Defaults: defaults,
}
tmpl, err := template.New("").Parse(s)
if err != nil {
return s, err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return s, err
}
return buf.String(), nil
}
// Render all string fields in CertificateDef using Go templates and return a new CertificateDef
func renderCertificateDefTemplates(def CertificateDef, defaults *CertificateDefaults) CertificateDef {
newDef := def
// Subject: use def.Subject if set, else defaults.Subject (rendered)
if def.Subject != "" {
if rendered, err := applyTemplate(def.Subject, def, defaults); err == nil {
newDef.Subject = rendered
}
} else if defaults != nil && defaults.Subject != "" {
if rendered, err := applyTemplate(defaults.Subject, def, defaults); err == nil {
newDef.Subject = rendered
}
}
// Type: use def.Type if set, else defaults.Type (rendered)
if def.Type != "" {
if rendered, err := applyTemplate(def.Type, def, defaults); err == nil {
newDef.Type = rendered
}
} else if defaults != nil && defaults.Type != "" {
if rendered, err := applyTemplate(defaults.Type, def, defaults); err == nil {
newDef.Type = rendered
}
}
// Validity: use def.Validity if set, else defaults.Validity (rendered)
if def.Validity != "" {
if rendered, err := applyTemplate(def.Validity, def, defaults); err == nil {
newDef.Validity = rendered
}
} else if defaults != nil && defaults.Validity != "" {
if rendered, err := applyTemplate(defaults.Validity, def, defaults); err == nil {
newDef.Validity = rendered
}
}
// SAN: use def.SAN if set, else defaults.SAN (rendered)
if len(def.SAN) > 0 {
newSAN := make([]string, len(def.SAN))
for i, s := range def.SAN {
if rendered, err := applyTemplate(s, def, defaults); err == nil {
newSAN[i] = rendered
} else {
newSAN[i] = s
}
}
newDef.SAN = newSAN
} else if defaults != nil && len(defaults.SAN) > 0 {
newSAN := make([]string, len(defaults.SAN))
for i, s := range defaults.SAN {
if rendered, err := applyTemplate(s, def, defaults); err == nil {
newSAN[i] = rendered
} else {
newSAN[i] = s
}
}
newDef.SAN = newSAN
}
return newDef
}
// Helper: convert optional string to []string or nil // Helper: convert optional string to []string or nil
func optionalSlice(s string) []string { func optionalSlice(s string) []string {
if s == "" { if s == "" {

191
certdb.go Normal file
View File

@@ -0,0 +1,191 @@
// A certificate database management functions
package main
import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
)
// _CAState represents the persisted CA state in JSON
type _CAState struct {
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Serial int `json:"serial,omitempty"`
CRLNumber int `json:"crlNumber"`
Certificates []CertificateRecord `json:"certificates"`
}
// CertificateRecord represents a single certificate record in the CA state
type CertificateRecord struct {
Name string `json:"name"`
Issued string `json:"issued"`
Expires string `json:"expires"`
Serial string `json:"serial"`
RevokedAt string `json:"revokedAt,omitempty"`
RevokeReason int `json:"revokeReason,omitempty"`
}
func caStatePath() string {
return filepath.Join(filepath.Dir(CAConfigPath), CAConfig.StateName())
}
// LoadCAState loads the CA state from a JSON file
func LoadCAState() error {
path := caStatePath()
fmt.Printf("Loading CA state from %s\n", path)
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
CAState = &_CAState{}
if err := json.NewDecoder(f).Decode(CAState); err != nil {
return err
}
return nil
}
// SaveCAState saves the CA state to a JSON file
func SaveCAState() error {
CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
f, err := os.Create(caStatePath())
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(CAState)
}
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate
func (s *_CAState) UpdateCAStateAfterIssue(serialType, basename string, serialNumber any, validity time.Duration) error {
if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n")
os.Exit(1)
}
issued := time.Now().UTC().Format(time.RFC3339)
expires := time.Now().Add(validity).UTC().Format(time.RFC3339)
serialStr := ""
switch serialType {
case "sequential":
serialStr = fmt.Sprintf("%d", CAState.Serial)
CAState.Serial++
case "random":
serialStr = fmt.Sprintf("%x", serialNumber)
default:
serialStr = fmt.Sprintf("%v", serialNumber)
}
s.AddCertificate(basename, issued, expires, serialStr)
return nil
}
func (s *_CAState) AddCertificate(name, issued, expires, serial string) {
if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n")
os.Exit(1)
}
rec := CertificateRecord{
Name: name,
Issued: issued,
Expires: expires,
Serial: serial,
}
s.Certificates = append(s.Certificates, rec)
}
// 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

@@ -5,6 +5,11 @@ defaults {
san = ["DNS:{{ .Name }}.koszewscy.waw.pl"] san = ["DNS:{{ .Name }}.koszewscy.waw.pl"]
} }
variables = {
Domain = "koszewscy.email"
Country = "PL"
}
certificate "grafana" { certificate "grafana" {
# from default: subject = "{{ .Name }}.koszewscy.waw.pl" # result: grafana.koszewscy.waw.pl # from default: subject = "{{ .Name }}.koszewscy.waw.pl" # result: grafana.koszewscy.waw.pl
# from default: type = "server" # from default: type = "server"
@@ -18,3 +23,10 @@ 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" {}
certificate "prometheus" {
subject = "{{ .Name }}.{{ .Domain }}" # result: prometheus.koszewscy.email
san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:prometheus.koszewscy.email" ]
}

View File

@@ -0,0 +1,14 @@
{
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"serial": 1,
"certificates": [
{
"name": "",
"issued": "",
"expires": "",
"serial": "",
"valid": true
}
]
}

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

234
main.go
View File

@@ -10,16 +10,21 @@ import (
var Version = "dev" var Version = "dev"
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
var validity string var validity string
var san []string var san []string
var name string var name 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 provisionFile string
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "lab-ca", Use: "lab-ca",
@@ -30,115 +35,156 @@ func main() {
}, },
} }
var initcaCmd = &cobra.Command{ // Define persistent flags (global for all commands)
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(&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")
// lab-ca initca 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(configPath, overwrite) InitCA(overwrite)
}, },
} }
rootCmd.AddCommand(initCmd)
initcaCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file") // lab-ca issue command
initcaCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
var issueCmd = &cobra.Command{ var issueCmd = &cobra.Command{
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 fromFile != "" { err := IssueCertificate(CertificateDefinition{
certDefs, defaults, err := LoadCertificatesFile(fromFile) Name: name,
Subject: subject,
Type: certType,
Validity: validity,
SAN: san,
}, overwrite, dryRun, verbose)
if err != nil { if err != nil {
fmt.Printf("Error loading certificates file: %v\n", err) fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return os.Exit(1)
} }
successes := 0
errors := 0
for i, def := range certDefs {
if defaults != nil {
if def.Type == "" {
def.Type = defaults.Type
}
if def.Validity == "" {
def.Validity = defaults.Validity
}
if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
def.SAN = defaults.SAN
}
}
finalDef := renderCertificateDefTemplates(def, defaults)
fmt.Printf("[%d/%d] Issuing %s... ", i+1, len(certDefs), finalDef.Name)
if dryRun {
fmt.Printf("(dry run)\n")
}
if verbose {
fmt.Printf("\n Name: %s\n", finalDef.Name)
fmt.Printf(" Subject: %s\n", finalDef.Subject)
fmt.Printf(" Type: %s\n", finalDef.Type)
fmt.Printf(" Validity: %s\n", finalDef.Validity)
fmt.Printf(" SAN: %v\n\n", finalDef.SAN)
}
basename := finalDef.Name + "." + finalDef.Type
if dryRun {
successes++
continue
}
err := IssueCertificateWithBasename(configPath, basename, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite, dryRun)
if err != nil {
fmt.Printf("ERROR: %v\n", err)
errors++
} else {
if !verbose {
fmt.Printf("done\n")
}
successes++
}
}
fmt.Printf("Batch complete: %d succeeded, %d failed.\n", successes, errors)
return
}
// Simple mode
subjectName := subject
if subjectName == "" {
subjectName = name
}
finalDef := renderCertificateDefTemplates(CertificateDef{Name: name, Subject: subject, Type: certType, Validity: validity, SAN: san}, nil)
if verbose {
fmt.Printf("\nCertificate:\n")
fmt.Printf(" Name: %s\n", finalDef.Name)
fmt.Printf(" Subject: %s\n", finalDef.Subject)
fmt.Printf(" Type: %s\n", finalDef.Type)
fmt.Printf(" Validity: %s\n", finalDef.Validity)
fmt.Printf(" SAN: %v\n", finalDef.SAN)
}
IssueCertificate(configPath, finalDef.Name, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite)
}, },
} }
issueCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file") 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, server-only, code-signing, email")
issueCmd.Flags().StringArrayVar(&san, "san", nil, issueCmd.Flags().StringArrayVar(&san, "san", nil,
"Subject Alternative Name (SAN). Use multiple times for multiple values.\n"+ "Subject Alternative Name (SAN). Use multiple times for multiple values.\nFormat: dns:example.com, ip:1.2.3.4, email:user@example.com")
"Format: 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.")
issueCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files") issueCmd.MarkFlagRequired("name")
issueCmd.Flags().StringVar(&fromFile, "from-file", "", "Path to HCL file with multiple certificate definitions (batch mode)") rootCmd.AddCommand(issueCmd)
issueCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)")
issueCmd.Flags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate") // lab-ca provision command
// Only require --name in simple mode var provisionCmd = &cobra.Command{
issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)") Use: "provision",
issueCmd.PreRun = func(cmd *cobra.Command, args []string) { Short: "Provision certificates from a batch file (HCL)",
if fromFile == "" { Run: func(cmd *cobra.Command, args []string) {
cmd.MarkFlagRequired("name")
err := ProvisionCertificates(provisionFile, overwrite, false, verbose)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
} }
},
} }
provisionCmd.Flags().StringVar(&provisionFile, "file", "", "Path to HCL file with certificate definitions (required)")
provisionCmd.MarkFlagRequired("file")
rootCmd.AddCommand(provisionCmd)
// lab-ca revoke command
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(revokeCmd)
// lab-ca crl command
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)")
rootCmd.AddCommand(crlCmd)
// lab-ca version command
var versionCmd = &cobra.Command{ var versionCmd = &cobra.Command{
Use: "version", Use: "version",
Short: "Show version information", Short: "Show version information",
@@ -146,8 +192,6 @@ func main() {
fmt.Printf("lab-ca version: %s\n", Version) fmt.Printf("lab-ca version: %s\n", Version)
}, },
} }
rootCmd.AddCommand(initcaCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(versionCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
@@ -156,14 +200,18 @@ func main() {
} }
func printMainHelp() { func printMainHelp() {
fmt.Println("lab-ca - Certificate Authority Utility") fmt.Printf("lab-ca - Certificate Authority Utility\n")
fmt.Printf("Version: %s\n", Version)
fmt.Println() fmt.Println()
fmt.Println("Usage:") fmt.Println("Usage:")
fmt.Println(" lab-ca <command> [options]") fmt.Println(" lab-ca <command> [options]")
fmt.Println() fmt.Println()
fmt.Println("Available commands:") fmt.Println("Available commands:")
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 certificate")
fmt.Println(" provision Provision certificates from a batch file (HCL)")
fmt.Println(" revoke Revoke a certificate by name or serial number")
fmt.Println(" crl Generate a Certificate Revocation List (CRL)")
fmt.Println(" version Show version information") fmt.Println(" version Show version information")
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.")

42
run-test.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Build and install
go install
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 provision --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