diff --git a/public/js/app-util.min.js b/public/js/app-util.min.js index f7a5b9f2a..dcee249fd 100644 --- a/public/js/app-util.min.js +++ b/public/js/app-util.min.js @@ -5,9 +5,9 @@ $jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var a=$jscomp.global. $jscomp.initSymbolAsyncIterator=function(){$jscomp.initSymbol();var a=$jscomp.global.Symbol.asyncIterator;a||(a=$jscomp.global.Symbol.asyncIterator=$jscomp.global.Symbol("asyncIterator"));$jscomp.initSymbolAsyncIterator=function(){}};$jscomp.arrayIterator=function(a){var d=0;return $jscomp.iteratorPrototype(function(){return d",number:"1234567890",char:"abcdefghijklmnopqrstuvwxyz"}},random:function(b){a.info("password:random");var c="";this.config.complexity.symbols&&(c+=this.config.charset.special);this.config.complexity.numbers&&(c+=this.config.charset.number);this.config.complexity.chars&&(c+=this.config.charset.char,this.config.complexity.uppercase&& -(c+=this.config.charset.char.toUpperCase()));var f=function(){for(var a="",b=0;b++",number:"1234567890",char:"abcdefghijklmnopqrstuvwxyz"}},randomIndex:function(b){var c=new Uint32Array(1),f=4294967296-4294967296%b;do window.crypto.getRandomValues(c);while(c[0]>=f);return c[0]%b},random:function(b){a.info("password:random");var c="";this.config.complexity.symbols&&(c+=this.config.charset.special);this.config.complexity.numbers&&(c+=this.config.charset.number);this.config.complexity.chars&&(c+=this.config.charset.char,this.config.complexity.uppercase&& +(c+=this.config.charset.char.toUpperCase()));var f=function(){for(var a="",b=0;b++n;n++){e=f();if(m(e))break}this.config.passLength=e.length;"function"===typeof b&&b(e,zxcvbn(e))},output:function(b,c){a.info("password:outputResult");var g=$("#password-strength-"+c.attr("id"));c=$("#password-level-"+c.attr("id"));var f=b.score;c.removeClass("weak good strong strongest");g.removeClass("weak good strong strongest");var h=g.find(".password-strength-bar"),j=g.find(".password-strength-label"); if(0===this.config.passLength){c.attr("data-level-msg","");g.hide();h.css("width","0");j.text("")}else{g.show();if(this.config.passLength").html(a).text()},resizeImage:function(a){var b=.9*$(window).width(),d=.9*$(window).height(),e={width:a.width(),height:a.height()},g={calc:0,main:0,secondary:0,factor:.9,rel:e.width/e.height},h=function(a){a.main>a.secondary?a.calc=a.main/a.rel:a.maina.secondary&&(a.main*=a.factor,h(a));return a},k=function(){g.main=b;g.secondary=d;var c=h(g);a.css({width:c.main, diff --git a/tests/Unit/Infrastructure/Adapter/In/Web/View/PasswordGeneratorUsesTheCsprngTest.php b/tests/Unit/Infrastructure/Adapter/In/Web/View/PasswordGeneratorUsesTheCsprngTest.php new file mode 100644 index 000000000..0397fd298 --- /dev/null +++ b/tests/Unit/Infrastructure/Adapter/In/Web/View/PasswordGeneratorUsesTheCsprngTest.php @@ -0,0 +1,129 @@ +. + */ + +declare(strict_types=1); + +namespace SP\Tests\Unit\Infrastructure\Adapter\In\Web\View; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +/** + * The "Generate" button behind every password field in the application — an account's password, a + * user's, the master password, the administrator account the installer creates — is + * `sysPass.Util.password.random()`, in `public/js/app-util.min.js`. It used to draw each character + * with `Math.random()`, which is not a cryptographic generator: V8 seeds one xorshift128+ stream + * per context and its state is recoverable from a modest run of outputs, so passwords generated in + * a session were predictable from each other. + * + * These files are authored directly — there is no unminified source and no build step — so this + * asserts against what actually ships. It is a source check rather than a behavioural one because + * the alternative is asserting that output looks random, which is exactly the assertion that + * passes for a broken generator. + */ +#[Group('unitary')] +class PasswordGeneratorUsesTheCsprngTest extends TestCase +{ + private const UTIL = REAL_APP_ROOT . '/public/js/app-util.min.js'; + + /** + * The generator draws from the platform CSPRNG. + */ + #[Test] + public function theGeneratorDrawsFromTheCryptoApi(): void + { + self::assertStringContainsString( + 'window.crypto.getRandomValues', + self::generator(), + 'the password generator must draw from crypto.getRandomValues' + ); + } + + /** + * And from nothing else. Math.random() is still used elsewhere in this file to mint DOM element + * ids, which is a fine use for it, so this is scoped to the generator rather than the file. + */ + #[Test] + public function theGeneratorDrawsFromNothingElse(): void + { + self::assertStringNotContainsString( + 'Math.random', + self::generator(), + 'the password generator must not fall back to Math.random' + ); + } + + /** + * Every character of the alphabet can be produced. + * + * The index was `Math.floor(Math.random() * (c.length - 1))`, which is exclusive at both ends + * of that multiplication — so the last character of the assembled charset could never appear. + * Which character that was depended on which classes were enabled, so it silently shrank the + * alphabet by one wherever the button was used. + */ + #[Test] + public function theWholeAlphabetCanBeDrawn(): void + { + $generator = self::generator(); + + self::assertStringContainsString('d.randomIndex(c.length)', $generator); + self::assertStringNotContainsString('c.length-1', $generator); + } + + /** + * The rejection loop cannot run forever. + * + * It regenerated the whole candidate until it satisfied every enabled character class. The + * length is set in the UI from an input whose `min` is 1, and the default complexity requires + * four classes, so any length below four made the condition unsatisfiable and the loop froze + * the tab. + */ + #[Test] + public function theRejectionLoopIsBounded(): void + { + self::assertStringNotContainsString( + 'do e=f();while(!m(e));', + self::generator(), + 'the rejection loop must not be unbounded' + ); + } + + /** + * `sysPass.Util.password`'s generator, from `randomIndex` through to the end of `random()`. + */ + private static function generator(): string + { + $source = (string)file_get_contents(self::UTIL); + + $start = strpos($source, 'randomIndex:function'); + self::assertNotFalse($start, 'the generator must define randomIndex'); + + $end = strpos($source, 'output:function', $start); + self::assertNotFalse($end, 'the generator must be followed by output()'); + + return substr($source, $start, $end - $start); + } +}