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\DigestMD5Encoder; 15use FreeDSx\Sasl\Exception\SaslException; 16use FreeDSx\Sasl\Factory\DigestMD5MessageFactory; 17use FreeDSx\Sasl\Mechanism\DigestMD5Mechanism; 18use FreeDSx\Sasl\Message; 19use FreeDSx\Sasl\SaslContext; 20 21/** 22 * The DIGEST-MD5 challenge / response class. 23 * 24 * @author Chad Sikorra <Chad.Sikorra@gmail.com> 25 */ 26class DigestMD5Challenge implements ChallengeInterface 27{ 28 /** 29 * @var array 30 */ 31 protected const DEFAULTS = [ 32 'use_integrity' => false, 33 'use_privacy' => false, 34 'service' => 'ldap', 35 'nonce_size' => null, 36 ]; 37 38 /** 39 * @var SaslContext 40 */ 41 protected $context; 42 43 /** 44 * @var DigestMD5MessageFactory 45 */ 46 protected $factory; 47 48 /** 49 * @var DigestMD5Encoder 50 */ 51 protected $encoder; 52 53 /** 54 * @var null|Message 55 */ 56 protected $challenge; 57 58 public function __construct(bool $isServerMode = false) 59 { 60 $this->factory = new DigestMD5MessageFactory(); 61 $this->encoder = new DigestMD5Encoder(); 62 $this->context = new SaslContext(); 63 $this->context->setIsServerMode($isServerMode); 64 } 65 66 /** 67 * {@inheritDoc} 68 */ 69 public function challenge(?string $received = null, array $options = []): SaslContext 70 { 71 $options = $options + self::DEFAULTS; 72 73 $received = $received === null ? null : $this->encoder->decode($received, $this->context); 74 if ($this->context->isServerMode()) { 75 $response = $this->generateServerResponse($received, $options); 76 } else { 77 $response = $this->generateClientResponse($received, $options); 78 } 79 $this->context->setResponse($response); 80 81 return $this->context; 82 } 83 84 /** 85 * @throws SaslException 86 */ 87 protected function generateClientResponse(?Message $message, $options): ?string 88 { 89 if ($message === null) { 90 return null; 91 } 92 if ($this->isClientChallengeNeeded($message)) { 93 return $this->createClientResponse($message, $options); 94 } 95 96 if ($message->has('rspauth') && $this->context->get('verification') === null) { 97 throw new SaslException('The rspauth value was received before the response was generated.'); 98 } 99 if ($message->has('rspauth') && $message->get('rspauth') === $this->context->get('verification')) { 100 $this->context->setIsAuthenticated(true); 101 $this->context->setHasSecurityLayer($options['use_integrity'] || $options['use_privacy']); 102 } 103 if ($this->context->hasSecurityLayer()) { 104 $this->context->set('seqnumsnt', 0); 105 $this->context->set('seqnumrcv', 0); 106 } 107 $this->context->setIsComplete($message->has('rspauth')); 108 109 return null; 110 } 111 112 protected function generateServerResponse(?Message $received, array $options): ?string 113 { 114 if ($received === null) { 115 $response = $this->generateServerChallenge($options); 116 } else { 117 $response = $this->generateServerVerification($received, $options); 118 } 119 120 return $response === null ? null : $this->encoder->encode($response, $this->context); 121 } 122 123 protected function isClientChallengeNeeded(Message $message): bool 124 { 125 if ($this->context->isServerMode()) { 126 return false; 127 } 128 129 return $message->get('rspauth') === null; 130 } 131 132 /** 133 * @throws SaslException 134 */ 135 protected function createClientResponse(Message $message, array $options): string 136 { 137 $password = $options['password'] ?? ''; 138 139 if ($options['use_privacy']) { 140 $this->context->set('qop', 'auth-conf'); 141 } elseif ($options['use_integrity']) { 142 $this->context->set('qop', 'auth-int'); 143 } else { 144 $this->context->set('qop', 'auth'); 145 } 146 147 $messageOpts = [ 148 'username' => $options['username'] ?? null, 149 'digest-uri' => isset($options['host']) ? ($options['service'] . '/' . $options['host']) : null, 150 'qop' => $this->context->get('qop'), 151 'nonce_size' => $options['nonce_size'], 152 'service' => $options['service'] 153 ]; 154 if (isset($options['cnonce'])) { 155 $messageOpts['cnonce'] = $options['cnonce']; 156 } 157 if (isset($options['nonce'])) { 158 $messageOpts['nonce'] = $options['nonce']; 159 } 160 if (isset($options['cipher'])) { 161 $messageOpts['cipher'] = $options['cipher']; 162 } 163 $response = $this->factory->create( 164 DigestMD5MessageFactory::MESSAGE_CLIENT_RESPONSE, $messageOpts, $message 165 ); 166 $response->set('response', DigestMD5Mechanism::computeResponse( 167 $password, 168 $message, 169 $response, 170 $this->context->isServerMode() 171 )); 172 173 # The verification is used to check the response value returned from the server for authentication. 174 $this->context->set( 175 'verification', 176 DigestMD5Mechanism::computeResponse( 177 $password, 178 $message, 179 $response, 180 !$this->context->isServerMode() 181 ) 182 ); 183 184 # Pre-compute some stuff in advance. The A1 / cipher value is used in the security layer. 185 if ($options['use_integrity'] || $options['use_privacy']) { 186 $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1($password, $message, $response))); 187 $this->context->set('cipher', $response->get('cipher')); 188 } 189 190 return $this->encoder->encode($response, $this->context); 191 } 192 193 protected function generateServerVerification(Message $received, array $options): ?Message 194 { 195 $this->context->setIsComplete(true); 196 # The client sent a response without us sending a challenge... 197 if ($this->challenge === null) { 198 return null; 199 } 200 201 # @todo This should accept some kind of computed value, like the a1. Then it could generate the other values 202 # using that. 203 $password = $options['password'] ?? null; 204 $qop = $received->get('qop'); 205 $cipher = $received->get('cipher'); 206 if ($password === null) { 207 return null; 208 } 209 # Client selected a qop we did not advertise... 210 if (!in_array($qop, $this->challenge->get('qop'), true)) { 211 return null; 212 } 213 # Client selected a cipher we did not advertise... 214 if (!in_array($cipher, $this->challenge->get('cipher'), true)) { 215 return null; 216 } 217 # The client sent a nonce without the minimum length from the RFC... 218 if (strlen((string) $received->get('cnonce')) < 12) { 219 return null; 220 } 221 # The client sent back a nonce different than what we sent them... 222 if ($received->get('nonce') !== $this->challenge->get('nonce')) { 223 return null; 224 } 225 226 # Generate our own response to compare against what we received from the client. If they do not match, 227 # then the password was incorrect. 228 $expected = DigestMD5Mechanism::computeResponse($password, $this->challenge, $received); 229 if ($expected !== $received->get('response')) { 230 return null; 231 } 232 233 $response = DigestMD5Mechanism::computeResponse($password, $this->challenge, $received, true); 234 $this->context->setIsAuthenticated(true); 235 if ($qop === 'auth-int' || $qop === 'auth-conf') { 236 $this->context->setHasSecurityLayer(true); 237 $this->context->set('a1', hex2bin(DigestMD5Mechanism::computeA1($password, $this->challenge, $received))); 238 $this->context->set('cipher', $received->get('cipher')); 239 $this->context->set('seqnumsnt', 0); 240 $this->context->set('seqnumrcv', 0); 241 } 242 243 return $this->factory->create( 244 DigestMD5MessageFactory::MESSAGE_SERVER_RESPONSE, 245 ['rspauth' => $response] 246 ); 247 } 248 249 protected function generateServerChallenge(array $options): Message 250 { 251 $messageOpts = []; 252 if (isset($options['nonce'])) { 253 $messageOpts['nonce'] = $options['nonce']; 254 } 255 if (isset($options['cipher'])) { 256 $messageOpts['cipher'] = $options['cipher']; 257 } 258 $this->challenge = $this->factory->create( 259 DigestMD5MessageFactory::MESSAGE_SERVER_CHALLENGE, $options 260 ); 261 262 return $this->challenge; 263 } 264} 265