1<?php
2
3/**
4 * This file is part of the FreeDSx SASL package.
5 *
6 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace FreeDSx\Sasl\Challenge;
13
14use FreeDSx\Sasl\Encoder\DigestMD5Encoder;
15use FreeDSx\Sasl\Exception\SaslException;
16use FreeDSx\Sasl\Factory\DigestMD5MessageFactory;
17use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism;
18use FreeDSx\Sasl\Message;
19use FreeDSx\Sasl\SaslContext;
20
21/**
22 * The DIGEST-MD5 challenge / response class.
23 *
24 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
25 */
26class DigestMD5Challenge implements ChallengeInterface
27{
28    /**
29     * @var array
30     */
31    protected const DEFAULTS = [
32        'use_integrity' => false,
33        'use_privacy' => false,
34        'service' => 'ldap',
35        'nonce_size' => null,
36    ];
37
38    /**
39     * @var SaslContext
40     */
41    protected $context;
42
43    /**
44     * @var DigestMD5MessageFactory
45     */
46    protected $factory;
47
48    /**
49     * @var DigestMD5Encoder
50     */
51    protected $encoder;
52
53    /**
54     * @var null|Message
55     */
56    protected $challenge;
57
58    public function __construct(bool $isServerMode = false)
59    {
60        $this->factory = new DigestMD5MessageFactory();
61        $this->encoder = new DigestMD5Encoder();
62        $this->context = new SaslContext();
63        $this->context->setIsServerMode($isServerMode);
64    }
65
66    /**
67     * {@inheritDoc}
68     */
69    public function challenge(?string $received = null, array $options = []): SaslContext
70    {
71        $options = $options + self::DEFAULTS;
72
73        $received = $received === null ? null : $this->encoder->decode($received, $this->context);
74        if ($this->context->isServerMode()) {
75            $response = $this->generateServerResponse($received, $options);
76        } else {
77            $response = $this->generateClientResponse($received, $options);
78        }
79        $this->context->setResponse($response);
80
81        return $this->context;
82    }
83
84    /**
85     * @throws SaslException
86     */
87    protected function generateClientResponse(?Message $message, $options): ?string
88    {
89        if ($message === null) {
90            return null;
91        }
92        if ($this->isClientChallengeNeeded($message)) {
93            return $this->createClientResponse($message, $options);
94        }
95
96        if ($message->has('rspauth') && $this->context->get('verification') === null) {
97            throw new SaslException('The rspauth value was received before the response was generated.');
98        }
99        if ($message->has('rspauth') && $message->get('rspauth') === $this->context->get('verification')) {
100            $this->context->setIsAuthenticated(true);
101            $this->context->setHasSecurityLayer($options['use_integrity'] || $options['use_privacy']);
102        }
103        if ($this->context->hasSecurityLayer()) {
104            $this->context->set('seqnumsnt', 0);
105            $this->context->set('seqnumrcv', 0);
106        }
107        $this->context->setIsComplete($message->has('rspauth'));
108
109        return null;
110    }
111
112    protected function generateServerResponse(?Message $received, array $options): ?string
113    {
114        if ($received === null) {
115            $response = $this->generateServerChallenge($options);
116        } else {
117            $response = $this->generateServerVerification($received, $options);
118        }
119
120        return $response === null ? null : $this->encoder->encode($response, $this->context);
121    }
122
123    protected function isClientChallengeNeeded(Message $message): bool
124    {
125        if ($this->context->isServerMode()) {
126            return false;
127        }
128
129        return $message->get('rspauth') === null;
130    }
131
132    /**
133     * @throws SaslException
134     */
135    protected function createClientResponse(Message $message, array $options): string
136    {
137        $password = $options['password'] ?? '';
138
139        if ($options['use_privacy']) {
140            $this->context->set('qop', 'auth-conf');
141        } elseif ($options['use_integrity']) {
142            $this->context->set('qop', 'auth-int');
143        } else {
144            $this->context->set('qop', 'auth');
145        }
146
147        $messageOpts = [
148            'username' => $options['username'] ?? null,
149            'digest-uri' => isset($options['host']) ? ($options['service'] . '/' . $options['host']) : null,
150            'qop' => $this->context->get('qop'),
151            'nonce_size' => $options['nonce_size'],
152            'service' => $options['service']
153        ];
154        if (isset($options['cnonce'])) {
155            $messageOpts['cnonce'] = $options['cnonce'];
156        }
157        if (isset($options['nonce'])) {
158            $messageOpts['nonce'] = $options['nonce'];
159        }
160        if (isset($options['cipher'])) {
161            $messageOpts['cipher'] = $options['cipher'];
162        }
163        $response = $this->factory->create(
164            DigestMD5MessageFactory::MESSAGE_CLIENT_RESPONSE, $messageOpts, $message
165        );
166        $response->set('response', DigestMD5Mechanism::computeResponse(
167            $password,
168            $message,
169            $response,
170            $this->context->isServerMode()
171        ));
172
173        # The verification is used to check the response value returned from the server for authentication.
174        $this->context->set(
175            'verification',
176            DigestMD5Mechanism::computeResponse(
177                $password,
178                $message,
179                $response,
180                !$this->context->isServerMode()
181            )
182        );
183
184        # Pre-compute some stuff in advance. The A1 / cipher value is used in the security layer.
185        if ($options['use_integrity'] || $options['use_privacy']) {
186            $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1($password, $message, $response)));
187            $this->context->set('cipher', $response->get('cipher'));
188        }
189
190        return $this->encoder->encode($response, $this->context);
191    }
192
193    protected function generateServerVerification(Message $received, array $options): ?Message
194    {
195        $this->context->setIsComplete(true);
196        # The client sent a response without us sending a challenge...
197        if ($this->challenge === null) {
198            return null;
199        }
200
201        # @todo This should accept some kind of computed value, like the a1. Then it could generate the other values
202        #       using that.
203        $password = $options['password'] ?? null;
204        $qop = $received->get('qop');
205        $cipher = $received->get('cipher');
206        if ($password === null) {
207            return null;
208        }
209        # Client selected a qop we did not advertise...
210        if (!in_array($qop, $this->challenge->get('qop'), true)) {
211            return null;
212        }
213        # Client selected a cipher we did not advertise...
214        if (!in_array($cipher, $this->challenge->get('cipher'), true)) {
215            return null;
216        }
217        # The client sent a nonce without the minimum length from the RFC...
218        if (strlen((string) $received->get('cnonce')) < 12) {
219            return null;
220        }
221        # The client sent back a nonce different than what we sent them...
222        if ($received->get('nonce') !== $this->challenge->get('nonce')) {
223            return null;
224        }
225
226        # Generate our own response to compare against what we received from the client. If they do not match,
227        # then the password was incorrect.
228        $expected = DigestMD5Mechanism::computeResponse($password, $this->challenge, $received);
229        if ($expected !== $received->get('response')) {
230            return null;
231        }
232
233        $response = DigestMD5Mechanism::computeResponse($password, $this->challenge, $received, true);
234        $this->context->setIsAuthenticated(true);
235        if ($qop === 'auth-int' || $qop === 'auth-conf') {
236            $this->context->setHasSecurityLayer(true);
237            $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1($password, $this->challenge, $received)));
238            $this->context->set('cipher', $received->get('cipher'));
239            $this->context->set('seqnumsnt', 0);
240            $this->context->set('seqnumrcv', 0);
241        }
242
243        return $this->factory->create(
244            DigestMD5MessageFactory::MESSAGE_SERVER_RESPONSE,
245            ['rspauth' => $response]
246        );
247    }
248
249    protected function generateServerChallenge(array $options): Message
250    {
251        $messageOpts = [];
252        if (isset($options['nonce'])) {
253            $messageOpts['nonce'] = $options['nonce'];
254        }
255        if (isset($options['cipher'])) {
256            $messageOpts['cipher'] = $options['cipher'];
257        }
258        $this->challenge = $this->factory->create(
259            DigestMD5MessageFactory::MESSAGE_SERVER_CHALLENGE, $options
260        );
261
262        return $this->challenge;
263    }
264}
265