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\Security;
13
14use FreeDSx\Sasl\Exception\SaslException;
15use FreeDSx\Sasl\SaslContext;
16
17/**
18 * The DIGEST-MD5 security layer.
19 *
20 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
21 */
22class DigestMD5SecurityLayer implements SecurityLayerInterface
23{
24    protected const MAXBUF = 65536;
25
26    protected const KCC_MC = 'Digest H(A1) to client-to-server sealing key magic constant';
27
28    protected const KCS_MC = 'Digest H(A1) to server-to-client sealing key magic constant';
29
30    protected const KIC_MC = 'Digest session key to client-to-server signing key magic constant';
31
32    protected const KIS_MC = 'Digest session key to server-to-client signing key magic constant';
33
34    /**
35     * RFC2831 Section 2.3 / 2.4
36     */
37    protected const MESSAGE_TYPE = 1;
38
39    /**
40     * Cipher specific details related to the encryption / decryption process.
41     */
42    protected const CIPHERS = [
43        '3des' => [
44            'block_size' => 8,
45            'kcn' => 16,
46            'cipher' => 'des-ede3-cbc',
47        ],
48        'des' => [
49            'block_size' => 8,
50            'kcn' => 16,
51            'cipher' => 'des-ede-cbc'
52        ],
53        'rc4' => [
54            'block_size' => 1,
55            'kcn' => 16,
56            'cipher' => 'rc4',
57        ],
58        'rc4-40' => [
59            'block_size' => 1,
60            'kcn' => 5,
61            'cipher' => 'rc4-40',
62        ],
63        'rc4-56' => [
64            'block_size' => 1,
65            'kcn' => 7,
66            'cipher' => 'rc4-56',
67        ],
68    ];
69
70    /**
71     * {@inheritDoc}
72     */
73    public function wrap(string $data, SaslContext $context): string
74    {
75        $qop = $context->get('qop');
76
77        if ($qop === 'auth-conf') {
78            $wrapped = $this->encrypt($data, $context);
79        } elseif ($qop === 'auth-int') {
80            $wrapped = $this->sign($data, $context);
81        } else {
82            throw new SaslException(sprintf('The qop option "%s" is not recognized as a security layer.', $qop));
83        }
84        $this->validateBufferLength($wrapped, $context);
85        $context->set('seqnumsnt', $context->get('seqnumsnt') + 1);
86
87        return $wrapped;
88    }
89
90    /**
91     * {@inheritDoc}
92     */
93    public function unwrap(string $data, SaslContext $context): string
94    {
95        $qop = $context->get('qop');
96        $this->validateBufferLength($data, $context);
97
98        if ($qop === 'auth-conf') {
99            $unwrapped = $this->decrypt($data, $context);
100        } elseif ($qop === 'auth-int') {
101            $unwrapped = $this->verify($data, $context);
102        } else {
103            throw new SaslException(sprintf('The qop option "%s" is not recognized as a security layer.', $qop));
104        }
105        $context->set('seqnumrcv', $context->get('seqnumrcv') + 1);
106
107        return $unwrapped;
108    }
109
110    /**
111     * @throws SaslException
112     */
113    protected function decrypt(string $data, SaslContext $context): string
114    {
115        # At the very least we are expect 16 bytes. 10 for the actual MAC, 4 for the seqnum, 2 for the msgtype.
116        if (strlen($data) < 16) {
117            throw new SaslException('The data to decrypt must be at least 16 bytes.');
118        }
119        $receivedMsgType = hexdec(bin2hex(substr($data, -6, 2)));
120        $receivedSeqNum = hexdec(bin2hex(substr($data, -4)));
121        if (self::MESSAGE_TYPE !== $receivedMsgType) {
122            throw new SaslException(sprintf(
123                'The received message type of "%s" was unexpected.',
124                $receivedMsgType
125            ));
126        }
127        $seqnum = $context->get('seqnumrcv');
128        if (!is_int($seqnum) || $seqnum !== $receivedSeqNum) {
129            throw new SaslException(sprintf(
130                'The received sequence number was unexpected. Expected %s, but got %s.',
131                $seqnum,
132                $receivedSeqNum
133            ));
134        }
135
136        $cipher = $context->get('cipher');
137        $a1 = $context->get('a1');
138        $isServerMode = $context->isServerMode();
139        $this->validateCipher($cipher);
140        $encrypted = substr($data, 0, -6);
141
142        # Inverted selection of constants here and for $mcKi, as this would be the receiving end.
143        $mcKc = $isServerMode ? self::KCC_MC : self::KCS_MC;
144        $kc = $this->generateKeyKc($a1, $cipher, $mcKc);
145        [$iv, $key] = $this->generateKeyAndIV($cipher, $kc);
146        $data = openssl_decrypt($encrypted, self::CIPHERS[$cipher]['cipher'], $key, OPENSSL_NO_PADDING | OPENSSL_RAW_DATA, $iv);
147        if ($data === false) {
148            throw new SaslException('Failed the decrypt the message.');
149        }
150        $message = substr($data, 0, -10);
151        if (self::CIPHERS[$cipher]['block_size'] > 1) {
152            $message = $this->removePadding($message, self::CIPHERS[$cipher]['block_size']);
153        }
154
155        $receivedMac = substr($data, -10);
156        $mcKi = $isServerMode ? self::KIC_MC : self::KIS_MC;
157        $ki = $this->generateKeyKi($a1, $mcKi);
158        $expectedMac = substr($this->generateMACBlock($ki, $message, $seqnum), 0, 10);
159
160        if ($receivedMac !== $expectedMac) {
161            throw new SaslException('The received MAC does not match the expected MAC.');
162        }
163
164        return $message;
165    }
166
167    /**
168     * SEAL(Ki, Kc, SeqNum, msg) = {CIPHER(Kc, {msg, pad, HMAC(Ki, {SeqNum, msg})[0..9])}), 0x0001, SeqNum}
169     *
170     * @throws SaslException
171     */
172    protected function encrypt(string $data, SaslContext $context): string
173    {
174        $cipher = $context->get('cipher');
175        $a1 = $context->get('a1');
176        $isServerMode = $context->isServerMode();
177        $seqnum = $context->get('seqnumsnt');
178        $this->validateCipher($cipher);
179
180        $mcKc = $isServerMode ? self::KCS_MC : self::KCC_MC;
181        $kc = $this->generateKeyKc($a1, $cipher, $mcKc);
182
183        $mcKi = $isServerMode ? self::KIS_MC : self::KIC_MC;
184        $ki = $this->generateKeyKi($a1, $mcKi);
185
186        # The first 10 bytes of the MAC block is used. Extract the last 6 bytes, as that gets tacked onto the end.
187        $macBlock = $this->generateMACBlock($ki, $data, $seqnum);
188        $ending = substr($macBlock, 10);
189        $macBlock = substr($macBlock, 0, 10);
190
191        $padding = $this->generatePadding($data, self::CIPHERS[$cipher]['block_size']);
192        [$iv, $key] = $this->generateKeyAndIV($cipher, $kc);
193        $encrypted = openssl_encrypt($data . $padding . $macBlock, self::CIPHERS[$cipher]['cipher'], $key, OPENSSL_NO_PADDING | OPENSSL_RAW_DATA, $iv);
194
195        return $encrypted . $ending;
196    }
197
198    /**
199     * @throws SaslException
200     */
201    protected function removePadding(string $message, int $blockSize): string
202    {
203        $padOrd = isset($message[-1]) ? ord($message[-1]) : 0;
204        $padRaw = $message[-1] ?? '';
205
206        # The padding size should only ever be between these values...
207        if ($padOrd < 1 || $padOrd > $blockSize) {
208            throw new SaslException('The padding size is not correct.');
209        }
210
211        $msgLength = strlen($message);
212        for ($i = ($msgLength - $padOrd); $i < ($msgLength - 1); $i++) {
213            if ($message[$i] !== $padRaw) {
214                throw new SaslException('The padding does not match the expected value.');
215            }
216        }
217
218        return  substr($message, 0, strlen($message) - $padOrd);
219    }
220
221    /**
222     * @throws SaslException
223     */
224    protected function validateCipher(string $cipher): void
225    {
226        if (!isset(self::CIPHERS[$cipher])) {
227            throw new SaslException(sprintf(
228                'The cipher "%s" is not supported.',
229                $cipher
230            ));
231        }
232    }
233
234    /**
235     * Append a signed MAC to the message.
236     */
237    protected function sign(string $message, SaslContext $context): string
238    {
239        $seqnum = $context->get('seqnumsnt');
240        $mc = $context->isServerMode() ? self::KIS_MC : self::KIC_MC;
241        $ki = $this->generateKeyKi($context->get('a1'), $mc);
242        $macBlock = $this->generateMACBlock($ki, $message, $seqnum);
243
244        return $message . $macBlock;
245    }
246
247    /**
248     * Verify a signed message. Return the unsigned message without the MAC.
249     *
250     * @throws SaslException
251     */
252    protected function verify(string $data, SaslContext $context): string
253    {
254        $receivedMac = substr($data, -16);
255        if (strlen($receivedMac) !== 16) {
256            throw new SaslException('Expected at least 16 bytes of data for the MAC.');
257        }
258
259        $seqnum = $context->get('seqnumrcv');
260        $message = substr($data, 0, -16);
261        # Inverted selection of constant here, as this would be the receiving end.
262        $mc = $context->isServerMode() ? self::KIC_MC : self::KIS_MC;
263        $ki = $this->generateKeyKi($context->get('a1'), $mc);
264        $expectedMac = $this->generateMACBlock($ki, $message, $seqnum);
265
266        if ($receivedMac !== $expectedMac) {
267            throw new SaslException('The received MAC is invalid.');
268        }
269
270        return $message;
271    }
272
273    /**
274     * Per the RFC:
275     *
276     *   If the blocksize of the chosen cipher is not 1 byte, the padding prefix is one or more octets each containing the
277     *   number of padding bytes, such that total length of the encrypted part of the message is a multiple of the
278     *   blocksize.
279     */
280    protected function generatePadding(string $data, int $blockSize): string
281    {
282        if ($blockSize === 1) {
283            return '';
284        }
285        $pad = $blockSize - (strlen($data) + 10) & ($blockSize - 1);
286
287        return str_repeat(chr($pad), $pad);
288    }
289
290    /**
291     * RFC2831 Section 2.3
292     *
293     * The MAC block is 16 bytes: the first 10 bytes of the HMAC-MD5 [RFC2104] of the message, a 2-byte message type
294     * number in network byte order with value 1, and the 4-byte sequence number in network byte order. The message type
295     * is to allow for future extensions such as rekeying.
296     *
297     *   MAC(Ki, SeqNum, msg) = (HMAC(Ki, {SeqNum, msg})[0..9], 0x0001, SeqNum)
298     */
299    protected function generateMACBlock(string $key, string $message, int $seqNum): string
300    {
301        /** 4-byte sequence number in network byte order. */
302        $seqNum = pack('N', $seqNum);
303        $macBlock = substr(hash_hmac('md5', $seqNum . $message, $key, true), 0, 10);
304        /** a 2-byte message type number in network byte order with value 1 */
305        $macBlock .= "\x00\x01";
306        $macBlock .= $seqNum;
307
308        return $macBlock;
309    }
310
311    /**
312     * The keys for integrity protecting messages from client to server / server to client:
313     *
314     *   Kic = MD5({H(A1), "Digest session key to client-to-server signing key magic constant"})
315     *   Kis = MD5({H(A1), "Digest session key to server-to-client signing key magic constant"})
316     *
317     */
318    protected function generateKeyKi(string $a1, string $mc): string
319    {
320        return hash('md5', $a1 . $mc, true);
321    }
322
323    /**
324     * The key for encrypting messages from client to server / server to client:
325     *
326     *   Kcc = MD5({H(A1)[0..n], "Digest H(A1) to client-to-server sealing key magic constant"})
327     *   Kcs = MD5({H(A1)[0..n], "Digest H(A1) to server-to-client sealing key magic constant"})
328     *
329     * Where the key size is determined by "n" above.
330     */
331    protected function generateKeyKc(string $a1, string $cipher, string $mc): string
332    {
333        return hash(
334            'md5',
335            substr($a1, 0, self::CIPHERS[$cipher]['kcn']) . $mc,
336            true
337        );
338    }
339
340    protected function generateKeyAndIV(string $cipher, string $kc): array
341    {
342        # No IV and all of the kc for the key with RC4 types
343        if ($cipher === 'rc4' || $cipher === 'rc4-40' || $cipher === 'rc4-56') {
344            return ['', $kc];
345        }
346
347        $iv = substr($kc, 8, 8);
348        if ($cipher === 'des') {
349            $key = $this->expandDesKey(substr($kc, 0, 7));
350        } else {
351            $key1 = substr($kc, 0, 7);
352            $key2 = substr($kc, 7, 7);
353
354            $key = '';
355            foreach ([$key1, $key2, $key1] as $desKey) {
356                $key .= $this->expandDesKey($desKey);
357            }
358        }
359
360        return [$iv, $key];
361    }
362
363    /**
364     * We need to manually expand the 7-byte DES keys to 8-bytes. This shifts the first 7 bytes into the high seven bits.
365     * This also ignores parity, as it should not be strictly necessary and just adds additional complexity here.
366     */
367    protected function expandDesKey(string $key): string
368    {
369        $bytes = [];
370
371        for ($i = 0; $i < 7; $i++) {
372            $bytes[$i] = ord($key[$i]);
373        }
374
375        return
376            chr($bytes[0] & 0xfe) .
377            chr(($bytes[0] << 7) | ($bytes[1] >> 1)) .
378            chr(($bytes[1] << 6) | ($bytes[2] >> 2)) .
379            chr(($bytes[2] << 5) | ($bytes[3] >> 3)) .
380            chr(($bytes[3] << 4) | ($bytes[4] >> 4)) .
381            chr(($bytes[4] << 3) | ($bytes[5] >> 5)) .
382            chr(($bytes[5] << 2) | ($bytes[6] >> 6)) .
383            chr($bytes[6] << 1);
384    }
385
386    /**
387     * @throws SaslException
388     */
389    protected function validateBufferLength(string $data, SaslContext $context): void
390    {
391        $maxbuf = $context->has('maxbuf') ? (int) $context->get('maxbuf') : self::MAXBUF;
392        if (strlen($data) > $maxbuf) {
393            throw new SaslException(sprintf(
394                'The wrapped buffer exceeds the maxbuf length of %s',
395                $maxbuf
396            ));
397        }
398    }
399}
400