php / php-src

The PHP Interpreter
https://www.php.net
Other
38.22k stars 7.75k forks source link

LDAPS client certificate authentication does not work #12081

Open ghost opened 1 year ago

ghost commented 1 year ago

Description

The following code:

<?php

// Test case for PHP bug: https://bugs.php.net/bug.php?id=73558
//
// The two commands below work totally fine:
//
// OpenSSL:
// $ openssl s_client -verifyCAfile ca.crt -cert ldap-client.crt -key ldap-client.key -connect ldap.home.arpa:636
//
// ldapsearch:
// $ LDAPTLS_CACERT=ca.crt LDAPTLS_CERT=ldap-client.crt LDAPTLS_KEY=ldap-client.key ldapwhoami -H ldaps://ldap.home.arpa -x
// anonymous

$ldapUri = 'ldaps://ldap.home.arpa';
$caFile = __DIR__.'/ca.crt';
$certFile = __DIR__.'/ldap-client.crt';
$keyFile = __DIR__.'/ldap-client.key';

//putenv(sprintf('LDAPTLS_CACERT=%s', $caFile));
//putenv(sprintf('LDAPTLS_CERT=%s', $certFile));
//putenv(sprintf('LDAPTLS_KEY=%s', $keyFile));

$ldapResource = ldap_connect($ldapUri);
$ldapOptions = [
    LDAP_OPT_PROTOCOL_VERSION => 3,
    LDAP_OPT_REFERRALS => 0,
    LDAP_OPT_X_TLS_CACERTFILE => $caFile,
    LDAP_OPT_X_TLS_CERTFILE => $certFile,
    LDAP_OPT_X_TLS_KEYFILE => $keyFile,
];

foreach($ldapOptions as $k => $v) {
    ldap_set_option($ldapResource, $k, $v);
}

if(false === ldap_bind($ldapResource)) {
    ldap_get_option($ldapResource, LDAP_OPT_DIAGNOSTIC_MESSAGE, $errMsg);
    echo sprintf(
        "%s (%d): %s\n",
        ldap_error($ldapResource),
        ldap_errno($ldapResource),
        $errMsg
    );
}

var_dump(
    ldap_exop_whoami($ldapResource)
);

Resulted in this output:

PHP Warning:  ldap_bind(): Unable to bind to server: Can't contact LDAP server in /home/fkooman/ldap.home.arpa/ldap_test.php on line 36
Can't contact LDAP server (-1): error:0A000086:SSL routines::certificate verify failed (self-signed certificate in certificate chain)
PHP Warning:  ldap_exop_whoami(): Whoami extended operation failed: Can't contact LDAP server (-1) in /home/fkooman/ldap.home.arpa/ldap_test.php on line 47
bool(false)

But I expected this output instead (anonymous bind):

string(0) ""

Workaround

When you enable the three putenv lines, things start working, but this probably not how it should be :)

Additional details

It seems the options LDAP_OPT_X_TLS_CACERTFILE, LDAP_OPT_X_TLS_CERTFILE and LDAP_OPT_X_TLS_KEYFILE are somehow ignored.

We tested this on Fedora 38 (PHP 8.2.9 (cli) (built: Aug 3 2023 11:39:08) (NTS gcc x86_64)):

LDAP Support => enabled
Total Links => 0/unlimited
API Version => 3001
Vendor Name => OpenLDAP
Vendor Version => 20604
SASL Support => Enabled

Directive => Local Value => Master Value
ldap.max_links => Unlimited => Unlimited

And on Debian 12 (PHP 8.2.7 (cli) (built: Jun 9 2023 19:37:27) (NTS)):

LDAP Support => enabled
Total Links => 0/unlimited
API Version => 3001
Vendor Name => OpenLDAP
Vendor Version => 20513
SASL Support => Enabled

Directive => Local Value => Master Value
ldap.max_links => Unlimited => Unlimited

See also: https://bugs.php.net/bug.php?id=73558

Test Setup without LDAP server

If you do not have an LDAP server, you can also use openssl to create a test server that results in the exact same error message in the PHP script as the problem is in the TLS setup, not the actual LDAP connection:

$ openssl s_server -CAfile ca.crt -cert ldap.home.arpa.crt -key ldap.home.arpa.key

All you need is the client cert/key, server cert/key and CA and connect to port 4433 (the default of s_server).

PHP Version

PHP 8.2.9 / 8.2.7

Operating System

Fedora 38 / Debian 12

heiglandreas commented 1 year ago

According to https://bugs.php.net/bug.php?id=73558 there seem to be issues with the underlying OpenLDAP library where one needs to set the CACERTDIR const needs to be set.

Also the putenv makes sure that the variables are set for the underlying OpenLDAP lib regardless of what you set in PHP.

As you are setting the options AFTER creating the connection via ldap_connect the CA options can not be used any more for the connection.

Have you tried setting them before the ldap_connect using null as connection parameter?

ghost commented 1 year ago

According to https://bugs.php.net/bug.php?id=73558 there seem to be issues with the underlying OpenLDAP library where one needs to set the CACERTDIR const needs to be set.

But set to what? I tried '', null, /tmp and /etc/pki/tls/certs (Fedora), nothing works.

Also the putenv makes sure that the variables are set for the underlying OpenLDAP lib regardless of what you set in PHP.

Exactly, that is why putenv works.

As you are setting the options AFTER creating the connection via ldap_connect the CA options can not be used any more for the connection.

How would one do that? Also here you can read this: Note: This function does not open a connection. Also, only the ldap_bind() call errors as at that point an actually connection is established.

Have you tried setting them before the ldap_connect using null as connection parameter?

You mean using ldap_connect(null), then setting the options including LDAP_OPT_HOST_NAME? How would one then specify that LDAPS should be used, and how to configure the port? The LDAP_OPT_URI (0x5006) option is not (yet) exposed in PHP which would take URIs, but it is documented in ldap_set_option(3), but also this does not work:

$ldapResource = ldap_connect();
$ldapOptions = [
    0x5006 => $ldapUri,
    LDAP_OPT_PROTOCOL_VERSION => 3,
    LDAP_OPT_REFERRALS => 0,
    LDAP_OPT_X_TLS_CACERTDIR => '/etc/pki/tls/certs',
    LDAP_OPT_X_TLS_CACERTFILE => $caFile,
    LDAP_OPT_X_TLS_CERTFILE => $certFile,
    LDAP_OPT_X_TLS_KEYFILE => $keyFile,
];
heiglandreas commented 1 year ago

But set to what? I tried '', null, /tmp and /etc/pki/tls/certs (Fedora), nothing works.

It needs to be set to the folder where your custom certificates are located. In the script above that would be __DIR__

How would one do that? Also here you can read this: Note: This function does not open a connection. Also, only the ldap_bind() call errors as at that point an actually connection is established.

The connection itself is not opened. But the main information to create the connection is initialized at that point and associated with the returned resource/object handle. So whenever you use the resource/object returned by ldap_connect those informations are used. And at that point there are no informations associated regarding the TLS cert.

You mean using ldap_connect(null), then setting the options including LDAP_OPT_HOST_NAME?

I was more thinking along these lines:

<?php

// Test case for PHP bug: https://bugs.php.net/bug.php?id=73558
//
// The two commands below work totally fine:
//
// OpenSSL:
// $ openssl s_client -verifyCAfile ca.crt -cert ldap-client.crt -key ldap-client.key -connect ldap.home.arpa:636
//
// ldapsearch:
// $ LDAPTLS_CACERT=ca.crt LDAPTLS_CERT=ldap-client.crt LDAPTLS_KEY=ldap-client.key ldapwhoami -H ldaps://ldap.home.arpa -x
// anonymous

$ldapUri = 'ldaps://ldap.home.arpa';
$caFile = __DIR__.'/ca.crt';
$certFile = __DIR__.'/ldap-client.crt';
$keyFile = __DIR__.'/ldap-client.key';

//putenv(sprintf('LDAPTLS_CACERT=%s', $caFile));
//putenv(sprintf('LDAPTLS_CERT=%s', $certFile));
//putenv(sprintf('LDAPTLS_KEY=%s', $keyFile));

$ldapTlsOptions = [
    LDAP_OPT_X_TLS_CACERTDIR => __DIR__,
    LDAP_OPT_X_TLS_CACERTFILE => $caFile,
    LDAP_OPT_X_TLS_CERTFILE => $certFile,
    LDAP_OPT_X_TLS_KEYFILE => $keyFile,
];

foreach($ldapTlsOptions as $k => $v) {
    ldap_set_option(null, $k, $v);
}

$ldapResource = ldap_connect($ldapUri);
$ldapOptions = [
    LDAP_OPT_PROTOCOL_VERSION => 3,
    LDAP_OPT_REFERRALS => 0,
];

foreach($ldapOptions as $k => $v) {
    ldap_set_option($ldapResource, $k, $v);
}

if(false === ldap_bind($ldapResource)) {
    ldap_get_option($ldapResource, LDAP_OPT_DIAGNOSTIC_MESSAGE, $errMsg);
    echo sprintf(
        "%s (%d): %s\n",
        ldap_error($ldapResource),
        ldap_errno($ldapResource),
        $errMsg
    );
}

var_dump(
    ldap_exop_whoami($ldapResource)
);

See the explanation unter ldap of ldap_set_option for more info.

This assumes that you actually have the TLS certificates and keys at the appropriate locations.

ghost commented 1 year ago

I managed to get it working!

It needs to be set to the folder where your custom certificates are located. In the script above that would be DIR

This turns out not to be necessary (for me).

See the explanation unter ldap of ldap_set_option for more info.

I missed that the connection in ldap_set_option can be null, although not sure it would have occurred to me to use that, perhaps eventually!

Full working version:

<?php

$ldapUri = 'ldaps://ldap.home.arpa:636';
$caFile = __DIR__.'/ca.crt';
$certFile = __DIR__.'/ldap-client.crt';
$keyFile = __DIR__.'/ldap-client.key';

$ldapOptions = [
    LDAP_OPT_X_TLS_CACERTFILE => $caFile,
    LDAP_OPT_X_TLS_CERTFILE => $certFile,
    LDAP_OPT_X_TLS_KEYFILE => $keyFile,
    LDAP_OPT_PROTOCOL_VERSION => 3,
    LDAP_OPT_REFERRALS => 0,
];

foreach($ldapOptions as $k => $v) {
    ldap_set_option(null, $k, $v);
}

$ldapResource = ldap_connect($ldapUri);

if(false === ldap_bind($ldapResource)) {
    ldap_get_option($ldapResource, LDAP_OPT_DIAGNOSTIC_MESSAGE, $errMsg);
    echo sprintf(
        "%s (%d): %s\n",
        ldap_error($ldapResource),
        ldap_errno($ldapResource),
        $errMsg
    );
}

var_dump(
    ldap_exop_whoami($ldapResource)
);

Should this be documented somewhere that it works like this? It is not obvious to have 'global' options that are needed for some, but not others.

Thanks for the help! :1st_place_medal:

Edit: I notice this comment now that writes something similar, not 100% identical but close enough: https://www.php.net/manual/en/function.ldap-set-option.php#124602