certauth/api/signing.py
Jarian Cottingham afedb6e9ba fix: security hardening - credentials, CSRF, XSS, keys, CRL
- #8: Remove hardcoded credentials, require env vars (YK_ROOT_PIN, YK_INT_PIN, ADMIN_PASSWORD, JWT_SECRET)
- #11: JWT secret now random via secrets.token_hex(32) if not set
- #12: Admin password from env var, not hardcoded
- #14: XSS prevention - sanitize error messages, html.escape
- #15: CSRF tokens on all forms
- #16: Cookie Secure flag added
- #7: datetime.utcnow() → datetime.now(timezone.utc)
- #3: Temp files in tempfile.mkdtemp, cleaned after use
- #22: Private keys via cryptography library (NoEncryption for now)
- #26: DER construction via cryptography library
- #27: CRL table added for certificate revocation
- #29: WAL autocheckpoint enabled
- #30: Caddyfile already has TLS (no change needed)
- #6: .gitignore for .password, *.pem, *.key
2026-07-05 04:19:11 +00:00

154 lines
5.4 KiB
Python

import subprocess
import os
import logging
import tempfile
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, BestAvailableEncryption
from cryptography.x509.oid import NameOID, ExtensionOID
from config import *
logger = logging.getLogger(__name__)
TMP_DIR = tempfile.mkdtemp(prefix="certauth_")
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 sign_tbs_with_yk(tbs_bytes, yk_pin, token_label="certauth Intermediate CA"):
with tempfile.NamedTemporaryFile(suffix=".der", delete=False, dir=TMP_DIR) as tbs_file:
tbs_file.write(tbs_bytes)
tbs_path = tbs_file.name
sig_path = os.path.join(TMP_DIR, f"sig_{os.path.basename(tbs_path)}")
try:
r = subprocess.run(
[
"sudo", "pkcs11-tool", "--module", PKCS11_MODULE,
"--login", "--pin-source", "stdin",
"--sign", "--mechanism", "ECDSA-SHA384",
"--token-label", token_label,
"--label", "SIGN key",
"--input-file", tbs_path,
"--output-file", sig_path,
],
input=yk_pin.encode(),
capture_output=True,
text=True,
timeout=30,
)
if r.returncode != 0:
logger.error("YubiKey signing failed: %s", r.stderr[:200])
return None, "Signing failed"
with open(sig_path, "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
finally:
for f in [tbs_path, sig_path]:
try:
os.unlink(f)
except OSError:
pass
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.now(timezone.utc))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=days))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage([
x509.OID_SERVER_AUTH,
x509.OID_CLIENT_AUTH,
]),
critical=False,
)
)
if sans:
san_names = [x509.DNSName(s.strip()) for s in sans.split(",") if s.strip()]
if san_names:
builder = builder.add_extension(
x509.SubjectAlternativeName(san_names),
critical=False,
)
tbs_bytes = builder.signature_algorithm_oid
cert_bytes = builder.sign(leaf_key, hashes.SHA384())
serial = x509.load_der_x509_certificate(cert_bytes).serial_number
key_pem = leaf_key.private_bytes(
encoding=Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=NoEncryption(),
).decode()
return (
{
"serial": hex(serial),
"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)