Instruction

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 kinit and 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 -- provides kinit, klist, kvno
  • krb5-libs -- provides /usr/lib64/libgssapi_krb5.so.2 (the GSSAPI C library loaded via ctypes)
  • openssl -- provides the openssl CLI for key and CSR generation
  • python3 -- Python 3.9 (system default on OL9)
  • bind-utils -- provides the host command for reverse DNS verification

Manual setup

  1. 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.

  1. 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}
  1. 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.

  1. 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.

  1. 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

  1. 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
  1. 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 --negotiate does not support EPA. When IIS requires Extended Protection for Authentication, curl cannot compute TLS Channel Binding Tokens. Authentication fails with HTTP 401.
  • pip is unavailable. The requests and python-gssapi packages 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-verify flag 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

  1. Verify the SAN matches the request
openssl x509 -in server.crt -noout -ext subjectAltName

Expected output:

X509v3 Subject Alternative Name:
    DNS:{CLIENT_FQDN}
  1. Verify the certificate chain
openssl verify -CAfile ca.crt server.crt

Expected output:

server.crt: OK
  1. 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.

  1. 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}
  1. 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

References