Skip to content

Commit c201ef1

Browse files
Merge pull request #2290 from coreinfrastructure/hmac_runtime_passwords
Hmac runtime passwords
2 parents e38fdf0 + f95a494 commit c201ef1

File tree

8 files changed

+146
-39
lines changed

8 files changed

+146
-39
lines changed

app/models/bad_password.rb

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,82 @@
33
# Copyright the OpenSSF Best Practices badge contributors
44
# SPDX-License-Identifier: MIT
55

6-
# This is a simple list of records with column "forbidden" of all
7-
# "known bad passwords". There's not anything to it; ActiveRecord handles it.
6+
# This is a simple list of HMAC (keyed cryptographically hashed) records with
7+
# column "forbidden_hash" of all "lowercased known bad passwords".
8+
# There's not much to it; ActiveRecord handles quickly seeing if it exists.
9+
10+
# In *production*, make sure you set 'BADGEAPP_BADPWKEY' to a secret key
11+
# and initialize this database table.
12+
# You can (re)initialize this database table at any time by running:
13+
# rake update_bad_password_db
814

915
class BadPassword < ApplicationRecord
16+
# Should we do the bad password lookups?
17+
# We will do a lookup in the test environment *or* if the log level
18+
# is not debug log level (level 0).
19+
# The ||= is because Rails reloads. It's okay if it recalculates this
20+
# twice if it's false, it'll produce the same result each time.
21+
DO_LOOKUPS ||= Rails.env.test? || (Rails.logger.level != 0)
22+
23+
# Provide warning if we are NOT actually doing the bad password lookups.
24+
# We want to make sure we avoid logging those lookups, by not doing them,
25+
# but we want to warn that it's happening.
26+
Rails.logger.info('Bad password lookups disabled') unless DO_LOOKUPS
27+
28+
# Use this key in HMAC to encrypt and cryptographically hash
29+
# the 'bad passwords' and any password used to
30+
# compare them. As a result, at runtime the database only sees HMACs
31+
# of passwords, never passwords, when comparing to the bad password table.
32+
# This isn't necessary for security; passwords are only
33+
# *stored* in bcrypt format. However, if an attacker manages to read
34+
# communication lines from the app to the *running* database system,
35+
# or control the running database system itself,
36+
# this measure will ensure that the attacker never gets
37+
# access to unencrypted passwords.
38+
# In an effort to be max-hard on
39+
# attackers, we'll use a keyed cryptographic hash, and whine when we don't
40+
# get a key. Then an attacker can only get a keyed HMAC, even if
41+
# they can see the database communication in real time (including via a
42+
# log of SQL queries). Even a rainbow table won't help if the key isn't known.
43+
# We *allow* execution with a known key, because that way we can run tests
44+
# or do interactive development without having to rebuild the database
45+
# each time.
46+
# We use SHA-512, so 128 bytes (512 bits) of key is recommended.
47+
# Here's one way to create a key: openssl rand -hex 128
48+
DEFAULT_BADPWKEY ||= 'a5' * 128 # For testing and development
49+
BADPWKEY ||= ENV['BADGEAPP_BADPWKEY'] || DEFAULT_BADPWKEY
50+
Rails.logger.info('BADGEAPP_BADPWKEY unset') if BADPWKEY == DEFAULT_BADPWKEY
51+
BADPWKEY_BYTES ||= [BADPWKEY].pack('H*')
52+
53+
# Return string representation of HMAC of pw (password)
54+
def self.hash_password(pw)
55+
OpenSSL::HMAC.hexdigest('SHA512', BADPWKEY_BYTES, pw)
56+
end
57+
58+
# Load "bad passwords" from a file, up to "max" count.
1059
# rubocop:disable Metrics/MethodLength
11-
def self.bad_passwords_from_file
60+
def self.bad_passwords_from_file(max = nil)
1261
require 'zlib'
1362
bad_password_array = []
63+
count = 0
1464
Zlib::GzipReader.open('raw-bad-passwords-lowercase.txt.gz') do |gz|
1565
gz.each_line do |line|
16-
bad_password_array.push({ forbidden: line.chomp.downcase.freeze })
66+
pw = line.chomp.downcase.freeze
67+
hashed_pw = hash_password(pw)
68+
bad_password_array.push({ forbidden_hash: hashed_pw })
69+
count += 1
70+
break unless max.nil? || max < count
1771
end
1872
end
1973
bad_password_array
2074
end
2175
# rubocop:enable Metrics/MethodLength
2276

23-
# Force load into the database the list of bad passwords.
24-
def self.force_load
77+
# Force load into the database the list of bad passwords, up to max number.
78+
# We have a "max" so that testing doesn't take forever.
79+
def self.force_load(max = nil)
2580
BadPassword.delete_all
26-
bad_password_array = bad_passwords_from_file
81+
bad_password_array = bad_passwords_from_file(max)
2782
# Update all in one transaction, or it will take a *long* time
2883
transaction do
2984
# TODO: Speed this up with Rails 6's "insert_all!" (bulk insert)
@@ -47,21 +102,16 @@ def self.force_load
47102
# global logging object is *not* okay. If we simply silenced logging,
48103
# we would sometimes disable logging of other events.
49104
# Since we only check for unlogged passwords as an extra help for users,
50-
# losing this isn't a problem.
51-
52-
# Should we do the bad password lookups?
53-
# We will do a lookup in the test environment *or* if the log level
54-
# is not debug log level (level 0).
55-
# The ||= is because Rails reloads. It's okay if it recalculates this
56-
# twice if it's false, it'll produce the same result each time.
57-
DO_LOOKUPS ||= Rails.env.test? || (Rails.logger.level != 0)
58-
59-
# Provide warning if we are NOT actually doing the bad password lookups.
60-
# We want to make sure we avoid logging those lookups, by not doing them,
61-
# but we want to warn that it's happening.
62-
Rails.logger.info('Bad password lookups disabled') unless DO_LOOKUPS
105+
# losing this functionality isn't a serious problem.
106+
# It's better to not check for bad passwords to ensure *no* password
107+
# is ever exposed.
108+
#
109+
# Note that we only checked keyed cryptographic hashes. That way, even
110+
# if an attacker manages to see the queries, they'll also need the key
111+
# before they can *start* doing brute-force searches for a password.
63112

64-
def self.unlogged_exists?(forbidden)
65-
DO_LOOKUPS && exists?(forbidden)
113+
def self.unlogged_exists?(pw)
114+
pw_hash = hash_password(pw.downcase)
115+
DO_LOOKUPS && exists?(forbidden_hash: pw_hash)
66116
end
67117
end

app/validators/password_validator.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
class PasswordValidator < ActiveModel::EachValidator
88
def validate_each(record, attribute, value)
9-
return unless BadPassword.unlogged_exists?(forbidden: value.downcase)
9+
return unless BadPassword.unlogged_exists?(value)
1010

1111
record.errors.add attribute,
1212
options[:message] ||
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
class RenameForbiddenToForbiddenHashInBadPasswords < ActiveRecord::Migration[8.0]
4+
def change
5+
# Because we're *renaming* a column, the index will continue to exist
6+
rename_column :bad_passwords, 'forbidden', 'forbidden_hash'
7+
puts 'Remember to run: rake update_bad_password_db'
8+
end
9+
end

db/schema.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.0].define(version: 2024_12_27_211821) do
13+
ActiveRecord::Schema[8.0].define(version: 2025_02_22_215212) do
1414
create_schema "heroku_ext"
1515

1616
# These are extensions that must be enabled in order to support this database
@@ -29,8 +29,8 @@
2929
end
3030

3131
create_table "bad_passwords", id: false, force: :cascade do |t|
32-
t.string "forbidden"
33-
t.index ["forbidden"], name: "index_bad_passwords_on_forbidden"
32+
t.string "forbidden_hash"
33+
t.index ["forbidden_hash"], name: "index_bad_passwords_on_forbidden_hash"
3434
end
3535

3636
create_table "pg_search_documents", id: :serial, force: :cascade do |t|

docs/assurance-case.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ We *do* consider this data higher-value and protect them specially.
543543

544544
User passwords for local accounts are only stored on the server as
545545
iterated per-user salted hashes (using bcrypt), and thus cannot
546-
be retrieved in an unencrypted form.
546+
be retrieved in an unencrypted form later on from a recorded database.
547547

548548
Any user password sent to the system is sent by the application router
549549
to the "update" method of the user controller
@@ -563,6 +563,44 @@ until that memory is recycled, but we assume that the underlying
563563
system will protect memory during the time before it is garbage-collected
564564
and reused.
565565

566+
To help users avoid the *worst* passwords, we check proposed passwords
567+
against a list of bad passwords and don't allow passwords to change to them.
568+
This complies with NIST Special Publication 800-63B section 5.1.1.2.
569+
That list is long, so for speed we keep this list in the database
570+
(if we kept it in memory it would use a lot of memory).
571+
We don't trust long-term storage, so it's vital that we *never* record
572+
in logs a query that includes an unencrypted password.
573+
Also, while we trust our database and its connections, we want to limit
574+
privileges where we can. Thus, we take two steps to protect passwords
575+
while we check them against the bad password list in the database:
576+
577+
1. We disable the bad password check if the log level is set to debug
578+
(which logs SQL queries) and we aren't running a test.
579+
It's better to disable the bad password check than to risk exposing
580+
a password. When this occurs, a warning it noted in the log.
581+
Note that the production environments settings do *not* use log level
582+
debug by default anyway, but we want to make sure it won't happen.
583+
2. Instead of storing the bad passwords directly, we stored keyed HMAC
584+
values of them, and the new password must *also* be converted into a
585+
keyed HMAC for comparison. The key is a secret for the application.
586+
This means that even if an attacker gains control over the database
587+
and/or can read communications to it (which we assume they can't do),
588+
the attacker would have to use a brute-force to find the key
589+
(we recommend 512 bits), *then* use that key to compute HMACs to attempt
590+
to discover a user's password. Rekeying the bad password database
591+
is trivial, when desired. A new key can be set in `BADGEAPP_BADPWKEY`
592+
and then you can run `rake update_bad_password_db` (it takes a few minutes).
593+
We do this to protect passwords used for queries between the application
594+
and the database, to counter someone snooping it.
595+
We *never* store the HMAC of the password anywhere. We instead use
596+
bcrypt for stored passwords (it's designed for that).
597+
Strictly speaking using HMAC isn't necessary, since we don't store it,
598+
but providing another layer of protections for passwords seemed appropriate.
599+
Theoretically a user could choose another password that isn't
600+
on the bad password list but matches its HMAC; that's so astronomically
601+
unlikely that it won't happen, and even if it did, it would just mean
602+
that the user would have to choose a different password.
603+
566604
#### Remember me token
567605

568606
Users may choose to "remember me" to automatically re-login on
@@ -1946,9 +1984,9 @@ as of 2015-12-14:
19461984
6. *User management.*
19471985
Local passwords have a minimum length (8) and cannot be
19481986
a member of a set of known-bad passwords. We allow much longer passwords.
1949-
This complies with draft NIST Special Publication 800-63B,
1987+
This complies with NIST Special Publication 800-63B,
19501988
"Digital Authentication Guideline: Authentication and Lifecycle Management"
1951-
dated Thu, 24 Nov 2016 08:15:51 -0500 <https://pages.nist.gov/800-63-3/>.
1989+
<https://pages.nist.gov/800-63-3/sp800-63b.html> section 5.1.1.2.
19521990
We expect users to
19531991
protect their own passwords; we do not try to protect users from themselves.
19541992
The system is not fast enough for a naive password-guesser to succeed

docs/implementation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ The application is configured by various environment variables:
9393
Used by aes-256-gcm (256-bit AES in GCM mode).
9494
* EMAIL_BLIND_INDEX_KEY: Key for blind index created for user email addresses
9595
(used by PBKDF2-HMAC-SHA256). Must be 64 hex digits (==32 bytes==256 bits).
96+
* BADGEAPP_BADPWKEY: Key used for additional protection for passwords
97+
when checking the bad password database. The list of bad passwords
98+
is converted by HMAC (using SHA-512 and this key) before being stored in
99+
the database, and the same happens to passwords before the database is
100+
asked to compare them. We recommend setting this to a cryptographically
101+
random 512-bit value; you can get that with `openssl rand -hex 128` or
102+
similar. Then run `rake update_bad_password_db` to load the database
103+
with the bad password list (it will take a few minutes to load them all).
96104
* BADGEAPP_DENY_LOGIN: If a non-blank value is set ("true" is recommended),
97105
then no on can log in, no one can create a new account (sign up),
98106
and no one can do anything that requires being logged (users are always

test/fixtures/bad_passwords.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2+
# We presume tests use the default BADPWKEY (that's why it's the default).
23

3-
# World's worst password
44
one:
5-
forbidden: "password"
5+
# World's worst password
6+
# BadPassword::hash_password("password")
7+
forbidden_hash: "587a7deba2325735fbbfbba4a6797c5d3adbbdc5f4a37d516fcae5ba31210f0dadc3cbf3bac0b4230d910a054f24577625532226f6c1a047b7ffe3e03c19b184"
68

79
two:
8-
forbidden: "123456"
10+
# BadPassword::hash_password("123456")
11+
forbidden_hash: "5cc3782528f34852f90f130cad8e55e75a5aec78bbea4331eb6f2008cedfc399de70acc085dac4fc14a7e2eacaced720eb6fdcc9d3d71fd4c951dee2e92281f0"

test/models/bad_password_test.rb

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,18 @@
77

88
class BadPasswordTest < ActiveSupport::TestCase
99
test 'Bad password found in BadPassword' do
10-
assert BadPassword.exists?(forbidden: '123456')
10+
assert BadPassword.unlogged_exists?('123456')
1111
end
1212

1313
test 'Good password not found in BadPassword' do
14-
assert_not BadPassword.exists?(forbidden: 'asjfdksajdklfajdfkjaslkdfj')
15-
end
16-
17-
test 'Ensure that we have small bad password list for tests' do
18-
assert_not BadPassword.exists?(forbidden: '10101010')
14+
assert_not BadPassword.unlogged_exists?('asjfdksajdklfajdfkjaslkdfj')
1915
end
2016

17+
# Running BadPassword.force_load without limit would take a *long* time.
18+
# For testing purposes, we load a few values and make sure it's found.
2119
test 'Load full bad password list' do
2220
BadPassword.force_load
23-
assert BadPassword.exists?(forbidden: '10101010')
21+
assert BadPassword.unlogged_exists?('10101010')
22+
assert_not BadPassword.unlogged_exists?('A_decent_pass_Maybe?_581905012')
2423
end
2524
end

0 commit comments

Comments
 (0)