Compare commits

...

4 Commits

Author SHA1 Message Date
6bae5b0630 Add Go port documentation and usage instructions to README 2026-04-25 00:02:42 +02:00
0cdf249942 Update: enhance CI workflow to include Python and Go tests
All checks were successful
/ test-bash (push) Successful in 20s
/ test-python (push) Successful in 48s
/ test-go (push) Successful in 10m14s
2026-04-24 23:46:47 +02:00
e71b71ac49 Added Go convertion. 2026-04-24 23:40:43 +02:00
938bcfd05d Update: enhance .gitignore to include Python and Go build outputs 2026-04-24 23:39:27 +02:00
7 changed files with 731 additions and 89 deletions

View File

@@ -2,14 +2,46 @@ on:
push: push:
paths: paths:
- 'simple-ca.sh' - 'simple-ca.sh'
- 'simple-ca.py'
- 'run-tests.sh' - 'run-tests.sh'
- 'src/simple-ca/**'
- '.gitea/workflows/test.yaml'
jobs: jobs:
test: test-bash:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Run tests - name: Run bash tests
run: ./run-tests.sh run: ./run-tests.sh bash
test-python:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Run python tests
run: ./run-tests.sh python
test-go:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache-dependency-path: src/simple-ca/go.sum
- name: Run go tests
run: ./run-tests.sh go

9
.gitignore vendored
View File

@@ -1,2 +1,11 @@
bin
data data
tests tests
# Python
__pycache__/
*.pyc
# Go build output
/src/simple-ca/simple-ca
/src/simple-ca/simple-ca.exe

View File

@@ -55,3 +55,42 @@ make_pfx --ca-dir <ca_directory> [--issuing-ca <file_prefix>] --path <pfx_file_p
- `--issuing-ca <file_prefix>`: The file prefix of the issuing CA to include in the chain. - `--issuing-ca <file_prefix>`: The file prefix of the issuing CA to include in the chain.
- `--path <pfx_file_path>`: The path where the generated PFX file will be saved. - `--path <pfx_file_path>`: The path where the generated PFX file will be saved.
- `--password <pfx_password>`: Optional. The custom password to protect the PFX, instead of the default `changeit`. - `--password <pfx_password>`: Optional. The custom password to protect the PFX, instead of the default `changeit`.
## Go binary
A Go port with the same feature set lives in [`src/simple-ca`](src/simple-ca). It compiles to a single self-contained binary (~56 MB) with no runtime dependencies, and exposes `make-ca`, `make-cert`, and `make-pfx` as subcommands mirroring the Bash flag names.
### Build for the host platform
```bash
cd src/simple-ca
go build -o simple-ca .
./simple-ca --help
```
### Cross-compile
Go builds statically linked binaries for any target from any host:
```bash
cd src/simple-ca
# Linux
GOOS=linux GOARCH=amd64 go build -o simple-ca-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o simple-ca-linux-arm64 .
# macOS
GOOS=darwin GOARCH=amd64 go build -o simple-ca-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o simple-ca-darwin-arm64 .
# Windows
GOOS=windows GOARCH=amd64 go build -o simple-ca-windows-amd64.exe .
```
### Usage
```bash
simple-ca make-ca [--days N] [--issuing-ca PREFIX] [--aia-base-url URL] CA_DIR CA_NAME
simple-ca make-cert [--ca-dir DIR] [--days N] [--issuing-ca PREFIX] CERT_DIR SUBJECT [SAN...]
simple-ca make-pfx --ca-dir DIR [--issuing-ca PREFIX] --path CERT_PATH [--password PASS]
```

View File

@@ -21,115 +21,105 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.
# This script runs all the test required to verify the functionality of the simple CA implementation. # This script runs integration tests against one or more simple-ca implementations.
# Usage: run-tests.sh [bash|python|go|all] (default: all)
set -e set -e
# Load the certificate functions TEST_TARGET="${1:-all}"
source "$(dirname "$BASH_SOURCE[0]")/simple-ca.sh"
function clean_up_test_dir() { SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CERT_DIR="$SCRIPT_DIR/tests"
CA_DIR="$CERT_DIR/ca"
clean_up_test_dir() {
if [[ -d "$CERT_DIR" ]]; then if [[ -d "$CERT_DIR" ]]; then
echo "Cleaning up test directory $CERT_DIR..." echo "Cleaning up test directory $CERT_DIR..."
rm -rf "$CERT_DIR"/* rm -rf "$CERT_DIR"/*
fi fi
echo "Creating test directory $CERT_DIR and ca subdirectory..." echo "Creating test directory $CERT_DIR and ca subdirectory..."
mkdir -p "$CERT_DIR/ca" mkdir -p "$CA_DIR"
} }
function display_certificate() { display_certificate() {
local CERT_PATH="$1" local CERT_PATH="$1"
echo -e "\nDisplaying generated certificate for verification ($CERT_PATH):" echo -e "\nDisplaying generated certificate for verification ($CERT_PATH):"
# Display the certificate details for verification
openssl x509 -in "$CERT_PATH" -noout -subject -issuer -serial -fingerprint openssl x509 -in "$CERT_PATH" -noout -subject -issuer -serial -fingerprint
echo echo
echo -e "\nVerifying certificate against the CA bundle ($CA_DIR/ca_bundle.pem)..." echo -e "\nVerifying certificate against the CA bundle ($CA_DIR/ca_bundle.pem)..."
# Verify the certificate against the CA bundle
if openssl verify -CAfile "$CA_DIR/ca_bundle.pem" "$CERT_PATH" 2>/dev/null; then if openssl verify -CAfile "$CA_DIR/ca_bundle.pem" "$CERT_PATH" 2>/dev/null; then
echo "Certificate verification successful." echo "Certificate verification successful."
else else
echo "ERROR: Certificate verification failed." >&2 echo "ERROR: Certificate verification failed." >&2
exit 1
fi fi
} }
# Create a temporary directory for the test certificates # run_flow NAME MAKE_CA_CMD MAKE_CERT_CMD MAKE_PFX_CMD
CERT_DIR="$(dirname "$BASH_SOURCE[0]")/tests" # Command variables are left unquoted on use, so multi-word prefixes
CA_DIR="$CERT_DIR/ca" # (e.g. "python3 simple-ca.py make-ca") word-split as expected.
run_flow() {
local NAME="$1"
local MAKE_CA_CMD="$2"
local MAKE_CERT_CMD="$3"
local MAKE_PFX_CMD="$4"
# Clean up any existing files in the temporary directory echo
clean_up_test_dir "$CERT_DIR" echo "============================================================"
echo "Running tests for '$NAME' implementation"
echo "============================================================"
echo
echo "--- [$NAME] Standalone CA ---"
clean_up_test_dir
$MAKE_CA_CMD "$CA_DIR" "Test CA"
display_certificate "$CA_DIR/ca_cert.pem"
$MAKE_CERT_CMD --ca-dir "$CA_DIR" "$CERT_DIR" "test" "test.example.com" "127.0.0.1"
display_certificate "$CERT_DIR/test_cert.pem"
echo
echo "--- [$NAME] Two-level CA ---"
clean_up_test_dir
$MAKE_CA_CMD "$CA_DIR" "Test Two Level CA"
display_certificate "$CA_DIR/ca_cert.pem"
$MAKE_CA_CMD --issuing-ca "issuing_ca" "$CA_DIR" "Issuing CA"
display_certificate "$CA_DIR/issuing_ca_cert.pem"
$MAKE_CERT_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "$CERT_DIR" "test" "test.example.com" "127.0.0.1"
display_certificate "$CERT_DIR/test_cert.pem"
$MAKE_PFX_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" --path "$CERT_DIR/test_cert.pem" --password "s3cr3t"
echo -e "\nVerifying contents of generated PKCS#12 (PFX) file ($CERT_DIR/test.pfx):"
openssl pkcs12 -in "$CERT_DIR/test.pfx" -noout -info -password pass:"s3cr3t"
}
# Uses ;;& to fall through to subsequent patterns so 'all' matches every block.
case "$TEST_TARGET" in
bash|all)
# shellcheck disable=SC1091
source "$SCRIPT_DIR/simple-ca.sh"
run_flow "bash" "make_ca" "make_cert" "make_pfx"
;;&
python|all)
command -v python3 >/dev/null || { echo "ERROR: python3 not found" >&2; exit 1; }
PY_PREFIX="python3 $SCRIPT_DIR/simple-ca.py"
run_flow "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX make-pfx"
;;&
go|all)
command -v go >/dev/null || { echo "ERROR: go not found" >&2; exit 1; }
GO_SRC="$SCRIPT_DIR/src/simple-ca"
GO_BIN="$GO_SRC/simple-ca"
echo "Building Go binary..."
(cd "$GO_SRC" && go build -o simple-ca .)
run_flow "go" "$GO_BIN make-ca" "$GO_BIN make-cert" "$GO_BIN make-pfx"
;;&
bash|python|go|all)
;;
*)
echo "ERROR: unknown target '$TEST_TARGET' (expected: bash|python|go|all)" >&2
exit 1
;;
esac
echo echo
echo "Running tests for standalone CA..." echo "All requested tests passed."
echo "----------------------------------"
echo
# Create a standalone CA for testing purposes
if ! make_ca "$CA_DIR" "Test CA"; then
echo "ERROR: Failed to create CA." >&2
exit 1
fi
# List the generated certificates and keys for verification
display_certificate "$CA_DIR/ca_cert.pem"
# Make a server certificate signed by the CA
if ! make_cert --ca-dir "$CA_DIR" "$CERT_DIR" "test" "test.example.com" "127.0.0.1"; then
echo "ERROR: Failed to create server certificate." >&2
exit 1
fi
# List the generated server certificate and key for verification
display_certificate "$CERT_DIR/test_cert.pem"
# Remove all files from the directory
clean_up_test_dir "$CERT_DIR"
echo
echo "Running tests for two-level CA..."
echo "---------------------------------"
echo
# Create a new CA with pathlen 1
if ! make_ca "$CA_DIR" "Test Two Level CA"; then
echo "ERROR: Failed to create CA." >&2
exit 1
fi
# List the generated certificates and keys for verification
display_certificate "$CA_DIR/ca_cert.pem"
# Create an issuing CA signed by the first CA
if ! make_ca --issuing-ca "issuing_ca" "$CA_DIR" "Issuing CA"; then
echo "ERROR: Failed to create issuing CA." >&2
exit 1
fi
# List the generated certificates and keys for verification
display_certificate "$CA_DIR/issuing_ca_cert.pem"
# Make a server certificate signed by the CA
if ! make_cert --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "$CERT_DIR" "test" "test.example.com" "127.0.0.1"; then
echo "ERROR: Failed to create server certificate." >&2
exit 1
fi
# List the generated server certificate and key for verification
display_certificate "$CERT_DIR/test_cert.pem"
# Create a PKCS#12 (PFX) file for the server certificate
if ! make_pfx --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" --path "$CERT_DIR/test_cert.pem" --password "s3cr3t"; then
echo "ERROR: Failed to create PKCS#12 (PFX) file." >&2
exit 1
fi
# Read the generated PKCS#12 (PFX) file to verify its contents
echo -e "\nVerifying contents of generated PKCS#12 (PFX) file ($CERT_DIR/test.pfx):"
if ! openssl pkcs12 -in "$CERT_DIR/test.pfx" -noout -info -password pass:"s3cr3t"; then
echo "ERROR: Failed to read PKCS#12 (PFX) file." >&2
exit 1
fi

14
src/simple-ca/go.mod Normal file
View File

@@ -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
)

14
src/simple-ca/go.sum Normal file
View File

@@ -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=

544
src/simple-ca/main.go Normal file
View File

@@ -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)
}
}