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