mirror of https://github.com/keeweb/keeweb
password quality warnings
parent
1796ac743d
commit
01fac1f0ca
@ -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 };
|
@ -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 };
|
@ -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 };
|
@ -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}}
|
@ -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 });
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue