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\Encoder;
13
14use FreeDSx\Sasl\Exception\SaslEncodingException;
15use FreeDSx\Sasl\Message;
16use FreeDSx\Sasl\SaslContext;
17use function dechex, explode, implode, preg_match, sprintf, str_pad, strlen, substr;
18
19/**
20 * Responsible for encoding / decoding DIGEST-MD5 messages.
21 *
22 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
23 */
24class DigestMD5Encoder implements EncoderInterface
25{
26    protected const MATCH_KEY = '/(([a-zA-Z-]+)=)/';
27
28    protected const MATCH_QD_STR_VAL = '/("((.*?)(?<!\\\))")/';
29
30    protected const MATCH_DIGITS = '/([0-9]+)/';
31
32    protected const MATCH_ALPHA_NUMERIC = '/([A-Za-z0-9-]+)/';
33
34    protected const MATCH_LHEX = '/([0-9a-fA-F]{1,})/';
35
36    protected const ONCE_ONLY = [
37        'stale',
38        'maxbuf',
39        'charset',
40        'algorithm',
41        'nonce',
42        'cnonce',
43        'nc',
44        'qop',
45        'digest-uri',
46        'response',
47        'cipher',
48    ];
49
50    /**
51     * @var string
52     */
53    protected $binary;
54
55    /**
56     * @var int
57     */
58    protected $pos = 0;
59
60    /**
61     * @var int
62     */
63    protected $length = 0;
64
65    /**
66     * Tracks the number of times a specific option is encountered during decoding.
67     */
68    protected $occurrences = [];
69
70    /**
71     * {@inheritDoc}
72     */
73    public function decode(string $data, SaslContext $context): Message
74    {
75        return $this->parse($data, !$context->isServerMode());
76    }
77
78    /**
79     * {@inheritDoc}
80     */
81    public function encode(Message $message, SaslContext $context): string
82    {
83        $response = '';
84
85        foreach ($message->toArray() as $key => $value) {
86            if ($response !== '') {
87                $response .= ',';
88            }
89            $response .= $key . '=' . $this->encodeOptValue(
90                $key,
91                $value,
92                $context->isServerMode()
93            );
94        }
95
96        return $response;
97    }
98
99    protected function startParsing(string $binary): void
100    {
101        $this->binary = $binary;
102        $this->pos = 0;
103        $this->length = strlen($binary);
104        $this->occurrences = [];
105    }
106
107    protected function endParsing(): void
108    {
109        $this->binary = '';
110        $this->pos = 0;
111        $this->length = 0;
112        $this->occurrences = [];
113    }
114
115    /**
116     * @throws SaslEncodingException
117     */
118    protected function parse(string $digest, bool $isServerMode): Message
119    {
120        $this->startParsing($digest);
121
122        $message = new Message();
123        while ($this->pos < $this->length) {
124            $keyMatches = null;
125            if (!preg_match(self::MATCH_KEY, substr($this->binary, $this->pos), $keyMatches)) {
126                throw new SaslEncodingException('The digest is malformed. Expected a key, but none was found.');
127            }
128            $this->pos += strlen($keyMatches[1]);
129            if (!isset($this->binary[$this->pos])) {
130                throw new SaslEncodingException('Unexpected end of digest. Expected a value following a key.');
131            }
132            $message->set($keyMatches[2], $this->parseOptValue($keyMatches[2], $isServerMode));
133        }
134        $this->endParsing();
135
136        return $message;
137    }
138
139    /**
140     * @return mixed
141     * @throws SaslEncodingException
142     */
143    protected function parseOptValue(string $opt, bool $isServerMode)
144    {
145        $value = null;
146
147        switch ($opt) {
148            case 'realm':
149            case 'nonce':
150            case 'username':
151            case 'cnonce':
152            case 'authzid':
153            case 'digest-uri':
154                $value = $this->parseQuotedValue();
155                break;
156            case 'qop':
157            case 'cipher':
158                if ($isServerMode) {
159                    $value = $this->parseQuotedCommaList();
160                } else {
161                    $value = $this->parseRegex(self::MATCH_ALPHA_NUMERIC, 'The value is malformed.');
162                }
163                break;
164            case 'stale':
165                $value = $this->parseExact('true');
166                break;
167            case 'maxbuf':
168                $value = $this->parseRegex(self::MATCH_DIGITS, 'Expected a series of digits for a key value.');
169                break;
170            case 'algorithm':
171                $value = $this->parseExact('md5-sess');
172                break;
173            case 'charset':
174                $value = $this->parseExact('utf-8');
175                break;
176            case 'nc':
177                $value = $this->parseLHexValue(8);
178                break;
179            case 'response':
180            case 'rspauth':
181                $value = $this->parseLHexValue(32);
182                break;
183            default:
184                throw new SaslEncodingException(sprintf(
185                    'Digest option %s is not supported.',
186                    $opt
187                ));
188                break;
189        }
190
191        if (isset($this->binary[$this->pos]) && $this->binary[$this->pos] !== ',') {
192            throw new SaslEncodingException(sprintf(
193                'Expected a comma following digest value for %s.',
194                $opt
195            ));
196        }
197        if (isset($this->binary[$this->pos]) && $this->binary[$this->pos] === ',') {
198            $this->pos++;
199        }
200
201        if (isset($this->occurrences[$opt]) && in_array($opt, self::ONCE_ONLY, true)) {
202            throw new SaslEncodingException(sprintf('The option "%s" may occur only once.', $opt));
203        } elseif (isset($this->occurrences[$opt])) {
204            $this->occurrences[$opt]++;
205        } else {
206            $this->occurrences[$opt] = 1;
207        }
208
209        return $value;
210    }
211
212    /**
213     * @return mixed
214     * @throws SaslEncodingException
215     */
216    protected function encodeOptValue(string $name, $value, bool $isServerMode)
217    {
218        $encoded = null;
219
220        switch ($name) {
221            case 'realm':
222            case 'nonce':
223            case 'username':
224            case 'cnonce':
225            case 'authzid':
226            case 'digest-uri':
227                $encoded = '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $value) . '"';
228                break;
229            case 'qop':
230            case 'cipher':
231                if ($isServerMode) {
232                    $encoded = '"' . implode(',', (array) $value) . '"';
233                } else {
234                    $encoded = (string) $value;
235                }
236                break;
237            case 'stale':
238                $encoded = 'true';
239                break;
240            case 'maxbuf':
241            case 'algorithm':
242            case 'charset':
243                $encoded = (string) $value;
244                break;
245            case 'nc':
246                $encoded = str_pad(dechex($value), 8, '0', STR_PAD_LEFT);
247                break;
248            case 'response':
249            case 'rspauth':
250                $encoded = $this->encodeLHexValue($value, 32);
251                break;
252            default:
253                throw new SaslEncodingException(sprintf(
254                    'Digest option %s is not supported.',
255                    $name
256                ));
257                break;
258        }
259
260        return $encoded;
261    }
262
263    /**
264     * @throws SaslEncodingException
265     */
266    protected function parseExact(string $expected): string
267    {
268        $length = strlen($expected);
269        if (substr($this->binary, $this->pos, $length) !== $expected) {
270            throw new SaslEncodingException(sprintf(
271                'Expected the directive value to be "%s", but it is not.',
272                $expected
273            ));
274        }
275        $this->pos += $length;
276
277        return $expected;
278    }
279
280    /**
281     * @throws SaslEncodingException
282     */
283    protected function parseQuotedValue(): string
284    {
285        if (!preg_match(self::MATCH_QD_STR_VAL, substr($this->binary, $this->pos), $matches)) {
286            throw new SaslEncodingException('The value is malformed. Expected a qdstr-val.');
287        }
288        $this->pos += strlen($matches[1]);
289
290        return stripslashes($matches[2]);
291    }
292
293    /**
294     * @throws SaslEncodingException
295     */
296    protected function parseQuotedCommaList(): array
297    {
298        $value = $this->parseQuotedValue();
299
300        return explode(',', $value);
301    }
302
303    /**
304     * @throws SaslEncodingException
305     */
306    protected function parseLHexValue(int $length): string
307    {
308        if (!preg_match(self::MATCH_LHEX, substr($this->binary, $this->pos), $matches)) {
309            throw new SaslEncodingException('Expected a hex value.');
310        }
311        if (strlen($matches[1]) !== $length) {
312            throw new SaslEncodingException(sprintf('Expected the hex value to be %s characters long.', $length));
313        }
314        $this->pos += strlen($matches[1]);
315
316        return $matches[1];
317    }
318
319    /**
320     * @throws SaslEncodingException
321     */
322    protected function parseRegex(string $regex, string $errorMessage)
323    {
324        if (!preg_match($regex, substr($this->binary, $this->pos), $matches)) {
325            throw new SaslEncodingException($errorMessage);
326        }
327        $this->pos += strlen($matches[1]);
328
329        return $matches[1];
330    }
331
332    /**
333     * @throws SaslEncodingException
334     */
335    protected function encodeLHexValue(string $data, int $length): string
336    {
337        if (strlen($data) !== $length) {
338            throw new SaslEncodingException(sprintf('Expected the encoded hex value to be %s characters long.', $length));
339        }
340
341        return $data;
342    }
343}
344