1746 lines
69 KiB
Bash
1746 lines
69 KiB
Bash
#!/bin/bash
|
|
###############################################################################
|
|
# CertAuth Setup Script
|
|
# Provisions a fresh Ubuntu machine as a Certificate Authority
|
|
#
|
|
# Prerequisites:
|
|
# - Fresh Ubuntu 24.04+ Server (aarch64/x86_64)
|
|
# - Two YubiKey 5 Nano devices plugged in
|
|
# - Root or sudo access
|
|
# - Network access to Ubuntu repositories
|
|
#
|
|
# Usage:
|
|
# sudo bash setup-certauth.sh
|
|
#
|
|
# Configuration: Edit the variables below or pass as environment variables
|
|
###############################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# ===================== CONFIGURATION =====================
|
|
CA_ORG="${CA_ORG:-Home}"
|
|
CA_COUNTRY="${CA_COUNTRY:-US}"
|
|
ROOT_CA_CN="${ROOT_CA_CN:-certauth Root CA}"
|
|
INT_CA_CN="${INT_CA_CN:-certauth Intermediate CA}"
|
|
ROOT_VALID_DAYS="${ROOT_VALID_DAYS:-9125}" # 25 years
|
|
INT_VALID_DAYS="${INT_VALID_DAYS:-5475}" # 15 years
|
|
LEAF_VALID_DAYS="${LEAF_VALID_DAYS:-365}" # 1 year per cert
|
|
NETWORK_CIDR="${NETWORK_CIDR:-192.168.8.0/24}"
|
|
ADMIN_USER="${ADMIN_USER:-certauth}"
|
|
ADMIN_PASS="${ADMIN_PASS:-CHANGE_ME_ADMIN_PASS}"
|
|
|
|
# YubiKey assignment (will be detected automatically if not set)
|
|
YK1_SERIAL="${YK1_SERIAL:-}" # Root CA YubiKey serial
|
|
YK2_SERIAL="${YK2_SERIAL:-}" # Intermediate CA YubiKey serial
|
|
|
|
# PINs (will be generated if not set)
|
|
YK1_PIN="${YK1_PIN:-}"
|
|
YK1_PUK="${YK1_PUK:-}"
|
|
YK2_PIN="${YK2_PIN:-}"
|
|
YK2_PUK="${YK2_PUK:-}"
|
|
|
|
# ===================== COLORS =====================
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
|
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
|
|
|
# ===================== PRE-FLIGHT CHECKS =====================
|
|
check_prerequisites() {
|
|
info "Running pre-flight checks..."
|
|
|
|
# Must be root
|
|
if [[ $EUID -ne 0 ]]; then
|
|
error "This script must be run as root (use sudo)"
|
|
fi
|
|
|
|
# Check Ubuntu version
|
|
if ! grep -q "Ubuntu" /etc/os-release; then
|
|
error "This script requires Ubuntu"
|
|
fi
|
|
UBUNTU_VERSION=$(grep VERSION_ID /etc/os-release | cut -d'"' -f2)
|
|
info "Ubuntu version: $UBUNTU_VERSION"
|
|
|
|
# Check architecture
|
|
ARCH=$(uname -m)
|
|
info "Architecture: $ARCH"
|
|
|
|
# Check YubiKeys
|
|
YK_COUNT=$(lsusb 2>/dev/null | grep -c "Yubico" || true)
|
|
if [[ $YK_COUNT -lt 2 ]]; then
|
|
error "Need at least 2 YubiKeys connected (found: $YK_COUNT)"
|
|
fi
|
|
success "Found $YK_COUNT YubiKeys"
|
|
|
|
# Check disk space
|
|
AVAIL=$(df -BG / | awk 'NR==2 {print $4}' | tr -d 'G')
|
|
if [[ $AVAIL -lt 5 ]]; then
|
|
error "Need at least 5GB free disk space (have: ${AVAIL}GB)"
|
|
fi
|
|
success "Disk space: ${AVAIL}GB available"
|
|
}
|
|
|
|
# ===================== STEP 1: SYSTEM SETUP =====================
|
|
setup_system() {
|
|
info "=== Step 1: System Setup ==="
|
|
|
|
# Update system
|
|
info "Updating package lists..."
|
|
apt-get update -qq
|
|
|
|
# Install base packages
|
|
info "Installing packages..."
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
|
ufw fail2ban opensc pcscd yubikey-manager ykcs11 \
|
|
python3-pip python3-venv caddy sqlite3 libsqlite3-dev \
|
|
haveged apparmor-utils curl wget \
|
|
2>/dev/null || true
|
|
|
|
# Install Python packages
|
|
info "Installing Python packages..."
|
|
pip3 install --break-system-packages --ignore-installed \
|
|
typing_extensions 2>/dev/null || true
|
|
pip3 install --break-system-packages \
|
|
fastapi "uvicorn[standard]" jinja2 python-multipart \
|
|
bcrypt "python-jose[cryptography]" ecdsa cryptography \
|
|
2>/dev/null || true
|
|
|
|
success "Packages installed"
|
|
}
|
|
|
|
# ===================== STEP 2: CREATE USER =====================
|
|
setup_user() {
|
|
info "=== Step 2: Create $ADMIN_USER user ==="
|
|
|
|
if ! id "$ADMIN_USER" &>/dev/null; then
|
|
useradd -m -s /bin/bash "$ADMIN_USER"
|
|
usermod -aG sudo,adm,plugdev,pcscd "$ADMIN_USER"
|
|
echo "$ADMIN_USER:$ADMIN_PASS" | chpasswd
|
|
echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/certauth
|
|
chmod 0440 /etc/sudoers.d/certauth
|
|
success "User $ADMIN_USER created"
|
|
else
|
|
warn "User $ADMIN_USER already exists"
|
|
fi
|
|
}
|
|
|
|
# ===================== STEP 3: FIREWALL =====================
|
|
setup_firewall() {
|
|
info "=== Step 3: Configure Firewall ==="
|
|
|
|
ufw --force reset 2>/dev/null || true
|
|
ufw default deny incoming
|
|
ufw default deny outgoing
|
|
ufw allow in on lo
|
|
ufw allow out on lo
|
|
ufw allow out 53
|
|
ufw allow out 123
|
|
ufw allow out 80/tcp
|
|
ufw allow out 443/tcp
|
|
ufw allow from "$NETWORK_CIDR" port 22 proto tcp
|
|
ufw allow from "$NETWORK_CIDR" port 80 proto tcp
|
|
ufw allow from "$NETWORK_CIDR" port 443 proto tcp
|
|
echo "y" | ufw enable
|
|
|
|
# Create swap
|
|
if [[ ! -f /swapfile ]]; then
|
|
fallocate -l 4G /swapfile
|
|
chmod 600 /swapfile
|
|
mkswap /swapfile
|
|
swapon /swapfile
|
|
echo "/swapfile none swap sw 0 0" >> /etc/fstab
|
|
success "Swap created (4GB)"
|
|
fi
|
|
|
|
success "Firewall configured"
|
|
}
|
|
|
|
# ===================== STEP 4: DETECT YUBIKEYS =====================
|
|
detect_yubikeys() {
|
|
info "=== Step 4: Detect YubiKeys ==="
|
|
|
|
# Start pcscd
|
|
systemctl enable pcscd
|
|
systemctl start pcscd
|
|
sleep 2
|
|
|
|
# Fix polkit for pcscd access
|
|
mkdir -p /etc/polkit-1/rules.d
|
|
cat > /etc/polkit-1/rules.d/45-access-pcsc.rules << 'POLKIT'
|
|
polkit.addRule(function(action, subject) {
|
|
if (action.id == "org.debian.pcsc-lite.access_pcsc") {
|
|
return polkit.Result.YES;
|
|
}
|
|
});
|
|
POLKIT
|
|
systemctl restart polkit
|
|
systemctl restart pcscd
|
|
sleep 2
|
|
|
|
# List YubiKeys
|
|
info "Connected YubiKeys:"
|
|
sudo -u "$ADMIN_USER" ykman list 2>/dev/null || ykman list
|
|
|
|
# Auto-detect serials if not set
|
|
SERIALS=$(ykman list 2>/dev/null | grep -oP 'Serial: \K\d+' || true)
|
|
if [[ -z "$YK1_SERIAL" ]]; then
|
|
YK1_SERIAL=$(echo "$SERIALS" | head -1)
|
|
fi
|
|
if [[ -z "$YK2_SERIAL" ]]; then
|
|
YK2_SERIAL=$(echo "$SERIALS" | tail -1)
|
|
fi
|
|
|
|
info "YK1 (Root CA): $YK1_SERIAL"
|
|
info "YK2 (Intermediate): $YK2_SERIAL"
|
|
|
|
# Generate PINs if not set
|
|
if [[ -z "$YK1_PIN" ]]; then
|
|
YK1_PIN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 8)
|
|
YK1_PUK=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 8)
|
|
fi
|
|
if [[ -z "$YK2_PIN" ]]; then
|
|
YK2_PIN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 8)
|
|
YK2_PUK=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 8)
|
|
fi
|
|
|
|
echo ""
|
|
echo "============================================================"
|
|
echo " YUBIKEY CREDENTIALS - RECORD THESE SECURELY"
|
|
echo "============================================================"
|
|
echo ""
|
|
echo "YK1 (Root CA) - Serial $YK1_SERIAL:"
|
|
echo " PIN: $YK1_PIN"
|
|
echo " PUK: $YK1_PUK"
|
|
echo ""
|
|
echo "YK2 (Intermediate) - Serial $YK2_SERIAL:"
|
|
echo " PIN: $YK2_PIN"
|
|
echo " PUK: $YK2_PUK"
|
|
echo ""
|
|
echo "============================================================"
|
|
echo ""
|
|
}
|
|
|
|
# ===================== STEP 5: CONFIGURE YUBIKEYS =====================
|
|
configure_yubikeys() {
|
|
info "=== Step 5: Configure YubiKeys ==="
|
|
DEFAULT_MGMT="010203040506070801020304050607080102030405060708"
|
|
|
|
# --- YK1: Root CA ---
|
|
info "Configuring YK1 (Root CA)..."
|
|
|
|
echo "y" | ykman -d "$YK1_SERIAL" piv reset
|
|
ykman -d "$YK1_SERIAL" piv keys generate --algorithm ECCP384 \
|
|
-m "$DEFAULT_MGMT" 9c /tmp/yk1-root-pub.pem
|
|
ykman -d "$YK1_SERIAL" piv access change-pin -P 123456 --new-pin "$YK1_PIN"
|
|
ykman -d "$YK1_SERIAL" piv access change-puk -p 12345678 --new-puk "$YK1_PUK"
|
|
echo "" | ykman -d "$YK1_SERIAL" piv certificates generate \
|
|
-P "$YK1_PIN" -m "$DEFAULT_MGMT" \
|
|
--subject "CN=$ROOT_CA_CN,O=$CA_ORG,C=$CA_COUNTRY" \
|
|
--valid-days "$ROOT_VALID_DAYS" --hash-algorithm SHA384 \
|
|
9c /tmp/yk1-root-pub.pem
|
|
|
|
ykman -d "$YK1_SERIAL" piv certificates export 9c /tmp/root-ca-temp.crt
|
|
success "YK1 configured"
|
|
|
|
# --- YK2: Intermediate CA ---
|
|
info "Configuring YK2 (Intermediate CA)..."
|
|
|
|
echo "y" | ykman -d "$YK2_SERIAL" piv reset
|
|
ykman -d "$YK2_SERIAL" piv keys generate --algorithm ECCP384 \
|
|
-m "$DEFAULT_MGMT" 9c /tmp/yk2-int-pub.pem
|
|
ykman -d "$YK2_SERIAL" piv access change-pin -P 123456 --new-pin "$YK2_PIN"
|
|
ykman -d "$YK2_SERIAL" piv access change-puk -p 12345678 --new-puk "$YK2_PUK"
|
|
echo "" | ykman -d "$YK2_SERIAL" piv certificates request \
|
|
-P "$YK2_PIN" \
|
|
--subject "CN=$INT_CA_CN,O=$CA_ORG,C=$CA_COUNTRY" \
|
|
9c /tmp/yk2-int-pub.pem /tmp/yk2-intermediate.csr
|
|
|
|
success "YK2 configured"
|
|
}
|
|
|
|
# ===================== STEP 6: GENERATE CA CERTS =====================
|
|
generate_ca_certs() {
|
|
info "=== Step 6: Generate CA Certificates ==="
|
|
|
|
# Create directories
|
|
mkdir -p /etc/ssl/ca/{root,intermediate,issued,crl}
|
|
mkdir -p /var/lib/certauth/tmp
|
|
mkdir -p /var/log/certauth
|
|
chown -R "$ADMIN_USER:$ADMIN_USER" /etc/ssl/ca /var/lib/certauth /var/log/certauth
|
|
chmod -R 700 /etc/ssl/ca /var/lib/certauth /var/log/certauth
|
|
|
|
# Generate Root CA cert with proper extensions
|
|
info "Generating Root CA certificate..."
|
|
python3 << ROOTPY
|
|
import datetime, subprocess, base64
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
with open("/tmp/yk1-root-pub.pem", "rb") as f:
|
|
root_pub = serialization.load_pem_public_key(f.read())
|
|
|
|
subj = x509.Name([
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "$CA_COUNTRY"),
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "$CA_ORG"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, "$ROOT_CA_CN"),
|
|
])
|
|
|
|
builder = (x509.CertificateBuilder()
|
|
.subject_name(subj).issuer_name(subj)
|
|
.public_key(root_pub).serial_number(x509.random_serial_number())
|
|
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
|
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=$ROOT_VALID_DAYS))
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=1), critical=True)
|
|
.add_extension(x509.KeyUsage(digital_signature=True, key_cert_sign=True, crl_sign=True,
|
|
key_encipherment=False, content_commitment=False, data_encipherment=False,
|
|
key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(root_pub), critical=False)
|
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(root_pub), critical=False))
|
|
|
|
tmp = ec.generate_private_key(ec.SECP384R1())
|
|
temp = builder.sign(tmp, hashes.SHA384())
|
|
td = temp.public_bytes(serialization.Encoding.DER)
|
|
|
|
# Parse TBS
|
|
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]
|
|
|
|
# Sign with YubiKey
|
|
with open("/tmp/tbs.der", "wb") as f: f.write(tbs_full)
|
|
r = subprocess.run(["pkcs11-tool", "--login", "--pin", "$YK1_PIN",
|
|
"--sign", "--mechanism", "ECDSA-SHA384", "--label", "SIGN key",
|
|
"--input-file", "/tmp/tbs.der", "--output-file", "/tmp/sig.bin"],
|
|
capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
print(f"Sign failed: {r.stderr}")
|
|
exit(1)
|
|
|
|
with open("/tmp/sig.bin", "rb") as f: raw = f.read()
|
|
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
|
|
new_sig = b"\x03" + bytes([len(bs)]) + bs
|
|
|
|
content = tbs_full + alg_full + new_sig
|
|
cl = len(content)
|
|
final = b"\x30\x82" + bytes([cl>>8, cl&0xff]) + content
|
|
|
|
with open("/tmp/root.der", "wb") as f: f.write(final)
|
|
r = subprocess.run(["openssl", "x509", "-inform", "DER", "-outform", "PEM",
|
|
"-in", "/tmp/root.der", "-out", "/tmp/root.pem"], capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
print(f"DER->PEM failed: {r.stderr}")
|
|
exit(1)
|
|
|
|
with open("/tmp/root.pem") as f: pem = f.read()
|
|
with open("/etc/ssl/ca/root/root-ca.crt", "w") as f: f.write(pem)
|
|
|
|
# Verify
|
|
r = subprocess.run(["openssl", "verify", "-CAfile", "/etc/ssl/ca/root/root-ca.crt",
|
|
"/etc/ssl/ca/root/root-ca.crt"], capture_output=True, text=True)
|
|
print(f"Root CA self-verify: {r.stdout.strip() or r.stderr.strip()}")
|
|
|
|
# Import to YK1
|
|
subprocess.run(["echo", "", "|", "ykman", "-d", "$YK1_SERIAL", "piv", "certificates",
|
|
"import", "9c", "/etc/ssl/ca/root/root-ca.crt"], shell=True)
|
|
print("Root CA imported to YK1")
|
|
ROOTPY
|
|
|
|
# Generate Intermediate CA cert signed by Root
|
|
info "Generating Intermediate CA certificate..."
|
|
python3 << INTPY
|
|
import datetime, subprocess, base64
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
with open("/tmp/yk1-root-pub.pem", "rb") as f:
|
|
root_pub = serialization.load_pem_public_key(f.read())
|
|
with open("/tmp/yk2-int-pub.pem", "rb") as f:
|
|
int_pub = serialization.load_pem_public_key(f.read())
|
|
|
|
root_subj = x509.Name([
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "$CA_COUNTRY"),
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "$CA_ORG"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, "$ROOT_CA_CN"),
|
|
])
|
|
int_subj = x509.Name([
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "$CA_COUNTRY"),
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "$CA_ORG"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, "$INT_CA_CN"),
|
|
])
|
|
|
|
builder = (x509.CertificateBuilder()
|
|
.subject_name(int_subj).issuer_name(root_subj)
|
|
.public_key(int_pub).serial_number(x509.random_serial_number())
|
|
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
|
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=$INT_VALID_DAYS))
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
|
.add_extension(x509.KeyUsage(digital_signature=True, key_cert_sign=True, crl_sign=True,
|
|
key_encipherment=False, content_commitment=False, data_encipherment=False,
|
|
key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(int_pub), critical=False)
|
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(root_pub), critical=False))
|
|
|
|
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]
|
|
|
|
with open("/tmp/tbs.der", "wb") as f: f.write(tbs_full)
|
|
r = subprocess.run(["pkcs11-tool", "--login", "--pin", "$YK1_PIN",
|
|
"--sign", "--mechanism", "ECDSA-SHA384", "--label", "SIGN key",
|
|
"--input-file", "/tmp/tbs.der", "--output-file", "/tmp/sig.bin"],
|
|
capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
print(f"Sign failed: {r.stderr}")
|
|
exit(1)
|
|
|
|
with open("/tmp/sig.bin", "rb") as f: raw = f.read()
|
|
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
|
|
new_sig = b"\x03" + bytes([len(bs)]) + bs
|
|
|
|
content = tbs_full + alg_full + new_sig
|
|
cl = len(content)
|
|
final = b"\x30\x82" + bytes([cl>>8, cl&0xff]) + content
|
|
|
|
with open("/tmp/int.der", "wb") as f: f.write(final)
|
|
r = subprocess.run(["openssl", "x509", "-inform", "DER", "-outform", "PEM",
|
|
"-in", "/tmp/int.der", "-out", "/tmp/int.pem"], capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
print(f"DER->PEM failed: {r.stderr}")
|
|
exit(1)
|
|
|
|
with open("/tmp/int.pem") as f: pem = f.read()
|
|
with open("/etc/ssl/ca/intermediate/intermediate-ca.crt", "w") as f: f.write(pem)
|
|
|
|
# Verify chain
|
|
r = subprocess.run(["openssl", "verify", "-CAfile", "/etc/ssl/ca/root/root-ca.crt",
|
|
"/etc/ssl/ca/intermediate/intermediate-ca.crt"], capture_output=True, text=True)
|
|
print(f"Chain verify: {r.stdout.strip() or r.stderr.strip()}")
|
|
|
|
# Import to YK2
|
|
subprocess.run(["echo", "", "|", "ykman", "-d", "$YK2_SERIAL", "piv", "certificates",
|
|
"import", "9c", "/etc/ssl/ca/intermediate/intermediate-ca.crt"], shell=True)
|
|
print("Intermediate CA imported to YK2")
|
|
INTPY
|
|
|
|
# Create CA chain file
|
|
cat /etc/ssl/ca/intermediate/intermediate-ca.crt /etc/ssl/ca/root/root-ca.crt \
|
|
> /etc/ssl/ca/ca-chain.crt
|
|
|
|
success "CA certificates generated and verified"
|
|
}
|
|
|
|
# ===================== STEP 7: INSTALL API =====================
|
|
install_api() {
|
|
info "=== Step 7: Install Certificate API ==="
|
|
|
|
# Create API directory
|
|
mkdir -p /opt/certauth/{api/templates,api/static,logs}
|
|
chown -R "$ADMIN_USER:$ADMIN_USER" /opt/certauth
|
|
|
|
# Write config
|
|
cat > /opt/certauth/api/config.py << CONFIGEOF
|
|
import os
|
|
|
|
YK_ROOT_SERIAL = "$YK1_SERIAL"
|
|
YK_ROOT_PIN = os.environ.get("YK_ROOT_PIN", "$YK1_PIN")
|
|
YK_INT_SERIAL = "$YK2_SERIAL"
|
|
YK_INT_PIN = os.environ.get("YK_INT_PIN", "$YK2_PIN")
|
|
ROOT_CA_PATH = "/etc/ssl/ca/root/root-ca.crt"
|
|
INT_CA_PATH = "/etc/ssl/ca/intermediate/intermediate-ca.crt"
|
|
CA_CHAIN_PATH = "/etc/ssl/ca/ca-chain.crt"
|
|
ISSUED_DIR = "/etc/ssl/ca/issued"
|
|
DB_PATH = "/var/lib/certauth/certauth.db"
|
|
SECRET_KEY = os.environ.get("JWT_SECRET", "CHANGE_ME_JWT_SECRET-$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32)")
|
|
ALGORITHM = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
|
ADMIN_USERNAME = "$ADMIN_USER"
|
|
PKCS11_MODULE = "/usr/lib/$(uname -m)-linux-gnu/opensc-pkcs11.so"
|
|
YK_PUB_ROOT = "/tmp/yk1-root-pub.pem"
|
|
YK_PUB_INT = "/tmp/yk2-int-pub.pem"
|
|
CONFIGEOF
|
|
|
|
# Embedded API files (heredocs) — always used for reproducibility
|
|
info "Deploying API files..."
|
|
|
|
cat > /opt/certauth/api/models.py << 'MODELS_EOF'
|
|
import sqlite3, datetime, secrets, bcrypt, os
|
|
from config import DB_PATH, ADMIN_USERNAME
|
|
|
|
def get_db():
|
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=30000")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
def init_db():
|
|
conn = get_db()
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS domains (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL,
|
|
description TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_by INTEGER REFERENCES users(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS certificates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain_id INTEGER REFERENCES domains(id),
|
|
subject TEXT NOT NULL,
|
|
san TEXT,
|
|
serial TEXT UNIQUE,
|
|
status TEXT DEFAULT 'pending',
|
|
cert_path TEXT,
|
|
issued_at TIMESTAMP,
|
|
expires_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_by INTEGER REFERENCES users(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
key_hash TEXT UNIQUE NOT NULL,
|
|
prefix TEXT NOT NULL,
|
|
permissions TEXT DEFAULT 'read',
|
|
active BOOLEAN DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_by INTEGER REFERENCES users(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
action TEXT NOT NULL,
|
|
details TEXT,
|
|
user_id INTEGER REFERENCES users(id),
|
|
ip_address TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
""")
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT id FROM users WHERE username = ?", (ADMIN_USERNAME,))
|
|
if not cur.fetchone():
|
|
pw_hash = bcrypt.hashpw(b"CHANGE_ME_ADMIN_PASS", bcrypt.gensalt())
|
|
if isinstance(pw_hash, bytes): pw_hash = pw_hash.decode()
|
|
cur.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
|
(ADMIN_USERNAME, pw_hash))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def hash_password(password):
|
|
h = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
|
return h.decode() if isinstance(h, bytes) else h
|
|
|
|
def verify_password(password, hash_):
|
|
if isinstance(hash_, str): hash_ = hash_.encode()
|
|
return bcrypt.checkpw(password.encode(), hash_)
|
|
MODELS_EOF
|
|
|
|
cat > /opt/certauth/api/auth.py << 'AUTH_EOF'
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from jose import jwt, JWTError
|
|
from datetime import datetime, timedelta
|
|
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
from models import get_db, verify_password
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
|
|
|
def create_access_token(data: dict, expires_delta: timedelta = None):
|
|
to_encode = data.copy()
|
|
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
|
to_encode.update({"exp": expire})
|
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
|
|
def get_current_user(token: str = Depends(oauth2_scheme)):
|
|
credentials_exception = HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
try:
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
username = payload.get("sub")
|
|
if username is None:
|
|
raise credentials_exception
|
|
except JWTError:
|
|
raise credentials_exception
|
|
return username
|
|
AUTH_EOF
|
|
|
|
cat > /opt/certauth/api/signing.py << 'SIGNING_EOF'
|
|
|
|
import subprocess, datetime, os, hashlib, ipaddress, re
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.x509.oid import NameOID
|
|
from config import *
|
|
|
|
TMP_DIR = "/var/lib/certauth/tmp"
|
|
os.makedirs(TMP_DIR, exist_ok=True)
|
|
|
|
def get_root_pub_key():
|
|
with open(YK_PUB_ROOT, "rb") as f:
|
|
return serialization.load_pem_public_key(f.read())
|
|
|
|
def get_int_pub_key():
|
|
with open(YK_PUB_INT, "rb") as f:
|
|
return serialization.load_pem_public_key(f.read())
|
|
|
|
def get_root_ca_cert():
|
|
with open(ROOT_CA_PATH, "rb") as f:
|
|
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"):
|
|
tbs_file = os.path.join(TMP_DIR, "tbs_sign.der")
|
|
sig_file = os.path.join(TMP_DIR, "sig_out.bin")
|
|
with open(tbs_file, "wb") as f:
|
|
f.write(tbs_bytes)
|
|
r = subprocess.run([
|
|
"sudo", "pkcs11-tool", "--module", PKCS11_MODULE,
|
|
"--login", "--pin", yk_pin,
|
|
"--sign", "--mechanism", "ECDSA-SHA384",
|
|
"--token-label", token_label,
|
|
"--label", "SIGN key",
|
|
"--input-file", tbs_file,
|
|
"--output-file", sig_file
|
|
], capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
return None, r.stderr
|
|
with open(sig_file, "rb") as f:
|
|
raw = f.read()
|
|
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
|
|
|
|
def build_leaf_cert(cn, sans, days=365):
|
|
root_cert = get_root_ca_cert()
|
|
int_pub = get_int_pub_key()
|
|
root_pub = get_root_pub_key()
|
|
leaf_key = ec.generate_private_key(ec.SECP384R1())
|
|
subject = x509.Name([
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Home"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, cn),
|
|
])
|
|
issuer = x509.Name([
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Home"),
|
|
x509.NameAttribute(NameOID.COMMON_NAME, "certauth Intermediate CA"),
|
|
])
|
|
builder = (x509.CertificateBuilder()
|
|
.subject_name(subject).issuer_name(issuer)
|
|
.public_key(leaf_key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
|
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=days))
|
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
.add_extension(x509.KeyUsage(
|
|
digital_signature=True, key_encipherment=True,
|
|
key_cert_sign=False, crl_sign=False,
|
|
content_commitment=False, data_encipherment=False,
|
|
key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
|
.add_extension(x509.ExtendedKeyUsage([
|
|
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
|
|
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
|
|
]), critical=False)
|
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()), critical=False)
|
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(int_pub), critical=False))
|
|
if sans:
|
|
san_list = []
|
|
for s in sans.split(","):
|
|
s = s.strip()
|
|
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", s):
|
|
san_list.append(x509.IPAddress(ipaddress.IPv4Address(s)))
|
|
else:
|
|
san_list.append(x509.DNSName(s))
|
|
builder = builder.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
|
|
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(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption()
|
|
).decode()
|
|
cert = x509.load_pem_x509_certificate(leaf_pem.encode())
|
|
serial = format(cert.serial_number, 'x')
|
|
return {"cert_pem": leaf_pem, "key_pem": key_pem, "serial": serial,
|
|
"expires_at": cert.not_valid_after.isoformat()}, None
|
|
SIGNING_EOF
|
|
cat > /opt/certauth/api/main.py << 'MAINPYEOF'
|
|
import os, sqlite3, datetime, secrets, hashlib, subprocess, json
|
|
from fastapi import FastAPI, Request, Depends, HTTPException, Form
|
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
from jose import jwt
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
from config import *
|
|
from models import get_db, init_db, hash_password, verify_password
|
|
from auth import create_access_token, get_current_user
|
|
from signing import build_leaf_cert
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
app = FastAPI(title="CertAuth Key Vault")
|
|
app.mount("/static", StaticFiles(directory="/opt/certauth/api/static"), name="static")
|
|
|
|
|
|
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
|
|
|
|
jinja_env = Environment(
|
|
loader=FileSystemLoader("/opt/certauth/api/templates"),
|
|
autoescape=select_autoescape(["html"])
|
|
)
|
|
|
|
@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: pass
|
|
|
|
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, 1))
|
|
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, cn, sans, "pending", 1))
|
|
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()
|
|
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
|
if err: raise HTTPException(500, f"Signing failed: {err}")
|
|
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
|
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
|
open(cf, "w").write(result["cert_pem"])
|
|
open(kf, "w").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.datetime.now().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):
|
|
"""Download cert + chain as bundled PEM."""
|
|
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"/var/lib/certauth/tmp/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 = "certauth", request: Request = None):
|
|
"""Download cert + key + chain as PKCS12/PFX."""
|
|
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"/var/lib/certauth/tmp/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")
|
|
|
|
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)
|
|
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})
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
async def login_page(request: Request):
|
|
return render("login.html", {"request": request, "error": None})
|
|
|
|
@app.post("/login")
|
|
async def login_post(username: str = Form(...), password: str = Form(...)):
|
|
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"})
|
|
token = create_access_token({"sub": username})
|
|
resp = RedirectResponse("/", status_code=302)
|
|
resp.set_cookie("token", token, httponly=True, samesite="lax", 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)
|
|
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 issued")
|
|
conn.close()
|
|
try:
|
|
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
|
if err:
|
|
return HTMLResponse(f'<span class="text-red-400">Issue failed: {err}</span>')
|
|
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
|
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
|
open(cf, "w").write(result["cert_pem"])
|
|
open(kf, "w").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.datetime.now().isoformat(), result["expires_at"], cert_id))
|
|
conn2.commit()
|
|
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>')
|
|
except Exception as ex:
|
|
return HTMLResponse(f'<span class="text-red-400">Issue failed: {str(ex)}</span>')
|
|
|
|
|
|
@app.get("/logout")
|
|
async def logout():
|
|
resp = RedirectResponse("/login", status_code=302)
|
|
resp.delete_cookie("token", path="/")
|
|
return resp
|
|
|
|
# --- Web API (cookie auth) ---
|
|
@app.post("/api/domains/web")
|
|
async def create_domain_web(name: str = Form(...), description: str = Form(""), request: Request = None):
|
|
user = get_user_from_cookie(request)
|
|
if not user:
|
|
return RedirectResponse("/login", status_code=302)
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, 1))
|
|
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), request: Request = None):
|
|
user = get_user_from_cookie(request)
|
|
if not user:
|
|
return RedirectResponse("/login", status_code=302)
|
|
conn = get_db()
|
|
cur = conn.cursor()
|
|
# Look up domain by CN if domain_id not provided
|
|
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", 1))
|
|
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]})
|
|
|
|
@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]})
|
|
|
|
@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():
|
|
"""One-liner bash setup script for Linux/macOS."""
|
|
script = r'''#!/bin/bash
|
|
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=""
|
|
if [[ -n "$1" ]]; then
|
|
DETECTED_IP="$1"
|
|
elif [[ -n "$CERTAUTH_IP" ]]; then
|
|
DETECTED_IP="$CERTAUTH_IP"
|
|
else
|
|
# Try Linux hostname -I first
|
|
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
|
|
# 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
|
|
|
|
# Prompt if auto-detection failed
|
|
if [[ -z "$DETECTED_IP" ]]; then
|
|
read -r -p "Enter CertAuth server IP (e.g., 192.168.8.248): " DETECTED_IP
|
|
fi
|
|
|
|
CHAIN_URL="http://$DETECTED_IP/api/ca-chain"
|
|
|
|
echo "Downloading CA chain..."
|
|
curl -sL "$CHAIN_URL" -o /tmp/ca-chain.crt || { echo "Failed to download CA chain from $CHAIN_URL"; exit 1; }
|
|
|
|
# Detect OS and install
|
|
if [[ -f /etc/os-release ]]; then
|
|
. /etc/os-release
|
|
if [[ "$ID" == "debian" || "$ID" == "ubuntu" || "$ID" == "linuxmint" ]]; then
|
|
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
|
sudo update-ca-certificates
|
|
echo "✅ CA chain installed (Debian/Ubuntu)"
|
|
elif [[ "$ID" == "centos" || "$ID" == "rhel" || "$ID" == "fedora" ]]; then
|
|
sudo cp /tmp/ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt
|
|
sudo update-ca-trust
|
|
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
|
|
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
|
sudo update-ca-certificates
|
|
echo "✅ CA chain installed (Alpine)"
|
|
else
|
|
echo "❌ Unsupported Linux distribution: $ID"
|
|
echo " Download /tmp/ca-chain.crt and install manually"
|
|
exit 1
|
|
fi
|
|
elif [[ "$(uname)" == "Darwin" ]]; then
|
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/ca-chain.crt
|
|
echo "✅ CA chain installed (macOS)"
|
|
else
|
|
echo "❌ Unsupported OS: $(uname -s)"
|
|
echo " Download /tmp/ca-chain.crt and install manually"
|
|
exit 1
|
|
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
|
|
echo "Done!"
|
|
'''
|
|
return PlainTextResponse(script, media_type="text/x-shellscript")
|
|
|
|
@app.get("/setup.ps1")
|
|
async def setup_ps1():
|
|
"""PowerShell setup script for Windows."""
|
|
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 = "")
|
|
|
|
if (-not $CertAuthIP) {
|
|
# Try to detect from environment or prompt
|
|
$CertAuthIP = Read-Host "Enter CertAuth server IP (e.g., 192.168.8.248)"
|
|
}
|
|
|
|
$ChainUrl = "http://$CertAuthIP/api/ca-chain"
|
|
$ChainPath = "$env:TEMP\ca-chain.crt"
|
|
|
|
Write-Host "Downloading CA chain from $ChainUrl ..." -ForegroundColor Cyan
|
|
try {
|
|
(New-Object Net.WebClient).DownloadFile($ChainUrl, $ChainPath)
|
|
} catch {
|
|
Write-Host "Failed to download CA chain: $_" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
# 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
|
|
Write-Host "Done!" -ForegroundColor Green
|
|
'''
|
|
return PlainTextResponse(script, media_type="text/plain")
|
|
|
|
MAINPYEOF
|
|
|
|
cat > /opt/certauth/api/templates/base.html << 'TPL_BASE.HTML_EOF'
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="bg-gray-900 text-gray-100">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CertAuth{% block title %}{% endblock %}</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
</head>
|
|
<body class="min-h-screen">
|
|
{% if user %}
|
|
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-3 flex items-center justify-between">
|
|
<div class="flex items-center gap-6">
|
|
<a href="/" class="font-bold text-lg text-blue-400 hover:text-blue-300">CertAuth</a>
|
|
<a href="/domains" class="hover:text-blue-400">Domains</a>
|
|
<a href="/certs" class="hover:text-blue-400">Certificates</a>
|
|
<a href="/history" class="hover:text-blue-400">History</a>
|
|
<a href="/setup" class="hover:text-blue-400">Setup</a>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-sm text-gray-400">{{ user.get("sub", "") }}</span>
|
|
<a href="/logout" class="text-sm text-gray-400 hover:text-white">Logout</a>
|
|
</div>
|
|
</nav>
|
|
{% endif %}
|
|
<main class="p-6 max-w-6xl mx-auto">
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
|
|
TPL_BASE.HTML_EOF
|
|
|
|
cat > /opt/certauth/api/templates/certs.html << 'TPL_CERTS.HTML_EOF'
|
|
{% extends "base.html" %}
|
|
{% block title %} - Certificates{% endblock %}
|
|
{% block content %}
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold">Certificates</h1>
|
|
</div>
|
|
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
|
<h2 class="font-bold mb-4">Request New Certificate</h2>
|
|
<form hx-post="/api/certs/web/request" hx-swap="innerHTML" hx-target="#cert-result"
|
|
class="flex gap-3 flex-wrap">
|
|
<input name="cn" placeholder="CN (e.g., git.example.com)" required
|
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
|
<input name="sans" placeholder="SANs (comma-separated, e.g., git.example.com,*.git.example.com)"
|
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
|
<select name="domain_id" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
|
<option value="0">No domain</option>
|
|
{% for d in domains %}
|
|
<option value="{{ d.id }}">{{ d.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded font-medium">Request</button>
|
|
</form>
|
|
<div id="cert-result" class="mt-3 text-sm"></div>
|
|
</div>
|
|
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-gray-700">
|
|
<tr>
|
|
<th class="text-left p-3 text-gray-400">Subject</th>
|
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
|
<th class="text-left p-3 text-gray-400">Status</th>
|
|
<th class="text-left p-3 text-gray-400">Expires</th>
|
|
<th class="text-left p-3 text-gray-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in certs %}
|
|
<tr class="border-b border-gray-700/50">
|
|
<td class="p-3 font-mono">{{ c.subject }}</td>
|
|
<td class="p-3">{{ c.domain_name or '-' }}</td>
|
|
<td class="p-3">
|
|
<span class="px-2 py-1 rounded text-xs {% if c.status == 'issued' %}bg-green-900 text-green-300{% elif c.status == 'pending' %}bg-yellow-900 text-yellow-300{% endif %}">
|
|
{{ c.status }}
|
|
</span>
|
|
</td>
|
|
<td class="p-3 text-gray-400">{{ c.expires_at[:10] if c.expires_at else '-' }}</td>
|
|
<td class="p-3">
|
|
{% if c.status == 'issued' %}
|
|
<a href="/api/certs/{{ c.id }}/pem" class="text-blue-400 hover:text-blue-300 mr-2">PEM</a>
|
|
<a href="/api/certs/{{ c.id }}/pfx" class="text-blue-400 hover:text-blue-300">PFX</a>
|
|
{% elif c.status == 'pending' %}
|
|
<button hx-post="/api/certs/{{ c.id }}/sign/web" hx-target="#sign-msg-{{ c.id }}"
|
|
class="text-yellow-400 hover:text-yellow-300">Issue</button>
|
|
<span id="sign-msg-{{ c.id }}" class="ml-2"></span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not certs %}
|
|
<tr><td colspan="5" class="p-6 text-center text-gray-500">No certificates yet</td></tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
TPL_CERTS.HTML_EOF
|
|
|
|
cat > /opt/certauth/api/templates/dashboard.html << 'TPL_DASHBOARD.HTML_EOF'
|
|
{% extends "base.html" %}
|
|
{% block title %} - Dashboard{% endblock %}
|
|
{% block content %}
|
|
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<div class="text-sm text-gray-400">Issued Certificates</div>
|
|
<div class="text-3xl font-bold text-green-400">{{ issued }}</div>
|
|
</div>
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<div class="text-sm text-gray-400">Pending Issue</div>
|
|
<div class="text-3xl font-bold text-yellow-400">{{ pending }}</div>
|
|
</div>
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<div class="text-sm text-gray-400">Registered Domains</div>
|
|
<div class="text-3xl font-bold text-blue-400">{{ domains|length }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="text-xl font-bold mb-4">Recent Certificates</h2>
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-gray-750 border-b border-gray-700">
|
|
<tr>
|
|
<th class="text-left p-3 text-gray-400">Subject</th>
|
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
|
<th class="text-left p-3 text-gray-400">Status</th>
|
|
<th class="text-left p-3 text-gray-400">Expires</th>
|
|
<th class="text-left p-3 text-gray-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in certs %}
|
|
<tr class="border-b border-gray-700/50">
|
|
<td class="p-3 font-mono">{{ c.subject }}</td>
|
|
<td class="p-3">{{ c.domain_name or '-' }}</td>
|
|
<td class="p-3">
|
|
<span class="px-2 py-1 rounded text-xs {% if c.status == 'issued' %}bg-green-900 text-green-300{% elif c.status == 'pending' %}bg-yellow-900 text-yellow-300{% else %}bg-gray-700{% endif %}">
|
|
{{ c.status }}
|
|
</span>
|
|
</td>
|
|
<td class="p-3 text-gray-400">{{ c.expires_at[:10] if c.expires_at else '-' }}</td>
|
|
<td class="p-3">
|
|
{% if c.status == 'issued' %}
|
|
<a href="/api/certs/{{ c.id }}/pem" class="text-blue-400 hover:text-blue-300 mr-2">PEM</a>
|
|
<a href="/api/certs/{{ c.id }}/pfx" class="text-blue-400 hover:text-blue-300">PFX</a>
|
|
{% elif c.status == 'pending' %}
|
|
<button hx-post="/api/certs/{{ c.id }}/sign/web" hx-target="#sign-msg-{{ c.id }}"
|
|
class="text-yellow-400 hover:text-yellow-300">Issue</button>
|
|
<span id="sign-msg-{{ c.id }}" class="ml-2"></span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not certs %}
|
|
<tr><td colspan="5" class="p-6 text-center text-gray-500">No certificates yet</td></tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
TPL_DASHBOARD.HTML_EOF
|
|
|
|
cat > /opt/certauth/api/templates/domains.html << 'TPL_DOMAINS.HTML_EOF'
|
|
{% extends "base.html" %}
|
|
{% block title %} - Domains{% endblock %}
|
|
{% block content %}
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold">Domains</h1>
|
|
</div>
|
|
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
|
<h2 class="font-bold mb-4">Register New Domain</h2>
|
|
<form hx-post="/api/domains/web" hx-swap="innerHTML" hx-target="#domain-result"
|
|
class="flex gap-3 flex-wrap">
|
|
<input name="name" placeholder="*.example.com" required
|
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
|
<input name="description" placeholder="Description (optional)"
|
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
|
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded font-medium">Register</button>
|
|
</form>
|
|
<div id="domain-result" class="mt-3 text-sm"></div>
|
|
</div>
|
|
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-gray-700">
|
|
<tr>
|
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
|
<th class="text-left p-3 text-gray-400">Description</th>
|
|
<th class="text-left p-3 text-gray-400">Status</th>
|
|
<th class="text-left p-3 text-gray-400">Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for d in domains %}
|
|
<tr class="border-b border-gray-700/50">
|
|
<td class="p-3 font-mono">{{ d.name }}</td>
|
|
<td class="p-3 text-gray-400">{{ d.description or '-' }}</td>
|
|
<td class="p-3"><span class="px-2 py-1 rounded text-xs bg-green-900 text-green-300">{{ d.status }}</span></td>
|
|
<td class="p-3 text-gray-400">{{ d.created_at[:10] }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not domains %}
|
|
<tr><td colspan="4" class="p-6 text-center text-gray-500">No domains registered</td></tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
TPL_DOMAINS.HTML_EOF
|
|
|
|
cat > /opt/certauth/api/templates/history.html << 'TPL_HISTORY.HTML_EOF'
|
|
{% extends "base.html" %}
|
|
{% block title %} - History{% endblock %}
|
|
{% block content %}
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold">Certificate History</h1>
|
|
</div>
|
|
|
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-gray-700">
|
|
<tr>
|
|
<th class="text-left p-3 text-gray-400">Serial</th>
|
|
<th class="text-left p-3 text-gray-400">Subject</th>
|
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
|
<th class="text-left p-3 text-gray-400">SANs</th>
|
|
<th class="text-left p-3 text-gray-400">Status</th>
|
|
<th class="text-left p-3 text-gray-400">Issued</th>
|
|
<th class="text-left p-3 text-gray-400">Expires</th>
|
|
<th class="text-left p-3 text-gray-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in certs %}
|
|
<tr class="border-b border-gray-700/50">
|
|
<td class="p-3 font-mono text-xs">{{ c.serial or '-' }}</td>
|
|
<td class="p-3 font-mono">{{ c.subject }}</td>
|
|
<td class="p-3">{{ c.domain_name or '-' }}</td>
|
|
<td class="p-3 text-xs text-gray-400">{{ c.san or '-' }}</td>
|
|
<td class="p-3">
|
|
<span class="px-2 py-1 rounded text-xs {% if c.status == 'issued' %}bg-green-900 text-green-300{% elif c.status == 'pending' %}bg-yellow-900 text-yellow-300{% else %}bg-gray-700{% endif %}">
|
|
{{ c.status }}
|
|
</span>
|
|
</td>
|
|
<td class="p-3 text-gray-400">{{ c.issued_at[:10] if c.issued_at else '-' }}</td>
|
|
<td class="p-3 text-gray-400">{{ c.expires_at[:10] if c.expires_at else '-' }}</td>
|
|
<td class="p-3">
|
|
{% if c.status == 'issued' %}
|
|
<a href="/api/certs/{{ c.id }}/pem" class="text-blue-400 hover:text-blue-300 mr-2">PEM</a>
|
|
<a href="/api/certs/{{ c.id }}/pfx" class="text-blue-400 hover:text-blue-300">PFX</a>
|
|
{% elif c.status == 'pending' %}
|
|
<button hx-post="/api/certs/{{ c.id }}/sign/web" hx-target="#sign-msg-{{ c.id }}"
|
|
class="text-yellow-400 hover:text-yellow-300">Issue</button>
|
|
<span id="sign-msg-{{ c.id }}" class="ml-2"></span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% if not certs %}
|
|
<tr><td colspan="8" class="p-6 text-center text-gray-500">No certificates yet</td></tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
TPL_HISTORY.HTML_EOF
|
|
|
|
cat > /opt/certauth/api/templates/login.html << 'TPL_LOGIN.HTML_EOF'
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="bg-gray-900 text-gray-100">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CertAuth - Login</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
</head>
|
|
<body class="min-h-screen flex items-center justify-center">
|
|
<div class="bg-gray-800 rounded-lg p-8 border border-gray-700 w-full max-w-md">
|
|
<h1 class="text-2xl font-bold mb-2 text-blue-400">CertAuth</h1>
|
|
<p class="text-gray-400 mb-6">Certificate Authority Management</p>
|
|
{% if error %}
|
|
<div class="bg-red-900/50 border border-red-700 rounded p-3 mb-4 text-red-300 text-sm">{{ error }}</div>
|
|
{% endif %}
|
|
<form method="post" action="/login">
|
|
<div class="mb-4">
|
|
<label class="block text-sm text-gray-400 mb-1">Username</label>
|
|
<input type="text" name="username" required
|
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
|
</div>
|
|
<div class="mb-6">
|
|
<label class="block text-sm text-gray-400 mb-1">Password</label>
|
|
<input type="password" name="password" required
|
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
|
</div>
|
|
<button type="submit"
|
|
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2 rounded transition">
|
|
Sign In
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
|
|
TPL_LOGIN.HTML_EOF
|
|
|
|
cat > /opt/certauth/api/templates/setup.html << 'TPL_SETUP.HTML_EOF'
|
|
{% extends "base.html" %}
|
|
{% block title %} - Setup{% endblock %}
|
|
{% block content %}
|
|
<h1 class="text-2xl font-bold mb-2">Setup</h1>
|
|
<p class="text-gray-400 mb-6">Install the CA chain on client machines to trust certificates from this authority.</p>
|
|
|
|
<!-- Quick Install -->
|
|
<div class="bg-blue-900/50 rounded-lg p-6 border border-blue-700 mb-6">
|
|
<h2 class="font-bold mb-3">Quick Install</h2>
|
|
<p class="text-sm text-gray-300 mb-3">Run one command on any machine to download and install the CA chain automatically.</p>
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<span class="text-sm text-gray-400">Linux / macOS</span>
|
|
<pre class="bg-gray-900 rounded p-3 text-sm mt-1 overflow-x-auto"><code>curl -sL http://192.168.8.248/setup.sh | sudo bash</code></pre>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-400">Windows (PowerShell)</span>
|
|
<pre class="bg-gray-900 rounded p-3 text-sm mt-1 overflow-x-auto"><code>iwr http://192.168.8.248/setup.ps1 -UseBasicParsing | iex</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Download CA Chain -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
|
<h2 class="font-bold mb-4">Manual Download</h2>
|
|
<p class="text-sm text-gray-400 mb-4">Contains the Intermediate + Root CA certificates.</p>
|
|
<a href="/api/ca-chain" class="inline-block bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded font-medium text-white">Download ca-chain.crt</a>
|
|
</div>
|
|
|
|
<!-- Platform Instructions -->
|
|
<div class="space-y-4">
|
|
<h2 class="font-bold text-lg">Manual Installation</h2>
|
|
|
|
<!-- Linux -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="font-bold mb-2">Linux (Debian/Ubuntu)</h3>
|
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>sudo cp ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
|
sudo update-ca-certificates</code></pre>
|
|
</div>
|
|
|
|
<!-- macOS -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="font-bold mb-2">macOS</h3>
|
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca-chain.crt</code></pre>
|
|
</div>
|
|
|
|
<!-- Windows -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="font-bold mb-2">Windows</h3>
|
|
<p class="text-sm text-gray-400 mb-2">Double-click <code class="bg-gray-700 px-1 rounded">ca-chain.crt</code>, then:</p>
|
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>1. Click "Install Certificate"
|
|
2. Select "Local Machine" → Next
|
|
3. Select "Place all certificates in the following store"
|
|
4. Browse → "Trusted Root Certification Authorities"
|
|
5. OK → Next → Finish</code></pre>
|
|
</div>
|
|
|
|
<!-- Docker -->
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<h3 class="font-bold mb-2">Docker</h3>
|
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>COPY ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
|
RUN update-ca-certificates</code></pre>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
TPL_SETUP.HTML_EOF
|
|
|
|
|
|
chown -R "$ADMIN_USER:$ADMIN_USER" /opt/certauth
|
|
|
|
# Initialize database
|
|
cd /opt/certauth/api
|
|
sudo -u "$ADMIN_USER" python3 -c "from models import init_db; init_db()" 2>/dev/null || true
|
|
|
|
success "API installed"
|
|
}
|
|
|
|
# ===================== STEP 8: SERVICES =====================
|
|
setup_services() {
|
|
info "=== Step 8: Configure Services ==="
|
|
|
|
# Caddy
|
|
cat > /etc/caddy/Caddyfile << 'CADDYEOF'
|
|
:80 {
|
|
encode gzip
|
|
reverse_proxy 127.0.0.1:8000 {
|
|
header_up Host {host}
|
|
header_up X-Real-IP {remote}
|
|
}
|
|
}
|
|
CADDYEOF
|
|
|
|
# Systemd service for API
|
|
cat > /etc/systemd/system/certauth-api.service << SVCCEOF
|
|
[Unit]
|
|
Description=CertAuth Certificate Management API
|
|
After=network.target pcscd.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=$ADMIN_USER
|
|
Group=$ADMIN_USER
|
|
WorkingDirectory=/opt/certauth/api
|
|
Environment=PYTHONUNBUFFERED=1
|
|
ExecStart=/usr/bin/python3 -m uvicorn main:app --host 127.0.0.1 --port 8000
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
SVCCEOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable caddy certauth-api pcscd
|
|
systemctl start caddy certauth-api
|
|
|
|
success "Services configured and started"
|
|
}
|
|
|
|
# ===================== STEP 9: VERIFY =====================
|
|
verify_setup() {
|
|
info "=== Step 9: Verification ==="
|
|
|
|
sleep 3
|
|
|
|
# Check services
|
|
for svc in certauth-api caddy pcscd; do
|
|
STATUS=$(systemctl is-active "$svc")
|
|
if [[ "$STATUS" == "active" ]]; then
|
|
success "$svc: active"
|
|
else
|
|
warn "$svc: $STATUS"
|
|
fi
|
|
done
|
|
|
|
# Test API
|
|
HEALTH=$(curl -s http://localhost:8000/api/health 2>/dev/null || echo "FAILED")
|
|
if echo "$HEALTH" | grep -q "ok"; then
|
|
success "API health check: OK"
|
|
else
|
|
warn "API health check: $HEALTH"
|
|
fi
|
|
|
|
# Verify CA chain
|
|
openssl verify -CAfile /etc/ssl/ca/root/root-ca.crt \
|
|
/etc/ssl/ca/intermediate/intermediate-ca.crt 2>&1 | while read line; do
|
|
if echo "$line" | grep -q "OK"; then
|
|
success "CA chain verification: OK"
|
|
else
|
|
warn "CA chain: $line"
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "============================================================"
|
|
echo " SETUP COMPLETE"
|
|
echo "============================================================"
|
|
echo ""
|
|
echo " Access the Key Vault UI at:"
|
|
echo " http://$(hostname -I | awk '{print $1}')/"
|
|
echo ""
|
|
echo " Admin login:"
|
|
echo " Username: $ADMIN_USER"
|
|
echo " Password: $ADMIN_PASS"
|
|
echo ""
|
|
echo " API endpoints:"
|
|
echo " POST /api/token - Login (returns JWT)"
|
|
echo " GET /api/domains - List domains"
|
|
echo " POST /api/domains - Register domain"
|
|
echo " GET /api/certs - List certificates"
|
|
echo " POST /api/certs/request - Request certificate"
|
|
echo " POST /api/certs/{id}/sign - Sign certificate (requires YK2)"
|
|
echo " GET /api/certs/{id}/download - Download cert"
|
|
echo " GET /api/certs/{id}/key - Download private key"
|
|
echo " GET /api/ca-chain - Download CA chain"
|
|
echo ""
|
|
echo " IMPORTANT: Change the admin password after first login!"
|
|
echo "============================================================"
|
|
}
|
|
|
|
# ===================== MAIN =====================
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
echo ""
|
|
echo "============================================================"
|
|
echo " CertAuth Setup"
|
|
echo "============================================================"
|
|
echo ""
|
|
|
|
check_prerequisites
|
|
setup_system
|
|
setup_user
|
|
setup_firewall
|
|
detect_yubikeys
|
|
configure_yubikeys
|
|
generate_ca_certs
|
|
install_api
|
|
setup_services
|
|
verify_setup
|
|
|
|
echo ""
|
|
success "All done!" |