Automating SSL Certificate Requests from Linux against AD CS (Zero-Dependency Python Method)
Assumptions
This instruction assumes:
- Oracle Linux 9.x (x86_64). Also applies to RHEL 9, Rocky Linux 9, and AlmaLinux 9
- Root or sudo access for package installation
- The Linux client is joined to Active Directory via SSSD/realmd or equivalent AD integration tool. The AD integration provides
kinitand Kerberos ticket management - Python 3.9+ is installed (system default on OL9)
- The CA server runs Microsoft AD CS with the Web Enrollment role installed, accessible at
https://{CA_SERVER_FQDN}/certsrv/ - IIS on the CA server requires Negotiate (Kerberos/SPNEGO) authentication with Extended Protection for Authentication (EPA) enabled
- The requesting AD principal has Enroll permission on the target certificate template
- The certificate template is configured for auto-approval (no CA manager approval required)
- The reader understands basic PKI concepts: CSR, private key, certificate chain
- PyPI access is unavailable (corporate environment with no internet or PyPI access)
- OpenSSL 3.0+ is installed (system default on OL9)
- SELinux is in enforcing mode (default on OL9). The script uses only standard system operations permitted under default SELinux policy
Prerequisites
Automatic setup
dnf install -y krb5-workstation krb5-libs openssl python3 bind-utils
krb5-workstation-- provideskinit,klist,kvnokrb5-libs-- provides/usr/lib64/libgssapi_krb5.so.2(the GSSAPI C library loaded via ctypes)openssl-- provides theopensslCLI for key and CSR generationpython3-- Python 3.9 (system default on OL9)bind-utils-- provides thehostcommand for reverse DNS verification
Manual setup
- Verify Active Directory join
klist -k /etc/krb5.keytab
Expected output:
Keytab name: FILE:/etc/krb5.keytab
KVNO Principal
---- --------------------------------------------------------------------------
2 host/{CLIENT_FQDN}@{KERBEROS_REALM}
2 host/{CLIENT_FQDN}@{KERBEROS_REALM}
{CLIENT_FQDN} -- the fully qualified domain name of this Linux client (e.g., server01.example.com).
{KERBEROS_REALM} -- the Active Directory Kerberos realm in uppercase (e.g., EXAMPLE.COM).
If no keytab entries appear, the machine is not joined to AD. Complete the AD join before proceeding.
- Obtain a Kerberos ticket
kinit {USERNAME}@{KERBEROS_REALM}
{USERNAME} -- the AD account used for certificate enrollment.
Verify the ticket:
klist
Expected output:
Ticket cache: FILE:/tmp/krb5cc_1000
Default principal: {USERNAME}@{KERBEROS_REALM}
Valid starting Expires Service principal
03/03/2026 08:00 03/03/2026 18:00 krbtgt/{KERBEROS_REALM}@{KERBEROS_REALM}
- Verify SPN resolution for the CA server
kvno HTTP/{CA_SERVER_FQDN}
{CA_SERVER_FQDN} -- the fully qualified domain name of the AD CS server (e.g., ca.example.com).
Expected output:
HTTP/{CA_SERVER_FQDN}@{KERBEROS_REALM}: kvno = 5
If this fails with "Server not found in Kerberos database," check reverse DNS (step 4). The script's custom krb5.conf with rdns = false resolves this.
- Check reverse DNS for the CA server
host {CA_SERVER_IP}
{CA_SERVER_IP} -- the IP address of the CA server.
If the returned hostname does not match {CA_SERVER_FQDN}, reverse DNS is misconfigured. The script handles this automatically by disabling reverse DNS canonicalization in a custom krb5.conf.
- Verify HTTPS connectivity to the CA server
echo | openssl s_client -connect {CA_SERVER_FQDN}:443 2>/dev/null | openssl x509 -noout -subject
Expected output:
subject=CN = {CA_SERVER_FQDN}
Additional setup
- Verify the GSSAPI library exists
ls -l /usr/lib64/libgssapi_krb5.so.2
Expected output:
lrwxrwxrwx. 1 root root 25 Jan 15 2026 /usr/lib64/libgssapi_krb5.so.2 -> libgssapi_krb5.so.2.2
- Verify the server certificate signature algorithm
echo | openssl s_client -connect {CA_SERVER_FQDN}:443 2>/dev/null | openssl x509 -noout -text | grep "Signature Algorithm" | head -1
Expected output:
Signature Algorithm: sha256WithRSAEncryption
The script uses SHA-256 for channel binding computation. Per RFC 5929, SHA-256 is correct for certs signed with SHA-256, MD5, or SHA-1.
Background: Why Zero-Dependency?
Two constraints make standard approaches unworkable:
curl --negotiatedoes not support EPA. When IIS requires Extended Protection for Authentication,curlcannot compute TLS Channel Binding Tokens. Authentication fails with HTTP 401.pipis unavailable. Therequestsandpython-gssapipackages would simplify the code substantially, but corporate environments without PyPI access cannot install them.
The script uses Python's ctypes module to call libgssapi_krb5.so.2 directly, http.client for HTTPS connections, and subprocess for openssl commands. Every dependency ships with Oracle Linux 9.
Background: The Three Critical Problems
This script solves three problems that are not documented together in any published source. Each was discovered during production deployment.
Problem 1: Reverse DNS SPN mismatch
When GSSAPI constructs a service principal name for HTTP/{CA_SERVER_FQDN}, the MIT Kerberos library performs a reverse DNS lookup on the server's IP address. If the PTR record returns a different hostname than {CA_SERVER_FQDN}, the SPN lookup fails with "Server not found in Kerberos database."
Solution: Generate a custom krb5.conf with rdns = false and dns_canonicalize_hostname = false in [libdefaults]. Set the KRB5_CONFIG environment variable to point to this file before loading libgssapi_krb5.so.2. If set after loading, it has no effect -- the library reads its configuration once at load time.
Problem 2: IIS Extended Protection for Authentication (EPA)
IIS can require a TLS Channel Binding Token (CBT) as part of the authentication exchange. The CBT binds the Kerberos authentication to the specific TLS session, preventing man-in-the-middle relay attacks. Without a valid CBT, IIS returns HTTP 401 even though the Kerberos token itself is correct.
Solution: Compute the tls-server-end-point channel binding per RFC 5929: hash the DER-encoded server certificate with SHA-256, prepend tls-server-end-point:, and pass the result as application_data in a gss_channel_bindings_struct to gss_init_sec_context.
Problem 3: Load-balanced servers with different certificates
When the CA is behind a load balancer, different backends may present different TLS certificates. If the script opens one connection (capturing certificate A), then opens a second connection for the next request (hitting backend B with certificate B), the channel binding computed from certificate A will not match, and authentication fails.
Solution: Use a single http.client.HTTPSConnection for all requests. One TCP connection means one TLS session, one server certificate, and one valid channel binding for the entire workflow.
Gotcha: ctypes.pointer() vs ctypes.byref()
When passing the gss_channel_bindings_struct to gss_init_sec_context, use ctypes.pointer(), not ctypes.byref(). The byref() function creates a temporary lightweight pointer that can be garbage-collected before GSSAPI reads it. The resulting token will be identical in length to a token without channel bindings -- that is how this bug manifests. Use ctypes.pointer() to create a durable pointer object.
The Script
Key sections walkthrough
Configuration variables -- all environment-specific values are defined at the top of the script. Replace the placeholder defaults with your values.
CA_HOST = "{CA_SERVER_FQDN}" # AD CS server FQDN
CA_NAME = "{CA_COMMON_NAME}" # CA common name
TEMPLATE = "{CERT_TEMPLATE_NAME}" # Certificate template name
KRB5_REALM = "{KERBEROS_REALM}" # Kerberos realm (uppercase)
{CA_COMMON_NAME} -- the common name of the CA as displayed in the Certification Authority snap-in (e.g., CorpIssuingCA01).
{CERT_TEMPLATE_NAME} -- the AD CS certificate template name, not the display name (e.g., WebServer or WebServerExportable).
Custom krb5.conf generation -- the script writes a minimal krb5.conf to a temporary file with rdns = false, then sets KRB5_CONFIG before loading the GSSAPI library:
krb5_conf = f"""[libdefaults]
default_realm = {KRB5_REALM}
rdns = false
dns_canonicalize_hostname = false
"""
os.environ["KRB5_CONFIG"] = krb5_path # Must happen BEFORE ctypes.CDLL()
gssapi = ctypes.CDLL("libgssapi_krb5.so.2")
Channel binding computation -- per RFC 5929, the tls-server-end-point binding for a SHA-256-signed certificate is the SHA-256 hash of the DER-encoded server certificate, prefixed with tls-server-end-point::
cb_data = b"tls-server-end-point:" + hashlib.sha256(server_cert_der).digest()
GSSAPI context initialization with channel bindings -- the channel bindings struct is allocated with ctypes.pointer() (not byref()) and passed to gss_init_sec_context:
bindings = gss_channel_bindings_struct()
bindings.application_data.length = len(cb_data)
bindings.application_data.value = ctypes.cast(
ctypes.create_string_buffer(cb_data), ctypes.c_void_p
)
# pointer() creates a durable object; byref() creates a temporary
major = gssapi.gss_init_sec_context(
..., ctypes.pointer(bindings), ...
)
Complete script
Save the following as adcs_cert_request.py:
#!/usr/bin/env python3
"""
adcs_cert_request.py - Request SSL certificates from AD CS Web Enrollment.
Uses only the Python standard library and the system libgssapi_krb5.so.2.
No pip packages required.
Requires a valid Kerberos TGT (run kinit before this script).
"""
import argparse
import base64
import ctypes
import ctypes.util
import hashlib
import http.client
import os
import re
import shutil
import ssl
import subprocess
import sys
import tempfile
import urllib.parse
# ---------------------------------------------------------------------------
# Configuration defaults — override via command-line arguments or edit here
# ---------------------------------------------------------------------------
CA_HOST = "{CA_SERVER_FQDN}" # AD CS Web Enrollment server FQDN
CA_NAME = "{CA_COMMON_NAME}" # CA common name (as shown in certsrv)
TEMPLATE = "{CERT_TEMPLATE_NAME}" # Certificate template name (not display name)
KRB5_REALM = "{KERBEROS_REALM}" # Kerberos realm (uppercase)
GSSAPI_LIB = "/usr/lib64/libgssapi_krb5.so.2"
# Output file names
OUTPUT_KEY = "server.key"
OUTPUT_CERT = "server.crt"
OUTPUT_CA = "ca.crt"
# CSR subject fields — customize for your certificate
CSR_CN = "{CLIENT_FQDN}" # Common Name for the certificate
CSR_SAN = "{CLIENT_FQDN}" # Subject Alternative Name (comma-separated for multiple)
CSR_ORG = "Example Organization" # Organization (O)
CSR_COUNTRY = "US" # Country (C)
KEY_SIZE = 2048 # RSA key size in bits
# ---------------------------------------------------------------------------
# GSSAPI ctypes structures and constants
# ---------------------------------------------------------------------------
class gss_OID_desc(ctypes.Structure):
"""GSS-API OID descriptor."""
_fields_ = [
("length", ctypes.c_uint32),
("elements", ctypes.c_void_p),
]
class gss_buffer_desc(ctypes.Structure):
"""GSS-API buffer descriptor."""
_fields_ = [
("length", ctypes.c_size_t),
("value", ctypes.c_void_p),
]
class gss_channel_bindings_struct(ctypes.Structure):
"""GSS-API channel bindings for EPA/TLS Channel Binding."""
_fields_ = [
("initiator_addrtype", ctypes.c_uint32),
("initiator_address", gss_buffer_desc),
("acceptor_addrtype", ctypes.c_uint32),
("acceptor_address", gss_buffer_desc),
("application_data", gss_buffer_desc),
]
# OID for hostbased service name (1.2.840.113554.1.2.1.4)
_NT_HOSTBASED_SVC_OID = b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x01\x04"
GSS_C_NT_HOSTBASED_SERVICE = gss_OID_desc(
len(_NT_HOSTBASED_SVC_OID),
ctypes.cast(ctypes.create_string_buffer(_NT_HOSTBASED_SVC_OID),
ctypes.c_void_p),
)
# OID for SPNEGO mechanism (1.3.6.1.5.5.2)
_SPNEGO_OID = b"\x2b\x06\x01\x05\x05\x02"
GSS_MECH_SPNEGO = gss_OID_desc(
len(_SPNEGO_OID),
ctypes.cast(ctypes.create_string_buffer(_SPNEGO_OID), ctypes.c_void_p),
)
# GSSAPI constants
GSS_C_MUTUAL_FLAG = 2
GSS_C_REPLAY_FLAG = 4
GSS_C_SEQUENCE_FLAG = 8
GSS_C_NO_CREDENTIAL = ctypes.c_void_p(0)
GSS_C_NO_CONTEXT = ctypes.c_void_p(0)
GSS_C_NO_CHANNEL_BINDINGS = ctypes.c_void_p(0)
GSS_S_COMPLETE = 0
GSS_S_CONTINUE_NEEDED = 1
def check_gss_status(major, minor, gssapi_lib, context=""):
"""Check GSSAPI return status and raise on error."""
if major == GSS_S_COMPLETE or major == GSS_S_CONTINUE_NEEDED:
return
# Extract error message from GSSAPI
msg_ctx = ctypes.c_uint32(0)
msg_buf = gss_buffer_desc()
error_msgs = []
# Major status message
gssapi_lib.gss_display_status(
ctypes.byref(ctypes.c_uint32()),
major,
1, # GSS_C_GSS_CODE
ctypes.byref(gss_OID_desc()),
ctypes.byref(msg_ctx),
ctypes.byref(msg_buf),
)
if msg_buf.length > 0 and msg_buf.value:
error_msgs.append(ctypes.string_at(msg_buf.value, msg_buf.length).decode())
gssapi_lib.gss_release_buffer(ctypes.byref(ctypes.c_uint32()),
ctypes.byref(msg_buf))
# Minor status message
msg_ctx2 = ctypes.c_uint32(0)
msg_buf2 = gss_buffer_desc()
gssapi_lib.gss_display_status(
ctypes.byref(ctypes.c_uint32()),
minor,
2, # GSS_C_MECH_CODE
ctypes.byref(gss_OID_desc()),
ctypes.byref(msg_ctx2),
ctypes.byref(msg_buf2),
)
if msg_buf2.length > 0 and msg_buf2.value:
error_msgs.append(ctypes.string_at(msg_buf2.value, msg_buf2.length).decode())
gssapi_lib.gss_release_buffer(ctypes.byref(ctypes.c_uint32()),
ctypes.byref(msg_buf2))
detail = "; ".join(error_msgs) if error_msgs else f"major={major:#x}, minor={minor:#x}"
raise RuntimeError(f"GSSAPI error ({context}): {detail}")
def setup_gssapi_functions(lib):
"""Configure ctypes function signatures for the GSSAPI library."""
# gss_import_name
lib.gss_import_name.restype = ctypes.c_uint32
lib.gss_import_name.argtypes = [
ctypes.POINTER(ctypes.c_uint32), # minor_status
ctypes.POINTER(gss_buffer_desc), # input_name_buffer
ctypes.POINTER(gss_OID_desc), # input_name_type
ctypes.POINTER(ctypes.c_void_p), # output_name
]
# gss_init_sec_context
lib.gss_init_sec_context.restype = ctypes.c_uint32
lib.gss_init_sec_context.argtypes = [
ctypes.POINTER(ctypes.c_uint32), # minor_status
ctypes.c_void_p, # initiator_cred_handle
ctypes.POINTER(ctypes.c_void_p), # context_handle
ctypes.c_void_p, # target_name
ctypes.POINTER(gss_OID_desc), # mech_type
ctypes.c_uint32, # req_flags
ctypes.c_uint32, # time_req
ctypes.POINTER(gss_channel_bindings_struct), # input_chan_bindings
ctypes.POINTER(gss_buffer_desc), # input_token
ctypes.POINTER(ctypes.c_void_p), # actual_mech_type
ctypes.POINTER(gss_buffer_desc), # output_token
ctypes.POINTER(ctypes.c_uint32), # ret_flags
ctypes.POINTER(ctypes.c_uint32), # time_rec
]
# gss_release_buffer
lib.gss_release_buffer.restype = ctypes.c_uint32
lib.gss_release_buffer.argtypes = [
ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(gss_buffer_desc),
]
# gss_release_name
lib.gss_release_name.restype = ctypes.c_uint32
lib.gss_release_name.argtypes = [
ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(ctypes.c_void_p),
]
# gss_delete_sec_context
lib.gss_delete_sec_context.restype = ctypes.c_uint32
lib.gss_delete_sec_context.argtypes = [
ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(ctypes.c_void_p),
ctypes.POINTER(gss_buffer_desc),
]
# gss_display_status
lib.gss_display_status.restype = ctypes.c_uint32
lib.gss_display_status.argtypes = [
ctypes.POINTER(ctypes.c_uint32), # minor_status
ctypes.c_uint32, # status_value
ctypes.c_int, # status_type
ctypes.POINTER(gss_OID_desc), # mech_type
ctypes.POINTER(ctypes.c_uint32), # message_context
ctypes.POINTER(gss_buffer_desc), # status_string
]
def create_custom_krb5_conf(realm, work_dir):
"""
Create a minimal krb5.conf with rdns=false to prevent reverse DNS
canonicalization of hostnames. This solves SPN mismatch when PTR
records return a different hostname than the forward A record.
Returns the path to the temporary krb5.conf file.
"""
krb5_path = os.path.join(work_dir, "krb5_custom.conf")
content = f"""[libdefaults]
default_realm = {realm}
rdns = false
dns_canonicalize_hostname = false
default_tgs_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
default_tkt_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
permitted_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
[realms]
{realm} = {{
kdc = {realm.lower()}
admin_server = {realm.lower()}
}}
[domain_realm]
.{realm.lower()} = {realm}
{realm.lower()} = {realm}
"""
with open(krb5_path, "w") as f:
f.write(content)
return krb5_path
def get_server_cert_der(conn):
"""
Extract the DER-encoded server certificate from an active
HTTPSConnection. The connection must already be established.
"""
sock = conn.sock
if hasattr(sock, "getpeercert"):
cert_der = sock.getpeercert(binary_form=True)
if cert_der:
return cert_der
raise RuntimeError("Could not retrieve server certificate from TLS connection")
def compute_channel_binding(cert_der):
"""
Compute the tls-server-end-point channel binding per RFC 5929.
For certificates signed with SHA-256 (or MD5/SHA-1, which also map
to SHA-256), the binding is:
b"tls-server-end-point:" + SHA-256(cert_DER)
"""
return b"tls-server-end-point:" + hashlib.sha256(cert_der).digest()
def build_channel_bindings(cb_data):
"""
Build a gss_channel_bindings_struct with the provided channel binding
data as application_data. Uses ctypes.pointer() for durability —
ctypes.byref() creates temporaries that may be garbage-collected.
"""
# Allocate a persistent buffer for the channel binding data
cb_buf = ctypes.create_string_buffer(cb_data)
bindings = gss_channel_bindings_struct()
bindings.initiator_addrtype = 0
bindings.initiator_address.length = 0
bindings.initiator_address.value = None
bindings.acceptor_addrtype = 0
bindings.acceptor_address.length = 0
bindings.acceptor_address.value = None
bindings.application_data.length = len(cb_data)
bindings.application_data.value = ctypes.cast(cb_buf, ctypes.c_void_p)
# Return both to prevent garbage collection of the buffer
return bindings, cb_buf
def get_negotiate_token(gssapi_lib, spn, channel_bindings):
"""
Generate a SPNEGO Negotiate token for the given SPN with optional
channel bindings for EPA.
Returns the base64-encoded token string.
"""
minor = ctypes.c_uint32()
target_name = ctypes.c_void_p()
# Import the target SPN (e.g., "HTTP@ca.example.com")
spn_bytes = spn.encode("utf-8")
name_buf = gss_buffer_desc(len(spn_bytes),
ctypes.cast(ctypes.create_string_buffer(spn_bytes),
ctypes.c_void_p))
major = gssapi_lib.gss_import_name(
ctypes.byref(minor),
ctypes.byref(name_buf),
ctypes.byref(GSS_C_NT_HOSTBASED_SERVICE),
ctypes.byref(target_name),
)
check_gss_status(major, minor.value, gssapi_lib, "gss_import_name")
# Initialize the security context with SPNEGO mechanism
context = ctypes.c_void_p(0)
output_token = gss_buffer_desc()
input_token = gss_buffer_desc(0, None)
ret_flags = ctypes.c_uint32()
time_rec = ctypes.c_uint32()
actual_mech = ctypes.c_void_p()
req_flags = GSS_C_MUTUAL_FLAG | GSS_C_REPLAY_FLAG | GSS_C_SEQUENCE_FLAG
# Use ctypes.pointer() for channel bindings — NOT byref()
if channel_bindings is not None:
cb_ptr = ctypes.pointer(channel_bindings)
else:
cb_ptr = ctypes.cast(GSS_C_NO_CHANNEL_BINDINGS,
ctypes.POINTER(gss_channel_bindings_struct))
major = gssapi_lib.gss_init_sec_context(
ctypes.byref(minor),
GSS_C_NO_CREDENTIAL,
ctypes.byref(context),
target_name,
ctypes.byref(GSS_MECH_SPNEGO),
req_flags,
0, # time_req: use default
cb_ptr,
ctypes.byref(input_token),
ctypes.byref(actual_mech),
ctypes.byref(output_token),
ctypes.byref(ret_flags),
ctypes.byref(time_rec),
)
check_gss_status(major, minor.value, gssapi_lib, "gss_init_sec_context")
# Extract the token bytes and base64-encode
if output_token.length > 0 and output_token.value:
token_bytes = ctypes.string_at(output_token.value, output_token.length)
token_b64 = base64.b64encode(token_bytes).decode("ascii")
else:
raise RuntimeError("gss_init_sec_context produced no output token")
# Clean up GSSAPI resources
gssapi_lib.gss_release_buffer(ctypes.byref(minor), ctypes.byref(output_token))
gssapi_lib.gss_release_name(ctypes.byref(minor), ctypes.byref(target_name))
if context.value:
gssapi_lib.gss_delete_sec_context(
ctypes.byref(minor), ctypes.byref(context),
ctypes.byref(gss_buffer_desc()),
)
return token_b64
def authenticated_request(conn, method, path, gssapi_lib, spn,
channel_bindings, body=None, content_type=None):
"""
Send an authenticated HTTP request using Negotiate/SPNEGO.
All requests reuse the same persistent HTTPSConnection.
"""
token_b64 = get_negotiate_token(gssapi_lib, spn, channel_bindings)
headers = {
"Authorization": f"Negotiate {token_b64}",
"User-Agent": "adcs-cert-request/1.0",
}
if content_type:
headers["Content-Type"] = content_type
conn.request(method, path, body=body, headers=headers)
response = conn.getresponse()
response_body = response.read()
return response.status, response.reason, dict(response.getheaders()), response_body
def generate_csr(cn, san_list, org, country, key_size, key_path, csr_path):
"""
Generate an RSA private key and CSR using the openssl CLI.
Returns the PEM-encoded CSR as a string.
"""
# Generate RSA private key
subprocess.run(
["openssl", "genrsa", "-out", key_path, str(key_size)],
check=True, capture_output=True,
)
# Build SAN extension config
san_entries = ", ".join(f"DNS:{s.strip()}" for s in san_list)
openssl_conf = f"""[req]
default_bits = {key_size}
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req
[dn]
C = {country}
O = {org}
CN = {cn}
[v3_req]
subjectAltName = {san_entries}
"""
conf_path = csr_path + ".cnf"
with open(conf_path, "w") as f:
f.write(openssl_conf)
# Generate CSR
subprocess.run(
["openssl", "req", "-new", "-key", key_path,
"-out", csr_path, "-config", conf_path],
check=True, capture_output=True,
)
with open(csr_path, "r") as f:
csr_pem = f.read()
# Clean up config file
os.unlink(conf_path)
return csr_pem
def submit_csr(conn, gssapi_lib, spn, channel_bindings, csr_pem, template):
"""
Submit a CSR to AD CS Web Enrollment via POST to /certsrv/certfnsh.asp.
Returns the Request ID of the submitted certificate request.
"""
form_data = urllib.parse.urlencode({
"Mode": "newreq",
"CertRequest": csr_pem,
"CertAttrib": f"CertificateTemplate:{template}",
"FriendlyType": "Saved-Request Certificate",
"TargetStoreFlags": "0",
"SaveCert": "yes",
})
status, reason, headers, body = authenticated_request(
conn, "POST", "/certsrv/certfnsh.asp",
gssapi_lib, spn, channel_bindings,
body=form_data,
content_type="application/x-www-form-urlencoded",
)
if status != 200:
body_text = body.decode("utf-8", errors="replace")
raise RuntimeError(
f"CSR submission failed: HTTP {status} {reason}\n{body_text[:500]}"
)
# Parse the response HTML for the Request ID
body_text = body.decode("utf-8", errors="replace")
match = re.search(r"ReqID=(\d+)", body_text)
if not match:
# Check for common error messages
if "Certificate Pending" in body_text:
raise RuntimeError(
"Certificate request is pending CA manager approval. "
"The template may require manual approval."
)
if "Access Denied" in body_text or "not authorized" in body_text.lower():
raise RuntimeError(
"Access denied. The requesting account may lack Enroll "
"permission on the certificate template."
)
raise RuntimeError(
f"Could not find ReqID in response.\n{body_text[:1000]}"
)
return match.group(1)
def download_cert(conn, gssapi_lib, spn, channel_bindings, req_id):
"""
Download an issued certificate by Request ID from AD CS Web Enrollment.
Returns the PEM-encoded certificate as a string.
"""
path = f"/certsrv/certnew.cer?ReqID={req_id}&Enc=b64"
status, reason, headers, body = authenticated_request(
conn, "GET", path, gssapi_lib, spn, channel_bindings,
)
if status != 200:
raise RuntimeError(
f"Certificate download failed: HTTP {status} {reason}"
)
cert_pem = body.decode("utf-8", errors="replace")
if "-----BEGIN CERTIFICATE-----" not in cert_pem:
raise RuntimeError(
f"Downloaded content is not a valid PEM certificate:\n{cert_pem[:500]}"
)
return cert_pem
def download_ca_cert(conn, gssapi_lib, spn, channel_bindings):
"""
Download the CA certificate from AD CS Web Enrollment.
Returns the PEM-encoded CA certificate as a string.
"""
path = "/certsrv/certnew.cer?ReqID=CACert&Renewal=0&Enc=b64"
status, reason, headers, body = authenticated_request(
conn, "GET", path, gssapi_lib, spn, channel_bindings,
)
if status != 200:
raise RuntimeError(
f"CA certificate download failed: HTTP {status} {reason}"
)
cert_pem = body.decode("utf-8", errors="replace")
if "-----BEGIN CERTIFICATE-----" not in cert_pem:
raise RuntimeError(
f"Downloaded content is not a valid PEM certificate:\n{cert_pem[:500]}"
)
return cert_pem
def test_auth(conn, gssapi_lib, spn, channel_bindings):
"""
Test Kerberos/SPNEGO authentication against /certsrv/.
Returns True if authentication succeeds (HTTP 200).
"""
status, reason, headers, body = authenticated_request(
conn, "GET", "/certsrv/", gssapi_lib, spn, channel_bindings,
)
if status == 401:
auth_header = headers.get("WWW-Authenticate", headers.get("www-authenticate", ""))
if "Negotiate" not in auth_header:
raise RuntimeError(
"HTTP 401: Server did not offer Negotiate authentication. "
"Verify IIS is configured for Windows Authentication with "
"the Negotiate provider."
)
raise RuntimeError(
"HTTP 401: Negotiate token rejected. Possible causes:\n"
" - Channel binding mismatch (EPA failure)\n"
" - SPN mismatch (check rdns setting)\n"
" - Expired Kerberos ticket (run klist)\n"
"Enable KRB5_TRACE for detailed diagnostics."
)
if status != 200:
raise RuntimeError(f"Authentication test failed: HTTP {status} {reason}")
return True
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Request SSL certificate from AD CS Web Enrollment"
)
parser.add_argument("--ca-host", default=CA_HOST,
help=f"CA server FQDN (default: {CA_HOST})")
parser.add_argument("--ca-name", default=CA_NAME,
help=f"CA common name (default: {CA_NAME})")
parser.add_argument("--template", default=TEMPLATE,
help=f"Certificate template (default: {TEMPLATE})")
parser.add_argument("--realm", default=KRB5_REALM,
help=f"Kerberos realm (default: {KRB5_REALM})")
parser.add_argument("--cn", default=CSR_CN,
help=f"Certificate CN (default: {CSR_CN})")
parser.add_argument("--san", default=CSR_SAN,
help="SAN entries, comma-separated (default: same as CN)")
parser.add_argument("--org", default=CSR_ORG,
help=f"Organization name (default: {CSR_ORG})")
parser.add_argument("--country", default=CSR_COUNTRY,
help=f"Country code (default: {CSR_COUNTRY})")
parser.add_argument("--key-size", type=int, default=KEY_SIZE,
help=f"RSA key size (default: {KEY_SIZE})")
parser.add_argument("--key-file", default=OUTPUT_KEY,
help=f"Output key file (default: {OUTPUT_KEY})")
parser.add_argument("--cert-file", default=OUTPUT_CERT,
help=f"Output cert file (default: {OUTPUT_CERT})")
parser.add_argument("--ca-cert-file", default=OUTPUT_CA,
help=f"Output CA cert file (default: {OUTPUT_CA})")
parser.add_argument("--gssapi-lib", default=GSSAPI_LIB,
help=f"Path to libgssapi_krb5 (default: {GSSAPI_LIB})")
parser.add_argument("--skip-verify", action="store_true",
help="Skip TLS certificate verification (not recommended)")
return parser.parse_args()
def main():
args = parse_args()
work_dir = tempfile.mkdtemp(prefix="adcs_cert_")
try:
# ---------------------------------------------------------------
# Step 1: Verify Kerberos ticket exists
# ---------------------------------------------------------------
print("[1/8] Verifying Kerberos ticket...")
result = subprocess.run(["klist"], capture_output=True, text=True)
if result.returncode != 0:
print("ERROR: No valid Kerberos ticket found.", file=sys.stderr)
print(f"Run: kinit <username>@{args.realm}", file=sys.stderr)
sys.exit(1)
print(f" Ticket cache: found")
# ---------------------------------------------------------------
# Step 2: Create custom krb5.conf with rdns=false
# ---------------------------------------------------------------
print("[2/8] Creating custom krb5.conf (rdns=false)...")
krb5_path = create_custom_krb5_conf(args.realm, work_dir)
os.environ["KRB5_CONFIG"] = krb5_path
print(f" KRB5_CONFIG={krb5_path}")
# ---------------------------------------------------------------
# Step 3: Load GSSAPI library (AFTER setting KRB5_CONFIG)
# ---------------------------------------------------------------
print("[3/8] Loading GSSAPI library...")
gssapi_lib = ctypes.CDLL(args.gssapi_lib)
setup_gssapi_functions(gssapi_lib)
print(f" Loaded: {args.gssapi_lib}")
# ---------------------------------------------------------------
# Step 4: Establish persistent HTTPS connection and compute
# channel binding from the server certificate
# ---------------------------------------------------------------
print(f"[4/8] Connecting to {args.ca_host}:443...")
ssl_ctx = ssl.create_default_context()
if args.skip_verify:
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(
args.ca_host, 443, context=ssl_ctx
)
conn.connect()
# Capture server cert DER from the live TLS session
cert_der = get_server_cert_der(conn)
cb_data = compute_channel_binding(cert_der)
bindings, _cb_buf_ref = build_channel_bindings(cb_data)
print(f" TLS connected, channel binding computed "
f"({len(cb_data)} bytes)")
spn = f"HTTP@{args.ca_host}"
# ---------------------------------------------------------------
# Step 5: Test authentication against /certsrv/
# ---------------------------------------------------------------
print("[5/8] Testing Kerberos authentication...")
test_auth(conn, gssapi_lib, spn, bindings)
print(" Authentication successful")
# ---------------------------------------------------------------
# Step 6: Download CA certificate
# ---------------------------------------------------------------
print("[6/8] Downloading CA certificate...")
ca_cert_pem = download_ca_cert(conn, gssapi_lib, spn, bindings)
with open(args.ca_cert_file, "w") as f:
f.write(ca_cert_pem)
print(f" Saved: {args.ca_cert_file}")
# ---------------------------------------------------------------
# Step 7: Generate CSR and submit to AD CS
# ---------------------------------------------------------------
print("[7/8] Generating CSR and submitting to AD CS...")
san_list = [s.strip() for s in args.san.split(",")]
csr_path = os.path.join(work_dir, "request.csr")
csr_pem = generate_csr(
cn=args.cn,
san_list=san_list,
org=args.org,
country=args.country,
key_size=args.key_size,
key_path=args.key_file,
csr_path=csr_path,
)
print(f" Private key: {args.key_file}")
print(f" CSR generated for CN={args.cn}, SAN={', '.join(san_list)}")
req_id = submit_csr(
conn, gssapi_lib, spn, bindings, csr_pem, args.template
)
print(f" Request submitted, ReqID={req_id}")
# ---------------------------------------------------------------
# Step 8: Download issued certificate
# ---------------------------------------------------------------
print("[8/8] Downloading issued certificate...")
cert_pem = download_cert(conn, gssapi_lib, spn, bindings, req_id)
with open(args.cert_file, "w") as f:
f.write(cert_pem)
print(f" Saved: {args.cert_file}")
# ---------------------------------------------------------------
# Done
# ---------------------------------------------------------------
print("\n--- Certificate request complete ---")
print(f" Private key: {args.key_file}")
print(f" Certificate: {args.cert_file}")
print(f" CA cert: {args.ca_cert_file}")
print(f" Template: {args.template}")
print(f" CA: {args.ca_name}")
print(f" ReqID: {req_id}")
except Exception as e:
print(f"\nERROR: {e}", file=sys.stderr)
sys.exit(1)
finally:
# Clean up temporary working directory
conn_ref = locals().get("conn")
if conn_ref:
try:
conn_ref.close()
except Exception:
pass
shutil.rmtree(work_dir, ignore_errors=True)
if __name__ == "__main__":
main()
Running the Script
Step 1: Obtain a Kerberos ticket
kinit {USERNAME}@{KERBEROS_REALM}
Verify the ticket:
klist
Expected output:
Ticket cache: FILE:/tmp/krb5cc_1000
Default principal: {USERNAME}@{KERBEROS_REALM}
Valid starting Expires Service principal
03/03/2026 08:00 03/03/2026 18:00 krbtgt/{KERBEROS_REALM}@{KERBEROS_REALM}
Step 2: Run the script with your configuration
Warning: The script accepts a
--skip-verifyflag that disables TLS certificate verification. This removes protection against man-in-the-middle attacks and should never be used in production. It is provided only for lab or diagnostic use.
python3 adcs_cert_request.py \
--ca-host {CA_SERVER_FQDN} \
--ca-name {CA_COMMON_NAME} \
--template {CERT_TEMPLATE_NAME} \
--realm {KERBEROS_REALM} \
--cn {CLIENT_FQDN} \
--san {CLIENT_FQDN} \
--org "Your Organization" \
--country US
Expected output:
[1/8] Verifying Kerberos ticket...
Ticket cache: found
[2/8] Creating custom krb5.conf (rdns=false)...
KRB5_CONFIG=/tmp/adcs_cert_XXXXXXXX/krb5_custom.conf
[3/8] Loading GSSAPI library...
Loaded: /usr/lib64/libgssapi_krb5.so.2
[4/8] Connecting to {CA_SERVER_FQDN}:443...
TLS connected, channel binding computed (53 bytes)
[5/8] Testing Kerberos authentication...
Authentication successful
[6/8] Downloading CA certificate...
Saved: ca.crt
[7/8] Generating CSR and submitting to AD CS...
Private key: server.key
CSR generated for CN={CLIENT_FQDN}, SAN={CLIENT_FQDN}
Request submitted, ReqID=1234
[8/8] Downloading issued certificate...
Saved: server.crt
--- Certificate request complete ---
Private key: server.key
Certificate: server.crt
CA cert: ca.crt
Template: {CERT_TEMPLATE_NAME}
CA: {CA_COMMON_NAME}
ReqID: 1234
Step 3: Set permissions on the private key
chmod 600 server.key
Validation
Quick check
openssl x509 -in server.crt -noout -subject -dates
Expected output:
subject=C = US, O = Your Organization, CN = {CLIENT_FQDN}
notBefore=Mar 3 12:00:00 2026 GMT
notAfter=Mar 3 12:00:00 2031 GMT
Full validation
- Verify the SAN matches the request
openssl x509 -in server.crt -noout -ext subjectAltName
Expected output:
X509v3 Subject Alternative Name:
DNS:{CLIENT_FQDN}
- Verify the certificate chain
openssl verify -CAfile ca.crt server.crt
Expected output:
server.crt: OK
- Verify the private key matches the certificate
diff <(openssl x509 -in server.crt -noout -modulus) \
<(openssl rsa -in server.key -noout -modulus)
Expected output: No output (the modulus values match). Any output indicates a mismatch.
- Verify the certificate template
openssl x509 -in server.crt -noout -text | grep -A1 "Certificate Template"
Expected output:
X509v3 Certificate Template Information:
Template: {CERT_TEMPLATE_NAME}
- Verify the certificate in the target application
Warning: This step restarts the target service. Perform during a maintenance window if the service is in production.
Configure the target application (e.g., Apache, Nginx, or OEM Agent) with server.key and server.crt, then test HTTPS connectivity:
openssl s_client -connect {CLIENT_FQDN}:443 -CAfile ca.crt </dev/null 2>/dev/null | head -5
Expected output:
CONNECTED(00000003)
depth=1 CN = {CA_COMMON_NAME}
verify return:1
depth=0 C = US, O = Your Organization, CN = {CLIENT_FQDN}
verify return:1
Debugging
Enable Kerberos trace logging for detailed GSSAPI diagnostics:
KRB5_TRACE=/dev/stderr python3 adcs_cert_request.py --ca-host {CA_SERVER_FQDN} ...
Verify the SPN resolves in AD:
kvno HTTP/{CA_SERVER_FQDN}
Check reverse DNS for the CA server:
host {CA_SERVER_IP}
Check the server certificate signature algorithm (determines channel binding hash):
echo | openssl s_client -connect {CA_SERVER_FQDN}:443 2>/dev/null | openssl x509 -noout -text | grep "Signature Algorithm" | head -1
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
gss_init_sec_context error: "Server not found in Kerberos database" |
Reverse DNS returns wrong hostname, causing SPN mismatch | Verify the script creates krb5.conf with rdns = false. Check with: KRB5_TRACE=/dev/stderr python3 adcs_cert_request.py ... |
| HTTP 401 after sending Negotiate token | Channel binding mismatch (EPA failure). IIS rejected the token because the CBT does not match the TLS session | Verify ctypes.pointer() is used (not byref()). Verify the script uses one persistent HTTPSConnection. Check server cert signature algorithm matches SHA-256 |
HTTP 401 with no WWW-Authenticate: Negotiate header |
IIS not configured for Negotiate authentication, or the URL is wrong | Verify IIS Windows Authentication is enabled with the Negotiate provider on the /certsrv/ application |
| CSR submission returns "Certificate Pending" | The certificate template requires CA manager approval | Use a template configured for auto-approval, or approve the request manually in the Certification Authority snap-in |
ssl.SSLCertVerificationError on connection |
The CA server TLS certificate is not trusted by the system trust store | Add the CA cert to /etc/pki/ca-trust/source/anchors/ and run update-ca-trust, or use --skip-verify (not recommended for production) |
| Different results on retry (intermittent 401) | Load balancer routes to a different backend with a different TLS cert, invalidating the channel binding | Verify the script uses a single HTTPSConnection for all requests. Do not create new connections between requests |
OSError: libgssapi_krb5.so.2: cannot open shared object file |
krb5-libs RPM not installed |
Install: dnf install -y krb5-libs |
klist: No credentials cache found before running script |
No Kerberos TGT obtained | Run kinit {USERNAME}@{KERBEROS_REALM} before running the script |