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'])) $matched_pools += (int)preg_match('/[^A-Za-z0-9]/',
185            $pass); // we consider everything else special
186        if ($matched_pools < $this->min_pools) {
187            $this->error = helper_plugin_passpolicy::POOL_VIOLATION;
188            return false;
189        }
190
191        $pass = utf8_strtolower($pass);
192        $username = utf8_strtolower($username);
193
194        if ($this->usernamecheck && $username) {
195            // simplest case first
196            if (utf8_stripspecials($pass, '', '\._\-:\*') == utf8_stripspecials($username, '', '\._\-:\*')) {
197                $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION;
198                return false;
199            }
200
201            // find possible chunks in the lenght defined in policy
202            if ($this->usernamecheck > 1) {
203                $chunks = array();
204                for ($i = 0; $i < utf8_strlen($pass) - $this->usernamecheck + 1; $i++) {
205                    $chunk = utf8_substr($pass, $i, $this->usernamecheck + 1);
206                    if ($chunk == utf8_stripspecials($chunk, '', '\._\-:\*')) {
207                        $chunks[] = $chunk; // only word chars are checked
208                    }
209                }
210
211                // check chunks against user name
212                $chunks = array_map('preg_quote_cb', $chunks);
213                $re = join('|', $chunks);
214
215                if (preg_match("/($re)/", $username)) {
216                    $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION;
217                    return false;
218                }
219            }
220        }
221
222        if ($this->nocommon) {
223            $commons = file(__DIR__ . '/10k-common-passwords.txt');
224            if (in_array("$pass\n", $commons)) {
225                $this->error = helper_plugin_passpolicy::COMMON_VIOLATION;
226                return false;
227            }
228        }
229
230        if ($this->noleaked && $this->isLeaked($pass)) {
231            $this->error = helper_plugin_passpolicy::LEAK_VIOLATION;
232            return false;
233        }
234
235        return true;
236    }
237
238    /**
239     * Creates a completely random password
240     *
241     * @return string
242     */
243    protected function randomPassword()
244    {
245        $num_bits = $this->autobits;
246        $output = '';
247        $characters = '';
248
249        // always use these pools
250        foreach (array('lower', 'upper', 'numeric') as $pool) {
251            $pool_len = strlen($this->pools[$pool]);
252            $output .= $this->pools[$pool][$this->rand(0, $pool_len - 1)]; // add one char already
253            $characters .= $this->pools[$pool]; // add to full pool
254            $num_bits -= $this->bits($pool_len);
255        }
256
257        // if specials are wanted, limit them to a sane amount of 3
258        if (!empty($this->usepools['special'])) {
259            $pool_len = strlen($this->pools['special']);
260            $poolbits = $this->bits($pool_len);
261
262            $sane = ceil($this->autobits / 25);
263            for ($i = 0; $i < $sane; $i++) {
264                $output .= $this->pools['special'][$this->rand(0, $pool_len - 1)];
265                $num_bits -= $poolbits;
266            }
267        }
268
269        // now prepare the full pool
270        $pool_len = strlen($characters);
271        $poolbits = $this->bits($pool_len);
272
273        // add random chars
274        do {
275            $output .= $characters[$this->rand(0, $pool_len - 1)];
276            $num_bits -= $poolbits;
277        } while ($num_bits > 0 || strlen($output) < $this->min_length);
278
279        // shuffle to make sure our intial chars are not necessarily at the start
280        return str_shuffle($output);
281    }
282
283    /**
284     * Creates a pronouncable password
285     *
286     * @return bool|string  the new password, false on error
287     */
288    protected function pronouncablePassword()
289    {
290        $num_bits = $this->autobits;
291
292        // prepare speakable char classes
293        $consonants = 'bcdfghjklmnprstvwz'; //consonants except hard to speak ones
294        $vowels = 'aeiou';
295        $all = $consonants . $vowels;
296
297        // prepare lengths
298        $c_len = strlen($consonants);
299        $v_len = strlen($vowels);
300        $a_len = $c_len + $v_len;
301
302        // prepare bitcounts
303        $c_bits = $this->bits($c_len);
304        $v_bits = $this->bits($v_len);
305        $a_bits = $this->bits($a_len);
306
307        // prepare policy compliant postfix
308        $postfix = '';
309        if ($this->usepools['numeric']) {
310            $postfix .= $this->rand(10, 99);
311            $num_bits -= $this->bits(99 - 10);
312        }
313        if ($this->usepools['special']) {
314            $spec_len = strlen($this->pools['special']);
315            $postfix .= $this->pools['special'][rand(0, $spec_len - 1)];
316            $num_bits -= $this->bits($spec_len);
317        }
318
319        // create words
320        $output = '';
321        do {
322            $output .= $consonants[$this->rand(0, $c_len - 1)];
323            $output .= $vowels[$this->rand(0, $v_len - 1)];
324            $output .= $all[$this->rand(0, $a_len - 1)];
325
326            $num_bits -= $c_bits;
327            $num_bits -= $v_bits;
328            $num_bits -= $a_bits;
329        } while ($num_bits > 0 || strlen($output) < $this->min_length);
330
331        // now ensure policy compliance by uppercasing and postfixing
332        if ($this->usepools['upper']) $output = ucfirst($output);
333        if ($postfix) $output .= $postfix;
334
335        return $output;
336    }
337
338    /**
339     * Creates a passphrase from random words
340     *
341     * @return string
342     * @author Solar Designer
343     * @author Michael Samuel
344     */
345    protected function randomPassphrase()
346    {
347        $num_bits = $this->autobits;
348
349        // prepare policy compliant prefix
350        $prefix = '';
351        if ($this->usepools['numeric']) {
352            $prefix .= $this->rand(0, 999);
353            $num_bits -= $this->bits(999);
354        }
355        if ($this->usepools['special']) {
356            $spec_len = strlen($this->pools['special']);
357            $prefix .= $this->pools['special'][rand(0, $spec_len - 1)];
358            $num_bits -= $this->bits($spec_len);
359        }
360
361        // load the words to use
362        $this->loadwordlist();
363        $wordbits = $this->bits($this->wordlistlength);
364
365        // generate simple all lowercase word phrase
366        $output = '';
367        do {
368            $output .= $this->wordlist[$this->rand(0, $this->wordlistlength - 1)] . ' ';
369            $num_bits -= $wordbits;
370        } while ($num_bits > 0 || strlen($output) < $this->min_length);
371
372        // now ensure policy compliance by uppercasing and prefixing
373        if ($this->usepools['upper']) $output = ucwords($output);
374        if ($prefix) $output = $prefix . ' ' . $output;
375
376        return trim($output);
377    }
378
379    /**
380     * Return the number of bits in an integer
381     *
382     * @param int $number
383     * @return int
384     * @author Michael Samuel
385     */
386    protected function bits($number)
387    {
388        $bits = 0;
389
390        while ($number > 0) {
391            $number >>= 1;
392            $bits += 1;
393        }
394
395        return $bits;
396    }
397
398    /**
399     * Random number generator using the best available source
400     *
401     * @param int $min
402     * @param int $max
403     * @return int
404     * @author Michael Samuel
405     */
406    public function rand($min, $max)
407    {
408        $real_max = $max - $min;
409        $mask = (1 << $this->bits($real_max)) - 1;
410
411        try {
412            do {
413                $bytes = $this->trueRandomBytes(4);
414                $unpack = unpack("lnum", $bytes);
415                $integer = $unpack["num"] & $mask;
416            } while ($integer > $real_max);
417        } catch (Exception $e) {
418            if (!$this->msgshown) {
419                msg('No secure random generator available, falling back to less secure mt_rand()', -1);
420                $this->msgshown = true;
421            }
422            return mt_rand($min, $max);
423        }
424
425        return $integer + $min;
426    }
427
428    /**
429     * Return truly (pseudo) random bytes
430     *
431     * @param int $bytes number of bytes to get
432     * @return string binary random strings
433     * @throws Exception when no usable random generator is found
434     * @link   http://www.php.net/manual/de/function.mt-rand.php#83655
435     * @author Mark Seecof
436     */
437    protected function trueRandomBytes($bytes)
438    {
439        $strong = false;
440        $rbytes = false;
441
442        if (function_exists('openssl_random_pseudo_bytes')) {
443            $rbytes = openssl_random_pseudo_bytes($bytes, $strong);
444        }
445
446        // If no strong SSL randoms available, try OS the specific ways
447        if (!$strong) {
448            // Unix/Linux platform
449            $fp = @fopen('/dev/urandom', 'rb');
450            if ($fp !== false) {
451                $rbytes = fread($fp, $bytes);
452                fclose($fp);
453            }
454
455            // MS-Windows platform
456            if (class_exists('COM')) {
457                // http://msdn.microsoft.com/en-us/library/aa388176(VS.85).aspx
458                try {
459                    $CAPI_Util = new COM('CAPICOM.Utilities.1');
460                    $rbytes = $CAPI_Util->GetRandom($bytes, 0);
461
462                    // if we ask for binary data PHP munges it, so we
463                    // request base64 return value.  We squeeze out the
464                    // redundancy and useless ==CRLF by hashing...
465                    if ($rbytes) $rbytes = md5($rbytes, true);
466                } catch (Exception $ex) {
467                    // fail
468                }
469            }
470        }
471        if (strlen($rbytes) < $bytes) $rbytes = false;
472
473        if ($rbytes === false) throw new Exception('No true random generator available');
474
475        return $rbytes;
476    }
477
478    /**
479     * loads the word list for phrase generation
480     *
481     * Words are taken from the wiki's own search index and are complemented with a
482     * list of 4096 English words. This list comes from a passphrase generator
483     * mentioned on sci.crypt, religious and possibly offensive words have been
484     * replaced with less conflict laden words
485     */
486    protected function loadwordlist()
487    {
488        if ($this->wordlistlength) return; //list already loaded
489
490        // load one of the local word index files
491        $indexer = new helper_plugin_passpolicy__index();
492        $this->wordlist = $indexer->getIndex('w', $this->rand(4, 6));
493        $this->wordlist = array_filter($this->wordlist,
494            'utf8_isASCII'); //only ASCII, users might have trouble typing other things
495
496        // add our own word list to fill up
497        $this->wordlist += file(dirname(__FILE__) . '/words.txt', FILE_IGNORE_NEW_LINES);
498        $this->wordlistlength = count($this->wordlist);
499    }
500
501    /**
502     * Check if the given password has been leaked
503     *
504     * Uses k-anonymity
505     *
506     * @param string $password
507     * @return bool
508     */
509    protected function isLeaked($password)
510    {
511        $sha1 = sha1($password);
512        $prefix = substr($sha1, 0, 5);
513        $url = "https://api.pwnedpasswords.com/range/$prefix";
514        $http = new DokuHTTPClient();
515        $http->timeout = 5;
516        $list = $http->get($url);
517        if (!$list) return false; // we didn't get a proper response, assume the password is okay
518
519        $results = explode("\n", $list);
520        foreach ($results as $result) {
521            list($result,) = explode(':', $result); // strip off the number
522            $result = $prefix . strtolower($result);
523            if ($sha1 == $result) return true; // leak found
524        }
525
526        return false;
527    }
528}
529
530/**
531 * Class helper_plugin_passpolicy__index
532 *
533 * just to access a protected function
534 */
535class helper_plugin_passpolicy__index extends Doku_Indexer
536{
537    /** @inheritDoc */
538    public function getIndex($idx, $suffix)
539    {
540        return parent::getIndex($idx, $suffix);
541    }
542}
543