From e71b71ac491dd2182b7645e07f723982326c5e36 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 24 Apr 2026 23:40:43 +0200 Subject: [PATCH] Added Go convertion. --- src/simple-ca/go.mod | 14 ++ src/simple-ca/go.sum | 14 ++ src/simple-ca/main.go | 544 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 src/simple-ca/go.mod create mode 100644 src/simple-ca/go.sum create mode 100644 src/simple-ca/main.go diff --git a/src/simple-ca/go.mod b/src/simple-ca/go.mod new file mode 100644 index 0000000..e28389a --- /dev/null +++ b/src/simple-ca/go.mod @@ -0,0 +1,14 @@ +module simple-ca + +go 1.25 + +require ( + github.com/spf13/cobra v1.10.2 + software.sslmate.com/src/go-pkcs12 v0.5.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/crypto v0.37.0 // indirect +) diff --git a/src/simple-ca/go.sum b/src/simple-ca/go.sum new file mode 100644 index 0000000..5b3ba94 --- /dev/null +++ b/src/simple-ca/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/src/simple-ca/main.go b/src/simple-ca/main.go new file mode 100644 index 0000000..3dd4798 --- /dev/null +++ b/src/simple-ca/main.go @@ -0,0 +1,544 @@ +// MIT License +// +// Copyright (c) 2026 Sławomir Koszewski +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/spf13/cobra" + "software.sslmate.com/src/go-pkcs12" +) + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func randomSerial() (*big.Int, error) { + limit := new(big.Int).Lsh(big.NewInt(1), 159) + return rand.Int(rand.Reader, limit) +} + +func writePEMFile(path, blockType string, der []byte, mode os.FileMode) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer f.Close() + return pem.Encode(f, &pem.Block{Type: blockType, Bytes: der}) +} + +func writeKey(key *rsa.PrivateKey, path string) error { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return err + } + return writePEMFile(path, "PRIVATE KEY", der, 0o600) +} + +func writeCert(der []byte, path string) error { + return writePEMFile(path, "CERTIFICATE", der, 0o644) +} + +func loadCert(path string) (*x509.Certificate, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(b) + if block == nil || block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("no CERTIFICATE PEM block in %s", path) + } + return x509.ParseCertificate(block.Bytes) +} + +func loadKey(path string) (*rsa.PrivateKey, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(b) + if block == nil { + return nil, fmt.Errorf("no PEM block in %s", path) + } + if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { + if rsaKey, ok := k.(*rsa.PrivateKey); ok { + return rsaKey, nil + } + return nil, fmt.Errorf("not an RSA key in %s", path) + } + return x509.ParsePKCS1PrivateKey(block.Bytes) +} + +func rebuildCABundle(caDir string) error { + entries, err := os.ReadDir(caDir) + if err != nil { + return err + } + var bundle []byte + rootPath := filepath.Join(caDir, "ca_cert.pem") + if fileExists(rootPath) { + data, err := os.ReadFile(rootPath) + if err != nil { + return err + } + bundle = append(bundle, data...) + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || name == "ca_cert.pem" || !strings.HasSuffix(name, "_cert.pem") { + continue + } + data, err := os.ReadFile(filepath.Join(caDir, name)) + if err != nil { + return err + } + bundle = append(bundle, data...) + } + return os.WriteFile(filepath.Join(caDir, "ca_bundle.pem"), bundle, 0o644) +} + +func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error { + if issuingCA == "ca" { + return errors.New("--issuing-ca cannot be 'ca' as it is reserved for the root CA") + } + if caDir == "" || !dirExists(caDir) { + return fmt.Errorf("certificate directory %s does not exist", caDir) + } + if caName == "" { + return errors.New("CA name is required") + } + + aiaFile := filepath.Join(caDir, "aia_base_url.txt") + if aiaBaseURL == "" { + if data, err := os.ReadFile(aiaFile); err == nil { + aiaBaseURL = strings.TrimSpace(string(data)) + } + } + + prefix := issuingCA + if prefix == "" { + prefix = "ca" + } + rootCertPath := filepath.Join(caDir, "ca_cert.pem") + rootKeyPath := filepath.Join(caDir, "ca_key.pem") + caCertPath := filepath.Join(caDir, prefix+"_cert.pem") + caKeyPath := filepath.Join(caDir, prefix+"_key.pem") + isRoot := prefix == "ca" + + if !fileExists(rootCertPath) || !fileExists(rootKeyPath) { + if !isRoot { + return fmt.Errorf("cannot create issuing CA '%s' without existing root CA certificate and key. Please create the root CA first", caName) + } + fmt.Printf("Generating CA certificate '%s' and key...\n", caName) + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return err + } + serial, err := randomSerial() + if err != nil { + return err + } + now := time.Now().UTC() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: caName}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, days), + IsCA: true, + BasicConstraintsValid: true, + MaxPathLen: 1, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return err + } + if err := writeKey(key, rootKeyPath); err != nil { + return err + } + if err := writeCert(der, rootCertPath); err != nil { + return err + } + if err := rebuildCABundle(caDir); err != nil { + return err + } + if aiaBaseURL != "" { + if err := os.WriteFile(aiaFile, []byte(aiaBaseURL), 0o644); err != nil { + return err + } + } + return nil + } + + if !fileExists(caCertPath) || !fileExists(caKeyPath) { + fmt.Printf("Generating issuing CA certificate '%s' and key...\n", caName) + rootCert, err := loadCert(rootCertPath) + if err != nil { + return err + } + rootKey, err := loadKey(rootKeyPath) + if err != nil { + return err + } + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return err + } + serial, err := randomSerial() + if err != nil { + return err + } + now := time.Now().UTC() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: caName}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, days), + IsCA: true, + BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + if aiaBaseURL != "" { + tmpl.IssuingCertificateURL = []string{aiaBaseURL + "/ca_cert.crt"} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootCert, &key.PublicKey, rootKey) + if err != nil { + return err + } + if err := writeKey(key, caKeyPath); err != nil { + return err + } + if err := writeCert(der, caCertPath); err != nil { + return err + } + } + + if err := rebuildCABundle(caDir); err != nil { + return err + } + if aiaBaseURL != "" { + if err := os.WriteFile(aiaFile, []byte(aiaBaseURL), 0o644); err != nil { + return err + } + } + return nil +} + +var ( + ipRE = regexp.MustCompile(`^[0-9]{1,3}(\.[0-9]{1,3}){3}$`) + dnsRE = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)*$`) +) + +func makeCert(certDir, subjectName string, sans []string, caDir, issuingCA string, days int) error { + if issuingCA == "ca" { + return errors.New("--issuing-ca cannot be 'ca' as it is reserved for the root CA") + } + prefix := issuingCA + if prefix == "" { + prefix = "ca" + } + if caDir == "" { + caDir = certDir + } + if certDir == "" || !dirExists(certDir) { + return fmt.Errorf("certificate directory %s does not exist", certDir) + } + if !dirExists(caDir) { + return fmt.Errorf("CA directory %s does not exist", caDir) + } + if subjectName == "" { + return errors.New("subject name is required") + } + if !dnsRE.MatchString(subjectName) { + return fmt.Errorf("invalid subject name '%s'. Must be a valid DNS name", subjectName) + } + + aiaFile := filepath.Join(caDir, "aia_base_url.txt") + aiaURL := "" + if data, err := os.ReadFile(aiaFile); err == nil { + aiaURL = strings.TrimSpace(string(data)) + "/" + prefix + "_cert.crt" + } + + caCertPath := filepath.Join(caDir, prefix+"_cert.pem") + caKeyPath := filepath.Join(caDir, prefix+"_key.pem") + if !fileExists(caCertPath) || !fileExists(caKeyPath) { + return fmt.Errorf("signing CA certificate and key not found in %s. Please call setup a signing CA first", caDir) + } + + certName := subjectName + if i := strings.Index(certName, "."); i >= 0 { + certName = certName[:i] + } + + dnsNames := []string{subjectName} + var ips []net.IP + for _, entry := range sans { + switch { + case ipRE.MatchString(entry): + ips = append(ips, net.ParseIP(entry)) + case dnsRE.MatchString(entry): + dnsNames = append(dnsNames, entry) + default: + return fmt.Errorf("invalid SAN entry '%s'", entry) + } + } + + fmt.Printf("Generating server certificate for '%s' with SANs:\n", subjectName) + for _, dns := range dnsNames { + fmt.Printf(" - DNS:%s\n", dns) + } + for _, ip := range ips { + fmt.Printf(" - IP:%s\n", ip) + } + + certOut := filepath.Join(certDir, certName+"_cert.pem") + keyOut := filepath.Join(certDir, certName+"_key.pem") + if fileExists(certOut) && fileExists(keyOut) { + return nil + } + + fmt.Println("Generating server certificate and key...") + caCert, err := loadCert(caCertPath) + if err != nil { + return err + } + caKey, err := loadKey(caKeyPath) + if err != nil { + return err + } + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return err + } + serial, err := randomSerial() + if err != nil { + return err + } + now := time.Now().UTC() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: subjectName}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, days), + IsCA: false, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + DNSNames: dnsNames, + IPAddresses: ips, + } + if aiaURL != "" { + tmpl.IssuingCertificateURL = []string{aiaURL} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey) + if err != nil { + return err + } + if err := writeKey(key, keyOut); err != nil { + return err + } + return writeCert(der, certOut) +} + +func makePFX(certPath, caDir, issuingCA, password string) error { + if issuingCA == "ca" { + return errors.New("--issuing-ca cannot be 'ca' as it is reserved for the root CA") + } + prefix := issuingCA + if prefix == "" { + prefix = "ca" + } + rootCertPath := filepath.Join(caDir, "ca_cert.pem") + rootKeyPath := filepath.Join(caDir, "ca_key.pem") + caCertPath := filepath.Join(caDir, prefix+"_cert.pem") + caKeyPath := filepath.Join(caDir, prefix+"_key.pem") + + certDir := filepath.Dir(certPath) + certName := strings.TrimSuffix(filepath.Base(certPath), "_cert.pem") + keyPath := filepath.Join(certDir, certName+"_key.pem") + + if !dirExists(certDir) { + return fmt.Errorf("certificate directory %s does not exist", certDir) + } + if !dirExists(caDir) { + return fmt.Errorf("CA directory %s does not exist", caDir) + } + if !fileExists(certPath) || !fileExists(keyPath) { + return errors.New("server certificate or key not found") + } + if !fileExists(rootCertPath) || !fileExists(rootKeyPath) { + return fmt.Errorf("CA certificate or key not found in %s", caDir) + } + if issuingCA != "" { + if !fileExists(caCertPath) || !fileExists(caKeyPath) { + return fmt.Errorf("issuing CA certificate or key not found in %s", caDir) + } + } + + if password == "" { + password = "changeit" + } + + pfxPath := filepath.Join(certDir, certName+".pfx") + if fileExists(pfxPath) { + fmt.Println("PKCS#12 (PFX) file already exists, aborting generation.") + return errors.New("PFX file already exists") + } + + fmt.Print("Generating PKCS#12 (PFX) file...") + + cert, err := loadCert(certPath) + if err != nil { + return err + } + key, err := loadKey(keyPath) + if err != nil { + return err + } + chain := []*x509.Certificate{} + root, err := loadCert(rootCertPath) + if err != nil { + return err + } + chain = append(chain, root) + if issuingCA != "" { + issuing, err := loadCert(caCertPath) + if err != nil { + return err + } + chain = append(chain, issuing) + } + + pfxBytes, err := pkcs12.Modern.Encode(key, cert, chain, password) + if err != nil { + return err + } + if err := os.WriteFile(pfxPath, pfxBytes, 0o600); err != nil { + return err + } + fmt.Println("done.") + return nil +} + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "simple-ca", + Short: "Create and manage a simple Certificate Authority.", + SilenceUsage: true, + SilenceErrors: false, + } + root.AddCommand(newMakeCACmd(), newMakeCertCmd(), newMakePFXCmd()) + return root +} + +func newMakeCACmd() *cobra.Command { + var ( + days int + issuingCA string + aiaBaseURL string + ) + cmd := &cobra.Command{ + Use: "make-ca CA_DIR CA_NAME", + Short: "Create a root or issuing CA.", + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + return makeCA(args[0], args[1], days, issuingCA, aiaBaseURL) + }, + } + cmd.Flags().IntVar(&days, "days", 3650, "validity period in days") + cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA file prefix (creates an issuing CA signed by the root)") + cmd.Flags().StringVar(&aiaBaseURL, "aia-base-url", "", "base URL for the AIA caIssuers extension") + return cmd +} + +func newMakeCertCmd() *cobra.Command { + var ( + caDir string + issuingCA string + days int + ) + cmd := &cobra.Command{ + Use: "make-cert CERT_DIR SUBJECT [SAN...]", + Short: "Create a server/client certificate signed by the CA.", + Args: cobra.MinimumNArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + return makeCert(args[0], args[1], args[2:], caDir, issuingCA, days) + }, + } + cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA directory (defaults to CERT_DIR)") + cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA file prefix") + cmd.Flags().IntVar(&days, "days", 365, "validity period in days") + return cmd +} + +func newMakePFXCmd() *cobra.Command { + var ( + caDir string + issuingCA string + certPath string + password string + ) + cmd := &cobra.Command{ + Use: "make-pfx --ca-dir DIR --path CERT_PATH [flags]", + Short: "Create a PKCS#12 (PFX) bundle for a leaf certificate.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return makePFX(certPath, caDir, issuingCA, password) + }, + } + cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA directory") + cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA file prefix") + cmd.Flags().StringVar(&certPath, "path", "", "path to the leaf certificate PEM") + cmd.Flags().StringVar(&password, "password", "", "PFX password (default: changeit)") + _ = cmd.MarkFlagRequired("ca-dir") + _ = cmd.MarkFlagRequired("path") + return cmd +} + +func main() { + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } +}