password quality warnings

This commit is contained in:
antelle 2020-12-20 17:44:27 +01:00
parent 1796ac743d
commit 01fac1f0ca
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
20 changed files with 556 additions and 16 deletions

View File

@ -0,0 +1,37 @@
import kdbxweb from 'kdbxweb';
import { Logger } from 'util/logger';
const logger = new Logger('online-password-checker');
const exposedPasswords = {};
function checkIfPasswordIsExposedOnline(password) {
if (!password || !password.isProtected || !password.byteLength) {
return false;
}
const saltedValue = password.saltedValue();
const cached = exposedPasswords[saltedValue];
if (cached !== undefined) {
return cached;
}
const passwordBytes = password.getBinary();
return crypto.subtle
.digest({ name: 'SHA-1' }, passwordBytes)
.then((sha1) => {
kdbxweb.ByteUtils.zeroBuffer(passwordBytes);
sha1 = kdbxweb.ByteUtils.bytesToHex(sha1).toUpperCase();
const shaFirst = sha1.substr(0, 5);
return fetch(`https://api.pwnedpasswords.com/range/${shaFirst}`)
.then((response) => response.text())
.then((response) => {
const isPresent = response.includes(sha1.substr(5));
exposedPasswords[saltedValue] = isPresent;
return isPresent;
});
})
.catch((e) => {
logger.error('Error checking password online', e);
});
}
export { checkIfPasswordIsExposedOnline };

View File

@ -37,6 +37,10 @@ const DefaultAppSettings = {
useGroupIconForEntries: false, // automatically use group icon when creating new entries
enableUsb: true, // enable interaction with USB devices
fieldLabelDblClickAutoType: false, // trigger auto-type by doubleclicking field label
auditPasswords: true, // enable password audit
excludePinsFromAudit: true, // exclude PIN codes from audit
checkPasswordsOnHIBP: false, // check passwords on Have I Been Pwned
auditPasswordAge: 0,
yubiKeyShowIcon: true, // show an icon to open OTP codes from YubiKey
yubiKeyAutoOpen: false, // auto-load one-time codes when there are open files

View File

@ -17,7 +17,9 @@ const Links = {
Plugins: 'https://plugins.keeweb.info',
PluginDevelopStart: 'https://github.com/keeweb/keeweb/wiki/Plugins',
YubiKeyManual: 'https://github.com/keeweb/keeweb/wiki/YubiKey',
YubiKeyManagerInstall: 'https://github.com/Yubico/yubikey-manager#installation'
YubiKeyManagerInstall: 'https://github.com/Yubico/yubikey-manager#installation',
HaveIBeenPwned: 'https://haveibeenpwned.com',
HaveIBeenPwnedPrivacy: 'https://haveibeenpwned.com/Passwords'
};
export { Links };

View File

@ -16,7 +16,8 @@ const Timeouts = {
ExternalDeviceReconnect: 3000,
ExternalDeviceAfterReconnect: 1000,
FieldLabelDoubleClick: 300,
NativeModuleHostRestartTime: 3000
NativeModuleHostRestartTime: 3000,
FastAnimation: 100
};
export { Timeouts };

View File

@ -301,6 +301,12 @@
"detRevealField": "Reveal",
"detHideField": "Hide",
"detAutoTypeField": "Auto type",
"detIssuesHideTooltip": "Hide this warning",
"detIssueWeakPassword": "The password is weak, it's recommended to change it.",
"detIssuePoorPassword": "The password is very weak, it's strongly recommended to change it.",
"detIssuePwnedPassword": "This password has been exposed in a data breach according to {}, it's recommended to change it.",
"detIssuePasswordCheckError": "There was an error checking password strength online.",
"detIssueOldPassword": "The password is old.",
"autoTypeEntryFields": "Entry fields",
"autoTypeModifiers": "Modifier keys",
@ -442,6 +448,16 @@
"setGenShowAppLogs": "Show app logs",
"setGenReloadApp": "Reload the app",
"setGenFieldLabelDblClickAutoType": "Auto-type on double-clicking field labels",
"setGenAudit": "Audit",
"setGenAuditPasswords": "Show warnings about password strength",
"setGenExcludePinsFromAudit": "Never check short numeric PIN codes, such as 123456",
"setGenCheckPasswordsOnHIBP": "Check passwords using an online service {}",
"setGenHelpHIBP": "KeeWeb can check if your passwords have been previously exposed in a data breach using an online service. Your password cannot be recovered based on data sent online, however the number of passwords checked this way may be exposed. More about your privacy when using this service can be found {}. If this option is enabled, KeeWeb will automatically check your passwords there.",
"setGenHelpHIBPLink": "here",
"setGenAuditPasswordAge": "Old passwords",
"setGenAuditPasswordAgeOff": "Don't show warnings about old passwords",
"setGenAuditPasswordAgeOneYear": "Show warnings for passwords older than one year",
"setGenAuditPasswordAgeYears": "Show warnings for passwords older than {} years",
"setFilePath": "File path",
"setFileStorage": "This file is loaded from {}.",

View File

@ -77,36 +77,37 @@ class MenuModel extends Model {
locTitle: 'setGenAppearance',
icon: '0',
page: 'general',
section: 'appearance',
active: true
section: 'appearance'
},
{
locTitle: 'setGenFunction',
icon: '0',
page: 'general',
section: 'function',
active: true
section: 'function'
},
{
locTitle: 'setGenAudit',
icon: '0',
page: 'general',
section: 'audit'
},
{
locTitle: 'setGenLock',
icon: '0',
page: 'general',
section: 'lock',
active: true
section: 'lock'
},
{
locTitle: 'setGenStorage',
icon: '0',
page: 'general',
section: 'storage',
active: true
section: 'storage'
},
{
locTitle: 'advanced',
icon: '0',
page: 'general',
section: 'advanced',
active: true
section: 'advanced'
}
]);
this.shortcutsSection = new MenuSectionModel([

View File

@ -0,0 +1,85 @@
/**
* Password strength level estimation according to OWASP password recommendations and entropy
* https://auth0.com/docs/connections/database/password-strength
*/
const PasswordStrengthLevel = {
None: 0,
Low: 1,
Good: 2
};
const charClasses = new Uint8Array(128);
for (let i = 48 /* '0' */; i <= 57 /* '9' */; i++) {
charClasses[i] = 1;
}
for (let i = 97 /* 'a' */; i <= 122 /* 'z' */; i++) {
charClasses[i] = 2;
}
for (let i = 65 /* 'A' */; i <= 90 /* 'Z' */; i++) {
charClasses[i] = 3;
}
const symbolsPerCharClass = new Uint8Array([
95 /* ASCII symbols */,
10 /* digits */,
26 /* lowercase letters */,
26 /* uppercase letters */
]);
function passwordStrength(password) {
if (!password || !password.isProtected) {
throw new TypeError('Bad password type');
}
if (!password.byteLength) {
return { level: PasswordStrengthLevel.None, length: 0 };
}
let length = 0;
const countByClass = [0, 0, 0, 0];
let isSingleChar = true;
let prevCharCode = -1;
password.forEachChar((charCode) => {
const charClass = charCode < charClasses.length ? charClasses[charCode] : 0;
countByClass[charClass]++;
length++;
if (isSingleChar) {
if (charCode !== prevCharCode) {
if (prevCharCode === -1) {
prevCharCode = charCode;
} else {
isSingleChar = false;
}
}
}
});
const onlyDigits = countByClass[1] === length;
if (length < 6) {
return { level: PasswordStrengthLevel.None, length, onlyDigits };
}
if (isSingleChar) {
return { level: PasswordStrengthLevel.None, length, onlyDigits };
}
if (length < 8) {
return { level: PasswordStrengthLevel.Low, length, onlyDigits };
}
let alphabetSize = 0;
for (let i = 0; i < countByClass.length; i++) {
if (countByClass[i] > 0) {
alphabetSize += symbolsPerCharClass[i];
}
}
const entropy = Math.log2(Math.pow(alphabetSize, length));
const level = entropy < 60 ? PasswordStrengthLevel.Low : PasswordStrengthLevel.Good;
return { length, level, onlyDigits };
}
export { PasswordStrengthLevel, passwordStrength };

View File

@ -139,8 +139,6 @@ kdbxweb.ProtectedValue.prototype.indexOfSelfInLower = function (targetLower) {
return firstCharIndex;
};
window.PV = kdbxweb.ProtectedValue;
kdbxweb.ProtectedValue.prototype.equals = function (other) {
if (!other) {
return false;
@ -176,3 +174,19 @@ kdbxweb.ProtectedValue.prototype.isFieldReference = function () {
});
return true;
};
const RandomSalt = kdbxweb.Random.getBytes(128);
kdbxweb.ProtectedValue.prototype.saltedValue = function () {
if (!this.byteLength) {
return 0;
}
const value = this._value;
const salt = this._salt;
let salted = '';
for (let i = 0, len = value.length; i < len; i++) {
const byte = value[i] ^ salt[i];
salted += String.fromCharCode(byte ^ RandomSalt[i % RandomSalt.length]);
}
return salted;
};

View File

@ -0,0 +1,122 @@
import { View } from 'framework/views/view';
import template from 'templates/details/details-issues.hbs';
import { Alerts } from 'comp/ui/alerts';
import { Timeouts } from 'const/timeouts';
import { passwordStrength, PasswordStrengthLevel } from 'util/data/password-strength';
import { AppSettingsModel } from 'models/app-settings-model';
import { Links } from 'const/links';
import { checkIfPasswordIsExposedOnline } from 'comp/app/online-password-checker';
class DetailsIssuesView extends View {
parent = '.details__issues-container';
template = template;
events = {
'click .details__issues-close-btn': 'closeIssuesClick'
};
passwordIssue = null;
constructor(model) {
super(model);
this.listenTo(AppSettingsModel, 'change', this.settingsChanged);
if (AppSettingsModel.auditPasswords) {
this.checkPasswordIssues();
}
}
render(options) {
if (!AppSettingsModel.auditPasswords) {
super.render();
return;
}
super.render({
hibpLink: Links.HaveIBeenPwned,
passwordIssue: this.passwordIssue,
fadeIn: options?.fadeIn
});
}
settingsChanged() {
if (AppSettingsModel.auditPasswords) {
this.checkPasswordIssues();
}
this.render();
}
passwordChanged() {
const oldPasswordIssue = this.passwordIssue;
this.checkPasswordIssues();
if (oldPasswordIssue !== this.passwordIssue) {
const fadeIn = !oldPasswordIssue;
if (this.passwordIssue) {
this.render({ fadeIn });
} else {
this.el.classList.add('fade-out');
setTimeout(() => this.render(), Timeouts.FastAnimation);
}
}
}
checkPasswordIssues() {
const { password } = this.model;
if (!password || !password.isProtected || !password.byteLength) {
this.passwordIssue = null;
return;
}
const strength = passwordStrength(password);
if (AppSettingsModel.excludePinsFromAudit && strength.onlyDigits && strength.length <= 6) {
this.passwordIssue = null;
} else if (strength.level < PasswordStrengthLevel.Low) {
this.passwordIssue = 'poor';
} else if (strength.level < PasswordStrengthLevel.Good) {
this.passwordIssue = 'weak';
} else if (AppSettingsModel.auditPasswordAge && this.isOld()) {
this.passwordIssue = 'old';
} else {
this.passwordIssue = null;
this.checkOnHIBP();
}
}
isOld() {
if (!this.model.updated) {
return false;
}
const dt = new Date(this.model.updated);
dt.setFullYear(dt.getFullYear() + AppSettingsModel.auditPasswordAge);
return dt < Date.now();
}
checkOnHIBP() {
if (!AppSettingsModel.checkPasswordsOnHIBP) {
return;
}
const isExposed = checkIfPasswordIsExposedOnline(this.model.password);
if (typeof isExposed === 'boolean') {
this.passwordIssue = isExposed ? 'pwned' : null;
} else {
const iconEl = this.el?.querySelector('.details__issues-icon');
iconEl?.classList.add('details__issues-icon--loading');
isExposed.then((isExposed) => {
if (isExposed) {
this.passwordIssue = 'pwned';
} else if (isExposed === false) {
if (this.passwordIssue === 'pwned') {
this.passwordIssue = null;
}
} else {
this.passwordIssue = iconEl ? 'error' : null;
}
this.render();
});
}
}
closeIssuesClick() {
Alerts.notImplemented();
}
}
export { DetailsIssuesView };

View File

@ -20,6 +20,7 @@ import { DetailsAddFieldView } from 'views/details/details-add-field-view';
import { DetailsAttachmentView } from 'views/details/details-attachment-view';
import { DetailsAutoTypeView } from 'views/details/details-auto-type-view';
import { DetailsHistoryView } from 'views/details/details-history-view';
import { DetailsIssuesView } from 'views/details/details-issues-view';
import { DropdownView } from 'views/dropdown-view';
import { createDetailsFields } from 'views/details/details-fields';
import { FieldViewCustom } from 'views/fields/field-view-custom';
@ -117,11 +118,15 @@ class DetailsView extends View {
super.render();
return;
}
const model = { deleted: this.appModel.filter.trash, ...this.model };
const model = {
deleted: this.appModel.filter.trash,
...this.model
};
this.template = template;
super.render(model);
this.setSelectedColor(this.model.color);
this.addFieldViews();
this.checkPasswordIssues();
this.createScroll({
root: this.$el.find('.details__body')[0],
scroller: this.$el.find('.scroller')[0],
@ -576,6 +581,9 @@ class DetailsView extends View {
} else if (fieldName) {
this.model.setField(fieldName, e.val);
}
if (fieldName === 'Password' && this.views.issues) {
this.views.issues.passwordChanged();
}
} else if (e.field === 'Tags') {
this.model.setTags(e.val);
this.appModel.updateTags();
@ -988,6 +996,13 @@ class DetailsView extends View {
Events.emit('auto-type', { entry, sequence });
}
}
checkPasswordIssues() {
if (!this.model.readOnly) {
this.views.issues = new DetailsIssuesView(this.model);
this.views.issues.render();
}
}
}
Object.assign(DetailsView.prototype, Scrollable);

View File

@ -36,6 +36,11 @@ class SettingsGeneralView extends View {
'change .settings__general-auto-save-interval': 'changeAutoSaveInterval',
'change .settings__general-remember-key-files': 'changeRememberKeyFiles',
'change .settings__general-minimize': 'changeMinimize',
'change .settings__general-audit-passwords': 'changeAuditPasswords',
'change .settings__general-exclude-pins-from-audit': 'changeExcludePinsFromAudit',
'change .settings__general-check-passwords-on-hibp': 'changeCheckPasswordsOnHIBP',
'click .settings__general-toggle-help-hibp': 'clickToggleHelpHIBP',
'change .settings__general-audit-password-age': 'changeAuditPasswordAge',
'change .settings__general-lock-on-minimize': 'changeLockOnMinimize',
'change .settings__general-lock-on-copy': 'changeLockOnCopy',
'change .settings__general-lock-on-auto-type': 'changeLockOnAutoType',
@ -97,6 +102,12 @@ class SettingsGeneralView extends View {
canDetectMinimize: !!Launcher,
canDetectOsSleep: Launcher && Launcher.canDetectOsSleep(),
canAutoType: AutoType.enabled,
auditPasswords: AppSettingsModel.auditPasswords,
excludePinsFromAudit: AppSettingsModel.excludePinsFromAudit,
checkPasswordsOnHIBP: AppSettingsModel.checkPasswordsOnHIBP,
auditPasswordAge: AppSettingsModel.auditPasswordAge,
hibpLink: Links.HaveIBeenPwned,
hibpPrivacyLink: Links.HaveIBeenPwnedPrivacy,
lockOnMinimize: Launcher && AppSettingsModel.lockOnMinimize,
lockOnCopy: AppSettingsModel.lockOnCopy,
lockOnAutoType: AppSettingsModel.lockOnAutoType,
@ -320,6 +331,33 @@ class SettingsGeneralView extends View {
AppSettingsModel.minimizeOnClose = minimizeOnClose;
}
changeAuditPasswords(e) {
const auditPasswords = e.target.checked || false;
AppSettingsModel.auditPasswords = auditPasswords;
}
changeExcludePinsFromAudit(e) {
const excludePinsFromAudit = e.target.checked || false;
AppSettingsModel.excludePinsFromAudit = excludePinsFromAudit;
}
changeCheckPasswordsOnHIBP(e) {
if (e.target.closest('a')) {
return;
}
const checkPasswordsOnHIBP = e.target.checked || false;
AppSettingsModel.checkPasswordsOnHIBP = checkPasswordsOnHIBP;
}
clickToggleHelpHIBP() {
this.el.querySelector('.settings__general-help-hibp').classList.toggle('hide');
}
changeAuditPasswordAge(e) {
const auditPasswordAge = e.target.value | 0;
AppSettingsModel.auditPasswordAge = auditPasswordAge;
}
changeLockOnMinimize(e) {
const lockOnMinimize = e.target.checked || false;
AppSettingsModel.lockOnMinimize = lockOnMinimize;

View File

@ -575,6 +575,47 @@
}
}
&__issues {
margin-top: $base-padding-v;
color: var(--text-contrast-error-color);
background-color: var(--error-color);
border-radius: var(--block-border-radius);
display: flex;
align-items: stretch;
flex-direction: row;
justify-content: flex-start;
&-body {
padding: $medium-padding-v 0;
flex-grow: 1;
}
&-icon {
padding: $medium-padding;
width: 1em;
&-spin {
display: none;
.details__issues-icon--loading & {
display: inline-block;
}
}
&-warning {
.details__issues-icon--loading & {
display: none;
}
}
}
&-close-btn {
padding: $medium-padding;
cursor: pointer;
align-self: flex-start;
opacity: 0.8;
transition: opacity $base-duration $base-timing;
&:hover {
opacity: 1;
}
}
}
&__buttons {
display: flex;
align-items: stretch;

View File

@ -66,9 +66,12 @@ $titlebar-padding-large: 40px;
// Animations
$base-duration: 150ms;
$fast-duration: 80ms;
$base-timing: ease;
$slow-transition-in: $base-duration * 2 ease-in;
$slow-transition-out: $base-duration ease-out;
$fast-transition-in: $fast-duration ease-in;
$fast-transition-out: $fast-duration ease-out;
$tip-transition-in: 500ms $ease-in-expo;
$tip-transition-out: $slow-transition-out;

View File

@ -25,6 +25,15 @@
animation: shake 50s cubic-bezier(0.36, 0.07, 0.19, 0.97) 0s;
}
.fade-in {
animation: fade-in $fast-transition-in 0s;
}
.fade-out {
opacity: 0;
animation: fade-out $fast-transition-out 0s;
}
.rotate-90,
.fa.rotate-90:before {
transform: rotate(90deg);
@ -52,6 +61,24 @@
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes shake {
0%,
1%,

View File

@ -1,6 +1,10 @@
.info-btn {
cursor: pointer;
color: var(--muted-color);
margin-left: $tiny-spacing;
position: relative;
top: 0.15em;
font-size: 1.1em;
&:hover {
color: var(--text-color);
}

View File

@ -0,0 +1,28 @@
{{#if passwordIssue}}
<div class="details__issues {{#if fadeIn}}fade-in{{/if}}">
<div class="details__issues-icon">
<i class="fa fa-exclamation-triangle details__issues-icon-warning"></i>
<i class="fa fa-spinner spin details__issues-icon-spin"></i>
</div>
<div class="details__issues-body">
{{#ifeq passwordIssue 'weak'}}
{{~res 'detIssueWeakPassword'~}}
{{else ifeq passwordIssue 'poor'}}
{{~res 'detIssuePoorPassword'~}}
{{else ifeq passwordIssue 'pwned'}}
{{~#res 'detIssuePwnedPassword'~}}
<a href="{{hibpLink}}" rel="noreferrer noopener" target="_blank">Have I Been Pwned</a>
{{~/res~}}
{{else ifeq passwordIssue 'old'}}
{{~res 'detIssueOldPassword'~}}
{{else ifeq passwordIssue 'error'}}
{{~res 'detIssuePasswordCheckError'~}}
{{/ifeq}}
</div>
<div class="details__issues-close-btn" title="{{res 'detIssuesHideTooltip'}}">
<i class="fa fa-times-circle"></i>
</div>
</div>
{{else}}
<div></div>
{{/if}}

View File

@ -43,6 +43,8 @@
<div class="scroller__bar-wrapper"><div class="scroller__bar"></div></div>
</div>
{{#unless readOnly}}
<div class="details__issues-container">
</div>
<div class="details__buttons">
{{#if deleted~}}
<i class="details__buttons-trash-del fa fa-minus-circle" title="{{res 'detDelEntryPerm'}}" tip-placement="top"></i>

View File

@ -177,6 +177,52 @@
<label for="settings__general-use-group-icon-for-entries">{{res 'setGenUseGroupIconForEntries'}}</label>
</div>
<h2 id="audit">{{res 'setGenAudit'}}</h2>
<div>
<input type="checkbox" class="settings__input input-base settings__general-audit-passwords"
id="settings__general-audit-passwords" {{#if auditPasswords}}checked{{/if}} />
<label for="settings__general-audit-passwords">{{res 'setGenAuditPasswords'}}</label>
</div>
<div>
<input type="checkbox" class="settings__input input-base settings__general-exclude-pins-from-audit"
id="settings__general-exclude-pins-from-audit" {{#if excludePinsFromAudit}}checked{{/if}} />
<label for="settings__general-exclude-pins-from-audit">{{res 'setGenExcludePinsFromAudit'}}</label>
</div>
<div>
<input type="checkbox" class="settings__input input-base settings__general-check-passwords-on-hibp"
id="settings__general-check-passwords-on-hibp" {{#if checkPasswordsOnHIBP}}checked{{/if}} />
<label for="settings__general-check-passwords-on-hibp">
{{~#res 'setGenCheckPasswordsOnHIBP'~}}
<a href="{{hibpLink}}" rel="noreferrer noopener" target="_blank">Have I Been Pwned</a>
{{~/res~}}
</label>
<i class="fa fa-info-circle info-btn settings__general-toggle-help-hibp"></i>
<div class="settings__general-help-hibp hide">
{{~#res 'setGenHelpHIBP'~}}
<a href="{{hibpPrivacyLink}}" rel="noreferrer noopener" target="_blank">{{res 'setGenHelpHIBPLink'}}</a>
{{~/res~}}
</div>
</div>
<div>
<label for="settings__general-audit-password-age">{{res 'setGenAuditPasswordAge'}}:</label>
<select class="settings__select input-base settings__general-audit-password-age"
id="settings__general-audit-password-age">
<option value="0" {{#ifeq auditPasswordAge 0}}selected{{/ifeq}}>{{res 'setGenAuditPasswordAgeOff'}}</option>
<option value="1" {{#ifeq auditPasswordAge 1}}selected{{/ifeq}}>{{res 'setGenAuditPasswordAgeOneYear'}}</option>
<option value="2" {{#ifeq auditPasswordAge 2}}selected{{/ifeq}}>{{#res 'setGenAuditPasswordAgeYears'}}
2{{/res}}</option>
<option value="3" {{#ifeq auditPasswordAge 3}}selected{{/ifeq}}>{{#res 'setGenAuditPasswordAgeYears'}}
3{{/res}}</option>
<option value="5" {{#ifeq auditPasswordAge 5}}selected{{/ifeq}}>{{#res 'setGenAuditPasswordAgeYears'}}
5{{/res}}</option>
<option value="10" {{#ifeq auditPasswordAge 10}}selected{{/ifeq}}>{{#res 'setGenAuditPasswordAgeYears'}}
10{{/res}}</option>
</select>
</div>
<h2 id="lock">{{res 'setGenLock'}}</h2>
<div>
<label for="settings__general-idle-minutes">{{res 'setGenLockInactive'}}:</label>

View File

@ -1,10 +1,12 @@
Release notes
-------------
##### v1.17.0 (TBD)
`+` password quality warnings
`+` "Have I Been Pwned" service integration (opt-in)
`+` automatically switching between dark and light theme
`+` clear searchbox button
`+` favicon download improvements
`+` auto-type field selection dropdown improvements
`+` auto-type field selection dropdown improvements
##### v1.16.5 (2020-12-18)
`-` using custom OneDrive without a secret

View File

@ -0,0 +1,52 @@
import { expect } from 'chai';
import { ProtectedValue } from 'kdbxweb';
import { PasswordStrengthLevel, passwordStrength } from 'util/data/password-strength';
describe('PasswordStrength', () => {
function check(password, expected) {
let actual = passwordStrength(ProtectedValue.fromString(password));
expected = { onlyDigits: false, ...expected };
actual = { onlyDigits: false, ...actual };
for (const [prop, expVal] of Object.entries(expected)) {
expect(actual[prop]).to.eql(expVal, `${prop} is ${expVal} for password "${password}"`);
}
}
it('should throw an error for non-passwords', () => {
expect(() => passwordStrength('')).to.throw(TypeError);
expect(() => passwordStrength(null)).to.throw(TypeError);
});
it('should return level None for short passwords', () => {
check('', { level: PasswordStrengthLevel.None, length: 0 });
check('1234', { level: PasswordStrengthLevel.None, length: 4, onlyDigits: true });
});
it('should return level None for single character passwords', () => {
check('000000000000', { level: PasswordStrengthLevel.None, length: 12, onlyDigits: true });
});
it('should return level Low for simple passwords', () => {
check('12345=', { level: PasswordStrengthLevel.Low, length: 6 });
check('12345Aa', { level: PasswordStrengthLevel.Low, length: 7 });
check('1234567a', { level: PasswordStrengthLevel.Low, length: 8 });
check('1234567ab', { level: PasswordStrengthLevel.Low, length: 9 });
check('1234Ab', { level: PasswordStrengthLevel.Low, length: 6 });
check('1234567', { level: PasswordStrengthLevel.Low, length: 7, onlyDigits: true });
check('123456789012345678', { level: PasswordStrengthLevel.Low, onlyDigits: true });
check('abcdefghijkl', { level: PasswordStrengthLevel.Low });
});
it('should return level Good for passwords matching all criteria', () => {
check('123456ABcdef', { level: PasswordStrengthLevel.Good, length: 12 });
check('Abcdef=5k', { level: PasswordStrengthLevel.Good, length: 9 });
check('12345678901234567890123456', {
level: PasswordStrengthLevel.Good,
onlyDigits: true
});
});
it('should work with long passwords', () => {
check('ABCDabcd_-+=' + '1234567890'.repeat(100), { level: PasswordStrengthLevel.Good });
});
});