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\Factory;
13
14use Exception;
15use FreeDSx\Sasl\Exception\SaslException;
16use FreeDSx\Sasl\Message;
17
18/**
19 * The DIGEST-MD5 Message Factory.
20 *
21 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
22 */
23class DigestMD5MessageFactory implements MessageFactoryInterface
24{
25    use NonceTrait;
26
27    public const MESSAGE_CLIENT_RESPONSE = 1;
28
29    public const MESSAGE_SERVER_CHALLENGE = 2;
30
31    public const MESSAGE_SERVER_RESPONSE = 3;
32
33    protected const CIPHER_LIST = [
34        'rc4' => 'rc4',
35        'des-ede-cbc' => 'des',
36        'des-ede3-cbc' => '3des',
37        'rc4-40' => 'rc4-40',
38        'rc4-56' => 'rc4-56',
39    ];
40
41    /**
42     * Per the RFC:
43     *
44     *   It is RECOMMENDED that it contain at least 64 bits of entropy
45     *
46     * Byte length represented here. Bumping it up quite a bit from the recommendation. Can be controlled via an option.
47     */
48    protected const NONCE_SIZE = 32;
49
50    /**
51     * @var bool
52     */
53    protected $hasOpenSsl;
54
55    public function __construct()
56    {
57        $this->hasOpenSsl = extension_loaded('openssl');
58    }
59
60    /**
61     * {@inheritDoc}
62     */
63    public function create(int $type, array $options = [], ?Message $received = null): Message
64    {
65        if ($type === self::MESSAGE_CLIENT_RESPONSE && $received !== null) {
66            return $this->generateClientResponse($options, $received);
67        } elseif ($type === self::MESSAGE_SERVER_RESPONSE) {
68            return $this->generateServerResponse($options);
69        } elseif ($type === self::MESSAGE_SERVER_CHALLENGE) {
70            return $this->generateServerChallenge($options);
71        } else {
72            throw new SaslException(
73                'Unable to generate message. Unrecognized message type / received message combination.'
74            );
75        }
76    }
77
78    protected function generateServerChallenge(array $options): Message
79    {
80        $challenge = new Message();
81        $challenge->set('algorithm', 'md5-sess');
82        $challenge->set('nonce', $options['nonce'] ?? $this->generateNonce($options['nonce_size'] ?? self::NONCE_SIZE));
83        $challenge->set('qop', $this->generateAvailableQops($options));
84        $challenge->set('realm', $options['realm'] ?? $_SERVER['USERDOMAIN'] ?? gethostname());
85        $challenge->set('maxbuf', $options['maxbuf'] ?? '65536');
86        $challenge->set('charset', 'utf-8');
87        if (in_array('auth-conf', $challenge->get('qop'))) {
88            $challenge->set('cipher', $this->getAvailableCiphers($options));
89        }
90
91        return $challenge;
92    }
93
94    protected function generateServerResponse(array $options): Message
95    {
96        $rspAuth = $options['rspauth'] ?? null;
97        if ($rspAuth === null) {
98            throw new SaslException('The server response must include the rspauth value.');
99        }
100
101        return new Message(['rspauth' => $rspAuth]);
102    }
103
104    /**
105     * @throws SaslException
106     */
107    protected function generateClientResponse(array $options, Message $challenge): Message
108    {
109        $response = new Message();
110        $qop = isset($options['qop']) ? (string) $options['qop'] : null;
111
112        $response->set('algorithm', 'md5-sess');
113        $response->set('nonce', $challenge->get('nonce'));
114        $response->set('cnonce', $options['cnonce'] ?? $this->generateNonce($options['nonce_size'] ?? self::NONCE_SIZE));
115        $response->set('nc', $options['nc'] ?? 1);
116        $response->set('qop', $this->selectQopFromChallenge($challenge, $qop));
117        $response->set('username', $options['username'] ?? $this->getCurrentUser());
118        $response->set('realm', $options['realm'] ?? $this->getRealmFromChallenge($challenge));
119        $response->set('digest-uri', $options['digest-uri'] ?? $this->getDigestUri($options, $response, $challenge));
120        if ($response->get('qop') === 'auth-conf' && !$response->get('cipher')) {
121            $this->setCipherForChallenge($options, $response, $challenge);
122        }
123
124        return $response;
125    }
126
127    /**
128     * @throws SaslException
129     */
130    protected function getDigestUri(array $options, Message $response, Message $challenge): string
131    {
132        if (!isset($options['service'])) {
133            throw new SaslException('If you do not supply a digest-uri, you must specify a service.');
134        }
135
136        return sprintf(
137            '%s/%s',
138            $options['service'],
139            $response->get('realm')
140        );
141    }
142
143    protected function generateAvailableQops(array $options): array
144    {
145        $qop = ['auth'];
146
147        if (isset($options['use_integrity']) && $options['use_integrity'] === true) {
148            $qop[] = 'auth-int';
149        }
150        if (isset($options['use_privacy']) && $options['use_privacy'] === true) {
151            $qop[] = 'auth-conf';
152        }
153
154        return $qop;
155    }
156
157    /**
158     * @throws SaslException
159     */
160    protected function selectQopFromChallenge(Message $challenge, ?string $qop): string
161    {
162        $available = (array) ($challenge->get('qop') ?? []);
163        /* Per the RFC: This directive is optional; if not present it defaults to "auth". */
164        if (count($available) === 0) {
165            return 'auth';
166        }
167        $options = $qop !== null ? [$qop] : ['auth-conf', 'auth-int', 'auth'];
168
169        foreach ($options as $method) {
170            if (in_array($method, $available, true)) {
171                return $method;
172            }
173        }
174
175        throw new SaslException(sprintf(
176            'None of the qop values are recognized, or the one you selected is not available. Available methods are: %s',
177            implode($available)
178        ));
179    }
180
181    protected function getAvailableCiphers(array $options): array
182    {
183        $cipherList = self::CIPHER_LIST;
184
185        # If specific cipher(s) are already wanted, filter the list...
186        if (isset($options['cipher'])) {
187            $wanted = (array) $options['cipher'];
188            $cipherList = array_filter($cipherList, function ($name) use ($wanted) {
189                return in_array($name, $wanted, true);
190            });
191        }
192
193        # Now filter it based on what ciphers actually show as available in OpenSSL...
194        $available = openssl_get_cipher_methods();
195        foreach ($cipherList as $cipher => $name) {
196            if (!in_array($cipher, $available, true)) {
197                unset($cipherList[$cipher]);
198            }
199        }
200
201        if (empty($cipherList)) {
202            throw new SaslException('There are no available ciphers for auth-conf.');
203        }
204
205        return array_values($cipherList);
206    }
207
208    /**
209     * @throws SaslException
210     */
211    protected function setCipherForChallenge(array $options, Message $response, Message $challenge): void
212    {
213        if (!$challenge->has('cipher')) {
214            throw new SaslException('The client requested auth-conf, but the challenge contains no ciphers.');
215        }
216        $ciphers = $challenge->get('cipher');
217        # If we are requesting a specific cipher, then only check that one...
218        $toCheck = isset($options['cipher']) ? (array) $options['cipher'] : ['3des', 'des', 'rc4', 'rc4-56', 'rc4-40', ];
219
220        $selected = null;
221        foreach ($toCheck as $selection) {
222            if (in_array($selection, $ciphers, true)) {
223                $selected = $selection;
224                break;
225            }
226        }
227        if ($selected === null) {
228            throw new SaslException(sprintf(
229                'No recognized ciphers were offered in the challenge: %s',
230                implode(', ', $ciphers)
231            ));
232        }
233
234        $response->set('cipher', $selected);
235    }
236
237    protected function getCurrentUser(): string
238    {
239        if (isset($_SERVER['USERNAME'])) {
240            return $_SERVER['USERNAME'];
241        } elseif (isset($_SERVER['USER'])) {
242            return $_SERVER['USER'];
243        }
244
245        throw new SaslException('Unable to determine a username for the response. You must supply a username.');
246    }
247
248    /**
249     * Only populate if one realm is provided in the challenge. If more than one exists then the client must supply this.
250     */
251    protected function getRealmFromChallenge(Message $challenge): string
252    {
253        if (!$challenge->has('realm')) {
254            throw new SaslException('Unable to determine a realm for the response.');
255        }
256        $realms = (array) $challenge->get('realm');
257        $selected = array_pop($realms);
258
259        return $selected;
260    }
261}
262