grindsa / acme2certifier

library implementing ACME server functionality
GNU General Public License v3.0
173 stars 36 forks source link

Kerberos for mscertsrv_ca_handler.py #153

Open 3engel opened 7 months ago

3engel commented 7 months ago

The mscertsrv_ca_handler.py handler doesn't support Kerberos authentications like mswcce_ca_handler.py. However, using NTLM is a security issue and therefore Kerberos is recommended. Therefore it would be great to support kerberos also for mscertsrv_ca_handler.py.

grindsa commented 7 months ago

You proposal makes sense however the implementation is not that straight forward as it also requires modifications on the certsrv module. I am planning to look into it as part of next release.

As I am not an MS expert. Do you know how to enable Kerberos for on the MS Web Enrollment Service?

3engel commented 7 months ago

Sure. Since the MS Web Enrollment Service is using IIS (Internet Information Services / MS Web Server) the authentication configured there. In IIS under the CertSrv node there is an "Authentication" Option. "Windows Authentication" should be enabled. In the providers list "Negotiate:Kerberos" should be configured as the only option. image

It is also discribed here: https://support.microsoft.com/en-gb/topic/kb5005413-mitigating-ntlm-relay-attacks-on-active-directory-certificate-services-ad-cs-3612b773-4043-4aa9-b23d-b87910cd3429#:~:text=Disable%20NTLM%20Authentication%20on%20your,Restrict%20NTLM%3A%20Incoming%20NTLM%20traffic.

Many thanks for considering it.

grindsa commented 4 months ago

Feature made it into v0.35. Thus, closing this issue. In case you have comments feel free to reopen...

3engel commented 4 months ago

@grindsa First of all, thanks a lot for the effort. I tested implementation and could not get to work. Then I tried to dig deeper in it by extracting the relevant code from certsrv.py parts to do some basic tests.

import gssapi.creds
import gssapi.raw
import gssapi.raw
import gssapi.sec_contexts
import requests
from requests_gssapi import HTTPSPNEGOAuth
import gssapi

username = "USERNAME"
password = "PASSWORD"
realm = "EXAMPLE.COM"
url = "https://winpki.example.com/certsrv"
oid = "1.3.6.1.5.5.2"  # SPNEGO

spnego = gssapi.OID.from_int_seq("1.3.6.1.5.5.2")

gssapiName = gssapi.Name(f"{username.upper()}@{realm.upper()}", gssapi.NameType.user)
accuire_cred = gssapi.raw.acquire_cred_with_password(
    gssapiName, password.encode(), mechs=[spnego], usage="initiate"
)
kerberos_auth = HTTPSPNEGOAuth(creds=accuire_cred.creds, mech=spnego)
response = requests.get(url, auth=kerberos_auth, verify=False)

if response.status_code == 200:
    print("Request was successful")
    print("Response content:", response.content)
else:
    print("Request failed with status code:", response.status_code)
    print("Response content:", response.content)

I never got this working with every kind of combination, I tried. When debugging the response object has a www-authenticate header with value 'Negotiate' only without the kerberos ticket. Then I tried this:

import subprocess
import requests
from requests_kerberos import HTTPKerberosAuth, REQUIRED

username = "username"
password = "password"
realm = "EXAMPLE.COM"
url = 'https://winpki.example.com/certsrv'

fullUsername = f"{username}@{realm.upper()}"

kinit_command = ['kinit', fullUsername]
process = subprocess.Popen(kinit_command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
process.communicate(input=password.encode())

# Check if kinit was successful
if process.returncode != 0:
    raise Exception("kinit failed")

# Create a Kerberos authentication object
kerberos_auth = HTTPKerberosAuth(mutual_authentication=REQUIRED, principal=fullUsername)
response = requests.get(url, auth=kerberos_auth, verify=False)

# Check the response
if response.status_code == 200:
    print('Request was successful')
    print('Response content:', response.content)
else:
    print('Request failed with status code:', response.status_code)
    print('Response content:', response.content)

And that worked instantly. So I guess there is an issue with kerberos and the gssapi lib combined with the requests lib.

I modified certsrv.py and added like so:

elif self.auth_method == "kerberos":
            from requests_kerberos import HTTPKerberosAuth, REQUIRED
            import subprocess

            kinit_command = ['kinit', username.upper()]
            process = subprocess.Popen(kinit_command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            process.communicate(input=password.encode())

            if process.returncode != 0:
                raise Exception("kinit failed with return code {0} for user {1}".format(process.returncode, username))

            self.session.auth = HTTPKerberosAuth(mutual_authentication=REQUIRED, principal=username.upper())

In mscertsrv_ca_handler.py I added 'kerberos' as auth_method: if 'auth_method' in config_dic['CAhandler'] and config_dic['CAhandler']['auth_method'] in ['basic', 'ntlm', 'gssapi','kerberos']:

Then I modified the requirements.txt and the dockerfile (nginx/wsgi) by adding the required libaries and build a new docker image in order to test the certificate issueing and it worked.

It is not really clear to me why the gssapi method is not working. I even could not make this work:

kinit <username>
curl --negotiate -u: <user>:<password> -k https://<host>/certsrv/

Step kinit works. I'm getting a valid ticket when doing a klist. When doing the curl request I'm getting a 401 status code.

grindsa commented 4 months ago

Can you describe your deployment (containerized, rpm, deb or manual installation).

Can you share your acme_srv.cfg as well as the related krb5.cfg? Can you please enable debugging in acme_srv.cfg replicate the issue in a2c and share the log for troubleshooting? Feel free to share the information via email to grindelsack(at)gmail.com

3engel commented 2 months ago

@grindsa sorry for the late reply. I'm using docker with docker compose. docker-compose.yml looks like this:

version: '3.2'
services:
  acme2certifier:
    container_name: acme2certifier
    image: grindsa/acme2certifier:nginx-wsgi
    volumes:
      - ./data:/var/www/acme2certifier/volume
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "22280:80"
#      - "22443:443"
    restart: always
    dns:
      - 10.193.132.1
      - 10.193.132.12

acme_srv.cfg looks like this.

[DEFAULT]
debug: True

[Nonce]
nonce_check_disable: False

[CAhandler]
handler_file: examples/ca_handler/mscertsrv_ca_handler.py
host: subca1.mydomain.com
user: <user with Enroll permissions>
password: 
ca_bundle: /var/www/acme2certifier/volume/RootCA.pem
auth_method: gssapi   #in my working scenario I use "kerberos" instead
template: WebServerTemplate1Year
krb5_config: /var/www/acme2certifier/volume/krb5.conf

[Certificate]
revocation_reason_check_disable: False

[Challenge]
challenge_validation_disable: False

[Order]
tnauthlist_support: False

krb5.conf looks like this:

[libdefaults]
default_realm = MYDOMAIN.COM
[realms]
MYDOMAIN.COM = {
  kdc = dc1.mydomain.com
  admin_server = dc1.mydomain.com
  default_domain = mydomain.com
}
[domain_realm]
.mydomain.com = MYDOMAIN.COM
mydomain.com = MYDOMAIN.COM

As far as I read in documentations a krb5.conf is not mandatory, when there is a well configured DNS server in the network. Nevertheless I used the config above. I tested the Kerberos authentication directly on the container shell via:

kinit -V causer@MYDOMAIN.COM

This worked and the Kerberos ticket is created. klist result is:

Default principal: causer@MYDOMAIN.COM

Valid starting     Expires            Service principal
09/10/24 09:14:15  09/10/24 19:14:15  krbtgt/MYDOMAIN.COM@MYDOMAIN.COM

        renew until 09/11/24 09:14:06

The acme2certifier log Looks like this:

https://mysubca.mydomain.com:443 "GET /certsrv/ HTTP/11" 401 1293
authenticate_user(): returning <Response [401]>
handle_401(): returning <Response [401]>
handle_response(): returning <Response [401]>
handle_response() has seen 0 401 responses
handle_401(): Handling: 401
authenticate_user(): Authorization header: Negotiate REMOVED_DUE_TO_SECURITY_REASONS
https://mysubca.mydomain.com:443 "GET /certsrv/ HTTP/11" 401 1293
authenticate_user(): returning <Response [401]>
handle_401(): returning <Response [401]>
handle_response(): returning <Response [401]>
handle_response() has seen 1 401 responses
handle_response(): returning 401 <Response [401]>
Sent GET request to https://mysubca.mydomain.com/certsrv/, with headers:
User-agent: Mozilla/5.0 certsrv (https://github.com/magnuswatn/certsrv)
Authorization: Negotiate REMOVED_DUE_TO_SECURITY_REASONS
      
:
None
Recieved response:
HTTP 401
Content-Type: text/html
Server: Microsoft-IIS/10.0
WWW-Authenticate: Negotiate
Date: Tue, 10 Sep 2024 06:54:46 GMT
Content-Length: 1293
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"/>
<title>401 - Unauthorized: Access is denied due to invalid credentials.</title>
<style type="text/css">
<!--
body{margin:0;font-size:.7em;font-family:Verdana, Arial, Helvetica, sans-serif;background:#EEEEEE;}
fieldset{padding:0 15px 10px 15px;} 
h1{font-size:2.4em;margin:0;color:#FFF;}
h2{font-size:1.7em;margin:0;color:#CC0000;} 
h3{font-size:1.2em;margin:10px 0 0 0;color:#000000;} 
#header{width:96%;margin:0 0 0 0;padding:6px 2% 6px 2%;font-family:"trebuchet MS", Verdana, sans-serif;color:#FFF;
background-color:#555555;}
#content{margin:0 0 0 2%;position:relative;}
.content-container{background:#FFF;width:96%;margin-top:8px;padding:10px;position:relative;}
-->
      
</head>
<body>
<div id="header"><h1>Server Error</h1></div>
<div id="content">
 <div class="content-container"><fieldset>
  <h2>401 - Unauthorized: Access is denied due to invalid credentials.</h2>
  <h3>You do not have permission to view this directory or page using the credentials that you supplied.</h3>
 </fieldset></div>
</div>
</body>
</html>
CAhandler.__check_credentials() ended with False
Connection or Credentialcheck failed
Certificate.enroll() ended
Certificate._enroll() ended
acme2certifier enrollment error: Connection or Credentialcheck failed.
Certificate._enrollerror_handler(Connection or Credentialcheck failed.)
Certificate._enrollerror_handler(): invalidating order as there is no certificate and no poll_identifier: Connection or Credentialcheck failed./mTxdFjIBwS8T
Certificate._order_update({'name': 'mTxdFjIBwS8T', 'status': 'invalid'})
order_update({'name': 'mTxdFjIBwS8T', 'status': 'invalid'})
DBStore._status_search(column:name, pattern:invalid)
DBStore._status_search() ended
DBStore.order_update() ended
Certificate._store_cert_error(V6WKWR2N7unT)
DBStore.certificate_add(V6WKWR2N7unT)
DBStore._certificate_search(column:name, pattern:V6WKWR2N7unT)
modified column to certificate.name
DBStore._certificate_search() ended with: True
_certificate_update() for V6WKWR2N7unT id:28
_certificate_update() ended with: 28
DBStore.certificate_add() ended with: 28
Certificate._store_cert_error(28) ended
Certificate._enrollerror_handler() ended with: None
Certificate._pre_hooks_process(V6WKWR2N7unT, mTxdFjIBwS8T
Certificate._post_hooks_process([])
Certificate._enroll_and_store() ended with: None:urn:ietf:params:acme:error:serverInternal
Certificate.enroll_and_store() ThreadWithReturnValue ended
Certificate.enroll_and_store() ended with: None:urn:ietf:params:acme:error:serverInternal
Order._csr_process() ended with order:mTxdFjIBwS8T 500:{urn:ietf:params:acme:error:serverInternal:None
Order._finalize() ended
Order._process() ended with order:mTxdFjIBwS8T 500:urn:ietf:params:acme:error:serverInternal:enrollment failed
Order._parse() ended with code: 500
Message.prepare_response()
Error.enrich_error()
Error.acme_errormessage(urn:ietf:params:acme:error:serverInternal)
Nonce.nonce_generate_and_add()
Nonce.nonce__new()
got nonce: de389a44fb9c466582aed2a78b56bc4e
DBStore.nonce_add(de389a44fb9c466582aed2a78b56bc4e)
DBStore.nonce_add() ended
Nonce.generate_and_add() ended with:de389a44fb9c466582aed2a78b56bc4e
Order.parse() returns: {"code": 500, "header": {"Replay-Nonce": "de389a44fb9c466582aed2a78b56bc4e"}, "data": {"status": 500, "type": "urn:ietf:params:acme:error:serverInternal", "detail": "enrollment failed"}}
172.20.0.1 /acme/order/mTxdFjIBwS8T/finalize {'code': 500, 'header': {'Replay-Nonce': '- modified -'}, 'data': {'status': 500, 'type': 'urn:ietf:params:acme:error:serverInternal', 'detail': 'enrollment failed'}}
[pid: 28|app: 0|req: 3/11] 172.20.0.1 () {48 vars in 779 bytes} [Tue Sep 10 08:54:16 2024] POST /acme/order/mTxdFjIBwS8T/finalize => generated 99 bytes in 1723 msecs (HTTP/1.1 500) 2 headers in 120 bytes (1 switches on core 0)

As client I used win-acme. With the exact same settings and the changes I made as described here I got it working. So I led back to the gssapi implementation and the way how the HTTP authentication header is created.