PHP の LDAP 関数でCA証明書を指定する

LDAP のサーバ証明書がプライベートCAで発行されているため、素のままだと ldaps で証明書の検証で失敗してアクセス出来ず、PHP 側で信頼するCA証明書を設定しようと試行錯誤したメモ。

簡単な解決方法・・ダメ

LDAP_OPT_X_TLS_CACERTFILE というオプションがあるのでこれで指定すれば OK です、完。

<?php
$ldap = ldap_connect('ldaps://ldap.local:636');
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_X_TLS_CACERTFILE, '/mnt/ldap.crt');
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

というわけにはいきませんでした。なにやらバグ?により機能していないようです。

サーバの設定ファイルでどうにかする

サーバの証明書ストアに追加したり、

cp ldap.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust

ldap.conf に追記したり。

echo TLS_CACERT /mnt/ldap.crt >> /etc/openldap/ldap.conf

ユーザーごとの .ldaprc でも良いようです。

echo TLS_CACERT /mnt/ldap.crt > ~/.ldaprc

PHP 側でどうにかする

環境変数 LDAPTLS_CACERT でサーバ証明書を指定すれば大丈夫です。

<?php
putenv('LDAPTLS_CACERT=/mnt/ldap.crt');
$ldap = ldap_connect('ldaps://ldap.local:636');
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

あるいは環境変数で LDAPTLS_REQCERT=never を指定すれば証明書関係のエラーを無視できます。

<?php
putenv('LDAPTLS_REQCERT=never');
$ldap = ldap_connect('ldaps://ldap.local:636');
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

もしくは・・・ ldap_set_optionLDAP_OPT_X_TLS_CACERTFILE を指定するときに第1引数に null を指定して libldap のグローバルなオプションにすれば良いようです。

<?php
ldap_set_option(null, LDAP_OPT_X_TLS_CACERTFILE, '/mnt/ldap.crt');
$ldap = ldap_connect('ldaps://ldap.local:1636', 1636);
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

null ではなく LDAP リソースを渡すとダメなのですが・・次のような C のコードでも同様だったので PHP の LDAP 拡張の問題ではないようです。

#include <stdio.h>
#include <stdlib.h>
#include <ldap.h>

void check(int rc)
{
    if (rc != LDAP_SUCCESS) {
        printf("%d: %s\n", rc, ldap_err2string(rc));
        exit(1);
    }
}

int main()
{
    LDAP *ldap = NULL;
    check(ldap_initialize(&ldap, "ldaps://ldap.local:1636"));

    check(ldap_set_option(NULL, LDAP_OPT_X_TLS_CACERTFILE, "/mnt/ldap.crt"));

    int v = 3;
    check(ldap_set_option(ldap, LDAP_OPT_PROTOCOL_VERSION, &v));

    char pw[] = "password";
    struct berval cred = {strlen(pw), pw};
    check(ldap_sasl_bind_s(ldap, "cn=admin,dc=example,dc=org", LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL));
    return 0;
}

最初に PHP の LDAP 拡張のバグかと思ったものの、実は libldap のバグあるいは仕様のようです(どっちなのかはわからない)。

おまけ:DNS SRV RR

通常、Active Directory のドメインコントローラーは複数台設け、DNS サーバが SRV リソースレコードを複数返すことで冗長化が図られています。

ので、Active Directory の LDAP サーバに問い合わせるなら、直接ドメインコントローラーの FQDN を名前解決して問い合わせるよりは、ドメインの SRV レコードを検索してドメインコントローラーの FQDN を特定したいものですが・・実は openldap の CLI も SRV レコードをサポートしています。

例えば次のように指定すると _ldap._tcp.example.com の SRV レコードが検索され、その結果を元に LDAP 問い合わせが行われます。

ldapwhoami -H ldap:///dc%3Dexample%2Cdc%3Dcom -x -D ore@example.com -W

-H オプションで指定した値は URL デコードすると ldap:///dc=example,dc=com です。

なお ldaps:///dc%3Dexample%2Cdc%3Dcom のように指定しても LDAPS ではアクセスできません。このように指定しても SRV レコードの問い合わせは _ldap._tcp.example.com のままです。SRV レコードにはホスト名だけでなくポート番号 389 も含まれています。そのため LDAPS で 389 ポートにアクセスして失敗します。

http/https で言うところの https://example.com:80/ のようなものと考えてもらえればわかりやすいと思います。

下記によると openldap の CLI では SRV レコードを解決しつつの LDAPS アクセスは出来ないようです。

次のように -ZZ を使えば STARTTLS には出来ます。

ldapwhoami -H ldap:///dc%3Dexample%2Cdc%3Dcom -x -D ore@example.com -W -ZZ

なお、この機能、そもそものところ PHP の LDAP 拡張では実装されていません、ちーん。

次のように SRV レコードを問い合わせても良いのかも?

$records = dns_get_record('_ldap._tcp.example.com', DNS_SRV);
usort($records, function ($a, $b) {
    $cmp = $a['pri'] <=> $b['pri'];
    if ($cmp != 0) {
        return $cmp;
    }
    $cmp = random_int(0, $a['weight']) <=> random_int(0, $b['weight']);
    return -$cmp;
});
foreach ($records as $rec) {
    $host = $rec['target'];
    $port = $rec['port'];
    try {
        // なにかする
        break;
    } catch (Throwable) {
        continue;
    }
}