certauth/api/main.py
Jarian Cottingham 4bd538b217 fix: YubiKey CA signing, remove inline API copies, env-driven config
Critical: build_leaf_cert self-signed leaves with the leaf key instead of
the YubiKey-held Intermediate CA key. Now extracts TBS, signs via
pkcs11-tool (ECDSA-SHA384), reassembles, and verifies against the
intermediate CA public key before returning.

- setup-certauth.sh: ~1100 lines of stale inline api/ copies replaced with
  copy-from-repo (single source of truth); writes private
  /etc/certauth/certauth.env (0600); DB init loads env, no more
  swallowed errors; systemd unit gets EnvironmentFile=
- config.py: YubiKey serials no longer hard-coded (env, fail closed);
  aarch64-only PKCS#11 path replaced with arch-neutral default; all
  paths env-overridable (CERTAUTH_*)
- main.py: removed dead fastapi.security.CSRFProtection import (crashed
  startup); module-relative static/templates dirs; created_by resolved
  from the authenticated user instead of hard-coded 1; unclosed file
  handles fixed; domain_id 0 stored as NULL (FK bug)
- models.py: certificates.domain_id FK pointed at users(id), now domains(id)
- login: CSRF token now actually sent and validated
- tests: 23 tests (auth, API flows, DER helpers, signing pipeline)
- README, LICENSE, requirements.txt, pyproject.toml
2026-08-20 22:03:01 +00:00

585 lines
20 KiB
Python

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("<span class='text-red-400'>Invalid request</span>", 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("<span class='text-red-400'>Not found or already issued</span>", status_code=400)
conn.close()
try:
result, err = build_leaf_cert(row["subject"], row["san"], 365)
if err:
return HTMLResponse("<span class='text-red-400'>Issue failed</span>")
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'<span class="text-green-400">Issued! '
f'<a href="/api/certs/{cert_id}/pem" class="underline">PEM</a> | '
f'<a href="/api/certs/{cert_id}/pfx" class="underline">PFX</a> | '
f'<a href="/certs" class="underline">Refresh</a></span>'
)
except Exception as ex:
logger.error("Signing failed: %s", sanitize_error(str(ex)))
return HTMLResponse("<span class='text-red-400'>Issue failed</span>")
@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("<span class='text-red-400'>Invalid request</span>", 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(
'<span class="text-green-400">Domain registered! '
'<a href="/domains" class="underline">Refresh</a></span>'
)
@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("<span class='text-red-400'>Invalid request</span>", 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(
'<span class="text-green-400">Certificate requested! Click Issue below. '
'<a href="/certs" class="underline">Refresh</a></span>'
)
@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")