Compare commits
4 Commits
c043410436
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bae5b0630 | |||
| 0cdf249942 | |||
| e71b71ac49 | |||
| 938bcfd05d |
@@ -2,14 +2,46 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- 'simple-ca.sh'
|
||||
- 'simple-ca.py'
|
||||
- 'run-tests.sh'
|
||||
- 'src/simple-ca/**'
|
||||
- '.gitea/workflows/test.yaml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
test-bash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run tests
|
||||
run: ./run-tests.sh
|
||||
- name: Run bash tests
|
||||
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
9
.gitignore
vendored
@@ -1,2 +1,11 @@
|
||||
bin
|
||||
data
|
||||
tests
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Go build output
|
||||
/src/simple-ca/simple-ca
|
||||
/src/simple-ca/simple-ca.exe
|
||||
|
||||
39
README.md
39
README.md
@@ -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.
|
||||
- `--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`.
|
||||
|
||||
## 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 (~5–6 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]
|
||||
```
|
||||
|
||||
140
run-tests.sh
140
run-tests.sh
@@ -21,115 +21,105 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# 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
|
||||
|
||||
# Load the certificate functions
|
||||
source "$(dirname "$BASH_SOURCE[0]")/simple-ca.sh"
|
||||
TEST_TARGET="${1:-all}"
|
||||
|
||||
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
|
||||
echo "Cleaning up test directory $CERT_DIR..."
|
||||
rm -rf "$CERT_DIR"/*
|
||||
fi
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
echo
|
||||
|
||||
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
|
||||
echo "Certificate verification successful."
|
||||
else
|
||||
echo "ERROR: Certificate verification failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Create a temporary directory for the test certificates
|
||||
CERT_DIR="$(dirname "$BASH_SOURCE[0]")/tests"
|
||||
CA_DIR="$CERT_DIR/ca"
|
||||
|
||||
# Clean up any existing files in the temporary directory
|
||||
clean_up_test_dir "$CERT_DIR"
|
||||
# run_flow NAME MAKE_CA_CMD MAKE_CERT_CMD MAKE_PFX_CMD
|
||||
# Command variables are left unquoted on use, so multi-word prefixes
|
||||
# (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"
|
||||
|
||||
echo
|
||||
echo "Running tests for standalone CA..."
|
||||
echo "----------------------------------"
|
||||
echo "============================================================"
|
||||
echo "Running tests for '$NAME' implementation"
|
||||
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
|
||||
echo "--- [$NAME] Standalone CA ---"
|
||||
clean_up_test_dir
|
||||
$MAKE_CA_CMD "$CA_DIR" "Test CA"
|
||||
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
|
||||
$MAKE_CERT_CMD --ca-dir "$CA_DIR" "$CERT_DIR" "test" "test.example.com" "127.0.0.1"
|
||||
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
|
||||
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"
|
||||
|
||||
# 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
|
||||
$MAKE_CA_CMD --issuing-ca "issuing_ca" "$CA_DIR" "Issuing CA"
|
||||
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
|
||||
$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"
|
||||
|
||||
# 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
|
||||
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
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "All requested tests passed."
|
||||
|
||||
14
src/simple-ca/go.mod
Normal file
14
src/simple-ca/go.mod
Normal 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
14
src/simple-ca/go.sum
Normal 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
544
src/simple-ca/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user