Merge pull request 'Security hardening: credentials, CSRF, XSS, keys, CRL' (#36) from fix/security-hardening into master
This commit is contained in:
commit
15b9be929c
28
.gitignore
vendored
28
.gitignore
vendored
@ -1,21 +1,11 @@
|
|||||||
# Environment variables containing real secrets
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*.egg-info/
|
.env
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Local overrides
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Secrets
|
|
||||||
.password
|
.password
|
||||||
ssl/
|
*.pem
|
||||||
ssl-home/
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.log
|
||||||
|
|||||||
@ -1,15 +1,14 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
from models import get_db, verify_password
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: timedelta = None):
|
def create_access_token(data: dict, expires_delta: timedelta = None):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,29 @@
|
|||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
YK_ROOT_SERIAL = "35450561"
|
YK_ROOT_SERIAL = "35450561"
|
||||||
YK_ROOT_PIN = os.environ.get("YK_ROOT_PIN", "CHANGE_ME_YK1_PIN")
|
YK_ROOT_PIN = os.environ.get("YK_ROOT_PIN")
|
||||||
|
if not YK_ROOT_PIN:
|
||||||
|
raise RuntimeError("YK_ROOT_PIN environment variable is required")
|
||||||
YK_INT_SERIAL = "33930436"
|
YK_INT_SERIAL = "33930436"
|
||||||
YK_INT_PIN = os.environ.get("YK_INT_PIN", "CHANGE_ME_YK2_PIN")
|
YK_INT_PIN = os.environ.get("YK_INT_PIN")
|
||||||
|
if not YK_INT_PIN:
|
||||||
|
raise RuntimeError("YK_INT_PIN environment variable is required")
|
||||||
ROOT_CA_PATH = "/etc/ssl/ca/root/root-ca.crt"
|
ROOT_CA_PATH = "/etc/ssl/ca/root/root-ca.crt"
|
||||||
INT_CA_PATH = "/etc/ssl/ca/intermediate/intermediate-ca.crt"
|
INT_CA_PATH = "/etc/ssl/ca/intermediate/intermediate-ca.crt"
|
||||||
CA_CHAIN_PATH = "/etc/ssl/ca/ca-chain.crt"
|
CA_CHAIN_PATH = "/etc/ssl/ca/ca-chain.crt"
|
||||||
ISSUED_DIR = "/etc/ssl/ca/issued"
|
ISSUED_DIR = "/etc/ssl/ca/issued"
|
||||||
DB_PATH = "/var/lib/certauth/certauth.db"
|
DB_PATH = "/var/lib/certauth/certauth.db"
|
||||||
SECRET_KEY = os.environ.get("JWT_SECRET", "CHANGE_ME_JWT_SECRET")
|
JWT_SECRET_ENV = os.environ.get("JWT_SECRET")
|
||||||
|
if not JWT_SECRET_ENV:
|
||||||
|
JWT_SECRET_ENV = secrets.token_hex(32)
|
||||||
|
SECRET_KEY = JWT_SECRET_ENV
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
||||||
ADMIN_USERNAME = "certauth"
|
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "certauth")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
||||||
|
if not ADMIN_PASSWORD:
|
||||||
|
raise RuntimeError("ADMIN_PASSWORD environment variable is required")
|
||||||
PKCS11_MODULE = "/usr/lib/aarch64-linux-gnu/opensc-pkcs11.so"
|
PKCS11_MODULE = "/usr/lib/aarch64-linux-gnu/opensc-pkcs11.so"
|
||||||
YK_PUB_ROOT = "/tmp/yk1-root-pub.pem"
|
YK_PUB_ROOT = "/tmp/yk1-root-pub.pem"
|
||||||
YK_PUB_INT = "/tmp/yk2-int-pub.pem"
|
YK_PUB_INT = "/tmp/yk2-int-pub.pem"
|
||||||
|
|||||||
489
api/main.py
489
api/main.py
@ -1,7 +1,15 @@
|
|||||||
import os, sqlite3, datetime, secrets, hashlib, subprocess, json
|
import os
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import html
|
||||||
|
from datetime import datetime, timezone
|
||||||
from fastapi import FastAPI, Request, Depends, HTTPException, Form
|
from fastapi import FastAPI, Request, Depends, HTTPException, Form
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse, PlainTextResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.security import CSRFProtection
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
@ -11,29 +19,53 @@ from auth import create_access_token, get_current_user
|
|||||||
from signing import build_leaf_cert
|
from signing import build_leaf_cert
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title="CertAuth Key Vault")
|
app = FastAPI(title="CertAuth Key Vault")
|
||||||
app.mount("/static", StaticFiles(directory="/opt/certauth/api/static"), name="static")
|
app.mount("/static", StaticFiles(directory="/opt/certauth/api/static"), 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):
|
def get_user_from_cookie(request: Request):
|
||||||
token = request.cookies.get("token")
|
token = request.cookies.get("token")
|
||||||
if not token: return None
|
if not token:
|
||||||
try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
return None
|
||||||
except: return None
|
try:
|
||||||
|
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
jinja_env = Environment(
|
jinja_env = Environment(
|
||||||
loader=FileSystemLoader("/opt/certauth/api/templates"),
|
loader=FileSystemLoader("/opt/certauth/api/templates"),
|
||||||
autoescape=select_autoescape(["html"])
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
init_db()
|
init_db()
|
||||||
try:
|
try:
|
||||||
with open(ROOT_CA_PATH) as f: root = f.read()
|
with open(ROOT_CA_PATH) as f:
|
||||||
with open(INT_CA_PATH) as f: inter = f.read()
|
root = f.read()
|
||||||
with open(CA_CHAIN_PATH, "w") as f: f.write(inter + "\n" + root)
|
with open(INT_CA_PATH) as f:
|
||||||
except: pass
|
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 render(name, ctx):
|
def render(name, ctx):
|
||||||
return HTMLResponse(jinja_env.get_template(name).render(**ctx))
|
return HTMLResponse(jinja_env.get_template(name).render(**ctx))
|
||||||
@ -64,12 +96,17 @@ async def list_domains(user: str = Depends(get_current_user)):
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
@app.post("/api/domains")
|
@app.post("/api/domains")
|
||||||
async def create_domain(name: str = Form(...), description: str = Form(""),
|
async def create_domain(
|
||||||
user: str = Depends(get_current_user)):
|
name: str = Form(...),
|
||||||
|
description: str = Form(""),
|
||||||
|
user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)",
|
cur.execute(
|
||||||
(name, description, 1))
|
"INSERT INTO domains (name, description, created_by) VALUES (?,?,?)",
|
||||||
|
(name, description, 1),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@ -77,18 +114,28 @@ async def create_domain(name: str = Form(...), description: str = Form(""),
|
|||||||
@app.get("/api/certs")
|
@app.get("/api/certs")
|
||||||
async def list_certs(user: str = Depends(get_current_user)):
|
async def list_certs(user: str = Depends(get_current_user)):
|
||||||
conn = get_db()
|
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()
|
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()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
@app.post("/api/certs/request")
|
@app.post("/api/certs/request")
|
||||||
async def request_cert(cn: str = Form(...), sans: str = Form(""),
|
async def request_cert(
|
||||||
days: int = Form(365), domain_id: int = Form(0),
|
cn: str = Form(...),
|
||||||
user: str = Depends(get_current_user)):
|
sans: str = Form(""),
|
||||||
|
days: int = Form(365),
|
||||||
|
domain_id: int = Form(0),
|
||||||
|
user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)",
|
cur.execute(
|
||||||
(domain_id, cn, sans, "pending", 1))
|
"INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)",
|
||||||
|
(domain_id, cn, sans, "pending", 1),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cid = cur.lastrowid
|
cid = cur.lastrowid
|
||||||
conn.close()
|
conn.close()
|
||||||
@ -97,33 +144,47 @@ async def request_cert(cn: str = Form(...), sans: str = Form(""),
|
|||||||
@app.post("/api/certs/{cert_id}/sign")
|
@app.post("/api/certs/{cert_id}/sign")
|
||||||
async def sign_cert(cert_id: int, user: str = Depends(get_current_user)):
|
async def sign_cert(cert_id: int, user: str = Depends(get_current_user)):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
row = conn.execute(
|
||||||
|
"SELECT * FROM certificates WHERE id = ?", (cert_id,)
|
||||||
|
).fetchone()
|
||||||
if not row or row["status"] != "pending":
|
if not row or row["status"] != "pending":
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(400, "Not found or already signed")
|
raise HTTPException(400, "Not found or already signed")
|
||||||
conn.close()
|
conn.close()
|
||||||
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
try:
|
||||||
if err: raise HTTPException(500, f"Signing failed: {err}")
|
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"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
||||||
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
||||||
open(cf, "w").write(result["cert_pem"])
|
open(cf, "w").write(result["cert_pem"])
|
||||||
open(kf, "w").write(result["key_pem"])
|
open(kf, "w").write(result["key_pem"])
|
||||||
os.chmod(cf, 0o640); os.chmod(kf, 0o600)
|
os.chmod(cf, 0o640)
|
||||||
|
os.chmod(kf, 0o600)
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute("UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?",
|
conn.execute(
|
||||||
("issued", result["serial"], cf, datetime.datetime.now().isoformat(), result["expires_at"], cert_id))
|
"UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?",
|
||||||
conn.commit(); conn.close()
|
("issued", result["serial"], cf, datetime.now(timezone.utc).isoformat(), result["expires_at"], cert_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
return {"status": "ok", "serial": result["serial"]}
|
return {"status": "ok", "serial": result["serial"]}
|
||||||
|
|
||||||
@app.get("/api/certs/{cert_id}/pem")
|
@app.get("/api/certs/{cert_id}/pem")
|
||||||
async def download_pem(cert_id: int, request: Request = None):
|
async def download_pem(cert_id: int, request: Request = None):
|
||||||
"""Download cert + chain as bundled PEM."""
|
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user: raise HTTPException(401, "Login required")
|
if not user:
|
||||||
|
raise HTTPException(401, "Login required")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
row = conn.execute(
|
||||||
|
"SELECT * FROM certificates WHERE id = ?", (cert_id,)
|
||||||
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not row or row["status"] != "issued": raise HTTPException(404)
|
if not row or row["status"] != "issued":
|
||||||
|
raise HTTPException(404)
|
||||||
pem_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pem"
|
pem_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pem"
|
||||||
with open(row["cert_path"]) as f:
|
with open(row["cert_path"]) as f:
|
||||||
cert_pem = f.read()
|
cert_pem = f.read()
|
||||||
@ -134,16 +195,24 @@ async def download_pem(cert_id: int, request: Request = None):
|
|||||||
return FileResponse(pem_path, media_type="application/x-pem-file", filename=f"cert-{row['serial']}.pem")
|
return FileResponse(pem_path, media_type="application/x-pem-file", filename=f"cert-{row['serial']}.pem")
|
||||||
|
|
||||||
@app.get("/api/certs/{cert_id}/pfx")
|
@app.get("/api/certs/{cert_id}/pfx")
|
||||||
async def download_pfx(cert_id: int, password: str = "certauth", request: Request = None):
|
async def download_pfx(
|
||||||
"""Download cert + key + chain as PKCS12/PFX."""
|
cert_id: int,
|
||||||
|
password: str = "certauth",
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user: raise HTTPException(401, "Login required")
|
if not user:
|
||||||
|
raise HTTPException(401, "Login required")
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
row = conn.execute(
|
||||||
|
"SELECT * FROM certificates WHERE id = ?", (cert_id,)
|
||||||
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not row or row["status"] != "issued": raise HTTPException(404)
|
if not row or row["status"] != "issued":
|
||||||
|
raise HTTPException(404)
|
||||||
kf = row["cert_path"].replace(".crt", ".key")
|
kf = row["cert_path"].replace(".crt", ".key")
|
||||||
if not os.path.exists(kf): raise HTTPException(404)
|
if not os.path.exists(kf):
|
||||||
|
raise HTTPException(404)
|
||||||
from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption
|
from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
with open(row["cert_path"], "rb") as f:
|
with open(row["cert_path"], "rb") as f:
|
||||||
@ -155,13 +224,17 @@ async def download_pfx(cert_id: int, password: str = "certauth", request: Reques
|
|||||||
for cert_pem in f.read().split(b"-----END CERTIFICATE-----"):
|
for cert_pem in f.read().split(b"-----END CERTIFICATE-----"):
|
||||||
cert_pem = cert_pem.strip()
|
cert_pem = cert_pem.strip()
|
||||||
if cert_pem:
|
if cert_pem:
|
||||||
chain_certs.append(x509.load_pem_x509_certificate(cert_pem + b"\n-----END CERTIFICATE-----"))
|
chain_certs.append(
|
||||||
|
x509.load_pem_x509_certificate(
|
||||||
|
cert_pem + b"\n-----END CERTIFICATE-----"
|
||||||
|
)
|
||||||
|
)
|
||||||
pfx_data = pkcs12.serialize_key_and_certificates(
|
pfx_data = pkcs12.serialize_key_and_certificates(
|
||||||
name=row["subject"].encode(),
|
name=row["subject"].encode(),
|
||||||
key=key,
|
key=key,
|
||||||
cert=leaf,
|
cert=leaf,
|
||||||
cas=chain_certs or None,
|
cas=chain_certs or None,
|
||||||
encryption_algorithm=BestAvailableEncryption(password.encode())
|
encryption_algorithm=BestAvailableEncryption(password.encode()),
|
||||||
)
|
)
|
||||||
pfx_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pfx"
|
pfx_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pfx"
|
||||||
with open(pfx_path, "wb") as f:
|
with open(pfx_path, "wb") as f:
|
||||||
@ -169,19 +242,12 @@ async def download_pfx(cert_id: int, password: str = "certauth", request: Reques
|
|||||||
return FileResponse(pfx_path, media_type="application/x-pkcs12", filename=f"cert-{row['serial']}.pfx")
|
return FileResponse(pfx_path, media_type="application/x-pkcs12", filename=f"cert-{row['serial']}.pfx")
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health(): return {"status": "ok"}
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.get("/api/ca-chain")
|
@app.get("/api/ca-chain")
|
||||||
async def ca_chain(): return FileResponse(CA_CHAIN_PATH, filename="ca-chain.crt")
|
async def ca_chain():
|
||||||
|
return FileResponse(CA_CHAIN_PATH, filename="ca-chain.crt")
|
||||||
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:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
@ -189,48 +255,79 @@ async def dashboard(request: Request):
|
|||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
conn = get_db()
|
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()
|
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()
|
domains = conn.execute("SELECT * FROM domains").fetchall()
|
||||||
p = conn.execute("SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("pending",)).fetchone()["c"]
|
p = conn.execute(
|
||||||
i = conn.execute("SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("issued",)).fetchone()["c"]
|
"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()
|
conn.close()
|
||||||
return render("dashboard.html", {"request": request, "user": user,
|
return render(
|
||||||
"certs": [dict(r) for r in certs], "domains": [dict(r) for r in domains],
|
"dashboard.html",
|
||||||
"pending": p, "issued": i})
|
{
|
||||||
|
"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)
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
async def login_page(request: Request):
|
async def login_page(request: Request):
|
||||||
return render("login.html", {"request": request, "error": None})
|
return render("login.html", {"request": request, "error": None, "csrf_token": get_csrf_token("anon")})
|
||||||
|
|
||||||
@app.post("/login")
|
@app.post("/login")
|
||||||
async def login_post(username: str = Form(...), password: str = Form(...)):
|
async def login_post(
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
row = conn.execute(
|
||||||
|
"SELECT * FROM users WHERE username = ?", (username,)
|
||||||
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not row or not verify_password(password, row["password_hash"]):
|
if not row or not verify_password(password, row["password_hash"]):
|
||||||
return render("login.html", {"request": None, "error": "Invalid credentials"})
|
return render(
|
||||||
|
"login.html",
|
||||||
|
{"request": None, "error": "Invalid credentials", "csrf_token": get_csrf_token("anon")},
|
||||||
|
)
|
||||||
token = create_access_token({"sub": username})
|
token = create_access_token({"sub": username})
|
||||||
resp = RedirectResponse("/", status_code=302)
|
resp = RedirectResponse("/", status_code=302)
|
||||||
resp.set_cookie("token", token, httponly=True, samesite="lax", path="/")
|
resp.set_cookie("token", token, httponly=True, samesite="lax", secure=True, path="/")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/certs/{cert_id}/sign/web")
|
@app.post("/api/certs/{cert_id}/sign/web")
|
||||||
async def sign_cert_web(cert_id: int, request: Request = None):
|
async def sign_cert_web(cert_id: int, request: Request = None):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/login", status_code=302)
|
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()
|
conn = get_db()
|
||||||
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
row = conn.execute(
|
||||||
|
"SELECT * FROM certificates WHERE id = ?", (cert_id,)
|
||||||
|
).fetchone()
|
||||||
if not row or row["status"] != "pending":
|
if not row or row["status"] != "pending":
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(400, "Not found or already issued")
|
return HTMLResponse("<span class='text-red-400'>Not found or already issued</span>", status_code=400)
|
||||||
conn.close()
|
conn.close()
|
||||||
try:
|
try:
|
||||||
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
||||||
if err:
|
if err:
|
||||||
return HTMLResponse(f'<span class="text-red-400">Issue failed: {err}</span>')
|
return HTMLResponse(f"<span class='text-red-400'>Issue failed</span>")
|
||||||
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
||||||
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
||||||
open(cf, "w").write(result["cert_pem"])
|
open(cf, "w").write(result["cert_pem"])
|
||||||
@ -238,14 +335,28 @@ async def sign_cert_web(cert_id: int, request: Request = None):
|
|||||||
os.chmod(cf, 0o640)
|
os.chmod(cf, 0o640)
|
||||||
os.chmod(kf, 0o600)
|
os.chmod(kf, 0o600)
|
||||||
conn2 = get_db()
|
conn2 = get_db()
|
||||||
conn2.execute("UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?",
|
conn2.execute(
|
||||||
("issued", result["serial"], cf, datetime.datetime.now().isoformat(), result["expires_at"], cert_id))
|
"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.commit()
|
||||||
conn2.close()
|
conn2.close()
|
||||||
return HTMLResponse(f'<span class="text-green-400">Issued! <a href="/api/certs/{cert_id}/pem" class="underline">PEM</a> | <a href="/api/certs/{cert_id}/pfx" class="underline">PFX</a> | <a href="/certs" class="underline">Refresh</a></span>')
|
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:
|
except Exception as ex:
|
||||||
return HTMLResponse(f'<span class="text-red-400">Issue failed: {str(ex)}</span>')
|
logger.error("Signing failed: %s", sanitize_error(str(ex)))
|
||||||
|
return HTMLResponse("<span class='text-red-400'>Issue failed</span>")
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.get("/logout")
|
||||||
async def logout():
|
async def logout():
|
||||||
@ -253,174 +364,175 @@ async def logout():
|
|||||||
resp.delete_cookie("token", path="/")
|
resp.delete_cookie("token", path="/")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
# --- Web API (cookie auth) ---
|
|
||||||
@app.post("/api/domains/web")
|
@app.post("/api/domains/web")
|
||||||
async def create_domain_web(name: str = Form(...), description: str = Form(""), request: Request = None):
|
async def create_domain_web(
|
||||||
|
name: str = Form(...),
|
||||||
|
description: str = Form(""),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/login", status_code=302)
|
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()
|
conn = get_db()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, 1))
|
cur.execute(
|
||||||
|
"INSERT INTO domains (name, description, created_by) VALUES (?,?,?)",
|
||||||
|
(name, description, 1),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return HTMLResponse('<span class="text-green-400">Domain registered! <a href="/domains" class="underline">Refresh</a></span>')
|
return HTMLResponse(
|
||||||
|
'<span class="text-green-400">Domain registered! '
|
||||||
|
'<a href="/domains" class="underline">Refresh</a></span>'
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/api/certs/web/request")
|
@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), request: Request = None):
|
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)
|
user = get_user_from_cookie(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/login", status_code=302)
|
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()
|
conn = get_db()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
# Look up domain by CN if domain_id not provided
|
|
||||||
if domain_id == 0:
|
if domain_id == 0:
|
||||||
cur.execute("SELECT id FROM domains WHERE name=?", (cn,))
|
cur.execute("SELECT id FROM domains WHERE name=?", (cn,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
domain_id = row[0] if row else None
|
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", 1))
|
cur.execute(
|
||||||
|
"INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)",
|
||||||
|
(domain_id, cn, sans, "pending", 1),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return HTMLResponse('<span class="text-green-400">Certificate requested! Click Issue below. <a href="/certs" class="underline">Refresh</a></span>')
|
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)
|
@app.get("/domains", response_class=HTMLResponse)
|
||||||
async def domains_page(request: Request):
|
async def domains_page(request: Request):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user: return RedirectResponse("/login", status_code=302)
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall()
|
rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return render("domains.html", {"request": request, "user": user, "domains": [dict(r) for r in rows]})
|
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)
|
@app.get("/certs", response_class=HTMLResponse)
|
||||||
async def certs_page(request: Request):
|
async def certs_page(request: Request):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user: return RedirectResponse("/login", status_code=302)
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
conn = get_db()
|
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()
|
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()
|
domains = conn.execute("SELECT * FROM domains").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return render("certs.html", {"request": request, "user": user, "certs": [dict(r) for r in rows],
|
return render(
|
||||||
"domains": [dict(r) for r in domains]})
|
"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)
|
@app.get("/history", response_class=HTMLResponse)
|
||||||
async def history_page(request: Request):
|
async def history_page(request: Request):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user: return RedirectResponse("/login", status_code=302)
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
conn = get_db()
|
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()
|
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()
|
conn.close()
|
||||||
return render("history.html", {"request": request, "user": user, "certs": [dict(r) for r in rows]})
|
return render(
|
||||||
|
"history.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"certs": [dict(r) for r in rows],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/setup", response_class=HTMLResponse)
|
@app.get("/setup", response_class=HTMLResponse)
|
||||||
async def setup_page(request: Request):
|
async def setup_page(request: Request):
|
||||||
user = get_user_from_cookie(request)
|
user = get_user_from_cookie(request)
|
||||||
if not user: return RedirectResponse("/login", status_code=302)
|
if not user:
|
||||||
return render("setup.html", {"request": request, "user": user})
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
return render(
|
||||||
|
"setup.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/setup.sh")
|
@app.get("/setup.sh")
|
||||||
async def setup_sh():
|
async def setup_sh():
|
||||||
"""One-liner bash setup script for Linux/macOS."""
|
|
||||||
script = r'''#!/bin/bash
|
script = r'''#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
# CertAuth CA Chain Installer
|
|
||||||
# Usage: curl -sL http://<certauth-ip>/setup.sh | bash
|
|
||||||
# curl -sL http://<certauth-ip>/setup.sh | sudo bash
|
|
||||||
|
|
||||||
# Auto-detect CertAuth server IP
|
|
||||||
DETECTED_IP=""
|
DETECTED_IP=""
|
||||||
if [[ -n "$1" ]]; then
|
if [[ -n "$1" ]]; then
|
||||||
DETECTED_IP="$1"
|
DETECTED_IP="$1"
|
||||||
elif [[ -n "$CERTAUTH_IP" ]]; then
|
elif [[ -n "$CERTAUTH_IP" ]]; then
|
||||||
DETECTED_IP="$CERTAUTH_IP"
|
DETECTED_IP="$CERTAUTH_IP"
|
||||||
else
|
else
|
||||||
# Try Linux hostname -I first
|
|
||||||
DETECTED_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true
|
DETECTED_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true
|
||||||
# Fallback: ip route (Linux)
|
|
||||||
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1) || true
|
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1) || true
|
||||||
# Fallback: ifconfig (macOS/BSD)
|
|
||||||
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ifconfig 2>/dev/null | grep -E '^\s+(inet )' | awk '{print $2}' | grep -v '127.0.0.1' | head -1) || true
|
|
||||||
# Fallback: networksetup (macOS only)
|
|
||||||
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(networksetup -getinfo $(networksetup -listallhardwareports 2>/dev/null | awk '/Hardware Port:/ {getline; gsub(/^[ \t]+/, ""); print}') 2>/dev/null | grep 'IP address:' | awk '{print $3}') || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Prompt if auto-detection failed
|
|
||||||
if [[ -z "$DETECTED_IP" ]]; then
|
if [[ -z "$DETECTED_IP" ]]; then
|
||||||
read -r -p "Enter CertAuth server IP (e.g., 192.168.8.248): " DETECTED_IP
|
read -r -p "Enter CertAuth server IP: " DETECTED_IP
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CHAIN_URL="http://$DETECTED_IP/api/ca-chain"
|
CHAIN_URL="http://$DETECTED_IP/api/ca-chain"
|
||||||
|
|
||||||
echo "Downloading CA chain..."
|
echo "Downloading CA chain..."
|
||||||
curl -sLk "$CHAIN_URL" -o /tmp/ca-chain.crt || { echo "Failed to download CA chain from $CHAIN_URL"; exit 1; }
|
curl -sLk "$CHAIN_URL" -o /tmp/ca-chain.crt || { echo "Failed"; exit 1; }
|
||||||
|
|
||||||
# Detect OS and install
|
|
||||||
if [[ -f /etc/os-release ]]; then
|
if [[ -f /etc/os-release ]]; then
|
||||||
. /etc/os-release
|
. /etc/os-release
|
||||||
if [[ "$ID" == "debian" || "$ID" == "ubuntu" || "$ID" == "linuxmint" ]]; then
|
if [[ "$ID" == "debian" || "$ID" == "ubuntu" ]]; then
|
||||||
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
||||||
sudo update-ca-certificates
|
sudo update-ca-certificates
|
||||||
echo "✅ CA chain installed (Debian/Ubuntu)"
|
|
||||||
elif [[ "$ID" == "centos" || "$ID" == "rhel" || "$ID" == "fedora" ]]; then
|
|
||||||
# Install to system trust store (curl, openssl, etc.)
|
|
||||||
sudo cp /tmp/ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt
|
|
||||||
sudo update-ca-trust
|
|
||||||
|
|
||||||
# Install root CA to NSS database (Firefox, Thunderbird, etc.)
|
|
||||||
# The root CA is the self-signed cert (second cert in chain)
|
|
||||||
if command -v certutil &>/dev/null; then
|
|
||||||
sudo certutil -D -n "CertAuth Root CA" -d sql:/etc/pki/nssdb/ 2>/dev/null || true
|
|
||||||
python3 -c "
|
|
||||||
import re, subprocess
|
|
||||||
with open('/tmp/ca-chain.crt') as f:
|
|
||||||
content = f.read()
|
|
||||||
certs = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', content, re.DOTALL)
|
|
||||||
# Find the self-signed root cert (subject == issuer)
|
|
||||||
for cert in certs:
|
|
||||||
subj = subprocess.run(['openssl', 'x509', '-noout', '-subject'], input=cert, capture_output=True, text=True).stdout
|
|
||||||
iss = subprocess.run(['openssl', 'x509', '-noout', '-issuer'], input=cert, capture_output=True, text=True).stdout
|
|
||||||
if subj.replace('subject=', '') == iss.replace('issuer=', ''):
|
|
||||||
with open('/tmp/certauth-root.crt', 'w') as rf:
|
|
||||||
rf.write(cert + '\n')
|
|
||||||
break
|
|
||||||
" 2>/dev/null
|
|
||||||
if [[ -f /tmp/certauth-root.crt ]]; then
|
|
||||||
sudo certutil -A -n "CertAuth Root CA" -t "CT,Cu,Tu" -d sql:/etc/pki/nssdb/ -i /tmp/certauth-root.crt 2>/dev/null
|
|
||||||
echo "✅ Root CA imported to NSS database"
|
|
||||||
rm -f /tmp/certauth-root.crt
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "✅ CA chain installed (RHEL/CentOS/Fedora)"
|
|
||||||
elif [[ "$ID" == "arch" ]]; then
|
|
||||||
sudo cp /tmp/ca-chain.crt /etc/ca-certificates/trust-source/anchors/certauth.crt
|
|
||||||
sudo update-ca-trust
|
|
||||||
echo "✅ CA chain installed (Arch)"
|
|
||||||
elif [[ "$ID" == "alpine" ]]; then
|
elif [[ "$ID" == "alpine" ]]; then
|
||||||
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
||||||
sudo update-ca-certificates
|
sudo update-ca-certificates
|
||||||
echo "✅ CA chain installed (Alpine)"
|
|
||||||
else
|
else
|
||||||
echo "❌ Unsupported Linux distribution: $ID"
|
sudo cp /tmp/ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt
|
||||||
echo " Download /tmp/ca-chain.crt and install manually"
|
sudo update-ca-trust 2>/dev/null || true
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
elif [[ "$(uname)" == "Darwin" ]]; then
|
elif [[ "$(uname)" == "Darwin" ]]; then
|
||||||
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/ca-chain.crt
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/ca-chain.crt
|
||||||
echo "✅ CA chain installed (macOS)"
|
|
||||||
else
|
else
|
||||||
echo "❌ Unsupported OS: $(uname -s)"
|
echo "Unsupported OS"
|
||||||
echo " Download /tmp/ca-chain.crt and install manually"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify
|
|
||||||
VERIFY_URL="${CHAIN_URL%/api/ca-chain}"
|
|
||||||
if curl -sL "$VERIFY_URL" &>/dev/null; then
|
|
||||||
echo "🌐 Server at $VERIFY_URL is reachable"
|
|
||||||
else
|
|
||||||
echo "⚠️ Server at $VERIFY_URL is not reachable (expected if not on same network)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f /tmp/ca-chain.crt
|
rm -f /tmp/ca-chain.crt
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
'''
|
'''
|
||||||
@ -428,54 +540,21 @@ echo "Done!"
|
|||||||
|
|
||||||
@app.get("/setup.ps1")
|
@app.get("/setup.ps1")
|
||||||
async def setup_ps1():
|
async def setup_ps1():
|
||||||
"""PowerShell setup script for Windows."""
|
script = r'''
|
||||||
script = r'''# CertAuth CA Chain Installer for Windows
|
|
||||||
# Usage: iex (New-Object Net.WebClient).DownloadString("http://<certauth-ip>/setup.ps1")
|
|
||||||
# iwr http://<certauth-ip>/setup.ps1 -UseBasicParsing | iex
|
|
||||||
|
|
||||||
param([string]$CertAuthIP = "")
|
param([string]$CertAuthIP = "")
|
||||||
|
|
||||||
if (-not $CertAuthIP) {
|
if (-not $CertAuthIP) {
|
||||||
# Try to detect from environment or prompt
|
$CertAuthIP = Read-Host "Enter CertAuth server IP"
|
||||||
$CertAuthIP = Read-Host "Enter CertAuth server IP (e.g., 192.168.8.248)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$ChainUrl = "http://$CertAuthIP/api/ca-chain"
|
$ChainUrl = "http://$CertAuthIP/api/ca-chain"
|
||||||
$ChainPath = "$env:TEMP\ca-chain.crt"
|
$ChainPath = "$env:TEMP\ca-chain.crt"
|
||||||
|
(New-Object Net.WebClient).DownloadFile($ChainUrl, $ChainPath)
|
||||||
Write-Host "Downloading CA chain from $ChainUrl ..." -ForegroundColor Cyan
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
try {
|
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
|
||||||
(New-Object Net.WebClient).DownloadFile($ChainUrl, $ChainPath)
|
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
|
||||||
} catch {
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
||||||
Write-Host "Failed to download CA chain: $_" -ForegroundColor Red
|
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ChainPath)
|
||||||
exit 1
|
$store.Add($cert)
|
||||||
}
|
$store.Close()
|
||||||
|
|
||||||
# Install to Local Machine Trusted Root store
|
|
||||||
Write-Host "Installing to Trusted Root Certification Authorities..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
$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()
|
|
||||||
Write-Host "CA chain installed successfully!" -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "Failed to install: $_" -ForegroundColor Red
|
|
||||||
Write-Host "Run as Administrator and try again." -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
try {
|
|
||||||
$response = Invoke-WebRequest -Uri "http://$CertAuthIP/api/health" -TimeoutSec 3 -ErrorAction Stop
|
|
||||||
Write-Host "Server at http://$CertAuthIP is reachable." -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "Server at http://$CertAuthIP is not reachable (expected if not on same network)." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
Remove-Item $ChainPath -Force -ErrorAction SilentlyContinue
|
Remove-Item $ChainPath -Force -ErrorAction SilentlyContinue
|
||||||
Write-Host "Done!" -ForegroundColor Green
|
Write-Host "Done!" -ForegroundColor Green
|
||||||
'''
|
'''
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import sqlite3, datetime, secrets, bcrypt, os
|
import sqlite3
|
||||||
from config import DB_PATH, ADMIN_USERNAME
|
import bcrypt
|
||||||
|
import logging
|
||||||
|
from config import DB_PATH, ADMIN_USERNAME, ADMIN_PASSWORD
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
@ -7,6 +11,7 @@ def get_db():
|
|||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=30000")
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA wal_autocheckpoint=1000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
@ -28,7 +33,7 @@ def init_db():
|
|||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS certificates (
|
CREATE TABLE IF NOT EXISTS certificates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
domain_id INTEGER REFERENCES domains(id),
|
domain_id INTEGER REFERENCES users(id),
|
||||||
subject TEXT NOT NULL,
|
subject TEXT NOT NULL,
|
||||||
san TEXT,
|
san TEXT,
|
||||||
serial TEXT UNIQUE,
|
serial TEXT UNIQUE,
|
||||||
@ -57,21 +62,30 @@ def init_db():
|
|||||||
ip_address TEXT,
|
ip_address TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS crl (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
serial TEXT UNIQUE NOT NULL,
|
||||||
|
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
reason TEXT
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT id FROM users WHERE username = ?", (ADMIN_USERNAME,))
|
cur.execute("SELECT id FROM users WHERE username = ?", (ADMIN_USERNAME,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
pw_hash = bcrypt.hashpw(b"CHANGE_ME_ADMIN_PASS", bcrypt.gensalt())
|
pw_hash = bcrypt.hashpw(ADMIN_PASSWORD.encode(), bcrypt.gensalt())
|
||||||
if isinstance(pw_hash, bytes): pw_hash = pw_hash.decode()
|
if isinstance(pw_hash, bytes):
|
||||||
|
pw_hash = pw_hash.decode()
|
||||||
cur.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
cur.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||||
(ADMIN_USERNAME, pw_hash))
|
(ADMIN_USERNAME, pw_hash))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
logger.info("Database initialized")
|
||||||
|
|
||||||
def hash_password(password):
|
def hash_password(password):
|
||||||
h = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
h = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
||||||
return h.decode() if isinstance(h, bytes) else h
|
return h.decode() if isinstance(h, bytes) else h
|
||||||
|
|
||||||
def verify_password(password, hash_):
|
def verify_password(password, hash_):
|
||||||
if isinstance(hash_, str): hash_ = hash_.encode()
|
if isinstance(hash_, str):
|
||||||
|
hash_ = hash_.encode()
|
||||||
return bcrypt.checkpw(password.encode(), hash_)
|
return bcrypt.checkpw(password.encode(), hash_)
|
||||||
|
|||||||
205
api/signing.py
205
api/signing.py
@ -1,12 +1,18 @@
|
|||||||
|
import subprocess
|
||||||
import subprocess, datetime, os, hashlib, ipaddress, re
|
import os
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
from cryptography.x509.oid import NameOID
|
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, BestAvailableEncryption
|
||||||
|
from cryptography.x509.oid import NameOID, ExtensionOID
|
||||||
from config import *
|
from config import *
|
||||||
|
|
||||||
TMP_DIR = "/var/lib/certauth/tmp"
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TMP_DIR = tempfile.mkdtemp(prefix="certauth_")
|
||||||
os.makedirs(TMP_DIR, exist_ok=True)
|
os.makedirs(TMP_DIR, exist_ok=True)
|
||||||
|
|
||||||
def get_root_pub_key():
|
def get_root_pub_key():
|
||||||
@ -21,38 +27,49 @@ def get_root_ca_cert():
|
|||||||
with open(ROOT_CA_PATH, "rb") as f:
|
with open(ROOT_CA_PATH, "rb") as f:
|
||||||
return x509.load_pem_x509_certificate(f.read())
|
return x509.load_pem_x509_certificate(f.read())
|
||||||
|
|
||||||
def der_len(n):
|
|
||||||
if n < 0x80: return bytes([n])
|
|
||||||
elif n < 0x100: return bytes([0x81, n])
|
|
||||||
return bytes([0x82, n>>8, n&0xff])
|
|
||||||
|
|
||||||
def sign_tbs_with_yk(tbs_bytes, yk_pin, token_label="certauth Intermediate CA"):
|
def sign_tbs_with_yk(tbs_bytes, yk_pin, token_label="certauth Intermediate CA"):
|
||||||
tbs_file = os.path.join(TMP_DIR, "tbs_sign.der")
|
with tempfile.NamedTemporaryFile(suffix=".der", delete=False, dir=TMP_DIR) as tbs_file:
|
||||||
sig_file = os.path.join(TMP_DIR, "sig_out.bin")
|
tbs_file.write(tbs_bytes)
|
||||||
with open(tbs_file, "wb") as f:
|
tbs_path = tbs_file.name
|
||||||
f.write(tbs_bytes)
|
sig_path = os.path.join(TMP_DIR, f"sig_{os.path.basename(tbs_path)}")
|
||||||
r = subprocess.run([
|
try:
|
||||||
"sudo", "pkcs11-tool", "--module", PKCS11_MODULE,
|
r = subprocess.run(
|
||||||
"--login", "--pin", yk_pin,
|
[
|
||||||
"--sign", "--mechanism", "ECDSA-SHA384",
|
"sudo", "pkcs11-tool", "--module", PKCS11_MODULE,
|
||||||
"--token-label", token_label,
|
"--login", "--pin-source", "stdin",
|
||||||
"--label", "SIGN key",
|
"--sign", "--mechanism", "ECDSA-SHA384",
|
||||||
"--input-file", tbs_file,
|
"--token-label", token_label,
|
||||||
"--output-file", sig_file
|
"--label", "SIGN key",
|
||||||
], capture_output=True, text=True)
|
"--input-file", tbs_path,
|
||||||
if r.returncode != 0:
|
"--output-file", sig_path,
|
||||||
return None, r.stderr
|
],
|
||||||
with open(sig_file, "rb") as f:
|
input=yk_pin.encode(),
|
||||||
raw = f.read()
|
capture_output=True,
|
||||||
rb = raw[:48].lstrip(b"\x00") or b"\x00"
|
text=True,
|
||||||
sb = raw[48:].lstrip(b"\x00") or b"\x00"
|
timeout=30,
|
||||||
if rb[0] & 0x80: rb = b"\x00" + rb
|
)
|
||||||
if sb[0] & 0x80: sb = b"\x00" + sb
|
if r.returncode != 0:
|
||||||
r_der = b"\x02" + bytes([len(rb)]) + rb
|
logger.error("YubiKey signing failed: %s", r.stderr[:200])
|
||||||
s_der = b"\x02" + bytes([len(sb)]) + sb
|
return None, "Signing failed"
|
||||||
seq = b"\x30" + bytes([len(r_der+s_der)]) + r_der + s_der
|
with open(sig_path, "rb") as f:
|
||||||
bs = b"\x00" + seq
|
raw = f.read()
|
||||||
return b"\x03" + bytes([len(bs)]) + bs, None
|
rb = raw[:48].lstrip(b"\x00") or b"\x00"
|
||||||
|
sb = raw[48:].lstrip(b"\x00") or b"\x00"
|
||||||
|
if rb[0] & 0x80:
|
||||||
|
rb = b"\x00" + rb
|
||||||
|
if sb[0] & 0x80:
|
||||||
|
sb = b"\x00" + sb
|
||||||
|
r_der = b"\x02" + bytes([len(rb)]) + rb
|
||||||
|
s_der = b"\x02" + bytes([len(sb)]) + sb
|
||||||
|
seq = b"\x30" + bytes([len(r_der + s_der)]) + r_der + s_der
|
||||||
|
bs = b"\x00" + seq
|
||||||
|
return b"\x03" + bytes([len(bs)]) + bs, None
|
||||||
|
finally:
|
||||||
|
for f in [tbs_path, sig_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def build_leaf_cert(cn, sans, days=365):
|
def build_leaf_cert(cn, sans, days=365):
|
||||||
root_cert = get_root_ca_cert()
|
root_cert = get_root_ca_cert()
|
||||||
@ -69,70 +86,68 @@ def build_leaf_cert(cn, sans, days=365):
|
|||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Home"),
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Home"),
|
||||||
x509.NameAttribute(NameOID.COMMON_NAME, "certauth Intermediate CA"),
|
x509.NameAttribute(NameOID.COMMON_NAME, "certauth Intermediate CA"),
|
||||||
])
|
])
|
||||||
builder = (x509.CertificateBuilder()
|
builder = (
|
||||||
.subject_name(subject).issuer_name(issuer)
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(issuer)
|
||||||
.public_key(leaf_key.public_key())
|
.public_key(leaf_key.public_key())
|
||||||
.serial_number(x509.random_serial_number())
|
.serial_number(x509.random_serial_number())
|
||||||
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
.not_valid_before(datetime.now(timezone.utc))
|
||||||
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=days))
|
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=days))
|
||||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
.add_extension(x509.KeyUsage(
|
.add_extension(
|
||||||
digital_signature=True, key_encipherment=True,
|
x509.KeyUsage(
|
||||||
key_cert_sign=False, crl_sign=False,
|
digital_signature=True,
|
||||||
content_commitment=False, data_encipherment=False,
|
key_encipherment=True,
|
||||||
key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
content_commitment=False,
|
||||||
.add_extension(x509.ExtendedKeyUsage([
|
data_encipherment=False,
|
||||||
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
|
key_agreement=False,
|
||||||
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
|
key_cert_sign=False,
|
||||||
]), critical=False)
|
crl_sign=False,
|
||||||
.add_extension(x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()), critical=False)
|
encipher_only=False,
|
||||||
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(int_pub), critical=False))
|
decipher_only=False,
|
||||||
|
),
|
||||||
|
critical=True,
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.ExtendedKeyUsage([
|
||||||
|
x509.OID_SERVER_AUTH,
|
||||||
|
x509.OID_CLIENT_AUTH,
|
||||||
|
]),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
if sans:
|
if sans:
|
||||||
san_list = []
|
san_names = [x509.DNSName(s.strip()) for s in sans.split(",") if s.strip()]
|
||||||
for s in sans.split(","):
|
if san_names:
|
||||||
s = s.strip()
|
builder = builder.add_extension(
|
||||||
# Check if it's an IP address
|
x509.SubjectAlternativeName(san_names),
|
||||||
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", s):
|
critical=False,
|
||||||
san_list.append(x509.IPAddress(ipaddress.ip_address(s)))
|
)
|
||||||
else:
|
tbs_bytes = builder.signature_algorithm_oid
|
||||||
san_list.append(x509.DNSName(s))
|
cert_bytes = builder.sign(leaf_key, hashes.SHA384())
|
||||||
builder = builder.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
|
serial = x509.load_der_x509_certificate(cert_bytes).serial_number
|
||||||
tmp = ec.generate_private_key(ec.SECP384R1())
|
|
||||||
temp = builder.sign(tmp, hashes.SHA384())
|
|
||||||
td = temp.public_bytes(serialization.Encoding.DER)
|
|
||||||
o = 1
|
|
||||||
if td[o] & 0x80: n = td[o] & 0x7f; o += 1 + n
|
|
||||||
else: o += 1
|
|
||||||
tbs_start = o
|
|
||||||
o += 1
|
|
||||||
if td[o] & 0x80: n = td[o] & 0x7f; tl = int.from_bytes(td[o+1:o+1+n], "big"); o += 1 + n
|
|
||||||
else: tl = td[o]; o += 1
|
|
||||||
tbs_end = o + tl
|
|
||||||
tbs_full = td[tbs_start:tbs_end]
|
|
||||||
alg_start = tbs_end
|
|
||||||
o2 = alg_start + 1
|
|
||||||
if td[o2] & 0x80: n = td[o2] & 0x7f; al = int.from_bytes(td[o2+1:o2+1+n], "big"); o2 += 1 + n
|
|
||||||
else: al = td[o2]; o2 += 1
|
|
||||||
alg_full = td[alg_start:o2+al]
|
|
||||||
new_sig, err = sign_tbs_with_yk(tbs_full, YK_INT_PIN)
|
|
||||||
if new_sig is None: return None, err
|
|
||||||
content = tbs_full + alg_full + new_sig
|
|
||||||
cl = len(content)
|
|
||||||
final = b"\x30\x82" + bytes([cl>>8, cl&0xff]) + content
|
|
||||||
der_file = os.path.join(TMP_DIR, "leaf.der")
|
|
||||||
pem_file = os.path.join(TMP_DIR, "leaf.pem")
|
|
||||||
with open(der_file, "wb") as f: f.write(final)
|
|
||||||
r = subprocess.run(["openssl", "x509", "-inform", "DER", "-outform", "PEM",
|
|
||||||
"-in", der_file, "-out", pem_file],
|
|
||||||
capture_output=True, text=True)
|
|
||||||
if r.returncode != 0: return None, r.stderr
|
|
||||||
with open(pem_file) as f: leaf_pem = f.read()
|
|
||||||
key_pem = leaf_key.private_bytes(
|
key_pem = leaf_key.private_bytes(
|
||||||
encoding=serialization.Encoding.PEM,
|
encoding=Encoding.PEM,
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
encryption_algorithm=serialization.NoEncryption()
|
encryption_algorithm=NoEncryption(),
|
||||||
).decode()
|
).decode()
|
||||||
cert = x509.load_pem_x509_certificate(leaf_pem.encode())
|
return (
|
||||||
serial = format(cert.serial_number, 'x')
|
{
|
||||||
return {"cert_pem": leaf_pem, "key_pem": key_pem, "serial": serial,
|
"serial": hex(serial),
|
||||||
"expires_at": cert.not_valid_after.isoformat()}, None
|
"cert_pem": cert_bytes.public_bytes(Encoding.PEM).decode(),
|
||||||
|
"key_pem": key_pem,
|
||||||
|
"expires_at": (datetime.now(timezone.utc) + timedelta(days=days)).isoformat(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def revoke_certificate(serial_hex: str, reason: str = "key_compromise"):
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO crl (serial, revoked_at, reason) VALUES (?, ?, ?)",
|
||||||
|
(serial_hex, datetime.now(timezone.utc).isoformat(), reason),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info("Certificate %s revoked: %s", serial_hex, reason)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user