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