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\Factory; 13 14use Exception; 15use FreeDSx\Sasl\Exception\SaslException; 16use FreeDSx\Sasl\Message; 17 18/** 19 * The DIGEST-MD5 Message Factory. 20 * 21 * @author Chad Sikorra <Chad.Sikorra@gmail.com> 22 */ 23class DigestMD5MessageFactory implements MessageFactoryInterface 24{ 25 use NonceTrait; 26 27 public const MESSAGE_CLIENT_RESPONSE = 1; 28 29 public const MESSAGE_SERVER_CHALLENGE = 2; 30 31 public const MESSAGE_SERVER_RESPONSE = 3; 32 33 protected const CIPHER_LIST = [ 34 'rc4' => 'rc4', 35 'des-ede-cbc' => 'des', 36 'des-ede3-cbc' => '3des', 37 'rc4-40' => 'rc4-40', 38 'rc4-56' => 'rc4-56', 39 ]; 40 41 /** 42 * Per the RFC: 43 * 44 * It is RECOMMENDED that it contain at least 64 bits of entropy 45 * 46 * Byte length represented here. Bumping it up quite a bit from the recommendation. Can be controlled via an option. 47 */ 48 protected const NONCE_SIZE = 32; 49 50 /** 51 * @var bool 52 */ 53 protected $hasOpenSsl; 54 55 public function __construct() 56 { 57 $this->hasOpenSsl = extension_loaded('openssl'); 58 } 59 60 /** 61 * {@inheritDoc} 62 */ 63 public function create(int $type, array $options = [], ?Message $received = null): Message 64 { 65 if ($type === self::MESSAGE_CLIENT_RESPONSE && $received !== null) { 66 return $this->generateClientResponse($options, $received); 67 } elseif ($type === self::MESSAGE_SERVER_RESPONSE) { 68 return $this->generateServerResponse($options); 69 } elseif ($type === self::MESSAGE_SERVER_CHALLENGE) { 70 return $this->generateServerChallenge($options); 71 } else { 72 throw new SaslException( 73 'Unable to generate message. Unrecognized message type / received message combination.' 74 ); 75 } 76 } 77 78 protected function generateServerChallenge(array $options): Message 79 { 80 $challenge = new Message(); 81 $challenge->set('algorithm', 'md5-sess'); 82 $challenge->set('nonce', $options['nonce'] ?? $this->generateNonce($options['nonce_size'] ?? self::NONCE_SIZE)); 83 $challenge->set('qop', $this->generateAvailableQops($options)); 84 $challenge->set('realm', $options['realm'] ?? $_SERVER['USERDOMAIN'] ?? gethostname()); 85 $challenge->set('maxbuf', $options['maxbuf'] ?? '65536'); 86 $challenge->set('charset', 'utf-8'); 87 if (in_array('auth-conf', $challenge->get('qop'))) { 88 $challenge->set('cipher', $this->getAvailableCiphers($options)); 89 } 90 91 return $challenge; 92 } 93 94 protected function generateServerResponse(array $options): Message 95 { 96 $rspAuth = $options['rspauth'] ?? null; 97 if ($rspAuth === null) { 98 throw new SaslException('The server response must include the rspauth value.'); 99 } 100 101 return new Message(['rspauth' => $rspAuth]); 102 } 103 104 /** 105 * @throws SaslException 106 */ 107 protected function generateClientResponse(array $options, Message $challenge): Message 108 { 109 $response = new Message(); 110 $qop = isset($options['qop']) ? (string) $options['qop'] : null; 111 112 $response->set('algorithm', 'md5-sess'); 113 $response->set('nonce', $challenge->get('nonce')); 114 $response->set('cnonce', $options['cnonce'] ?? $this->generateNonce($options['nonce_size'] ?? self::NONCE_SIZE)); 115 $response->set('nc', $options['nc'] ?? 1); 116 $response->set('qop', $this->selectQopFromChallenge($challenge, $qop)); 117 $response->set('username', $options['username'] ?? $this->getCurrentUser()); 118 $response->set('realm', $options['realm'] ?? $this->getRealmFromChallenge($challenge)); 119 $response->set('digest-uri', $options['digest-uri'] ?? $this->getDigestUri($options, $response, $challenge)); 120 if ($response->get('qop') === 'auth-conf' && !$response->get('cipher')) { 121 $this->setCipherForChallenge($options, $response, $challenge); 122 } 123 124 return $response; 125 } 126 127 /** 128 * @throws SaslException 129 */ 130 protected function getDigestUri(array $options, Message $response, Message $challenge): string 131 { 132 if (!isset($options['service'])) { 133 throw new SaslException('If you do not supply a digest-uri, you must specify a service.'); 134 } 135 136 return sprintf( 137 '%s/%s', 138 $options['service'], 139 $response->get('realm') 140 ); 141 } 142 143 protected function generateAvailableQops(array $options): array 144 { 145 $qop = ['auth']; 146 147 if (isset($options['use_integrity']) && $options['use_integrity'] === true) { 148 $qop[] = 'auth-int'; 149 } 150 if (isset($options['use_privacy']) && $options['use_privacy'] === true) { 151 $qop[] = 'auth-conf'; 152 } 153 154 return $qop; 155 } 156 157 /** 158 * @throws SaslException 159 */ 160 protected function selectQopFromChallenge(Message $challenge, ?string $qop): string 161 { 162 $available = (array) ($challenge->get('qop') ?? []); 163 /* Per the RFC: This directive is optional; if not present it defaults to "auth". */ 164 if (count($available) === 0) { 165 return 'auth'; 166 } 167 $options = $qop !== null ? [$qop] : ['auth-conf', 'auth-int', 'auth']; 168 169 foreach ($options as $method) { 170 if (in_array($method, $available, true)) { 171 return $method; 172 } 173 } 174 175 throw new SaslException(sprintf( 176 'None of the qop values are recognized, or the one you selected is not available. Available methods are: %s', 177 implode($available) 178 )); 179 } 180 181 protected function getAvailableCiphers(array $options): array 182 { 183 $cipherList = self::CIPHER_LIST; 184 185 # If specific cipher(s) are already wanted, filter the list... 186 if (isset($options['cipher'])) { 187 $wanted = (array) $options['cipher']; 188 $cipherList = array_filter($cipherList, function ($name) use ($wanted) { 189 return in_array($name, $wanted, true); 190 }); 191 } 192 193 # Now filter it based on what ciphers actually show as available in OpenSSL... 194 $available = openssl_get_cipher_methods(); 195 foreach ($cipherList as $cipher => $name) { 196 if (!in_array($cipher, $available, true)) { 197 unset($cipherList[$cipher]); 198 } 199 } 200 201 if (empty($cipherList)) { 202 throw new SaslException('There are no available ciphers for auth-conf.'); 203 } 204 205 return array_values($cipherList); 206 } 207 208 /** 209 * @throws SaslException 210 */ 211 protected function setCipherForChallenge(array $options, Message $response, Message $challenge): void 212 { 213 if (!$challenge->has('cipher')) { 214 throw new SaslException('The client requested auth-conf, but the challenge contains no ciphers.'); 215 } 216 $ciphers = $challenge->get('cipher'); 217 # If we are requesting a specific cipher, then only check that one... 218 $toCheck = isset($options['cipher']) ? (array) $options['cipher'] : ['3des', 'des', 'rc4', 'rc4-56', 'rc4-40', ]; 219 220 $selected = null; 221 foreach ($toCheck as $selection) { 222 if (in_array($selection, $ciphers, true)) { 223 $selected = $selection; 224 break; 225 } 226 } 227 if ($selected === null) { 228 throw new SaslException(sprintf( 229 'No recognized ciphers were offered in the challenge: %s', 230 implode(', ', $ciphers) 231 )); 232 } 233 234 $response->set('cipher', $selected); 235 } 236 237 protected function getCurrentUser(): string 238 { 239 if (isset($_SERVER['USERNAME'])) { 240 return $_SERVER['USERNAME']; 241 } elseif (isset($_SERVER['USER'])) { 242 return $_SERVER['USER']; 243 } 244 245 throw new SaslException('Unable to determine a username for the response. You must supply a username.'); 246 } 247 248 /** 249 * Only populate if one realm is provided in the challenge. If more than one exists then the client must supply this. 250 */ 251 protected function getRealmFromChallenge(Message $challenge): string 252 { 253 if (!$challenge->has('realm')) { 254 throw new SaslException('Unable to determine a realm for the response.'); 255 } 256 $realms = (array) $challenge->get('realm'); 257 $selected = array_pop($realms); 258 259 return $selected; 260 } 261} 262