Permalink
Browse files

passwords: Express the quality threshold as guesses required.

The original "quality score" was invented purely for populating
our password-strength progress bar, and isn't expressed in terms
that are particularly meaningful.  For configuration and the core
accept/reject logic, it's better to use units that are readily
understood.  Switch to those.

I considered using "bits of entropy", defined loosely as the log
of this number, but both the zxcvbn paper and the linked CACM
article (which I recommend!) are written in terms of the number
of guesses.  And reading (most of) those two papers made me
less happy about referring to "entropy" in our terminology.
I already knew that notion was a little fuzzy if looked at
too closely, and I gained a better appreciation of how it's
contributed to confusion in discussing password policies and
to adoption of perverse policies that favor "Password1!" over
"derived unusual ravioli raft".  So, "guesses" it is.

And although the log is handy for some analysis purposes
(certainly for a graph like those in the zxcvbn paper), it adds
a layer of abstraction, and I think makes it harder to think
clearly about attacks, especially in the online setting.  So
just use the actual number, and if someone wants to set a
gigantic value, they will have the pleasure of seeing just
how many digits are involved.

(Thanks to @YJDave for a prototype that the code changes in this
 commit are based on.)
  • Loading branch information...
gnprice authored and timabbott committed Oct 3, 2017
1 parent 11e767f commit a116303604e362796afa54b5d923ea5312b2ea23
View
@@ -43,37 +43,40 @@ announcement).
### Passwords
Zulip stores user passwords using the standard PBKDF2 algorithm.
Password strength is checked and weak passwords are visually
discouraged using the popular
[zxcvbn](https://github.com/dropbox/zxcvbn) library. The minimum
password strength allowed is controlled by two settings in
`/etc/zulip/settings.py`; `PASSWORD_MIN_LENGTH` and
`PASSWORD_MIN_ZXCVBN_QUALITY`. The former is self-explanatory; we
will explain the latter.
Password strength estimation is a complicated topic that we can't go
into great detail on here; we recommend reading the zxvcbn website for
details if you are not familiar with password strength analysis.
In Zulip's configuration, a password has quality `X` if zxcvbn
estimates that it would take `e^(X * 22)` seconds to crack the
password with a specific attack scenario. The scenario Zulip uses is
one where an the attacker breaks into the Zulip server and steals the
hashed passwords; in that case, with a slow hash, the attacker would
be able to make roughly 10,000 attempts per second. E.g. a password
with quality 0.5 (the default), it would take an attacker about 16
hours to crack such a password in this sort of offline attack.
Another important attack scenario is the online attacks (i.e. an
attacker sending tons of login requests guessing different passwords
to a Zulip server over the web). Those attacks are much slower (more
like 10/second without rate limiting), and you should estimate the
time to guess a password as correspondingly longer.
As a server administrators, you must balance the security risks
associated with attackers guessing weak passwords against the
usability challenges associated with requiring strong passwords in
your organization.
When the user is choosing a password, Zulip checks the password's
strength using the popular [zxcvbn][zxcvbn] library. Weak passwords
are rejected, and strong passwords encouraged. The minimum password
strength allowed is controlled by two settings in
`/etc/zulip/settings.py`:
* `PASSWORD_MIN_LENGTH`: The minimum acceptable length, in characters.
Shorter passwords are rejected even if they pass the `zxcvbn` test
controlled by `PASSWORD_MIN_GUESSES`.
* `PASSWORD_MIN_GUESSES`: The minimum acceptable strength of the
password, in terms of the estimated number of passwords an attacker
is likely to guess before trying this one. If the user attempts to
set a password that `zxcvbn` estimates to be guessable in less than
`PASSWORD_MIN_GUESSES`, then Zulip rejects the password.
Estimating the guessability of a password is a complex problem and
impossible to efficiently do perfectly. For background or when
considering an alternate value for this setting, recommended reading
includes the article ["Passwords and the Evolution of Imperfect
Authentication"][BHOS15] and the [2016 zxcvbn paper][zxcvbn-paper].
<!---
If the BHOS15 link ever goes dead: it's reference 30 of the zxcvbn
paper, aka https://dl.acm.org/citation.cfm?id=2699390 (but the
ACM has it paywalled.)
.
Hooray for USENIX: the zxcvbn paper's canonical link is not paywalled.
-->
[zxcvbn]: https://github.com/dropbox/zxcvbn
[zxcvbn-paper]: https://www.usenix.org/system/files/conference/usenixsecurity16/sec16_paper_wheeler.pdf
[BHOS15]: http://www.cl.cam.ac.uk/~fms27/papers/2015-BonneauHerOorSta-passwords.pdf
## Messages and History
@@ -32,46 +32,46 @@ var common = require("js/common.js");
return self;
}());
function password_field(min_length, min_quality) {
function password_field(min_length, min_guesses) {
var self = {};
self.data = function (field) {
if (field === 'minLength') {
return min_length;
} else if (field === 'minQuality') {
return min_quality;
} else if (field === 'minGuesses') {
return min_guesses;
}
};
return self;
}
password = 'z!X4@S_&';
accepted = common.password_quality(password, bar, password_field(10, 0.10));
accepted = common.password_quality(password, bar, password_field(10, 80000));
assert(!accepted);
assert.equal(bar.w, '39.7%');
assert.equal(bar.added_class, 'bar-danger');
warning = common.password_warning(password, password_field(10));
assert.equal(warning, 'translated: Password should be at least 10 characters long');
password = 'foo';
accepted = common.password_quality(password, bar, password_field(2, 0.001));
accepted = common.password_quality(password, bar, password_field(2, 200));
assert(accepted);
assert.equal(bar.w, '10.390277164940581%');
assert.equal(bar.added_class, 'bar-success');
warning = common.password_warning(password, password_field(2));
assert.equal(warning, 'translated: Password is too weak');
password = 'aaaaaaaa';
accepted = common.password_quality(password, bar, password_field(6, 1000));
accepted = common.password_quality(password, bar, password_field(6, 1e100));
assert(!accepted);
assert.equal(bar.added_class, 'bar-danger');
warning = common.password_warning(password, password_field(6));
assert.equal(warning, 'Repeats like "aaa" are easy to guess');
delete global.zxcvbn;
password = 'aaaaaaaa';
accepted = common.password_quality(password, bar, password_field(6, 1000));
accepted = common.password_quality(password, bar, password_field(6, 1e100));
assert(accepted === undefined);
warning = common.password_warning(password, password_field(6));
assert(warning === undefined);
View
@@ -30,16 +30,15 @@ exports.password_quality = function (password, bar, password_field) {
}
var min_length = password_field.data('minLength');
var min_quality = password_field.data('minQuality');
var min_guesses = password_field.data('minGuesses');
var result = zxcvbn(password);
var crack_time = result.crack_times_seconds.offline_slow_hashing_1e4_per_second;
var quality = Math.min(1, Math.log(1 + crack_time) / 22);
var acceptable = (password.length >= min_length
&& quality >= min_quality);
&& result.guesses >= min_guesses);
if (bar !== undefined) {
var bar_progress = quality;
var t = result.crack_times_seconds.offline_slow_hashing_1e4_per_second;
var bar_progress = Math.min(1, Math.log(1 + t) / 22);
// Even if zxcvbn loves your short password, the bar should be
// filled at most 1/3 of the way, because we won't accept it.
@@ -70,7 +70,7 @@
<div class="input-group">
<label for="new_password" class="inline-block title">{{t "New password" }}</label>
<input type="password" autocomplete="off" name="new_password" id="new_password" class="w-200 inline-block" value=""
data-min-length="{{ page_params.password_min_length }}" data-min-quality="{{ page_params.password_min_quality }}" />
data-min-length="{{ page_params.password_min_length }}" data-min-guesses="{{ page_params.password_min_guesses }}" />
<div class="warning">
<div class="progress inline-block" id="pw_strength">
<div class="bar bar-danger fade" style="width: 10%;"></div>
@@ -56,7 +56,7 @@ <h1>You're almost there.</h1>
value="{% if form.password.value() %}{{ form.password.value() }}{% endif %}"
maxlength={{ MAX_PASSWORD_LENGTH }}
data-min-length="{{password_min_length}}"
data-min-quality="{{password_min_quality}}" required />
data-min-guesses="{{password_min_guesses}}" required />
<label for="id_password" class="inline-block">{{ _('Password') }}</label>
{% if full_name %}
<span class="help-inline">
@@ -32,7 +32,7 @@ <h1>{{ _('Reset your password.') }}</h1>
value="{% if form.new_password1.value() %}{{ form.new_password1.value() }}{% endif %}"
maxlength="100"
data-min-length="{{password_min_length}}"
data-min-quality="{{password_min_quality}}" required />
data-min-guesses="{{password_min_guesses}}" required />
{% if form.new_password1.errors %}
{% for error in form.new_password1.errors %}
<div class="alert alert-error">{{ error }}</div>
@@ -131,7 +131,7 @@ def zulip_default_context(request):
'support_email': FromAddress.SUPPORT,
'find_team_link_disabled': find_team_link_disabled,
'password_min_length': settings.PASSWORD_MIN_LENGTH,
'password_min_quality': settings.PASSWORD_MIN_ZXCVBN_QUALITY,
'password_min_guesses': settings.PASSWORD_MIN_GUESSES,
'zulip_version': ZULIP_VERSION,
'user_is_authenticated': user_is_authenticated,
'settings_path': settings_path,
@@ -93,8 +93,8 @@ def test_home(self):
"narrow_stream",
"needs_tutorial",
"never_subscribed",
"password_min_guesses",
"password_min_length",
"password_min_quality",
"pm_content_in_desktop_notifications",
"pointer",
"poll_timeout",
View
@@ -179,7 +179,7 @@ def home_real(request):
server_inline_image_preview = settings.INLINE_IMAGE_PREVIEW,
server_inline_url_embed_preview = settings.INLINE_URL_EMBED_PREVIEW,
password_min_length = settings.PASSWORD_MIN_LENGTH,
password_min_quality = settings.PASSWORD_MIN_ZXCVBN_QUALITY,
password_min_guesses = settings.PASSWORD_MIN_GUESSES,
# Misc. extra data.
have_initial_messages = user_has_messages,
View
@@ -45,4 +45,4 @@
# Don't require anything about password strength in development
PASSWORD_MIN_LENGTH = 0
PASSWORD_MIN_ZXCVBN_QUALITY = 0
PASSWORD_MIN_GUESSES = 0
@@ -151,7 +151,7 @@
# Password strength requirements; learn about configuration at
# http://zulip.readthedocs.io/en/latest/security-model.html.
# PASSWORD_MIN_LENGTH = 6
# PASSWORD_MIN_ZXCVBN_QUALITY = 0.5
# PASSWORD_MIN_GUESSES = 600*1000*1000
# Controls whether Zulip sends "new login" email notifications.
#SEND_LOGIN_EMAILS = True
View
@@ -168,7 +168,7 @@ def get_secret(key):
'INLINE_URL_EMBED_PREVIEW': False,
'NAME_CHANGES_DISABLED': False,
'PASSWORD_MIN_LENGTH': 6,
'PASSWORD_MIN_ZXCVBN_QUALITY': 0.5,
'PASSWORD_MIN_GUESSES': 600*1000*1000,
'PUSH_NOTIFICATION_BOUNCER_URL': None,
'RATE_LIMITING': True,
'SEND_LOGIN_EMAILS': True,

0 comments on commit a116303

Please sign in to comment.