From 9c84e82672ffcb35887ee461cb08ca1db016f223 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 16 Oct 2026 14:20:04 +0200 Subject: [PATCH] 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. --- README.md | 42 +++- simple-ca.py | 144 +++++++---- src/simple-ca/main.go | 504 +++++++++++++++++++++++++++++-------- src/simple-ca/main_test.go | 178 +++++++++++++ test_simple_ca.py | 59 +++++ 5 files changed, 757 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index ccc540d..9551725 100644 --- a/README.md +++ b/README.md @@ -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-pfx [--ca-dir DIR] [--issuing-ca NAME] [--password PASS] [--apple-openssl] CERT_PATH +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. diff --git a/simple-ca.py b/simple-ca.py index 7486d94..89aa78f 100755 --- a/simple-ca.py +++ b/simple-ca.py @@ -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,63 +314,82 @@ 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) - - print(f"Generating server certificate for '{cert_subject_name}' with SANs:") + 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...") - req_cmd = [ - OPENSSL, "req", - "-newkey", "rsa:4096", - "-keyout", key_out, - "-noenc", - "-subj", f"/CN={cert_subject_name}", - "-addext", "basicConstraints=critical,CA:FALSE", + 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", + "-keyout", key_out, + "-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, ] - if aia_url: - req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"] - if cdp_url: - req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"] - x509_cmd = [ - OPENSSL, "x509", - "-req", - "-CA", ca_cert_path, - "-CAkey", ca_key_path, - "-copy_extensions", "copyall", - "-days", str(days), - "-text", - "-out", cert_out, + else: + req_cmd += [ + "-addext", "keyUsage=critical,digitalSignature,nonRepudiation", + "-addext", "extendedKeyUsage=clientAuth,emailProtection,codeSigning", ] - if not _pipe(req_cmd, x509_cmd): - _err("Failed to generate server certificate and key.") - return False - - _config.append_history(issuing_ca or "ca", { - "name": cert_subject_name, - "serial": _read_serial(cert_out), - "created": _now(), - "expires": _read_expiry(cert_out), - }) + 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: + req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"] + x509_cmd = [ + OPENSSL, "x509", + "-req", + "-CA", ca_cert_path, + "-CAkey", ca_key_path, + "-copy_extensions", "copyall", + "-days", str(days), + "-text", + "-out", cert_out, + ] + if not _pipe(req_cmd, x509_cmd): + _err("Failed to generate certificate and key.") + return False + _config.append_history(issuing_ca or "ca", { + "name": cert_subject_name, + "serial": _read_serial(cert_out), + "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) diff --git a/src/simple-ca/main.go b/src/simple-ca/main.go index 7bcf2ce..9defea2 100644 --- a/src/simple-ca/main.go +++ b/src/simple-ca/main.go @@ -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 ----------------------------------------------------------- - -func readAIABaseURL(caDir string) string { - b, err := os.ReadFile(filepath.Join(caDir, "aia_base_url.txt")) - if err != nil { - return "" +// 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 strings.TrimSpace(string(b)) + return s } -func writeAIABaseURL(caDir, url string) error { - return os.WriteFile(filepath.Join(caDir, "aia_base_url.txt"), []byte(url+"\n"), 0o644) +// ---- 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 nil, err + } + if err := json.Unmarshal(b, cfg); err != nil { + return nil, fmt.Errorf("could not read %s: %w", cfg.path, err) + } + return cfg, nil +} + +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 { - return err - } + if err := rebuildCABundle(caDir); err != nil { + return err } - return rebuildCABundle(caDir) + cfg.Name = caName + cfg.Created = now.Format(timeFormat) + return cfg.save() + } + + if issuingCA == "" { + fmt.Printf("Root CA already exists in %s, skipping.\n", caDir) + return cfg.save() } // ---- issuing CA ---- @@ -232,52 +300,59 @@ 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 + } + 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 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 { + return err + } + if err := writeKey(key, issuingCAKey); err != nil { + return err + } + if err := writeCert(der, issuingCACert); err != nil { + return err + } } - fmt.Printf("Generating issuing CA certificate '%s' and key...\n", caName) - if err := os.MkdirAll(issuingCADir, 0o755); err != nil { - return err + if !slices.Contains(cfg.Subordinates, issuingCA) { + cfg.Subordinates = append(cfg.Subordinates, issuingCA) } - 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, issuingCAKey); err != nil { - return err - } - if err := writeCert(der, issuingCACert); err != nil { + 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,36 +487,25 @@ func makeCert(subjectName string, sans []string, caDir, certDir, issuingCA, cert } now := time.Now().UTC() - var tmpl *x509.Certificate - if certType == "server" { - tmpl = &x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: subjectName}, - NotBefore: now, - NotAfter: now.AddDate(0, 0, days), - 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, - } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: subjectName}, + NotBefore: now, + NotAfter: now.AddDate(0, 0, days), + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + EmailAddresses: emails, + DNSNames: dnsNames, + IPAddresses: ips, } - - if aiaURL != "" { - tmpl.IssuingCertificateURL = []string{aiaURL} + if certType == "user" { + tmpl.KeyUsage = x509.KeyUsageDigitalSignature | x509.KeyUsageContentCommitment + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageEmailProtection, x509.ExtKeyUsageCodeSigning} + } + 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) diff --git a/src/simple-ca/main_test.go b/src/simple-ca/main_test.go index b94696d..6f57153 100644 --- a/src/simple-ca/main_test.go +++ b/src/simple-ca/main_test.go @@ -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") diff --git a/test_simple_ca.py b/test_simple_ca.py index 6fdca20..2f930d2 100644 --- a/test_simple_ca.py +++ b/test_simple_ca.py @@ -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()