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