3
3
# Copyright the OpenSSF Best Practices badge contributors
4
4
# SPDX-License-Identifier: MIT
5
5
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
8
14
9
15
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.
10
59
# rubocop:disable Metrics/MethodLength
11
- def self . bad_passwords_from_file
60
+ def self . bad_passwords_from_file ( max = nil )
12
61
require 'zlib'
13
62
bad_password_array = [ ]
63
+ count = 0
14
64
Zlib ::GzipReader . open ( 'raw-bad-passwords-lowercase.txt.gz' ) do |gz |
15
65
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
17
71
end
18
72
end
19
73
bad_password_array
20
74
end
21
75
# rubocop:enable Metrics/MethodLength
22
76
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 )
25
80
BadPassword . delete_all
26
- bad_password_array = bad_passwords_from_file
81
+ bad_password_array = bad_passwords_from_file ( max )
27
82
# Update all in one transaction, or it will take a *long* time
28
83
transaction do
29
84
# TODO: Speed this up with Rails 6's "insert_all!" (bulk insert)
@@ -47,21 +102,16 @@ def self.force_load
47
102
# global logging object is *not* okay. If we simply silenced logging,
48
103
# we would sometimes disable logging of other events.
49
104
# 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.
63
112
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 )
66
116
end
67
117
end
0 commit comments