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")