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