feat: Bring Go and Python implementations to feature parity
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:
1 parent
27461a0fbe
commit
9c84e82672
5 files changed
+673
-86
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user