diff --git a/ca.go b/ca.go index 4ef6031..0e8ba4c 100644 --- a/ca.go +++ b/ca.go @@ -25,7 +25,7 @@ type Paths struct { PrivateKeys string `hcl:"private_keys"` } -type CA struct { +type CAConfig struct { Label string `hcl:",label"` Name string `hcl:"name"` Country string `hcl:"country"` @@ -41,10 +41,10 @@ type CA struct { } type Configuration struct { - CA CA `hcl:"ca,block"` + Current CAConfig `hcl:"ca,block"` } -type CertificateDef struct { +type CertificateDefinition struct { Name string `hcl:",label"` Subject string `hcl:"subject,optional"` Type string `hcl:"type,optional"` @@ -60,11 +60,11 @@ type CertificateDefaults struct { } type Certificates struct { - Defaults *CertificateDefaults `hcl:"defaults,block"` - Certificates []CertificateDef `hcl:"certificate,block"` + Defaults *CertificateDefaults `hcl:"defaults,block"` + Certificates []CertificateDefinition `hcl:"certificate,block"` } -func LoadCA(path string) (*CA, error) { +func LoadCA(path string) (*CAConfig, error) { parser := hclparse.NewParser() file, diags := parser.ParseHCLFile(path) if diags.HasErrors() { @@ -75,17 +75,25 @@ func LoadCA(path string) (*CA, error) { if diags.HasErrors() { 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") } - 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 &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 -func LoadCertificatesFile(path string) ([]CertificateDef, *CertificateDefaults, error) { +func LoadCertificatesFile(path string) ([]CertificateDefinition, *CertificateDefaults, error) { parser := hclparse.NewParser() file, diags := parser.ParseHCLFile(path) 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 if keySize == 0 { keySize = 4096 @@ -203,7 +211,7 @@ func (p *Paths) Validate() error { return nil } -func (c *CA) Validate() error { +func (c *CAConfig) Validate() error { if c.Name == "" { return fmt.Errorf("CA 'name' is required") } @@ -213,10 +221,11 @@ func (c *CA) Validate() error { if c.Organization == "" { return fmt.Errorf("CA 'organization' is required") } - // SerialType is now optional; default to 'random' if empty + if c.SerialType == "" { c.SerialType = "random" } + if c.SerialType != "random" && c.SerialType != "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.") } -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 if (certType == "server" || certType == "server-only") && len(san) == 0 { 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) -func GetCertificateDefaults(path string) CertificateDef { +func GetCertificateDefaults(path string) CertificateDefinition { _, defaults, err := LoadCertificatesFile(path) if err != nil || defaults == nil { - return CertificateDef{} + return CertificateDefinition{} } - return CertificateDef{ + return CertificateDefinition{ Type: defaults.Type, Validity: defaults.Validity, SAN: defaults.SAN, @@ -551,6 +655,7 @@ func issueCertificateInternal(configPath, basename, subject, certType, validityF if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil { return fmt.Errorf("Error saving key: %v", err) } + return nil } @@ -592,14 +697,14 @@ func parseDistinguishedName(dn string) pkix.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) { +// Helper: apply Go template to a string using CertificateDefinition and CertificateDefaults as data +func applyTemplate(s string, def CertificateDefinition, defaults *CertificateDefaults) (string, error) { data := struct { - CertificateDef + CertificateDefinition Defaults *CertificateDefaults }{ - CertificateDef: def, - Defaults: defaults, + CertificateDefinition: def, + Defaults: defaults, } tmpl, err := template.New("").Parse(s) if err != nil { @@ -612,8 +717,8 @@ func applyTemplate(s string, def CertificateDef, defaults *CertificateDefaults) 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 { +// Render all string fields in CertificateDefinition using Go templates and return a new CertificateDefinition +func renderCertificateDefTemplates(def CertificateDefinition, defaults *CertificateDefaults) CertificateDefinition { newDef := def // Subject: use def.Subject if set, else defaults.Subject (rendered) if def.Subject != "" { diff --git a/certdb.go b/certdb.go new file mode 100644 index 0000000..1566ee6 --- /dev/null +++ b/certdb.go @@ -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) +} diff --git a/examples/example_ca_state.json b/examples/example_ca_state.json new file mode 100644 index 0000000..54ff61f --- /dev/null +++ b/examples/example_ca_state.json @@ -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 + } + ] +} diff --git a/main.go b/main.go index 1439db0..6132a4f 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,9 @@ import ( var Version = "dev" +// Global CA state variable +var GlobalCAState *CAState + func main() { var configPath string var overwrite bool @@ -30,7 +33,7 @@ func main() { }, } - var initcaCmd = &cobra.Command{ + var initCmd = &cobra.Command{ Use: "initca", Short: "Generate a new CA certificate and key", 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") - initcaCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files") + initCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file") + initCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files") var issueCmd = &cobra.Command{ Use: "issue", Short: "Issue a new certificate (client, server, server-only, code-signing, email)", Run: func(cmd *cobra.Command, args []string) { - 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) - 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) + IssueCertificate(configPath, subject, certType, validity, san, name, fromFile, overwrite, dryRun, verbose) }, } @@ -146,7 +78,7 @@ func main() { fmt.Printf("lab-ca version: %s\n", Version) }, } - rootCmd.AddCommand(initcaCmd) + rootCmd.AddCommand(initCmd) rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(versionCmd)