1<?php
2/**
3 * This file is part of the FreeDSx LDAP package.
4 *
5 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10
11namespace FreeDSx\Ldap\Protocol\ClientProtocolHandler;
12
13use FreeDSx\Ldap\Control\Control;
14use FreeDSx\Ldap\Exception\BindException;
15use FreeDSx\Ldap\Exception\ProtocolException;
16use FreeDSx\Ldap\Operation\Request\SaslBindRequest;
17use FreeDSx\Ldap\Operation\Response\BindResponse;
18use FreeDSx\Ldap\Operation\ResultCode;
19use FreeDSx\Ldap\Operations;
20use FreeDSx\Ldap\Protocol\LdapMessageResponse;
21use FreeDSx\Ldap\Protocol\Queue\ClientQueue;
22use FreeDSx\Ldap\Protocol\Queue\MessageWrapper\SaslMessageWrapper;
23use FreeDSx\Sasl\Mechanism\MechanismInterface;
24use FreeDSx\Sasl\Sasl;
25use FreeDSx\Sasl\SaslContext;
26
27/**
28 * Logic for handling a SASL bind.
29 *
30 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
31 */
32class ClientSaslBindHandler implements RequestHandlerInterface
33{
34    use MessageCreationTrait;
35
36    /**
37     * @var Control[]
38     */
39    protected $controls;
40
41    /**
42     * @var Sasl
43     */
44    protected $sasl;
45
46    public function __construct(?Sasl $sasl = null)
47    {
48        $this->sasl = $sasl ?? new Sasl();
49    }
50
51    /**
52     * @{@inheritDoc}
53     */
54    public function handleRequest(ClientProtocolContext $context): ?LdapMessageResponse
55    {
56        /** @var SaslBindRequest $request */
57        $request = $context->getRequest();
58        $this->controls = $context->getControls();
59
60        # If we are selecting a mechanism from the RootDSE, we must check for a downgrade afterwards.
61        $detectDowngrade = ($request->getMechanism() === '');
62        $mech = $this->selectSaslMech($request, $context);
63
64        $queue = $context->getQueue();
65        $message = $context->messageToSend();
66        $queue->sendMessage($message);
67
68        /** @var LdapMessageResponse $response */
69        $response = $queue->getMessage($message->getMessageId());
70        $saslResponse = $response->getResponse();
71        if (!$saslResponse instanceof BindResponse) {
72            throw new ProtocolException(sprintf(
73                'Expected a bind response during a SASL bind. But got: %s',
74                get_class($saslResponse)
75            ));
76        }
77        if ($saslResponse->getResultCode() !== ResultCode::SASL_BIND_IN_PROGRESS) {
78            return $response;
79        }
80        $response = $this->processSaslChallenge($request, $queue, $saslResponse, $mech);
81        if ($detectDowngrade
82            && $response !== null
83            && $response->getResponse() instanceof BindResponse
84            && $response->getResponse()->getResultCode() === ResultCode::SUCCESS
85        ) {
86            $this->checkDowngradeAttempt($context);
87        }
88
89        return $response;
90    }
91
92    protected function selectSaslMech(SaslBindRequest $request, ClientProtocolContext $context): MechanismInterface
93    {
94        if ($request->getMechanism() !== '') {
95            $mech = $this->sasl->get($request->getMechanism());
96            $request->setMechanism($mech->getName());
97
98            return $mech;
99        }
100        $rootDse = $context->getRootDse();
101        $availableMechs = $rootDse->get('supportedSaslMechanisms');
102        $availableMechs = $availableMechs === null ? [] : $availableMechs->getValues();
103        $mech = $this->sasl->select($availableMechs, $request->getOptions());
104        $request->setMechanism($mech->getName());
105
106        return $mech;
107    }
108
109    protected function processSaslChallenge(
110        SaslBindRequest $request,
111        ClientQueue $queue,
112        BindResponse $saslResponse,
113        MechanismInterface $mech
114    ): ?LdapMessageResponse {
115        $challenge = $mech->challenge();
116        $response = null;
117
118        do {
119            $context = $challenge->challenge($saslResponse->getSaslCredentials(), $request->getOptions());
120            $saslBind = Operations::bindSasl($request->getOptions(), $request->getMechanism(), $context->getResponse());
121            $response = $this->sendRequestGetResponse($saslBind, $queue);
122            $saslResponse = $response->getResponse();
123            if (!$saslResponse instanceof BindResponse) {
124                throw new BindException(sprintf(
125                    'Expected a bind response during a SASL bind. But got: %s',
126                    get_class($saslResponse)
127                ));
128            }
129        } while (!$this->isChallengeComplete($context, $saslResponse));
130
131        if (!$context->isComplete()) {
132            $context = $challenge->challenge($saslResponse->getSaslCredentials(), $request->getOptions());
133        }
134
135        if ($saslResponse->getResultCode() === ResultCode::SUCCESS && $context->hasSecurityLayer()) {
136            $queue->setMessageWrapper(new SaslMessageWrapper($mech->securityLayer(), $context));
137        }
138
139        return $response;
140    }
141
142    protected function sendRequestGetResponse(SaslBindRequest $request, ClientQueue $queue): LdapMessageResponse
143    {
144        $messageTo = $this->makeRequest($queue, $request, $this->controls);
145        $queue->sendMessage($messageTo);
146
147        /** @var LdapMessageResponse $messageFrom */
148        $messageFrom = $queue->getMessage($messageTo->getMessageId());
149
150        return $messageFrom;
151    }
152
153    protected function isChallengeComplete(SaslContext $context, BindResponse $response): bool
154    {
155        if ($context->isComplete() || $context->getResponse() === null) {
156            return true;
157        }
158
159        if ($response->getResultCode() === ResultCode::SUCCESS) {
160            return true;
161        }
162
163        return $response->getResultCode() !== ResultCode::SASL_BIND_IN_PROGRESS;
164    }
165
166    protected function checkDowngradeAttempt(ClientProtocolContext $context): void
167    {
168        $priorRootDse = $context->getRootDse();
169        $rootDse = $context->getRootDse(true);
170
171        $mechs = $rootDse->get('supportedSaslMechanisms');
172        $priorMechs = $priorRootDse->get('supportedSaslMechanisms');
173        $priorMechs = $priorMechs !== null ? $priorMechs->getValues() : [];
174        $mechs = $mechs !== null ? $mechs->getValues() : [];
175
176        if (count(array_diff($mechs, $priorMechs)) !== 0) {
177            throw new BindException(
178                'Possible SASL downgrade attack detected. The advertised SASL mechanisms have changed.'
179            );
180        }
181    }
182}
183