aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIsabella Bastaja <isabella@sorre.xyz>2026-06-04 21:15:44 +0200
committerIsabella Bastaja <isabella@sorre.xyz>2026-06-04 21:23:43 +0200
commit22c8784fde9222fadf30b2102b0a7a1e770438ee (patch)
tree57569e7eb4cc5ac4e9767eb0386659a95ed0ae24
parent60cd6bde8922ea76c9aa63a65491ef6ecfc5c0c6 (diff)
add python cert helper
-rwxr-xr-xetc/bin/cert.py244
1 files changed, 244 insertions, 0 deletions
diff --git a/etc/bin/cert.py b/etc/bin/cert.py
new file mode 100755
index 0000000..92baa7e
--- /dev/null
+++ b/etc/bin/cert.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+"""
+Usage:
+ python create-cert.py mydomain.internal
+ python create-cert.py app.internal --san api.internal --san admin.internal
+
+The CA material is expected under secrets/ca/ relative to the repo root:
+ secrets/ca/
+ rootCA.key # encrypted with sops
+ rootCA.crt # plaintext (public)
+ certs/ # output directory, created if missing
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import List
+
+
+def run(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
+ """Execute a command, check for errors."""
+ try:
+ return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs)
+ except subprocess.CalledProcessError as e:
+ print(f"Command failed: {' '.join(cmd)}", file=sys.stderr)
+ print(e.stderr, file=sys.stderr)
+ raise
+
+
+def check_dependencies() -> None:
+ """Verify that git, openssl, and sops are available on $PATH."""
+ required = ["git", "openssl", "sops"]
+ missing = [tool for tool in required if shutil.which(tool) is None]
+ if missing:
+ sys.exit(f"Missing required tools: {', '.join(missing)}")
+
+
+def get_repo_root() -> Path:
+ """Return the root directory of the git repository."""
+ result = run(["git", "rev-parse", "--show-toplevel"])
+ return Path(result.stdout.strip())
+
+
+def build_csr_config(domain: str, extra_sans: List[str]) -> str:
+ """Return an OpenSSL config for a CSR, including SANs."""
+ sans = [domain, f"*.{domain}"] + extra_sans
+ san_entries = "\n".join(f"DNS.{i+1} = {s}" for i, s in enumerate(sans))
+ return f"""\
+[ req ]
+default_bits = 2048
+distinguished_name = req_distinguished_name
+req_extensions = req_ext
+string_mask = utf8only
+prompt = no
+
+[ req_distinguished_name ]
+organizationName = Internal
+commonName = {domain}
+
+[ req_ext ]
+subjectAltName = @alt_names
+
+[ alt_names ]
+{san_entries}
+"""
+
+
+def build_ext_config(domain: str, extra_sans: List[str]) -> str:
+ """Return an OpenSSL extension config for signing a server certificate."""
+ sans = [domain, f"*.{domain}"] + extra_sans
+ san_entries = "\n".join(f"DNS.{i+1} = {s}" for i, s in enumerate(sans))
+ return f"""\
+[ v3_server ]
+subjectKeyIdentifier = hash
+authorityKeyIdentifier = keyid,issuer
+basicConstraints = critical, CA:FALSE
+keyUsage = critical, digitalSignature, keyEncipherment
+extendedKeyUsage = serverAuth, clientAuth
+subjectAltName = @alt_names
+
+[ alt_names ]
+{san_entries}
+"""
+
+
+def provision_cert(
+ domain: str,
+ extra_sans: List[str],
+ ca_key_path: Path,
+ ca_crt_path: Path,
+ out_dir: Path,
+ validity_days: int = 1095,
+) -> None:
+ """
+ Generate key, CSR, sign with CA, then encrypt artifacts.
+
+ All temporary work is done inside a temporary directory; the final
+ key and certificate are moved to `out_dir` only after successful
+ signing. Existing files are never overwritten.
+ """
+ key_path = out_dir / f"{domain}.key"
+ crt_path = out_dir / f"{domain}.crt"
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ if key_path.exists():
+ raise FileExistsError(f"File already exists: {key_path}")
+ if crt_path.exists():
+ raise FileExistsError(f"File already exists: {crt_path}")
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ key_tmp = tmp / f"{domain}.key"
+ crt_tmp = tmp / f"{domain}.crt"
+ csr_path = tmp / f"{domain}.csr"
+ csr_conf = tmp / "csr.cnf"
+ ext_conf = tmp / "ext.cnf"
+
+ # Generate private key inside temporary directory
+ subprocess.run(
+ ["openssl", "genrsa", "-out", str(key_tmp), "2048"],
+ check=True,
+ text=True,
+ capture_output=True,
+ )
+
+ # CSR configuration
+ csr_conf.write_text(build_csr_config(domain, extra_sans))
+ subprocess.run(
+ [
+ "openssl", "req", "-new",
+ "-key", str(key_tmp),
+ "-config", str(csr_conf),
+ "-out", str(csr_path),
+ ],
+ check=True,
+ text=True,
+ capture_output=True,
+ )
+
+ # Signing configuration
+ ext_conf.write_text(build_ext_config(domain, extra_sans))
+
+ # Sign the CSR with the decrypted CA key
+ sops_decrypt = subprocess.Popen(
+ ["sops", "decrypt", str(ca_key_path)],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ openssl_sign = subprocess.Popen(
+ [
+ "openssl", "x509", "-req",
+ "-in", str(csr_path),
+ "-CA", str(ca_crt_path),
+ "-CAkey", "/dev/stdin",
+ "-CAcreateserial",
+ "-days", str(validity_days),
+ "-sha256",
+ "-extfile", str(ext_conf),
+ "-extensions", "v3_server",
+ "-out", str(crt_tmp),
+ ],
+ stdin=sops_decrypt.stdout,
+ stdout=subprocess.DEVNULL,
+ )
+ sops_decrypt.stdout.close()
+ sops_return = sops_decrypt.wait()
+ sign_return = openssl_sign.wait()
+
+ if sops_return != 0:
+ raise RuntimeError("sops decrypt failed")
+ if sign_return != 0:
+ raise RuntimeError("openssl signing failed")
+
+ # Verify the certificate chain before moving anything
+ subprocess.run(
+ ["openssl", "verify", "-CAfile", str(ca_crt_path), str(crt_tmp)],
+ check=True,
+ text=True,
+ capture_output=True,
+ )
+
+ # Move the final artefacts to the output directory
+ shutil.move(str(key_tmp), str(key_path))
+ shutil.move(str(crt_tmp), str(crt_path))
+
+ # Encrypt both files in place (they are now in out_dir)
+ subprocess.run(
+ ["sops", "encrypt", "--in-place", str(key_path)],
+ check=True,
+ text=True,
+ capture_output=True,
+ )
+ subprocess.run(
+ ["sops", "encrypt", "--in-place", str(crt_path)],
+ check=True,
+ text=True,
+ capture_output=True,
+ )
+
+ print("✔ Certificate provisioned and encrypted:")
+ print(f" {key_path}")
+ print(f" {crt_path}")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Provision an internal TLS certificate using a sops‑encrypted Root CA."
+ )
+ parser.add_argument("domain", help="Primary domain name (e.g., myapp.internal)")
+ parser.add_argument(
+ "--san",
+ action="append",
+ default=[],
+ help="Additional Subject Alternative Names (can be used multiple times)",
+ )
+ args = parser.parse_args()
+
+ check_dependencies()
+
+ repo_root = get_repo_root()
+ ca_dir = repo_root / "secrets/ca"
+ ca_key_path = ca_dir / "rootCA.key"
+ ca_crt_path = repo_root / "etc/certs/rootCA.crt"
+ out_dir = ca_dir / "certs"
+
+ if not ca_key_path.exists():
+ sys.exit(f"CA private key not found at {ca_key_path}")
+ if not ca_crt_path.exists():
+ sys.exit(f"CA certificate not found at {ca_crt_path}")
+
+ try:
+ provision_cert(args.domain, args.san, ca_key_path, ca_crt_path, out_dir)
+ except Exception as e:
+ sys.exit(f"Error: {e}")
+
+
+if __name__ == "__main__":
+ main()