import os import secrets import logging import html from datetime import datetime, timezone from fastapi import FastAPI, Request, Depends, HTTPException, Form from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from jose import jwt from jinja2 import Environment, FileSystemLoader, select_autoescape from config import ( ALGORITHM, CA_CHAIN_PATH, INT_CA_PATH, ISSUED_DIR, PFX_DEFAULT_PASSWORD, ROOT_CA_PATH, SECRET_KEY, TMP_DIR, ) from models import get_db, init_db, verify_password from auth import create_access_token, get_current_user from signing import build_leaf_cert from cryptography.hazmat.primitives import serialization logger = logging.getLogger(__name__) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_DIR = os.environ.get("CERTAUTH_STATIC_DIR", os.path.join(BASE_DIR, "static")) TEMPLATES_DIR = os.environ.get("CERTAUTH_TEMPLATES_DIR", os.path.join(BASE_DIR, "templates")) app = FastAPI(title="CertAuth Key Vault") app.mount("/static", StaticFiles(directory=STATIC_DIR, check_dir=False), name="static") _csrf_secrets = {} def get_csrf_token(session_id: str) -> str: if session_id not in _csrf_secrets: _csrf_secrets[session_id] = secrets.token_hex(32) return _csrf_secrets[session_id] def verify_csrf_token(session_id: str, token: str) -> bool: stored = _csrf_secrets.get(session_id) if not stored: return False return secrets.compare_digest(stored, token) def sanitize_error(msg: str) -> str: return html.escape(str(msg)) def get_user_from_cookie(request: Request): token = request.cookies.get("token") if not token: return None try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except Exception: return None jinja_env = Environment( loader=FileSystemLoader(TEMPLATES_DIR), autoescape=select_autoescape(["html", "xml"]), ) @app.on_event("startup") def startup(): init_db() try: with open(ROOT_CA_PATH) as f: root = f.read() with open(INT_CA_PATH) as f: inter = f.read() with open(CA_CHAIN_PATH, "w") as f: f.write(inter + "\n" + root) except Exception as e: logger.warning("CA chain setup failed: %s", sanitize_error(str(e))) def get_user_id(conn, username: str) -> int: row = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() return row["id"] if row else 1 def render(name, ctx): return HTMLResponse(jinja_env.get_template(name).render(**ctx)) class LoginRequest(BaseModel): username: str password: str @app.post("/api/token") async def login(req: LoginRequest): conn = get_db() row = conn.execute("SELECT * FROM users WHERE username = ?", (req.username,)).fetchone() conn.close() if not row or not verify_password(req.password, row["password_hash"]): raise HTTPException(401, "Invalid credentials") token = create_access_token({"sub": req.username}) return {"access_token": token, "token_type": "bearer"} @app.get("/api/me") async def me(user: str = Depends(get_current_user)): return {"username": user} @app.get("/api/domains") async def list_domains(user: str = Depends(get_current_user)): conn = get_db() rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/domains") async def create_domain( name: str = Form(...), description: str = Form(""), user: str = Depends(get_current_user), ): conn = get_db() cur = conn.cursor() cur.execute( "INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, get_user_id(conn, user)), ) conn.commit() conn.close() return {"status": "ok"} @app.get("/api/certs") async def list_certs(user: str = Depends(get_current_user)): conn = get_db() rows = conn.execute( "SELECT c.*, d.name as domain_name FROM certificates c " "LEFT JOIN domains d ON c.domain_id = d.id " "ORDER BY c.created_at DESC" ).fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/certs/request") async def request_cert( cn: str = Form(...), sans: str = Form(""), days: int = Form(365), domain_id: int = Form(0), user: str = Depends(get_current_user), ): conn = get_db() cur = conn.cursor() cur.execute( "INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)", (domain_id or None, cn, sans, "pending", get_user_id(conn, user)), ) conn.commit() cid = cur.lastrowid conn.close() return {"status": "ok", "id": cid} @app.post("/api/certs/{cert_id}/sign") async def sign_cert(cert_id: int, user: str = Depends(get_current_user)): conn = get_db() row = conn.execute( "SELECT * FROM certificates WHERE id = ?", (cert_id,) ).fetchone() if not row or row["status"] != "pending": conn.close() raise HTTPException(400, "Not found or already signed") conn.close() try: result, err = build_leaf_cert(row["subject"], row["san"], 365) except Exception as e: logger.error("Signing failed: %s", sanitize_error(str(e))) raise HTTPException(500, "Signing failed") if err: raise HTTPException(500, "Signing failed") cf = f"{ISSUED_DIR}/cert-{result['serial']}.crt" kf = f"{ISSUED_DIR}/cert-{result['serial']}.key" with open(cf, "w") as f: f.write(result["cert_pem"]) with open(kf, "w") as f: f.write(result["key_pem"]) os.chmod(cf, 0o640) os.chmod(kf, 0o600) conn = get_db() conn.execute( "UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?", ("issued", result["serial"], cf, datetime.now(timezone.utc).isoformat(), result["expires_at"], cert_id), ) conn.commit() conn.close() return {"status": "ok", "serial": result["serial"]} @app.get("/api/certs/{cert_id}/pem") async def download_pem(cert_id: int, request: Request = None): user = get_user_from_cookie(request) if not user: raise HTTPException(401, "Login required") conn = get_db() row = conn.execute( "SELECT * FROM certificates WHERE id = ?", (cert_id,) ).fetchone() conn.close() if not row or row["status"] != "issued": raise HTTPException(404) pem_path = f"{TMP_DIR}/cert-{row['serial']}.pem" with open(row["cert_path"]) as f: cert_pem = f.read() with open(CA_CHAIN_PATH) as f: chain_pem = f.read() with open(pem_path, "w") as f: f.write(cert_pem.rstrip() + "\n" + chain_pem) return FileResponse(pem_path, media_type="application/x-pem-file", filename=f"cert-{row['serial']}.pem") @app.get("/api/certs/{cert_id}/pfx") async def download_pfx( cert_id: int, password: str = PFX_DEFAULT_PASSWORD, request: Request = None, ): user = get_user_from_cookie(request) if not user: raise HTTPException(401, "Login required") conn = get_db() row = conn.execute( "SELECT * FROM certificates WHERE id = ?", (cert_id,) ).fetchone() conn.close() if not row or row["status"] != "issued": raise HTTPException(404) kf = row["cert_path"].replace(".crt", ".key") if not os.path.exists(kf): raise HTTPException(404) from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption from cryptography import x509 with open(row["cert_path"], "rb") as f: leaf = x509.load_pem_x509_certificate(f.read()) with open(kf, "rb") as f: key = serialization.load_pem_private_key(f.read(), password=None) chain_certs = [] with open(CA_CHAIN_PATH, "rb") as f: for cert_pem in f.read().split(b"-----END CERTIFICATE-----"): cert_pem = cert_pem.strip() if cert_pem: chain_certs.append( x509.load_pem_x509_certificate( cert_pem + b"\n-----END CERTIFICATE-----" ) ) pfx_data = pkcs12.serialize_key_and_certificates( name=row["subject"].encode(), key=key, cert=leaf, cas=chain_certs or None, encryption_algorithm=BestAvailableEncryption(password.encode()), ) pfx_path = f"{TMP_DIR}/cert-{row['serial']}.pfx" with open(pfx_path, "wb") as f: f.write(pfx_data) return FileResponse(pfx_path, media_type="application/x-pkcs12", filename=f"cert-{row['serial']}.pfx") @app.get("/api/health") async def health(): return {"status": "ok"} @app.get("/api/ca-chain") async def ca_chain(): return FileResponse(CA_CHAIN_PATH, filename="ca-chain.crt") @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() certs = conn.execute( "SELECT c.*, d.name as domain_name FROM certificates c " "LEFT JOIN domains d ON c.domain_id = d.id " "ORDER BY c.created_at DESC LIMIT 20" ).fetchall() domains = conn.execute("SELECT * FROM domains").fetchall() p = conn.execute( "SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("pending",), ).fetchone()["c"] i = conn.execute( "SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("issued",), ).fetchone()["c"] conn.close() return render( "dashboard.html", { "request": request, "user": user, "certs": [dict(r) for r in certs], "domains": [dict(r) for r in domains], "pending": p, "issued": i, "csrf_token": get_csrf_token(user.get("sub", "anon")), }, ) @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): return render("login.html", {"request": request, "error": None, "csrf_token": get_csrf_token("anon")}) @app.post("/login") async def login_post( username: str = Form(...), password: str = Form(...), csrf_token: str = Form(""), ): if not verify_csrf_token("anon", csrf_token): return render( "login.html", {"request": None, "error": "Invalid request", "csrf_token": get_csrf_token("anon")}, ) conn = get_db() row = conn.execute( "SELECT * FROM users WHERE username = ?", (username,) ).fetchone() conn.close() if not row or not verify_password(password, row["password_hash"]): return render( "login.html", {"request": None, "error": "Invalid credentials", "csrf_token": get_csrf_token("anon")}, ) token = create_access_token({"sub": username}) resp = RedirectResponse("/", status_code=302) resp.set_cookie("token", token, httponly=True, samesite="lax", secure=True, path="/") return resp @app.post("/api/certs/{cert_id}/sign/web") async def sign_cert_web(cert_id: int, request: Request = None): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) csrf = request.form.get("csrf_token", "") if not verify_csrf_token(user.get("sub", "anon"), csrf): return HTMLResponse("Invalid request", status_code=403) conn = get_db() row = conn.execute( "SELECT * FROM certificates WHERE id = ?", (cert_id,) ).fetchone() if not row or row["status"] != "pending": conn.close() return HTMLResponse("Not found or already issued", status_code=400) conn.close() try: result, err = build_leaf_cert(row["subject"], row["san"], 365) if err: return HTMLResponse("Issue failed") cf = f"{ISSUED_DIR}/cert-{result['serial']}.crt" kf = f"{ISSUED_DIR}/cert-{result['serial']}.key" with open(cf, "w") as f: f.write(result["cert_pem"]) with open(kf, "w") as f: f.write(result["key_pem"]) os.chmod(cf, 0o640) os.chmod(kf, 0o600) conn2 = get_db() conn2.execute( "UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?", ( "issued", result["serial"], cf, datetime.now(timezone.utc).isoformat(), result["expires_at"], cert_id, ), ) conn2.commit() conn2.close() return HTMLResponse( f'Issued! ' f'PEM | ' f'PFX | ' f'Refresh' ) except Exception as ex: logger.error("Signing failed: %s", sanitize_error(str(ex))) return HTMLResponse("Issue failed") @app.get("/logout") async def logout(): resp = RedirectResponse("/login", status_code=302) resp.delete_cookie("token", path="/") return resp @app.post("/api/domains/web") async def create_domain_web( name: str = Form(...), description: str = Form(""), csrf_token: str = Form(""), request: Request = None, ): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) if not verify_csrf_token(user.get("sub", "anon"), csrf_token): return HTMLResponse("Invalid request", status_code=403) conn = get_db() cur = conn.cursor() cur.execute( "INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, get_user_id(conn, user.get("sub"))), ) conn.commit() conn.close() return HTMLResponse( 'Domain registered! ' 'Refresh' ) @app.post("/api/certs/web/request") async def request_cert_web( cn: str = Form(...), sans: str = Form(""), days: int = Form(365), domain_id: int = Form(0), csrf_token: str = Form(""), request: Request = None, ): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) if not verify_csrf_token(user.get("sub", "anon"), csrf_token): return HTMLResponse("Invalid request", status_code=403) conn = get_db() cur = conn.cursor() if domain_id == 0: cur.execute("SELECT id FROM domains WHERE name=?", (cn,)) row = cur.fetchone() domain_id = row[0] if row else None cur.execute( "INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)", (domain_id, cn, sans, "pending", get_user_id(conn, user.get("sub"))), ) conn.commit() conn.close() return HTMLResponse( 'Certificate requested! Click Issue below. ' 'Refresh' ) @app.get("/domains", response_class=HTMLResponse) async def domains_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall() conn.close() return render( "domains.html", { "request": request, "user": user, "domains": [dict(r) for r in rows], "csrf_token": get_csrf_token(user.get("sub", "anon")), }, ) @app.get("/certs", response_class=HTMLResponse) async def certs_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() rows = conn.execute( "SELECT c.*, d.name as domain_name FROM certificates c " "LEFT JOIN domains d ON c.domain_id = d.id " "ORDER BY c.created_at DESC" ).fetchall() domains = conn.execute("SELECT * FROM domains").fetchall() conn.close() return render( "certs.html", { "request": request, "user": user, "certs": [dict(r) for r in rows], "domains": [dict(r) for r in domains], "csrf_token": get_csrf_token(user.get("sub", "anon")), }, ) @app.get("/history", response_class=HTMLResponse) async def history_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() rows = conn.execute( "SELECT c.*, d.name as domain_name FROM certificates c " "LEFT JOIN domains d ON c.domain_id = d.id " "ORDER BY c.created_at DESC" ).fetchall() conn.close() return render( "history.html", { "request": request, "user": user, "certs": [dict(r) for r in rows], }, ) @app.get("/setup", response_class=HTMLResponse) async def setup_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) return render( "setup.html", { "request": request, "user": user, }, ) @app.get("/setup.sh") async def setup_sh(): script = r'''#!/bin/bash set -e DETECTED_IP="" if [[ -n "$1" ]]; then DETECTED_IP="$1" elif [[ -n "$CERTAUTH_IP" ]]; then DETECTED_IP="$CERTAUTH_IP" else DETECTED_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true [[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1) || true fi if [[ -z "$DETECTED_IP" ]]; then read -r -p "Enter CertAuth server IP: " DETECTED_IP fi CHAIN_URL="http://$DETECTED_IP/api/ca-chain" echo "Downloading CA chain..." curl -sLk "$CHAIN_URL" -o /tmp/ca-chain.crt || { echo "Failed"; exit 1; } if [[ -f /etc/os-release ]]; then . /etc/os-release if [[ "$ID" == "debian" || "$ID" == "ubuntu" ]]; then sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt sudo update-ca-certificates elif [[ "$ID" == "alpine" ]]; then sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt sudo update-ca-certificates else sudo cp /tmp/ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt sudo update-ca-trust 2>/dev/null || true fi elif [[ "$(uname)" == "Darwin" ]]; then sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/ca-chain.crt else echo "Unsupported OS" exit 1 fi rm -f /tmp/ca-chain.crt echo "Done!" ''' return PlainTextResponse(script, media_type="text/x-shellscript") @app.get("/setup.ps1") async def setup_ps1(): script = r''' param([string]$CertAuthIP = "") if (-not $CertAuthIP) { $CertAuthIP = Read-Host "Enter CertAuth server IP" } $ChainUrl = "http://$CertAuthIP/api/ca-chain" $ChainPath = "$env:TEMP\ca-chain.crt" (New-Object Net.WebClient).DownloadFile($ChainUrl, $ChainPath) $store = New-Object System.Security.Cryptography.X509Certificates.X509Store( [System.Security.Cryptography.X509Certificates.StoreName]::Root, [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ChainPath) $store.Add($cert) $store.Close() Remove-Item $ChainPath -Force -ErrorAction SilentlyContinue Write-Host "Done!" -ForegroundColor Green ''' return PlainTextResponse(script, media_type="text/plain")