feat: Bring Go and Python implementations to feature parity
test-go.yaml / test-go (push) Successful in 1m27s
test-python.yaml / test-python (push) Successful in 1m36s

Go:
- Read and write simple-ca.json shared with the Python implementation
- Add make-crl and revoke-cert commands and record certificate history
- Rename --aia-base-url to --ca-publish-base-url
- Add CRL Distribution Point extension to issuing CA and leaf
  certificates
- Take days and issuing_ca defaults from simple-ca.json
- Include email SANs in server certificates
- Reject IPv4 SANs with out-of-range octets
- Record existing issuing CAs and rebuild the CA bundle
- Print errors once

Python:
- Add make-cert --type server|user and email SANs
- Reject IPv4 SANs with out-of-range octets
- Skip make-ca when the root CA exists and no issuing CA is given
- Build ca_bundle.pem from issuing CA subdirectories
- Accept a bare filename in make-pfx and drop the root key requirement
- Honour explicit --days values and reject values below 1
- Persist and increment CRL numbers in simple-ca.json

Update README and add tests for the new behaviour.
This commit is contained in:
slawek committed 2026-10-16 14:20:04 +02:00
1 parent 27461a0fbe
commit 9c84e82672
5 files changed
+673 -86

No files matched your search

+28 -12
View File
@@ -9,15 +9,16 @@ Tools for creating and managing a simple Certificate Authority for testing and d
| | `simple-ca.sh` | `simple-ca.py` | Go binary |
|---|---|---|---|
| Usage | sourced function library | CLI | compiled CLI |
| Config file | none | `simple-ca.json` | none |
| AIA base URL | `aia_base_url.txt` | `simple-ca.json` | `aia_base_url.txt` |
| Certificate history | no | yes | no |
| CRL generation | no | yes | no |
| Revocation | no | yes | no |
| Config file | none | `simple-ca.json` | `simple-ca.json` |
| Publish base URL | `aia_base_url.txt` (AIA) | `simple-ca.json` (AIA + CDP) | `simple-ca.json` (AIA + CDP) |
| Server and user certificates | yes | yes | yes |
| Certificate history | no | yes | yes |
| CRL generation | no | yes | yes |
| Revocation | no | yes | yes |
| PFX (`--apple-openssl`) | yes | yes | yes |
| Requires | bash/zsh + openssl | Python 3.8+ + openssl | none (native crypto) |
All variants share the same directory layout and produce interchangeable files.
All variants share the same directory layout and produce interchangeable files. The Python and Go variants share `simple-ca.json`, so a CA directory can be managed by either of them.
## Directory layout
@@ -26,7 +27,7 @@ CA_DIR/
ca_cert.pem root CA certificate
ca_key.pem root CA private key
ca_bundle.pem root + all issuing CA certs concatenated
aia_base_url.txt base URL for AIA extension (shell/Go only)
aia_base_url.txt base URL for AIA extension (shell only)
{name}_cert.pem certificate issued by root CA
{name}_key.pem key for certificate issued by root CA
{issuing_ca}/
@@ -36,7 +37,7 @@ CA_DIR/
{name}_key.pem key for certificate issued by issuing CA
```
Python additionally writes:
Python and Go additionally write:
```
CA_DIR/
@@ -46,6 +47,17 @@ CA_DIR/
crl.pem issuing CA CRL (make-crl)
```
`simple-ca.json` keys:
- `name`, `created` - root CA name and creation time.
- `ca_publish_base_url` - base URL for AIA and CRL Distribution Point extensions.
- `subordinates` - issuing CA directory names.
- `issuing_ca` - default for `--issuing-ca`.
- `days` - defaults for `--days`: `ca`, `cert`, `crl`.
- `openssl` - path to the openssl binary (Python only).
- `history` - issued certificates per CA (`ca` for the root CA): `name`, `serial`, `created`, `expires`, `revoked`.
- `crl_number` - last CRL number issued per CA.
Use `ca_bundle.pem` with `openssl verify -CAfile CA_DIR/ca_bundle.pem` — this works identically on Linux, macOS, and Windows without relying on hash symlinks.
---
@@ -109,7 +121,7 @@ python3 simple-ca.py COMMAND [OPTIONS] [ARGS]
```bash
make-ca [--ca-dir DIR] [--days N] [--ca-publish-base-url URL] [--issuing-ca NAME] CA_NAME
make-cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] SUBJECT [SAN ...]
make-cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] [--type server|user] SUBJECT [SAN ...]
make-pfx [--ca-dir DIR] [--issuing-ca NAME] [--password PASS] [--apple-openssl] CERT_PATH
make-crl [--ca-dir DIR] [--issuing-ca NAME] [--days N]
revoke-cert [--ca-dir DIR] [--issuing-ca NAME] CERT_PATH
@@ -117,6 +129,8 @@ revoke-cert [--ca-dir DIR] [--issuing-ca NAME] CERT_PATH
`--ca-publish-base-url` embeds both AIA (Authority Information Access) and CRL Distribution Point extensions in issued certificates, pointing to the published location of the CA cert and CRL file.
`--type server` (default) creates a TLS certificate (serverAuth + clientAuth) with `SUBJECT` as the CN and first SAN, which must be a DNS name. `--type user` creates a certificate with clientAuth, emailProtection and codeSigning, where `SUBJECT` is any CN. SANs can be DNS names, IPv4 addresses or email addresses.
### Example — two-level CA with CRL
```bash
@@ -134,7 +148,7 @@ python3 simple-ca.py make-crl --ca-dir /tmp/ca --issuing-ca servers
## Go binary
Equivalent to the shell variant in features. Uses native Go crypto — no `openssl` subprocess required except for `--apple-openssl` PFX generation.
Equivalent to the Python variant in features and shares its `simple-ca.json`. Uses native Go crypto — no `openssl` subprocess required except for `--apple-openssl` PFX generation.
```bash
cd src/simple-ca && go build -o simple-ca .
@@ -143,9 +157,11 @@ cd src/simple-ca && go build -o simple-ca .
### Commands
```bash
simple-ca make-ca [--ca-dir DIR] [--days N] [--aia-base-url URL] [--issuing-ca NAME] CA_NAME
simple-ca make-cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] SUBJECT [SAN ...]
simple-ca make-ca [--ca-dir DIR] [--days N] [--ca-publish-base-url URL] [--issuing-ca NAME] CA_NAME
simple-ca make-cert [--ca-dir DIR] [--cert-dir DIR] [--days N] [--issuing-ca NAME] [--type server|user] SUBJECT [SAN ...]
simple-ca make-pfx [--ca-dir DIR] [--issuing-ca NAME] [--password PASS] [--apple-openssl] CERT_PATH
simple-ca make-crl [--ca-dir DIR] [--issuing-ca NAME] [--days N]
simple-ca revoke-cert [--ca-dir DIR] [--issuing-ca NAME] CERT_PATH
```
`SIMPLE_CA_DIR` environment variable is used as the default CA directory when `--ca-dir` is not specified.
+66 -20
View File
@@ -25,6 +25,7 @@
import argparse
import datetime
import ipaddress
import json
import os
import re
@@ -103,7 +104,7 @@ def _rebuild_ca_bundle(ca_dir):
if os.path.isfile(root):
with open(root, "rb") as f:
parts.append(f.read())
for name in sorted(_config.get("subordinates", [])):
for name in sorted(os.listdir(ca_dir)):
sub_cert = os.path.join(ca_dir, name, "ca_cert.pem")
if os.path.isfile(sub_cert):
with open(sub_cert, "rb") as f:
@@ -140,6 +141,7 @@ def _iso_to_asn1(iso: str) -> str:
_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$")
_EMAIL_RE = re.compile(r"^[^@]+@[^@]+\.[^@]+$")
def _is_ip(value):
@@ -150,6 +152,10 @@ def _is_dns(value):
return bool(_DNS_RE.match(value))
def _is_email(value):
return bool(_EMAIL_RE.match(value))
def _pipe(cmd1, cmd2):
p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE)
p2 = subprocess.Popen(cmd2, stdin=p1.stdout)
@@ -209,11 +215,18 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=Non
_config.update(patch)
return True
if not issuing_ca:
print(f"Root CA already exists in {ca_dir}, skipping.")
_config.update({"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {})
return True
issuing_ca_dir = os.path.join(ca_dir, issuing_ca)
issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem")
issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem")
if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key):
if os.path.isfile(issuing_ca_cert) and os.path.isfile(issuing_ca_key):
print(f"Issuing CA '{issuing_ca}' already exists in {issuing_ca_dir}, skipping.")
else:
print(f"Generating issuing CA certificate '{ca_name}' and key...")
os.makedirs(issuing_ca_dir, exist_ok=True)
req_cmd = [
@@ -254,11 +267,15 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=Non
def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
issuing_ca=None, days=365, ca_publish_base_url=None):
issuing_ca=None, days=365, ca_publish_base_url=None, cert_type="server"):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if cert_type not in ("server", "user"):
_err(f"--type must be 'server' or 'user', got '{cert_type}'.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
@@ -267,7 +284,7 @@ def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
_err("Subject name is required.")
return False
if not _is_dns(cert_subject_name):
if cert_type == "server" and not _is_dns(cert_subject_name):
_err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.")
return False
@@ -297,27 +314,38 @@ def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
cert_name = cert_subject_name.split(".", 1)[0]
san_entries = [f"DNS:{cert_subject_name}"]
san_entries = [f"DNS:{cert_subject_name}"] if cert_type == "server" else []
for entry in sans or []:
if _is_ip(entry):
try:
ipaddress.IPv4Address(entry)
except ipaddress.AddressValueError:
_err(f"Invalid SAN entry '{entry}'")
return False
san_entries.append(f"IP:{entry}")
elif _is_email(entry):
san_entries.append(f"email:{entry}")
elif _is_dns(entry):
san_entries.append(f"DNS:{entry}")
else:
_err(f"Invalid SAN entry '{entry}'")
return False
sans_ext = "subjectAltName=" + ",".join(san_entries)
if cert_type == "server":
print(f"Generating server certificate for '{cert_subject_name}' with SANs:")
else:
print(f"Generating user certificate for '{cert_subject_name}':")
for san in san_entries:
print(f" - {san}")
cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem")
key_out = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not os.path.isfile(cert_out) or not os.path.isfile(key_out):
print("Generating server certificate and key...")
if os.path.isfile(cert_out) and os.path.isfile(key_out):
print(f"Certificate already exists in {cert_dir}, skipping.")
return True
print("Generating certificate and key...")
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
@@ -325,10 +353,19 @@ def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
"-noenc",
"-subj", f"/CN={cert_subject_name}",
"-addext", "basicConstraints=critical,CA:FALSE",
]
if cert_type == "server":
req_cmd += [
"-addext", "keyUsage=critical,digitalSignature,keyEncipherment",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext", sans_ext,
]
else:
req_cmd += [
"-addext", "keyUsage=critical,digitalSignature,nonRepudiation",
"-addext", "extendedKeyUsage=clientAuth,emailProtection,codeSigning",
]
if san_entries:
req_cmd += ["-addext", "subjectAltName=" + ",".join(san_entries)]
if aia_url:
req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"]
if cdp_url:
@@ -344,7 +381,7 @@ def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
"-out", cert_out,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate server certificate and key.")
_err("Failed to generate certificate and key.")
return False
_config.append_history(issuing_ca or "ca", {
@@ -353,7 +390,6 @@ def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
"created": _now(),
"expires": _read_expiry(cert_out),
})
return True
@@ -362,7 +398,7 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=Fa
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
cert_dir = os.path.dirname(cert_path)
cert_dir = os.path.dirname(cert_path) or "."
cert_basename = os.path.basename(cert_path)
cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename
key_path = os.path.join(cert_dir, f"{cert_name}_key.pem")
@@ -380,9 +416,8 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=Fa
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
_err(f"CA certificate or key not found in {ca_dir}.")
if not os.path.isfile(root_ca_cert_path):
_err(f"Root CA certificate not found in {ca_dir}.")
return False
if issuing_ca:
@@ -447,6 +482,10 @@ def make_crl(ca_dir, issuing_ca=None, days=30):
e for e in _config.get("history", {}).get(history_key, [])
if "revoked" in e and "expires" in e
]
crl_numbers = dict(_config.get("crl_number", {}))
crl_numbers[history_key] = crl_numbers.get(history_key, 0) + 1
crl_number_hex = f"{crl_numbers[history_key]:X}"
crl_number_hex = crl_number_hex.zfill(len(crl_number_hex) + len(crl_number_hex) % 2)
with tempfile.TemporaryDirectory() as tmp:
index_txt = os.path.join(tmp, "index.txt")
@@ -459,7 +498,7 @@ def make_crl(ca_dir, issuing_ca=None, days=30):
revoked_asn1 = _iso_to_asn1(e["revoked"])
f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n")
with open(crlnumber, "w") as f:
f.write("01\n")
f.write(f"{crl_number_hex}\n")
with open(cnf_path, "w") as f:
f.write(
"[ ca ]\ndefault_ca = CA_default\n\n"
@@ -478,6 +517,7 @@ def make_crl(ca_dir, issuing_ca=None, days=30):
_err("Failed to generate CRL.")
return False
_config.update({"crl_number": crl_numbers})
print(f"CRL written to {crl_path}")
return True
@@ -528,6 +568,8 @@ def _build_parser():
p_cert.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_cert.add_argument("--days", type=int, default=None, help="Validity period in days (default: 365)")
p_cert.add_argument("--cert-dir", help="Directory to store the certificate files")
p_cert.add_argument("--type", dest="cert_type", choices=["server", "user"], default="server",
help="Certificate type (default: server)")
p_cert.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_cert.add_argument("subject_name", help="Subject name for the certificate")
@@ -568,9 +610,14 @@ def main(argv=None):
ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url")
days_cfg = _config.get("days", {})
days_default = {"make-ca": days_cfg.get("ca", 3650),
"make-cert": days_cfg.get("cert", 365),
"make-crl": days_cfg.get("crl", 30)}.get(args.command)
days = args.days if getattr(args, "days", None) is not None else days_default
if days is not None and days < 1:
parser.error("--days must be a positive integer")
if args.command == "make-ca":
days = args.days or days_cfg.get("ca", 3650)
ok = make_ca(
ca_dir, args.ca_name,
days=days,
@@ -578,7 +625,6 @@ def main(argv=None):
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-cert":
days = args.days or days_cfg.get("cert", 365)
ok = make_cert(
args.subject_name,
sans=args.sans,
@@ -587,6 +633,7 @@ def main(argv=None):
issuing_ca=issuing_ca,
days=days,
ca_publish_base_url=ca_publish_base_url,
cert_type=args.cert_type,
)
elif args.command == "make-pfx":
ok = make_pfx(
@@ -596,7 +643,6 @@ def main(argv=None):
apple_openssl=args.apple_openssl,
)
elif args.command == "make-crl":
days = args.days or days_cfg.get("crl", 30)
ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days)
elif args.command == "revoke-cert":
ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca)
+342 -54
View File
@@ -27,6 +27,7 @@ import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
@@ -36,6 +37,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
@@ -111,18 +113,71 @@ func loadKey(path string) (*rsa.PrivateKey, error) {
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
// ---- AIA base URL -----------------------------------------------------------
// serialHex formats a serial number the way `openssl x509 -serial` prints it.
func serialHex(n *big.Int) string {
s := fmt.Sprintf("%X", n)
if len(s)%2 == 1 {
s = "0" + s
}
return s
}
func readAIABaseURL(caDir string) string {
b, err := os.ReadFile(filepath.Join(caDir, "aia_base_url.txt"))
// ---- configuration ----------------------------------------------------------
const (
configFile = "simple-ca.json"
timeFormat = "2006-01-02T15:04:05Z"
)
type historyEntry struct {
Name string `json:"name"`
Serial string `json:"serial"`
Created string `json:"created"`
Expires string `json:"expires"`
Revoked string `json:"revoked,omitempty"`
}
type daysConfig struct {
CA int `json:"ca,omitempty"`
Cert int `json:"cert,omitempty"`
CRL int `json:"crl,omitempty"`
}
// config is the simple-ca.json file shared with simple-ca.py.
type config struct {
path string
Name string `json:"name,omitempty"`
Created string `json:"created,omitempty"`
CAPublishBaseURL string `json:"ca_publish_base_url,omitempty"`
Subordinates []string `json:"subordinates,omitempty"`
IssuingCA string `json:"issuing_ca,omitempty"`
OpenSSL string `json:"openssl,omitempty"`
Days daysConfig `json:"days,omitzero"`
History map[string][]historyEntry `json:"history,omitempty"`
CRLNumber map[string]int `json:"crl_number,omitempty"`
}
func loadConfig(caDir string) (*config, error) {
cfg := &config{path: filepath.Join(caDir, configFile)}
b, err := os.ReadFile(cfg.path)
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
if err != nil {
return ""
return nil, err
}
return strings.TrimSpace(string(b))
if err := json.Unmarshal(b, cfg); err != nil {
return nil, fmt.Errorf("could not read %s: %w", cfg.path, err)
}
return cfg, nil
}
func writeAIABaseURL(caDir, url string) error {
return os.WriteFile(filepath.Join(caDir, "aia_base_url.txt"), []byte(url+"\n"), 0o644)
func (c *config) save() error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(c.path, append(b, '\n'), 0o644)
}
// ---- CA bundle --------------------------------------------------------------
@@ -162,10 +217,13 @@ func rebuildCABundle(caDir string) error {
// ---- makeCA -----------------------------------------------------------------
func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error {
func makeCA(caDir, caName string, days int, issuingCA, baseURL string) error {
if issuingCA == "ca" {
return errors.New("--issuing-ca cannot be 'ca'")
}
if days < 1 {
return errors.New("--days must be a positive integer")
}
if !dirExists(caDir) {
return fmt.Errorf("CA directory %s does not exist", caDir)
}
@@ -173,9 +231,14 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
return errors.New("CA name is required")
}
if aiaBaseURL == "" {
aiaBaseURL = readAIABaseURL(caDir)
cfg, err := loadConfig(caDir)
if err != nil {
return err
}
if baseURL != "" {
cfg.CAPublishBaseURL = baseURL
}
baseURL = cfg.CAPublishBaseURL
rootCertPath := filepath.Join(caDir, "ca_cert.pem")
rootKeyPath := filepath.Join(caDir, "ca_key.pem")
@@ -217,12 +280,17 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
if err := writeCert(der, rootCertPath); err != nil {
return err
}
if aiaBaseURL != "" {
if err := writeAIABaseURL(caDir, aiaBaseURL); err != nil {
if err := rebuildCABundle(caDir); err != nil {
return err
}
cfg.Name = caName
cfg.Created = now.Format(timeFormat)
return cfg.save()
}
return rebuildCABundle(caDir)
if issuingCA == "" {
fmt.Printf("Root CA already exists in %s, skipping.\n", caDir)
return cfg.save()
}
// ---- issuing CA ----
@@ -232,9 +300,7 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
if fileExists(issuingCACert) && fileExists(issuingCAKey) {
fmt.Printf("Issuing CA '%s' already exists in %s, skipping.\n", issuingCA, issuingCADir)
return nil
}
} else {
fmt.Printf("Generating issuing CA certificate '%s' and key...\n", caName)
if err := os.MkdirAll(issuingCADir, 0o755); err != nil {
return err
@@ -267,8 +333,9 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
MaxPathLenZero: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
}
if aiaBaseURL != "" {
tmpl.IssuingCertificateURL = []string{aiaBaseURL + "/ca_cert.crt"}
if baseURL != "" {
tmpl.IssuingCertificateURL = []string{baseURL + "/ca_cert.crt"}
tmpl.CRLDistributionPoints = []string{baseURL + "/crl.pem"}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, rootCert, &key.PublicKey, rootKey)
if err != nil {
@@ -280,6 +347,14 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error
if err := writeCert(der, issuingCACert); err != nil {
return err
}
}
if !slices.Contains(cfg.Subordinates, issuingCA) {
cfg.Subordinates = append(cfg.Subordinates, issuingCA)
}
if err := cfg.save(); err != nil {
return err
}
return rebuildCABundle(caDir)
}
@@ -298,6 +373,9 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert
if certType != "server" && certType != "user" {
return fmt.Errorf("--type must be 'server' or 'user', got '%s'", certType)
}
if days < 1 {
return errors.New("--days must be a positive integer")
}
if !dirExists(caDir) {
return fmt.Errorf("CA directory %s does not exist", caDir)
}
@@ -327,13 +405,15 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert
signingDir)
}
aiaURL := ""
if base := readAIABaseURL(caDir); base != "" {
if issuingCA != "" {
aiaURL = base + "/" + issuingCA + "/ca_cert.crt"
} else {
aiaURL = base + "/ca_cert.crt"
cfg, err := loadConfig(caDir)
if err != nil {
return err
}
historyKey := "ca"
publishURL := cfg.CAPublishBaseURL
if issuingCA != "" {
historyKey = issuingCA
publishURL += "/" + issuingCA
}
certName := subjectName
@@ -352,7 +432,11 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert
for _, entry := range sans {
switch {
case ipRE.MatchString(entry):
ips = append(ips, net.ParseIP(entry))
ip := net.ParseIP(entry)
if ip == nil {
return fmt.Errorf("invalid SAN entry '%s'", entry)
}
ips = append(ips, ip)
case emailRE.MatchString(entry):
emails = append(emails, entry)
case dnsRE.MatchString(entry):
@@ -403,9 +487,7 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert
}
now := time.Now().UTC()
var tmpl *x509.Certificate
if certType == "server" {
tmpl = &x509.Certificate{
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: subjectName},
NotBefore: now,
@@ -413,26 +495,17 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
DNSNames: dnsNames,
IPAddresses: ips,
}
} else {
tmpl = &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: subjectName},
NotBefore: now,
NotAfter: now.AddDate(0, 0, days),
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageContentCommitment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageEmailProtection, x509.ExtKeyUsageCodeSigning},
EmailAddresses: emails,
DNSNames: dnsNames,
IPAddresses: ips,
}
if certType == "user" {
tmpl.KeyUsage = x509.KeyUsageDigitalSignature | x509.KeyUsageContentCommitment
tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageEmailProtection, x509.ExtKeyUsageCodeSigning}
}
if aiaURL != "" {
tmpl.IssuingCertificateURL = []string{aiaURL}
if cfg.CAPublishBaseURL != "" {
tmpl.IssuingCertificateURL = []string{publishURL + "/ca_cert.crt"}
tmpl.CRLDistributionPoints = []string{publishURL + "/crl.pem"}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey)
if err != nil {
@@ -441,7 +514,20 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert
if err := writeKey(key, keyOut); err != nil {
return err
}
return writeCert(der, certOut)
if err := writeCert(der, certOut); err != nil {
return err
}
if cfg.History == nil {
cfg.History = map[string][]historyEntry{}
}
cfg.History[historyKey] = append(cfg.History[historyKey], historyEntry{
Name: subjectName,
Serial: serialHex(serial),
Created: now.Format(timeFormat),
Expires: tmpl.NotAfter.Format(timeFormat),
})
return cfg.save()
}
// ---- makePFX ----------------------------------------------------------------
@@ -568,6 +654,132 @@ func makePFXViaOpenSSL(opensslBin, certPath, keyPath, rootCertPath, issuingCACer
return nil
}
// ---- makeCRL ----------------------------------------------------------------
func makeCRL(caDir, issuingCA string, days int) error {
if issuingCA == "ca" {
return errors.New("--issuing-ca cannot be 'ca'")
}
if days < 1 {
return errors.New("--days must be a positive integer")
}
signingDir := caDir
historyKey := "ca"
if issuingCA != "" {
signingDir = filepath.Join(caDir, issuingCA)
historyKey = issuingCA
}
caCertPath := filepath.Join(signingDir, "ca_cert.pem")
caKeyPath := filepath.Join(signingDir, "ca_key.pem")
if !fileExists(caCertPath) || !fileExists(caKeyPath) {
return fmt.Errorf("CA certificate or key not found in %s", signingDir)
}
caCert, err := loadCert(caCertPath)
if err != nil {
return err
}
if len(caCert.SubjectKeyId) == 0 {
return fmt.Errorf("CA certificate %s has no subject key identifier", caCertPath)
}
caKey, err := loadKey(caKeyPath)
if err != nil {
return err
}
cfg, err := loadConfig(caDir)
if err != nil {
return err
}
var revoked []x509.RevocationListEntry
for _, e := range cfg.History[historyKey] {
if e.Revoked == "" {
continue
}
serial, ok := new(big.Int).SetString(e.Serial, 16)
if !ok {
return fmt.Errorf("invalid serial '%s' in %s", e.Serial, cfg.path)
}
revokedAt, err := time.Parse(timeFormat, e.Revoked)
if err != nil {
return err
}
revoked = append(revoked, x509.RevocationListEntry{SerialNumber: serial, RevocationTime: revokedAt})
}
number := cfg.CRLNumber[historyKey] + 1
now := time.Now().UTC()
der, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{
Number: big.NewInt(int64(number)),
ThisUpdate: now,
NextUpdate: now.AddDate(0, 0, days),
RevokedCertificateEntries: revoked,
}, caCert, caKey)
if err != nil {
return err
}
crlPath := filepath.Join(signingDir, "crl.pem")
if err := writePEMFile(crlPath, "X509 CRL", der, 0o644); err != nil {
return err
}
if cfg.CRLNumber == nil {
cfg.CRLNumber = map[string]int{}
}
cfg.CRLNumber[historyKey] = number
if err := cfg.save(); err != nil {
return err
}
fmt.Printf("CRL written to %s\n", crlPath)
return nil
}
// ---- revokeCert -------------------------------------------------------------
func revokeCert(certPath, caDir, issuingCA string) error {
if issuingCA == "ca" {
return errors.New("--issuing-ca cannot be 'ca'")
}
if !fileExists(certPath) {
return fmt.Errorf("certificate not found: %s", certPath)
}
signingDir := caDir
historyKey := "ca"
if issuingCA != "" {
signingDir = filepath.Join(caDir, issuingCA)
historyKey = issuingCA
}
if !dirExists(signingDir) {
return fmt.Errorf("CA directory not found: %s", signingDir)
}
cert, err := loadCert(certPath)
if err != nil {
return err
}
serial := serialHex(cert.SerialNumber)
cfg, err := loadConfig(caDir)
if err != nil {
return err
}
entries := cfg.History[historyKey]
i := slices.IndexFunc(entries, func(e historyEntry) bool { return e.Serial == serial })
if i < 0 {
return fmt.Errorf("certificate with serial %s not found in history for CA '%s'", serial, historyKey)
}
if entries[i].Revoked != "" {
fmt.Printf("Certificate %s (serial %s) is already revoked.\n", certPath, serial)
return nil
}
entries[i].Revoked = time.Now().UTC().Format(timeFormat)
if err := cfg.save(); err != nil {
return err
}
fmt.Printf("Certificate %s (serial %s) marked as revoked.\n", certPath, serial)
return nil
}
// ---- CLI --------------------------------------------------------------------
func resolveCADir(flagVal string) string {
@@ -581,31 +793,51 @@ func resolveCADir(flagVal string) string {
return cwd
}
// loadDefaults loads simple-ca.json and applies its issuing_ca when --issuing-ca is not given.
func loadDefaults(cmd *cobra.Command, caDir string, issuingCA *string) (*config, error) {
cfg, err := loadConfig(caDir)
if err != nil {
return nil, err
}
if !cmd.Flags().Changed("issuing-ca") {
*issuingCA = cfg.IssuingCA
}
return cfg, nil
}
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "simple-ca",
Short: "Create and manage a simple Certificate Authority.",
SilenceUsage: true,
SilenceErrors: false,
SilenceErrors: true,
}
root.AddCommand(newMakeCACmd(), newMakeCertCmd(), newMakePFXCmd())
root.AddCommand(newMakeCACmd(), newMakeCertCmd(), newMakePFXCmd(), newMakeCRLCmd(), newRevokeCertCmd())
return root
}
func newMakeCACmd() *cobra.Command {
var days int
var issuingCA, aiaBaseURL, caDir string
var issuingCA, baseURL, caDir string
cmd := &cobra.Command{
Use: "make-ca CA_NAME",
Short: "Create a root or issuing CA.",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return makeCA(resolveCADir(caDir), args[0], days, issuingCA, aiaBaseURL)
RunE: func(cmd *cobra.Command, args []string) error {
dir := resolveCADir(caDir)
cfg, err := loadDefaults(cmd, dir, &issuingCA)
if err != nil {
return err
}
if !cmd.Flags().Changed("days") && cfg.Days.CA > 0 {
days = cfg.Days.CA
}
return makeCA(dir, args[0], days, issuingCA, baseURL)
},
}
cmd.Flags().IntVar(&days, "days", 3650, "validity period in days")
cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "create an issuing CA with this directory name")
cmd.Flags().StringVar(&aiaBaseURL, "aia-base-url", "", "base URL for AIA caIssuers extension")
cmd.Flags().StringVar(&baseURL, "ca-publish-base-url", "", "base URL for AIA and CRL distribution point extensions")
cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA root directory")
return cmd
}
@@ -617,8 +849,16 @@ func newMakeCertCmd() *cobra.Command {
Use: "make-cert SUBJECT [SAN...]",
Short: "Create a server/client certificate signed by the CA.",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return makeCert(args[0], args[1:], resolveCADir(caDir), certDir, issuingCA, certType, days)
RunE: func(cmd *cobra.Command, args []string) error {
dir := resolveCADir(caDir)
cfg, err := loadDefaults(cmd, dir, &issuingCA)
if err != nil {
return err
}
if !cmd.Flags().Changed("days") && cfg.Days.Cert > 0 {
days = cfg.Days.Cert
}
return makeCert(args[0], args[1:], dir, certDir, issuingCA, certType, days)
},
}
cmd.Flags().StringVar(&certDir, "cert-dir", "", "output directory (default: signing CA directory)")
@@ -636,8 +876,12 @@ func newMakePFXCmd() *cobra.Command {
Use: "make-pfx CERT_PATH",
Short: "Create a PKCS#12 (PFX) bundle for a leaf certificate.",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return makePFX(args[0], resolveCADir(caDir), issuingCA, password, appleOpenSSL)
RunE: func(cmd *cobra.Command, args []string) error {
dir := resolveCADir(caDir)
if _, err := loadDefaults(cmd, dir, &issuingCA); err != nil {
return err
}
return makePFX(args[0], dir, issuingCA, password, appleOpenSSL)
},
}
cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA root directory")
@@ -647,6 +891,50 @@ func newMakePFXCmd() *cobra.Command {
return cmd
}
func newMakeCRLCmd() *cobra.Command {
var caDir, issuingCA string
var days int
cmd := &cobra.Command{
Use: "make-crl",
Short: "Generate a CRL for a CA.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
dir := resolveCADir(caDir)
cfg, err := loadDefaults(cmd, dir, &issuingCA)
if err != nil {
return err
}
if !cmd.Flags().Changed("days") && cfg.Days.CRL > 0 {
days = cfg.Days.CRL
}
return makeCRL(dir, issuingCA, days)
},
}
cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA root directory")
cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "generate CRL for this issuing CA")
cmd.Flags().IntVar(&days, "days", 30, "CRL validity in days")
return cmd
}
func newRevokeCertCmd() *cobra.Command {
var caDir, issuingCA string
cmd := &cobra.Command{
Use: "revoke-cert CERT_PATH",
Short: "Revoke a certificate.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dir := resolveCADir(caDir)
if _, err := loadDefaults(cmd, dir, &issuingCA); err != nil {
return err
}
return revokeCert(args[0], dir, issuingCA)
},
}
cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA root directory")
cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA that signed the certificate")
return cmd
}
func main() {
if err := newRootCmd().Execute(); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
+178
View File
@@ -1,9 +1,12 @@
package main
import (
"crypto/x509"
"encoding/pem"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
@@ -133,6 +136,181 @@ func TestCertDirOverride(t *testing.T) {
verifyCert(t, filepath.Join(caDir, "ca_bundle.pem"), certPath)
}
func TestServerEmailSAN(t *testing.T) {
caDir := t.TempDir()
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCert("test", []string{"admin@example.com"}, caDir, "", "", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err)
}
cert, err := loadCert(filepath.Join(caDir, "test_cert.pem"))
if err != nil {
t.Fatalf("loadCert: %v", err)
}
if len(cert.EmailAddresses) != 1 || cert.EmailAddresses[0] != "admin@example.com" {
t.Fatalf("email SAN missing, got %v", cert.EmailAddresses)
}
}
func TestInvalidIPSAN(t *testing.T) {
caDir := t.TempDir()
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCert("test", []string{"999.1.1.1"}, caDir, "", "", "server", 365); err == nil {
t.Fatal("expected error for invalid IPv4 SAN")
}
}
func TestRootExists(t *testing.T) {
caDir := t.TempDir()
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA second run: %v", err)
}
}
func TestPublishURLs(t *testing.T) {
caDir := t.TempDir()
base := "http://pki.example.com"
if err := makeCA(caDir, "Test CA", 3650, "", base); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCA(caDir, "Issuing CA", 3650, "issuing_ca", ""); err != nil {
t.Fatalf("makeCA issuing: %v", err)
}
if err := makeCert("test", nil, caDir, "", "issuing_ca", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err)
}
issuing, err := loadCert(filepath.Join(caDir, "issuing_ca", "ca_cert.pem"))
if err != nil {
t.Fatalf("loadCert: %v", err)
}
if !slices.Equal(issuing.IssuingCertificateURL, []string{base + "/ca_cert.crt"}) ||
!slices.Equal(issuing.CRLDistributionPoints, []string{base + "/crl.pem"}) {
t.Fatalf("issuing CA URLs: AIA %v CDP %v", issuing.IssuingCertificateURL, issuing.CRLDistributionPoints)
}
leaf, err := loadCert(filepath.Join(caDir, "issuing_ca", "test_cert.pem"))
if err != nil {
t.Fatalf("loadCert: %v", err)
}
if !slices.Equal(leaf.IssuingCertificateURL, []string{base + "/issuing_ca/ca_cert.crt"}) ||
!slices.Equal(leaf.CRLDistributionPoints, []string{base + "/issuing_ca/crl.pem"}) {
t.Fatalf("leaf URLs: AIA %v CDP %v", leaf.IssuingCertificateURL, leaf.CRLDistributionPoints)
}
}
func TestConfigHistory(t *testing.T) {
caDir := t.TempDir()
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCA(caDir, "Issuing CA", 3650, "issuing_ca", ""); err != nil {
t.Fatalf("makeCA issuing: %v", err)
}
if err := makeCert("test", nil, caDir, "", "issuing_ca", "server", 365); err != nil {
t.Fatalf("makeCert: %v", err)
}
out, err := exec.Command("openssl", "x509", "-in", filepath.Join(caDir, "issuing_ca", "test_cert.pem"),
"-noout", "-serial").Output()
if err != nil {
t.Fatalf("openssl x509 -serial: %v", err)
}
opensslSerial := strings.TrimPrefix(strings.TrimSpace(string(out)), "serial=")
cfg, err := loadConfig(caDir)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.Name != "Test CA" || !slices.Equal(cfg.Subordinates, []string{"issuing_ca"}) {
t.Fatalf("unexpected config: name %q subordinates %v", cfg.Name, cfg.Subordinates)
}
entries := cfg.History["issuing_ca"]
if len(entries) != 1 || entries[0].Serial != opensslSerial || entries[0].Name != "test" {
t.Fatalf("history %+v does not match serial %s", entries, opensslSerial)
}
}
func TestRevokeAndCRL(t *testing.T) {
caDir := t.TempDir()
if err := makeCA(caDir, "Test CA", 3650, "", ""); err != nil {
t.Fatalf("makeCA: %v", err)
}
if err := makeCA(caDir, "Issuing CA", 3650, "issuing_ca", ""); err != nil {
t.Fatalf("makeCA issuing: %v", err)
}
for _, name := range []string{"alice", "bob"} {
if err := makeCert(name, nil, caDir, "", "issuing_ca", "server", 365); err != nil {
t.Fatalf("makeCert %s: %v", name, err)
}
}
alicePath := filepath.Join(caDir, "issuing_ca", "alice_cert.pem")
alice, err := loadCert(alicePath)
if err != nil {
t.Fatalf("loadCert: %v", err)
}
bob, err := loadCert(filepath.Join(caDir, "issuing_ca", "bob_cert.pem"))
if err != nil {
t.Fatalf("loadCert: %v", err)
}
if err := revokeCert(alicePath, caDir, "issuing_ca"); err != nil {
t.Fatalf("revokeCert: %v", err)
}
if err := revokeCert(alicePath, caDir, "issuing_ca"); err != nil {
t.Fatalf("revokeCert second run: %v", err)
}
crlPath := filepath.Join(caDir, "issuing_ca", "crl.pem")
for want := int64(1); want <= 2; want++ {
if err := makeCRL(caDir, "issuing_ca", 30); err != nil {
t.Fatalf("makeCRL: %v", err)
}
b, err := os.ReadFile(crlPath)
if err != nil {
t.Fatalf("read CRL: %v", err)
}
block, _ := pem.Decode(b)
if block == nil || block.Type != "X509 CRL" {
t.Fatal("no X509 CRL PEM block")
}
crl, err := x509.ParseRevocationList(block.Bytes)
if err != nil {
t.Fatalf("ParseRevocationList: %v", err)
}
if crl.Number.Int64() != want {
t.Fatalf("CRL number %d, want %d", crl.Number, want)
}
if len(crl.RevokedCertificateEntries) != 1 ||
crl.RevokedCertificateEntries[0].SerialNumber.Cmp(alice.SerialNumber) != 0 {
t.Fatalf("CRL entries %+v, want only alice", crl.RevokedCertificateEntries)
}
}
out, err := exec.Command("openssl", "verify", "-crl_check", "-CAfile", filepath.Join(caDir, "ca_bundle.pem"),
"-CRLfile", crlPath, alicePath).CombinedOutput()
if err == nil || !strings.Contains(string(out), "revoked") {
t.Fatalf("expected alice to be reported revoked:\n%s", out)
}
out, err = exec.Command("openssl", "verify", "-crl_check", "-CAfile", filepath.Join(caDir, "ca_bundle.pem"),
"-CRLfile", crlPath, filepath.Join(caDir, "issuing_ca", "bob_cert.pem")).CombinedOutput()
if err != nil {
t.Fatalf("bob (serial %X) should verify:\n%s", bob.SerialNumber, out)
}
}
func TestAppleOpenSSL(t *testing.T) {
if _, err := os.Stat("/usr/bin/openssl"); err != nil {
t.Skip("/usr/bin/openssl not available")
+59
View File
@@ -156,3 +156,62 @@ def test_crl(dirs):
assert root_crl.exists()
root_crl_text = openssl("crl", "-in", str(root_crl), "-noout", "-text").stdout
assert "No Revoked Certificates" in root_crl_text, "Root CA CRL should be empty"
def test_crl_number_increments(dirs):
ca = dirs
py("make-ca", "--ca-dir", str(ca), "CRL Test CA")
numbers = []
for _ in range(2):
py("make-crl", "--ca-dir", str(ca))
numbers.append(openssl("crl", "-in", str(ca / "crl.pem"), "-noout", "-crlnumber").stdout.strip())
assert numbers == ["crlNumber=0x01", "crlNumber=0x02"], numbers
# ---------------------------------------------------------------------------
# User certificate and SAN handling
# ---------------------------------------------------------------------------
def test_user_cert(dirs):
ca = dirs
py("make-ca", "--ca-dir", str(ca), "Test CA")
py("make-ca", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", "Issuing CA")
py("make-cert", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", "--type", "user",
"Alice Example", "alice@example.com")
cert = ca / "issuing_ca" / "Alice Example_cert.pem"
verify_cert(cert, ca / "ca_bundle.pem")
text = openssl("x509", "-in", str(cert), "-noout", "-text").stdout
for eku in ("TLS Web Client Authentication", "E-mail Protection", "Code Signing"):
assert eku in text, f"EKU {eku!r} not found"
assert "email:alice@example.com" in text
assert "TLS Web Server Authentication" not in text
def test_invalid_ip_san(dirs):
ca = dirs
py("make-ca", "--ca-dir", str(ca), "Test CA")
result = py("make-cert", "--ca-dir", str(ca), "test", "999.1.1.1", check=False)
assert result.returncode != 0
assert not (ca / "test_cert.pem").exists()
def test_make_ca_root_exists(dirs):
ca = dirs
py("make-ca", "--ca-dir", str(ca), "Test CA")
result = py("make-ca", "--ca-dir", str(ca), "Test CA", check=False)
assert result.returncode == 0
assert "Traceback" not in result.stderr
def test_pfx_bare_filename(dirs):
ca = dirs
py("make-ca", "--ca-dir", str(ca), "Test CA")
py("make-cert", "--ca-dir", str(ca), "test", "test.example.com")
result = subprocess.run(
[sys.executable, str(SCRIPT), "make-pfx", "--ca-dir", str(ca), "test_cert.pem"],
capture_output=True, text=True, cwd=ca,
)
assert result.returncode == 0, result.stderr
assert (ca / "test.pfx").exists()