1<?php 2 3/** 4 * DokuWiki Plugin passpolicy (Helper Component) 5 * 6 * Check password policies and generate random passwords accordingly 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author Andreas Gohr <andi@splitbrain.org> 10 */ 11class helper_plugin_passpolicy extends DokuWiki_Plugin 12{ 13 14 /** @var int number of character pools that have to be used at least */ 15 public $min_pools = 1; 16 17 /** @var int minimum length of the password (bytes) */ 18 public $min_length = 6; 19 20 /** @var string what type of password generation to use? */ 21 public $autotype = 'random'; 22 23 /** @var int minimum bit strength auto generated passwords should have */ 24 public $autobits = 64; 25 26 /** @var bool disallow common passwords */ 27 public $nocommon = true; 28 29 /** @var bool disallow leaked passwords */ 30 public $noleaked = true; 31 32 /** @var array allowed character pools */ 33 public $usepools = array( 34 'lower' => true, 35 'upper' => false, 36 'numeric' => true, 37 'special' => false, 38 ); 39 40 /** @var int number of consecutive letters that may not be in the username, 0 to disable */ 41 public $usernamecheck = 0; 42 43 /** @var int policy violation error */ 44 public $error = 0; 45 46 /** @var array the different pools to use when generating passwords */ 47 public $pools = array( 48 'lower' => 'abcdefghijklmnopqrstuvwxyz', 49 'upper' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 50 'numeric' => '0123456789', 51 'special' => '!"$%&/()=?{[]}\\*+~\'#,;.:-_<>|@', 52 ); 53 54 protected $wordlist = array(); 55 protected $wordlistlength = 0; 56 protected $msgshown = false; 57 58 const LENGTH_VIOLATION = 1; 59 const POOL_VIOLATION = 2; 60 const USERNAME_VIOLATION = 4; 61 const COMMON_VIOLATION = 8; 62 const LEAK_VIOLATION = 16; 63 64 /** 65 * Constructor 66 * 67 * Sets the policy from the DokuWiki config 68 */ 69 public function __construct() 70 { 71 $this->min_length = $this->getConf('minlen'); 72 $this->min_pools = $this->getConf('minpools'); 73 $this->usernamecheck = $this->getConf('user'); 74 $this->autotype = $this->getConf('autotype'); 75 $this->autobits = $this->getConf('autobits'); 76 $this->nocommon = $this->getConf('nocommon'); 77 $this->noleaked = $this->getConf('noleaked'); 78 79 $opts = explode(',', $this->getConf('pools')); 80 if (count($opts)) { // ignore empty pool setups 81 $this->usepools = array(); 82 foreach ($opts as $pool) { 83 $this->usepools[$pool] = true; 84 } 85 } 86 if ($this->min_pools > count($this->usepools)) $this->min_pools = $this->usepools; 87 } 88 89 /** 90 * Generates a random password according to the backend settings 91 * 92 * @param string $username 93 * @param int $try internal variable, do not set! 94 * @return bool|string 95 * @throws Exception when the generator fails to create a policy compliant password 96 */ 97 public function generatePassword($username, $try = 0) 98 { 99 if ($this->autotype == 'pronouncable') { 100 $pw = $this->pronouncablePassword(); 101 if ($pw && $this->checkPolicy($pw, $username)) return $pw; 102 } 103 104 if ($this->autotype == 'phrase') { 105 $pw = $this->randomPassphrase(); 106 if ($pw && $this->checkPolicy($pw, $username)) return $pw; 107 } 108 109 $pw = $this->randomPassword(); 110 if ($pw && $this->checkPolicy($pw, $username)) return $pw; 111 112 // still here? we might have clashed with the user name by accident 113 if ($try < 3) return $this->generatePassword($username, $try + 1); 114 115 // still here? now we have a real problem 116 throw new Exception('can\'t create a random password matching the password policy'); 117 } 118 119 /** 120 * Gives a human readable explanation of the current policy as plain text. 121 * 122 * @return string 123 */ 124 public function explainPolicy() 125 { 126 // we need access to the settings.php translations for the pool names 127 // FIXME core should provide a way to access them 128 global $conf; 129 $lang = array(); 130 $path = dirname(__FILE__); 131 @include($path . '/lang/en/settings.php'); 132 if ($conf['lang'] != 'en') @include($path . '/lang/' . $conf['lang'] . '/settings.php'); 133 134 // load pool names 135 $pools = array(); 136 foreach ($this->usepools as $pool => $on) { 137 if ($on) $pools[] = $lang['pools_' . $pool]; 138 } 139 140 $text = ''; 141 if ($this->min_length) { 142 $text .= sprintf($this->getLang('length'), $this->min_length) . "\n"; 143 } 144 if ($this->min_pools) { 145 $text .= sprintf($this->getLang('pools'), $this->min_pools, join(', ', $pools)) . "\n"; 146 } 147 if ($this->usernamecheck == 1) { 148 $text .= $this->getLang('user1') . "\n"; 149 } 150 if ($this->usernamecheck > 1) { 151 $text .= sprintf($this->getLang('user2'), $this->usernamecheck) . "\n"; 152 } 153 if ($this->nocommon) { 154 $text .= $this->getLang('nocommon'); 155 } 156 if ($this->noleaked) { 157 $text .= $this->getLang('noleaked'); 158 } 159 160 return trim($text); 161 } 162 163 /** 164 * Checks a given password for policy violation 165 * 166 * @param string $pass true if the password validates against the policy 167 * @param string $username 168 * @return bool 169 */ 170 public function checkPolicy($pass, $username) 171 { 172 $this->error = 0; 173 174 // check length first: 175 if (strlen($pass) < $this->min_length) { 176 $this->error = helper_plugin_passpolicy::LENGTH_VIOLATION; 177 return false; 178 } 179 180 $matched_pools = 0; 181 if (!empty($this->usepools['lower'])) $matched_pools += (int)preg_match('/[a-z]/', $pass); 182 if (!empty($this->usepools['upper'])) $matched_pools += (int)preg_match('/[A-Z]/', $pass); 183 if (!empty($this->usepools['numeric'])) $matched_pools += (int)preg_match('/[0-9]/', $pass); 184 if (!empty($this->usepools['special'])) { 185 $matched_pools += (int)preg_match('/[^A-Za-z0-9]/', 186 $pass); 187 } // we consider everything else special 188 if ($matched_pools < $this->min_pools) { 189 $this->error = helper_plugin_passpolicy::POOL_VIOLATION; 190 return false; 191 } 192 193 $pass = utf8_strtolower($pass); 194 $username = utf8_strtolower($username); 195 196 if ($this->usernamecheck && $username) { 197 // simplest case first 198 if (utf8_stripspecials($pass, '', '\._\-:\*') == utf8_stripspecials($username, '', '\._\-:\*')) { 199 $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION; 200 return false; 201 } 202 203 // find possible chunks in the lenght defined in policy 204 if ($this->usernamecheck > 1) { 205 $chunks = array(); 206 for ($i = 0; $i < utf8_strlen($pass) - $this->usernamecheck + 1; $i++) { 207 $chunk = utf8_substr($pass, $i, $this->usernamecheck + 1); 208 if ($chunk == utf8_stripspecials($chunk, '', '\._\-:\*')) { 209 $chunks[] = $chunk; // only word chars are checked 210 } 211 } 212 213 // check chunks against user name 214 $chunks = array_map('preg_quote_cb', $chunks); 215 $re = join('|', $chunks); 216 217 if (preg_match("/($re)/", $username)) { 218 $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION; 219 return false; 220 } 221 } 222 } 223 224 if ($this->nocommon) { 225 $commons = file(__DIR__ . '/10k-common-passwords.txt'); 226 if (in_array("$pass\n", $commons)) { 227 $this->error = helper_plugin_passpolicy::COMMON_VIOLATION; 228 return false; 229 } 230 } 231 232 if ($this->noleaked && $this->isLeaked($pass)) { 233 $this->error = helper_plugin_passpolicy::LEAK_VIOLATION; 234 return false; 235 } 236 237 return true; 238 } 239 240 /** 241 * Creates a completely random password 242 * 243 * @return string 244 */ 245 protected function randomPassword() 246 { 247 $num_bits = $this->autobits; 248 $output = ''; 249 $characters = ''; 250 251 // always use these pools 252 foreach (array('lower', 'upper', 'numeric') as $pool) { 253 $pool_len = strlen($this->pools[$pool]); 254 $output .= $this->pools[$pool][$this->rand(0, $pool_len - 1)]; // add one char already 255 $characters .= $this->pools[$pool]; // add to full pool 256 $num_bits -= $this->bits($pool_len); 257 } 258 259 // if specials are wanted, limit them to a sane amount of 3 260 if (!empty($this->usepools['special'])) { 261 $pool_len = strlen($this->pools['special']); 262 $poolbits = $this->bits($pool_len); 263 264 $sane = ceil($this->autobits / 25); 265 for ($i = 0; $i < $sane; $i++) { 266 $output .= $this->pools['special'][$this->rand(0, $pool_len - 1)]; 267 $num_bits -= $poolbits; 268 } 269 } 270 271 // now prepare the full pool 272 $pool_len = strlen($characters); 273 $poolbits = $this->bits($pool_len); 274 275 // add random chars 276 do { 277 $output .= $characters[$this->rand(0, $pool_len - 1)]; 278 $num_bits -= $poolbits; 279 } while ($num_bits > 0 || strlen($output) < $this->min_length); 280 281 // shuffle to make sure our intial chars are not necessarily at the start 282 return str_shuffle($output); 283 } 284 285 /** 286 * Creates a pronouncable password 287 * 288 * @return bool|string the new password, false on error 289 */ 290 protected function pronouncablePassword() 291 { 292 $num_bits = $this->autobits; 293 294 // prepare speakable char classes 295 $consonants = 'bcdfghjklmnprstvwz'; //consonants except hard to speak ones 296 $vowels = 'aeiou'; 297 $all = $consonants . $vowels; 298 299 // prepare lengths 300 $c_len = strlen($consonants); 301 $v_len = strlen($vowels); 302 $a_len = $c_len + $v_len; 303 304 // prepare bitcounts 305 $c_bits = $this->bits($c_len); 306 $v_bits = $this->bits($v_len); 307 $a_bits = $this->bits($a_len); 308 309 // prepare policy compliant postfix 310 $postfix = ''; 311 if ($this->usepools['numeric']) { 312 $postfix .= $this->rand(10, 99); 313 $num_bits -= $this->bits(99 - 10); 314 } 315 if ($this->usepools['special']) { 316 $spec_len = strlen($this->pools['special']); 317 $postfix .= $this->pools['special'][rand(0, $spec_len - 1)]; 318 $num_bits -= $this->bits($spec_len); 319 } 320 321 // create words 322 $output = ''; 323 do { 324 $output .= $consonants[$this->rand(0, $c_len - 1)]; 325 $output .= $vowels[$this->rand(0, $v_len - 1)]; 326 $output .= $all[$this->rand(0, $a_len - 1)]; 327 328 $num_bits -= $c_bits; 329 $num_bits -= $v_bits; 330 $num_bits -= $a_bits; 331 } while ($num_bits > 0 || strlen($output) < $this->min_length); 332 333 // now ensure policy compliance by uppercasing and postfixing 334 if ($this->usepools['upper']) $output = ucfirst($output); 335 if ($postfix) $output .= $postfix; 336 337 return $output; 338 } 339 340 /** 341 * Creates a passphrase from random words 342 * 343 * @return string 344 * @author Solar Designer 345 * @author Michael Samuel 346 */ 347 protected function randomPassphrase() 348 { 349 $num_bits = $this->autobits; 350 351 // prepare policy compliant prefix 352 $prefix = ''; 353 if ($this->usepools['numeric']) { 354 $prefix .= $this->rand(0, 999); 355 $num_bits -= $this->bits(999); 356 } 357 if ($this->usepools['special']) { 358 $spec_len = strlen($this->pools['special']); 359 $prefix .= $this->pools['special'][rand(0, $spec_len - 1)]; 360 $num_bits -= $this->bits($spec_len); 361 } 362 363 // load the words to use 364 $this->loadwordlist(); 365 $wordbits = $this->bits($this->wordlistlength); 366 367 // generate simple all lowercase word phrase 368 $output = ''; 369 do { 370 $output .= $this->wordlist[$this->rand(0, $this->wordlistlength - 1)] . ' '; 371 $num_bits -= $wordbits; 372 } while ($num_bits > 0 || strlen($output) < $this->min_length); 373 374 // now ensure policy compliance by uppercasing and prefixing 375 if ($this->usepools['upper']) $output = ucwords($output); 376 if ($prefix) $output = $prefix . ' ' . $output; 377 378 return trim($output); 379 } 380 381 /** 382 * Return the number of bits in an integer 383 * 384 * @param int $number 385 * @return int 386 * @author Michael Samuel 387 */ 388 protected function bits($number) 389 { 390 $bits = 0; 391 392 while ($number > 0) { 393 $number >>= 1; 394 $bits += 1; 395 } 396 397 return $bits; 398 } 399 400 /** 401 * Random number generator using the best available source 402 * 403 * @param int $min 404 * @param int $max 405 * @return int 406 * @author Michael Samuel 407 */ 408 public function rand($min, $max) 409 { 410 $real_max = $max - $min; 411 $mask = (1 << $this->bits($real_max)) - 1; 412 413 try { 414 do { 415 $bytes = $this->trueRandomBytes(4); 416 $unpack = unpack("lnum", $bytes); 417 $integer = $unpack["num"] & $mask; 418 } while ($integer > $real_max); 419 } catch (Exception $e) { 420 if (!$this->msgshown) { 421 msg('No secure random generator available, falling back to less secure mt_rand()', -1); 422 $this->msgshown = true; 423 } 424 return mt_rand($min, $max); 425 } 426 427 return $integer + $min; 428 } 429 430 /** 431 * Return truly (pseudo) random bytes 432 * 433 * @param int $bytes number of bytes to get 434 * @return string binary random strings 435 * @throws Exception when no usable random generator is found 436 * @link http://www.php.net/manual/de/function.mt-rand.php#83655 437 * @author Mark Seecof 438 */ 439 protected function trueRandomBytes($bytes) 440 { 441 $strong = false; 442 $rbytes = false; 443 444 if (function_exists('openssl_random_pseudo_bytes')) { 445 $rbytes = openssl_random_pseudo_bytes($bytes, $strong); 446 } 447 448 // If no strong SSL randoms available, try OS the specific ways 449 if (!$strong) { 450 // Unix/Linux platform 451 $fp = @fopen('/dev/urandom', 'rb'); 452 if ($fp !== false) { 453 $rbytes = fread($fp, $bytes); 454 fclose($fp); 455 } 456 457 // MS-Windows platform 458 if (class_exists('COM')) { 459 // http://msdn.microsoft.com/en-us/library/aa388176(VS.85).aspx 460 try { 461 $CAPI_Util = new COM('CAPICOM.Utilities.1'); 462 $rbytes = $CAPI_Util->GetRandom($bytes, 0); 463 464 // if we ask for binary data PHP munges it, so we 465 // request base64 return value. We squeeze out the 466 // redundancy and useless ==CRLF by hashing... 467 if ($rbytes) $rbytes = md5($rbytes, true); 468 } catch (Exception $ex) { 469 // fail 470 } 471 } 472 } 473 if (strlen($rbytes) < $bytes) $rbytes = false; 474 475 if ($rbytes === false) throw new Exception('No true random generator available'); 476 477 return $rbytes; 478 } 479 480 /** 481 * loads the word list for phrase generation 482 * 483 * Words are taken from the wiki's own search index and are complemented with a 484 * list of 4096 English words. This list comes from a passphrase generator 485 * mentioned on sci.crypt, religious and possibly offensive words have been 486 * replaced with less conflict laden words 487 */ 488 protected function loadwordlist() 489 { 490 if ($this->wordlistlength) return; //list already loaded 491 492 // load one of the local word index files 493 $indexer = new helper_plugin_passpolicy__index(); 494 $this->wordlist = $indexer->getIndex('w', $this->rand(4, 6)); 495 $this->wordlist = array_filter($this->wordlist, 496 'utf8_isASCII'); //only ASCII, users might have trouble typing other things 497 498 // add our own word list to fill up 499 $this->wordlist += file(dirname(__FILE__) . '/words.txt', FILE_IGNORE_NEW_LINES); 500 $this->wordlistlength = count($this->wordlist); 501 } 502 503 /** 504 * Check if the given password has been leaked 505 * 506 * Uses k-anonymity 507 * 508 * @param string $password 509 * @return bool 510 */ 511 protected function isLeaked($password) 512 { 513 $sha1 = sha1($password); 514 $prefix = substr($sha1, 0, 5); 515 $url = "https://api.pwnedpasswords.com/range/$prefix"; 516 $http = new DokuHTTPClient(); 517 $http->timeout = 5; 518 $list = $http->get($url); 519 if (!$list) return false; // we didn't get a proper response, assume the password is okay 520 521 $results = explode("\n", $list); 522 foreach ($results as $result) { 523 list($result,) = explode(':', $result); // strip off the number 524 $result = $prefix . strtolower($result); 525 if ($sha1 == $result) return true; // leak found 526 } 527 528 return false; 529 } 530} 531 532/** 533 * Class helper_plugin_passpolicy__index 534 * 535 * just to access a protected function 536 */ 537class helper_plugin_passpolicy__index extends Doku_Indexer 538{ 539 /** @inheritDoc */ 540 public function getIndex($idx, $suffix) 541 { 542 return parent::getIndex($idx, $suffix); 543 } 544} 545