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\Encoder; 13 14use FreeDSx\Sasl\Exception\SaslEncodingException; 15use FreeDSx\Sasl\Message; 16use FreeDSx\Sasl\SaslContext; 17use function dechex, explode, implode, preg_match, sprintf, str_pad, strlen, substr; 18 19/** 20 * Responsible for encoding / decoding DIGEST-MD5 messages. 21 * 22 * @author Chad Sikorra <Chad.Sikorra@gmail.com> 23 */ 24class DigestMD5Encoder implements EncoderInterface 25{ 26 protected const MATCH_KEY = '/(([a-zA-Z-]+)=)/'; 27 28 protected const MATCH_QD_STR_VAL = '/("((.*?)(?<!\\\))")/'; 29 30 protected const MATCH_DIGITS = '/([0-9]+)/'; 31 32 protected const MATCH_ALPHA_NUMERIC = '/([A-Za-z0-9-]+)/'; 33 34 protected const MATCH_LHEX = '/([0-9a-fA-F]{1,})/'; 35 36 protected const ONCE_ONLY = [ 37 'stale', 38 'maxbuf', 39 'charset', 40 'algorithm', 41 'nonce', 42 'cnonce', 43 'nc', 44 'qop', 45 'digest-uri', 46 'response', 47 'cipher', 48 ]; 49 50 /** 51 * @var string 52 */ 53 protected $binary; 54 55 /** 56 * @var int 57 */ 58 protected $pos = 0; 59 60 /** 61 * @var int 62 */ 63 protected $length = 0; 64 65 /** 66 * Tracks the number of times a specific option is encountered during decoding. 67 */ 68 protected $occurrences = []; 69 70 /** 71 * {@inheritDoc} 72 */ 73 public function decode(string $data, SaslContext $context): Message 74 { 75 return $this->parse($data, !$context->isServerMode()); 76 } 77 78 /** 79 * {@inheritDoc} 80 */ 81 public function encode(Message $message, SaslContext $context): string 82 { 83 $response = ''; 84 85 foreach ($message->toArray() as $key => $value) { 86 if ($response !== '') { 87 $response .= ','; 88 } 89 $response .= $key . '=' . $this->encodeOptValue( 90 $key, 91 $value, 92 $context->isServerMode() 93 ); 94 } 95 96 return $response; 97 } 98 99 protected function startParsing(string $binary): void 100 { 101 $this->binary = $binary; 102 $this->pos = 0; 103 $this->length = strlen($binary); 104 $this->occurrences = []; 105 } 106 107 protected function endParsing(): void 108 { 109 $this->binary = ''; 110 $this->pos = 0; 111 $this->length = 0; 112 $this->occurrences = []; 113 } 114 115 /** 116 * @throws SaslEncodingException 117 */ 118 protected function parse(string $digest, bool $isServerMode): Message 119 { 120 $this->startParsing($digest); 121 122 $message = new Message(); 123 while ($this->pos < $this->length) { 124 $keyMatches = null; 125 if (!preg_match(self::MATCH_KEY, substr($this->binary, $this->pos), $keyMatches)) { 126 throw new SaslEncodingException('The digest is malformed. Expected a key, but none was found.'); 127 } 128 $this->pos += strlen($keyMatches[1]); 129 if (!isset($this->binary[$this->pos])) { 130 throw new SaslEncodingException('Unexpected end of digest. Expected a value following a key.'); 131 } 132 $message->set($keyMatches[2], $this->parseOptValue($keyMatches[2], $isServerMode)); 133 } 134 $this->endParsing(); 135 136 return $message; 137 } 138 139 /** 140 * @return mixed 141 * @throws SaslEncodingException 142 */ 143 protected function parseOptValue(string $opt, bool $isServerMode) 144 { 145 $value = null; 146 147 switch ($opt) { 148 case 'realm': 149 case 'nonce': 150 case 'username': 151 case 'cnonce': 152 case 'authzid': 153 case 'digest-uri': 154 $value = $this->parseQuotedValue(); 155 break; 156 case 'qop': 157 case 'cipher': 158 if ($isServerMode) { 159 $value = $this->parseQuotedCommaList(); 160 } else { 161 $value = $this->parseRegex(self::MATCH_ALPHA_NUMERIC, 'The value is malformed.'); 162 } 163 break; 164 case 'stale': 165 $value = $this->parseExact('true'); 166 break; 167 case 'maxbuf': 168 $value = $this->parseRegex(self::MATCH_DIGITS, 'Expected a series of digits for a key value.'); 169 break; 170 case 'algorithm': 171 $value = $this->parseExact('md5-sess'); 172 break; 173 case 'charset': 174 $value = $this->parseExact('utf-8'); 175 break; 176 case 'nc': 177 $value = $this->parseLHexValue(8); 178 break; 179 case 'response': 180 case 'rspauth': 181 $value = $this->parseLHexValue(32); 182 break; 183 default: 184 throw new SaslEncodingException(sprintf( 185 'Digest option %s is not supported.', 186 $opt 187 )); 188 break; 189 } 190 191 if (isset($this->binary[$this->pos]) && $this->binary[$this->pos] !== ',') { 192 throw new SaslEncodingException(sprintf( 193 'Expected a comma following digest value for %s.', 194 $opt 195 )); 196 } 197 if (isset($this->binary[$this->pos]) && $this->binary[$this->pos] === ',') { 198 $this->pos++; 199 } 200 201 if (isset($this->occurrences[$opt]) && in_array($opt, self::ONCE_ONLY, true)) { 202 throw new SaslEncodingException(sprintf('The option "%s" may occur only once.', $opt)); 203 } elseif (isset($this->occurrences[$opt])) { 204 $this->occurrences[$opt]++; 205 } else { 206 $this->occurrences[$opt] = 1; 207 } 208 209 return $value; 210 } 211 212 /** 213 * @return mixed 214 * @throws SaslEncodingException 215 */ 216 protected function encodeOptValue(string $name, $value, bool $isServerMode) 217 { 218 $encoded = null; 219 220 switch ($name) { 221 case 'realm': 222 case 'nonce': 223 case 'username': 224 case 'cnonce': 225 case 'authzid': 226 case 'digest-uri': 227 $encoded = '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $value) . '"'; 228 break; 229 case 'qop': 230 case 'cipher': 231 if ($isServerMode) { 232 $encoded = '"' . implode(',', (array) $value) . '"'; 233 } else { 234 $encoded = (string) $value; 235 } 236 break; 237 case 'stale': 238 $encoded = 'true'; 239 break; 240 case 'maxbuf': 241 case 'algorithm': 242 case 'charset': 243 $encoded = (string) $value; 244 break; 245 case 'nc': 246 $encoded = str_pad(dechex($value), 8, '0', STR_PAD_LEFT); 247 break; 248 case 'response': 249 case 'rspauth': 250 $encoded = $this->encodeLHexValue($value, 32); 251 break; 252 default: 253 throw new SaslEncodingException(sprintf( 254 'Digest option %s is not supported.', 255 $name 256 )); 257 break; 258 } 259 260 return $encoded; 261 } 262 263 /** 264 * @throws SaslEncodingException 265 */ 266 protected function parseExact(string $expected): string 267 { 268 $length = strlen($expected); 269 if (substr($this->binary, $this->pos, $length) !== $expected) { 270 throw new SaslEncodingException(sprintf( 271 'Expected the directive value to be "%s", but it is not.', 272 $expected 273 )); 274 } 275 $this->pos += $length; 276 277 return $expected; 278 } 279 280 /** 281 * @throws SaslEncodingException 282 */ 283 protected function parseQuotedValue(): string 284 { 285 if (!preg_match(self::MATCH_QD_STR_VAL, substr($this->binary, $this->pos), $matches)) { 286 throw new SaslEncodingException('The value is malformed. Expected a qdstr-val.'); 287 } 288 $this->pos += strlen($matches[1]); 289 290 return stripslashes($matches[2]); 291 } 292 293 /** 294 * @throws SaslEncodingException 295 */ 296 protected function parseQuotedCommaList(): array 297 { 298 $value = $this->parseQuotedValue(); 299 300 return explode(',', $value); 301 } 302 303 /** 304 * @throws SaslEncodingException 305 */ 306 protected function parseLHexValue(int $length): string 307 { 308 if (!preg_match(self::MATCH_LHEX, substr($this->binary, $this->pos), $matches)) { 309 throw new SaslEncodingException('Expected a hex value.'); 310 } 311 if (strlen($matches[1]) !== $length) { 312 throw new SaslEncodingException(sprintf('Expected the hex value to be %s characters long.', $length)); 313 } 314 $this->pos += strlen($matches[1]); 315 316 return $matches[1]; 317 } 318 319 /** 320 * @throws SaslEncodingException 321 */ 322 protected function parseRegex(string $regex, string $errorMessage) 323 { 324 if (!preg_match($regex, substr($this->binary, $this->pos), $matches)) { 325 throw new SaslEncodingException($errorMessage); 326 } 327 $this->pos += strlen($matches[1]); 328 329 return $matches[1]; 330 } 331 332 /** 333 * @throws SaslEncodingException 334 */ 335 protected function encodeLHexValue(string $data, int $length): string 336 { 337 if (strlen($data) !== $length) { 338 throw new SaslEncodingException(sprintf('Expected the encoded hex value to be %s characters long.', $length)); 339 } 340 341 return $data; 342 } 343} 344