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\CramMD5Encoder;
15use FreeDSx\Sasl\Encoder\EncoderInterface;
16use FreeDSx\Sasl\Exception\SaslException;
17use FreeDSx\Sasl\Factory\NonceTrait;
18use FreeDSx\Sasl\Message;
19use FreeDSx\Sasl\SaslContext;
20
21/**
22 * The CRAM-MD5 challenge / response class.
23 *
24 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
25 */
26class CramMD5Challenge implements ChallengeInterface
27{
28    use NonceTrait;
29
30    /**
31     * @var SaslContext
32     */
33    protected $context;
34
35    /**
36     * @var EncoderInterface
37     */
38    protected $encoder;
39
40    public function __construct(bool $isServerMode = false)
41    {
42        $this->encoder = new CramMD5Encoder();
43        $this->context = new SaslContext();
44        $this->context->setIsServerMode($isServerMode);
45    }
46
47    /**
48     * {@inheritDoc}
49     */
50    public function challenge(?string $received = null, array $options = []): SaslContext
51    {
52        $received = ($received === null) ? null : $this->encoder->decode($received, $this->context);
53
54        if ($received === null) {
55            !$this->context->isServerMode() ? $this->context : $this->generateServerChallenge($options);
56
57            return $this->context;
58        }
59
60        if ($this->context->isServerMode()) {
61            $this->validateClientResponse($received, $options);
62        } else {
63            $this->generateClientResponse($received, $options);
64        }
65
66        return $this->context;
67    }
68
69    protected function generateServerChallenge(array $options): SaslContext
70    {
71        $nonce = $options['challenge'] ?? $this->generateNonce(32);
72        $challenge = new Message(['challenge' => $nonce]);
73        $this->context->setResponse($this->encoder->encode($challenge, $this->context));
74        $this->context->set('challenge', $challenge->get('challenge'));
75
76        return $this->context;
77    }
78
79    protected function generateClientResponse(Message $received, array $options): void
80    {
81        if (!$received->has('challenge')) {
82            throw new SaslException('Expected a server challenge to generate a client response.');
83        }
84        if (!(isset($options['username']) && isset($options['password']))) {
85            throw new SaslException('A username and password is required for a client response.');
86        }
87        $response = new Message([
88            'username' => $options['username'],
89            'digest' => $this->generateDigest($received->get('challenge'), $options['password']),
90        ]);
91        $this->context->setResponse($this->encoder->encode($response, $this->context));
92        $this->context->setIsComplete(true);
93    }
94
95    protected function validateClientResponse(Message $received, array $options): void
96    {
97        if (!$received->has('username')) {
98            throw new SaslException('The client response must have a username.');
99        }
100        if (!$received->has('digest')) {
101            throw new SaslException('The client response must have a digest.');
102        }
103        if (!isset($options['password'])) {
104            throw new SaslException('To validate the client response you must supply the password option.');
105        }
106        $username = $received->get('username');
107        $digest = $received->get('digest');
108
109        $password = $options['password'];
110        if (!is_callable($password)) {
111            throw new SaslException('The password option must be callable. It will be passed the username and challenge');
112        }
113        $expectedDigest = $password($username, $this->context->get('challenge'));
114
115        $this->context->setIsAuthenticated($expectedDigest === $digest);
116        $this->context->setIsComplete(true);
117    }
118
119    protected function generateDigest(string $challenge, string $key): string
120    {
121        return hash_hmac(
122            'md5',
123            $challenge,
124            $key
125        );
126    }
127}
128