package main import ( "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "math/big" "net" "os" "path/filepath" "strings" "text/template" "time" gohcl "github.com/hashicorp/hcl/v2/gohcl" hclparse "github.com/hashicorp/hcl/v2/hclparse" ) type Paths struct { Certificates string `hcl:"certificates"` PrivateKeys string `hcl:"private_keys"` } type CA struct { Label string `hcl:",label"` Name string `hcl:"name"` Country string `hcl:"country"` Organization string `hcl:"organization"` OrganizationalUnit string `hcl:"organizational_unit,optional"` Locality string `hcl:"locality,optional"` Province string `hcl:"province,optional"` Email string `hcl:"email,optional"` SerialType string `hcl:"serial_type,optional"` KeySize int `hcl:"key_size,optional"` Validity string `hcl:"validity,optional"` Paths Paths `hcl:"paths,block"` } type Configuration struct { CA CA `hcl:"ca,block"` } type CertificateDef struct { Name string `hcl:",label"` Subject string `hcl:"subject,optional"` Type string `hcl:"type,optional"` Validity string `hcl:"validity,optional"` SAN []string `hcl:"san,optional"` } type CertificateDefaults struct { Subject string `hcl:"subject,optional"` Type string `hcl:"type,optional"` Validity string `hcl:"validity,optional"` SAN []string `hcl:"san,optional"` } type Certificates struct { Defaults *CertificateDefaults `hcl:"defaults,block"` Certificates []CertificateDef `hcl:"certificate,block"` } func LoadCA(path string) (*CA, error) { parser := hclparse.NewParser() file, diags := parser.ParseHCLFile(path) if diags.HasErrors() { return nil, fmt.Errorf("failed to parse HCL: %s", diags.Error()) } var config Configuration diags = gohcl.DecodeBody(file.Body, nil, &config) if diags.HasErrors() { return nil, fmt.Errorf("failed to decode HCL: %s", diags.Error()) } if (CA{}) == config.CA { return nil, fmt.Errorf("no 'ca' block found in config file") } if err := config.CA.Validate(); err != nil { return nil, err } return &config.CA, nil } // Parse certificates.hcl file with defaults support func LoadCertificatesFile(path string) ([]CertificateDef, *CertificateDefaults, error) { parser := hclparse.NewParser() file, diags := parser.ParseHCLFile(path) if diags.HasErrors() { return nil, nil, fmt.Errorf("failed to parse HCL: %s", diags.Error()) } var certsFile Certificates diags = gohcl.DecodeBody(file.Body, nil, &certsFile) if diags.HasErrors() { return nil, nil, fmt.Errorf("failed to decode HCL: %s", diags.Error()) } return certsFile.Certificates, certsFile.Defaults, nil } func parseValidity(validity string) (time.Duration, error) { if validity == "" { return time.Hour * 24 * 365 * 5, nil // default 5 years } var n int var unit rune _, err := fmt.Sscanf(validity, "%d%c", &n, &unit) if err != nil { // If no unit, assume years _, err2 := fmt.Sscanf(validity, "%d", &n) if err2 != nil { return 0, fmt.Errorf("invalid validity format: %s", validity) } unit = 'y' } switch unit { case 'y': return time.Hour * 24 * 365 * time.Duration(n), nil case 'm': return time.Hour * 24 * 30 * time.Duration(n), nil case 'd': return time.Hour * 24 * time.Duration(n), nil default: return 0, fmt.Errorf("invalid validity unit: %c", unit) } } 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 { if !overwrite { if _, err := os.Stat(filename); err == nil { return fmt.Errorf("file %s already exists (overwrite not allowed)", filename) } else if !os.IsNotExist(err) { return fmt.Errorf("could not check file %s: %v", filename, err) } } if secure { return os.WriteFile(filename, data, 0600) } else { return os.WriteFile(filename, data, 0644) } } func (p *Paths) Validate() error { if p.Certificates == "" { return fmt.Errorf("paths.certificates is required") } if p.PrivateKeys == "" { return fmt.Errorf("paths.private_keys is required") } return nil } func (c *CA) Validate() error { if c.Name == "" { return fmt.Errorf("CA 'name' is required") } if c.Country == "" { return fmt.Errorf("CA 'country' is required") } 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'") } if err := c.Paths.Validate(); err != nil { return err } return nil } func InitCA(configPath string, overwrite bool) { ca, err := LoadCA(configPath) if err != nil { fmt.Println("Error loading config:", err) return } // Create certificates directory with 0755, private keys with 0700 if ca.Paths.Certificates != "" { if err := os.MkdirAll(ca.Paths.Certificates, 0755); err != nil { fmt.Printf("Error creating certificates directory '%s': %v\n", ca.Paths.Certificates, err) return } } if ca.Paths.PrivateKeys != "" { if err := os.MkdirAll(ca.Paths.PrivateKeys, 0700); err != nil { fmt.Printf("Error creating private keys directory '%s': %v\n", ca.Paths.PrivateKeys, err) return } } certPEM, keyPEM, err := GenerateCA(ca) if err != nil { fmt.Println("Error generating CA:", err) return } if err := SavePEM(filepath.Join(ca.Paths.Certificates, "ca_cert.pem"), certPEM, false, overwrite); err != nil { fmt.Println("Error saving CA certificate:", err) return } if err := SavePEM(filepath.Join(ca.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true, overwrite); err != nil { fmt.Println("Error saving CA key:", err) return } fmt.Println("CA certificate and key generated.") } func IssueCertificate(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) } 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) if err != nil { fmt.Println("Failed to generate private key:", err) return } serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { fmt.Println("Failed to generate serial number:", err) return } var validity time.Duration if validityFlag != "" { validity, err = parseValidity(validityFlag) if err != nil { fmt.Println("Invalid validity value:", 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 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 { fmt.Printf("Invalid SAN format: %s\n", s) return } } 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: fmt.Println("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) if err != nil { fmt.Println("Failed to create certificate:", err) return } certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) basename := name if basename == "" { basename = subject } certFile := filepath.Join(ca.Paths.Certificates, basename+"."+certType+".crt.pem") keyFile := filepath.Join(ca.Paths.PrivateKeys, basename+"."+certType+".key.pem") if err := SavePEM(certFile, certPEM, false, overwrite); err != nil { fmt.Println("Error saving certificate:", err) return } if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil { fmt.Println("Error saving key:", err) return } fmt.Printf("%s certificate and key for '%s' generated.\n", certType, subject) } // Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature) func GetCertificateDefaults(path string) CertificateDef { _, defaults, err := LoadCertificatesFile(path) if err != nil || defaults == nil { return CertificateDef{} } return CertificateDef{ Type: defaults.Type, Validity: defaults.Validity, SAN: defaults.SAN, } } // 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 { fmt.Printf("Would issue certificate: name=%s, subject=%s, type=%s, validity=%s, SAN=%v\n", basename, subject, certType, validityFlag, san) return nil } // Call IssueCertificate but override basename logic return issueCertificateInternal(configPath, basename, subject, certType, validityFlag, san, overwrite) } // Internal: like IssueCertificate but with explicit basename 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 { return fmt.Errorf("Error loading config: %v", err) } 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 { 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 } // Helper: check if string looks like a DN (contains at least CN=...) func isDNFormat(s string) bool { return len(s) > 0 && strings.Contains(s, "CN=") } // Helper: parse DN string into pkix.Name (supports CN, C, O, OU, L, ST, emailAddress) func parseDistinguishedName(dn string) pkix.Name { var name pkix.Name parts := strings.Split(dn, ",") for _, part := range parts { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { continue } key, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) switch key { case "CN": name.CommonName = val case "C": name.Country = append(name.Country, val) case "O": name.Organization = append(name.Organization, val) case "OU": name.OrganizationalUnit = append(name.OrganizationalUnit, val) case "L": name.Locality = append(name.Locality, val) case "ST": name.Province = append(name.Province, val) case "emailAddress": name.ExtraNames = append(name.ExtraNames, pkix.AttributeTypeAndValue{ Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID Value: val, }) } } 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 func optionalSlice(s string) []string { if s == "" { return nil } return []string{s} }