Partial implementation of CA database.

This commit is contained in:
2025-07-27 19:48:38 +02:00
parent 6e427acb18
commit 0c32da1e84
4 changed files with 249 additions and 100 deletions

153
ca.go
View File

@@ -25,7 +25,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"`
@@ -41,10 +41,10 @@ type CA struct {
} }
type Configuration struct { type Configuration struct {
CA CA `hcl:"ca,block"` Current CAConfig `hcl:"ca,block"`
} }
type CertificateDef struct { 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"`
@@ -60,11 +60,11 @@ type CertificateDefaults struct {
} }
type Certificates struct { type Certificates struct {
Defaults *CertificateDefaults `hcl:"defaults,block"` Defaults *CertificateDefaults `hcl:"defaults,block"`
Certificates []CertificateDef `hcl:"certificate,block"` Certificates []CertificateDefinition `hcl:"certificate,block"`
} }
func LoadCA(path string) (*CA, error) { func LoadCA(path string) (*CAConfig, error) {
parser := hclparse.NewParser() parser := hclparse.NewParser()
file, diags := parser.ParseHCLFile(path) file, diags := parser.ParseHCLFile(path)
if diags.HasErrors() { if diags.HasErrors() {
@@ -75,17 +75,25 @@ func LoadCA(path string) (*CA, error) {
if diags.HasErrors() { if diags.HasErrors() {
return nil, fmt.Errorf("failed to decode HCL: %s", diags.Error()) return nil, 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 nil, fmt.Errorf("no 'ca' block found in config file")
} }
if err := config.CA.Validate(); err != nil { if config.Current.Label == "" {
return nil, fmt.Errorf("the 'ca' block must have a label (e.g., ca \"mylabel\" {...})")
}
if err := config.Current.Validate(); err != nil {
return nil, err return nil, err
} }
return &config.CA, nil // Derive caStatePath from caConfig label and config file path
caDir := filepath.Dir(path)
caLabel := config.Current.Label
caStatePath := filepath.Join(caDir, caLabel+"_state.json")
GlobalCAState, _ = LoadCAState(caStatePath)
return &config.Current, 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() {
@@ -126,7 +134,7 @@ func parseValidity(validity string) (time.Duration, error) {
} }
} }
func GenerateCA(ca *CA) ([]byte, []byte, error) { func GenerateCA(ca *CAConfig) ([]byte, []byte, error) {
keySize := ca.KeySize keySize := ca.KeySize
if keySize == 0 { if keySize == 0 {
keySize = 4096 keySize = 4096
@@ -203,7 +211,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 +221,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'")
} }
@@ -263,7 +272,102 @@ func InitCA(configPath string, overwrite bool) {
fmt.Println("CA certificate and key generated.") fmt.Println("CA certificate and key generated.")
} }
func IssueCertificate(configPath, name string, subject, certType, validityFlag string, san []string, overwrite bool) { func IssueCertificate(configPath, subject, certType, validity string, san []string, name, fromFile string, overwrite, dryRun, verbose bool) {
// Load CA config to get label for state file
ca, err := LoadCA(configPath)
if err != nil {
fmt.Printf("Error loading CA config: %v\n", err)
return
}
if fromFile != "" {
certDefs, defaults, err := LoadCertificatesFile(fromFile)
if err != nil {
fmt.Printf("Error loading certificates file: %v\n", err)
return
}
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)
// Save CA state after batch issuance
caDir := filepath.Dir(configPath)
caLabel := ca.Label
caStatePath := filepath.Join(caDir, caLabel+"_state.json")
if err := SaveCAState(caStatePath, GlobalCAState); err != nil {
fmt.Printf("Error saving CA state: %v\n", err)
}
return
}
// Simple mode
subjectName := subject
if subjectName == "" {
subjectName = name
}
finalDef := renderCertificateDefTemplates(CertificateDefinition{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)
}
internalIssueCertificate(configPath, finalDef.Name, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite)
// Save CA state after single issuance
caDir := filepath.Dir(configPath)
caLabel := ca.Label
caStatePath := filepath.Join(caDir, caLabel+"_state.json")
if err := SaveCAState(caStatePath, GlobalCAState); err != nil {
fmt.Printf("Error saving CA state: %v\n", err)
}
}
func internalIssueCertificate(configPath, name string, subject, certType, validityFlag string, san []string, overwrite bool) {
// 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 (certType == "server" || certType == "server-only") && len(san) == 0 {
san = append(san, "dns:"+subject) san = append(san, "dns:"+subject)
@@ -407,12 +511,12 @@ func IssueCertificate(configPath, name string, subject, certType, validityFlag s
} }
// Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature) // Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature)
func GetCertificateDefaults(path string) CertificateDef { func GetCertificateDefaults(path string) CertificateDefinition {
_, defaults, err := LoadCertificatesFile(path) _, defaults, err := LoadCertificatesFile(path)
if err != nil || defaults == nil { if err != nil || defaults == nil {
return CertificateDef{} return CertificateDefinition{}
} }
return CertificateDef{ return CertificateDefinition{
Type: defaults.Type, Type: defaults.Type,
Validity: defaults.Validity, Validity: defaults.Validity,
SAN: defaults.SAN, SAN: defaults.SAN,
@@ -551,6 +655,7 @@ func issueCertificateInternal(configPath, basename, subject, certType, validityF
if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil { if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil {
return fmt.Errorf("Error saving key: %v", err) return fmt.Errorf("Error saving key: %v", err)
} }
return nil return nil
} }
@@ -592,14 +697,14 @@ func parseDistinguishedName(dn string) pkix.Name {
return name return name
} }
// Helper: apply Go template to a string using CertificateDef and CertificateDefaults as data // Helper: apply Go template to a string using CertificateDefinition and CertificateDefaults as data
func applyTemplate(s string, def CertificateDef, defaults *CertificateDefaults) (string, error) { func applyTemplate(s string, def CertificateDefinition, defaults *CertificateDefaults) (string, error) {
data := struct { data := struct {
CertificateDef CertificateDefinition
Defaults *CertificateDefaults Defaults *CertificateDefaults
}{ }{
CertificateDef: def, CertificateDefinition: def,
Defaults: defaults, Defaults: defaults,
} }
tmpl, err := template.New("").Parse(s) tmpl, err := template.New("").Parse(s)
if err != nil { if err != nil {
@@ -612,8 +717,8 @@ func applyTemplate(s string, def CertificateDef, defaults *CertificateDefaults)
return buf.String(), nil return buf.String(), nil
} }
// Render all string fields in CertificateDef using Go templates and return a new CertificateDef // Render all string fields in CertificateDefinition using Go templates and return a new CertificateDefinition
func renderCertificateDefTemplates(def CertificateDef, defaults *CertificateDefaults) CertificateDef { func renderCertificateDefTemplates(def CertificateDefinition, defaults *CertificateDefaults) CertificateDefinition {
newDef := def newDef := def
// Subject: use def.Subject if set, else defaults.Subject (rendered) // Subject: use def.Subject if set, else defaults.Subject (rendered)
if def.Subject != "" { if def.Subject != "" {

98
certdb.go Normal file
View File

@@ -0,0 +1,98 @@
// A certificate database management functions
package main
import (
"encoding/json"
"fmt"
"os"
"time"
)
// CAState represents the persisted CA state in JSON
// (matches the structure of example_ca.json)
type CAState struct {
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Serial int `json:"serial,omitempty"`
Certificates []CertificateRecord `json:"certificates"`
}
type CertificateRecord struct {
Name string `json:"name"`
Issued string `json:"issued"`
Expires string `json:"expires"`
Serial string `json:"serial"`
Valid bool `json:"valid"`
}
// LoadCAState loads the CA state from a JSON file
func LoadCAState(filename string) (*CAState, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
var state CAState
if err := json.NewDecoder(f).Decode(&state); err != nil {
return nil, err
}
return &state, nil
}
// SaveCAState saves the CA state to a JSON file
func SaveCAState(filename string, state *CAState) error {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(state)
}
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate
func UpdateCAStateAfterIssue(jsonFile, serialType, basename string, serialNumber any, validity time.Duration) error {
var err error
if GlobalCAState == nil {
GlobalCAState, err = LoadCAState(jsonFile)
if err != nil {
GlobalCAState = nil
}
}
if GlobalCAState == nil {
fmt.Fprintf(os.Stderr, "FATAL: GlobalCAState 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", GlobalCAState.Serial)
GlobalCAState.Serial++
case "random":
serialStr = fmt.Sprintf("%x", serialNumber)
default:
serialStr = fmt.Sprintf("%v", serialNumber)
}
AddCertificate(basename, issued, expires, serialStr, true)
return nil
}
// AddCertificate appends a new CertificateRecord to the GlobalCAState
func AddCertificate(name, issued, expires, serial string, valid bool) {
if GlobalCAState == nil {
fmt.Fprintf(os.Stderr, "FATAL: GlobalCAState is nil in AddCertificate. This indicates a programming error.\n")
os.Exit(1)
}
rec := CertificateRecord{
Name: name,
Issued: issued,
Expires: expires,
Serial: serial,
Valid: valid,
}
GlobalCAState.Certificates = append(GlobalCAState.Certificates, rec)
}

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
}
]
}

84
main.go
View File

@@ -9,6 +9,9 @@ import (
var Version = "dev" var Version = "dev"
// Global CA state variable
var GlobalCAState *CAState
func main() { func main() {
var configPath string var configPath string
var overwrite bool var overwrite bool
@@ -30,7 +33,7 @@ func main() {
}, },
} }
var initcaCmd = &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) {
@@ -38,85 +41,14 @@ func main() {
}, },
} }
initcaCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file") initCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file")
initcaCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files") initCmd.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 != "" { IssueCertificate(configPath, subject, certType, validity, san, name, fromFile, overwrite, dryRun, verbose)
certDefs, defaults, err := LoadCertificatesFile(fromFile)
if err != nil {
fmt.Printf("Error loading certificates file: %v\n", err)
return
}
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)
}, },
} }
@@ -146,7 +78,7 @@ func main() {
fmt.Printf("lab-ca version: %s\n", Version) fmt.Printf("lab-ca version: %s\n", Version)
}, },
} }
rootCmd.AddCommand(initcaCmd) rootCmd.AddCommand(initCmd)
rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(versionCmd)