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