Skip to content

Commit cee82e9

Browse files
Support 2b and 2y prefixes in bcrypt (elastic#76225)
Adds support for parsing "$2b$" and "$2y$" prefixes in bcrypt hashes. These non standard prefixes are commonly used by some other tools (e.g. python defaults to "$2b$" and PHP defaults to "$2y$"). Our implementation will continue to generate "$2a$" hashes, but will support hashes imported from tools that use these other prefixes. Co-authored-by: cactot <[email protected]>
1 parent c1d57cc commit cee82e9

File tree

4 files changed

+117
-6
lines changed

4 files changed

+117
-6
lines changed

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/BCrypt.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ public static String hashpw(SecureString password, String salt) {
667667
off = 3;
668668
else {
669669
minor = salt.charAt(2);
670-
if (minor != 'a' || salt.charAt(3) != '$')
670+
if (valid_minor(minor) == false || salt.charAt(3) != '$')
671671
throw new IllegalArgumentException ("Invalid salt revision");
672672
off = 4;
673673
}
@@ -727,6 +727,17 @@ public static String hashpw(SecureString password, String salt) {
727727
return rs.toString();
728728
}
729729

730+
static boolean valid_minor(char minor) {
731+
switch (minor) {
732+
case 'a':
733+
case 'b':
734+
case 'y':
735+
return true;
736+
default:
737+
return false;
738+
}
739+
}
740+
730741
/**
731742
* Generate a salt for use with the BCrypt.hashpw() method
732743
* @param log_rounds the log2 of the number of rounds of

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/Hasher.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ public boolean verify(SecureString text, char[] hash) {
475475
}
476476
};
477477

478-
private static final String BCRYPT_PREFIX = "$2a$";
478+
private static final int BCRYPT_PREFIX_LENGTH = 4;
479479
private static final String SHA1_PREFIX = "{SHA}";
480480
private static final String MD5_PREFIX = "{MD5}";
481481
private static final String SSHA256_PREFIX = "{SSHA256}";
@@ -572,8 +572,8 @@ public static Hasher resolve(String name) {
572572
* @return the hasher that can be used for validation
573573
*/
574574
public static Hasher resolveFromHash(char[] hash) {
575-
if (CharArrays.charsBeginsWith(BCRYPT_PREFIX, hash)) {
576-
int cost = Integer.parseInt(new String(Arrays.copyOfRange(hash, BCRYPT_PREFIX.length(), hash.length - 54)));
575+
if (isBcryptPrefix(hash)) {
576+
int cost = Integer.parseInt(new String(Arrays.copyOfRange(hash, BCRYPT_PREFIX_LENGTH, hash.length - 54)));
577577
return cost == BCRYPT_DEFAULT_COST ? Hasher.BCRYPT : resolve("bcrypt" + cost);
578578
} else if (CharArrays.charsBeginsWith(PBKDF2_STRETCH_PREFIX, hash)) {
579579
int cost = Integer.parseInt(new String(Arrays.copyOfRange(hash, PBKDF2_STRETCH_PREFIX.length(), hash.length - 90)));
@@ -593,6 +593,16 @@ public static Hasher resolveFromHash(char[] hash) {
593593
}
594594
}
595595

596+
private static boolean isBcryptPrefix(char[] hash) {
597+
if (hash.length < 4) {
598+
return false;
599+
}
600+
if (hash[0] == '$' && hash[1] == '2' && hash[3] == '$') {
601+
return BCrypt.valid_minor(hash[2]);
602+
}
603+
return false;
604+
}
605+
596606
/**
597607
* Verifies that the cryptographic hash of {@code data} is the same as {@code hash}. The
598608
* hashing algorithm and its parameters(cost factor-iterations, salt) are deduced from the
@@ -673,7 +683,7 @@ private static boolean verifyPbkdf2Hash(SecureString data, char[] hash, String p
673683

674684
private static boolean verifyBcryptHash(SecureString text, char[] hash) {
675685
String hashStr = new String(hash);
676-
if (hashStr.startsWith(BCRYPT_PREFIX) == false) {
686+
if (isBcryptPrefix(hash) == false) {
677687
return false;
678688
}
679689
return BCrypt.checkpw(text, hashStr);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
package org.elasticsearch.xpack.core.security.authc.support;
8+
9+
import org.elasticsearch.common.settings.SecureString;
10+
import org.elasticsearch.test.ESTestCase;
11+
12+
import java.util.function.Consumer;
13+
14+
import static org.hamcrest.Matchers.equalTo;
15+
16+
public class BCryptTests extends ESTestCase {
17+
18+
private static final SecureString PASSWORD = new SecureString("U21VniQwdEWATfqO".toCharArray());
19+
private static final String[] VALID_HASHES = {
20+
"$2a$04$OLNTeJiq3vjYqTZwgDi62OU5MvzkV3Jqz.KiR3pwgQv70pD6bUsGa",
21+
"$2a$05$XNLcDk8PSYbU70A4bWjY1ugWlNSVM.VPMp6lb9qLotOB9oPV5TyM6",
22+
"$2a$06$KMO7CTXk.rzWPve.dRYXgu8x028/6QlBmRTCijvbwFH5Xx4Xhn4tW",
23+
"$2a$07$tr.C.OmBfdIBg7gcMruQX.UHZtmoZfi6xNpK6A0/oa.ulR4rXj6Ny",
24+
"$2a$08$Er.JIbUaPM7JmIN0iFEhW.H2hgtRT9weKtLdqEgSMAzmEe2xZ0B7a",
25+
"$2a$09$OmkfXJKIWhUmnrIlOy9Cd.SOu337FXAKcbB10nMUwpKSez5G4jz8e",
26+
"$2a$10$qyfYQcOK13wQmGO3Y.nVj.he5w1.Z0WV81HqBW6NlV.nkmg90utxO",
27+
"$2a$11$oNdrIn9.RBEg.XXnZkqwk..2wBrU6SjEJkQTLyxEXVQQcw4BokSaa",
28+
"$2a$12$WMLT/yjmMvBTgBnnZw1EhO6r4g7cWoxEOhS9ln4dNVg8gK3es/BZm",
29+
"$2a$13$WHkGwOCLz8SnX13tYH0Ez.qwKK0YFD8DA4Anz0a0Laozw75vqmBee",
30+
"$2a$14$8Urbk50As1LIgDBWPmXcFOpMWJfy3ddFLgvDlH3G1y4TFo4sLXU9y" };
31+
32+
public void testVerifyHash() {
33+
for (String hash : VALID_HASHES) {
34+
runWithValidRevisions(hash, h -> assertTrue("Hash " + h, BCrypt.checkpw(PASSWORD, h)));
35+
runWithInvalidRevisions(hash, h -> expectThrows(IllegalArgumentException.class, () -> BCrypt.checkpw(PASSWORD, h)));
36+
37+
// Replace a random character in the hash
38+
int index = randomIntBetween(10, hash.length() - 1);
39+
String replace = randomValueOtherThan(hash.substring(index, index + 1), () -> randomAlphaOfLength(1));
40+
String invalid = hash.substring(0, index) + replace + hash.substring(index + 1);
41+
assertThat(invalid.length(), equalTo(hash.length()));
42+
runWithValidRevisions(invalid, h -> assertFalse("Hash " + h, BCrypt.checkpw(PASSWORD, h)));
43+
}
44+
}
45+
46+
static void runWithValidRevisions(String baseHash, Consumer<String> action) {
47+
for (String revision : new String[] { "$2a$", "$2b$", "$2y$" }) {
48+
action.accept(revision + baseHash.substring(4));
49+
}
50+
}
51+
52+
static void runWithInvalidRevisions(String baseHash, Consumer<String> action) {
53+
for (String revision : new String[] { "$2c$", "$2x$", "$2z$" }) {
54+
action.accept(revision + baseHash.substring(4));
55+
}
56+
}
57+
58+
}

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/HasherTests.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
1313

1414
import static org.hamcrest.Matchers.containsString;
15+
import static org.hamcrest.Matchers.equalTo;
1516
import static org.hamcrest.Matchers.sameInstance;
1617

1718
public class HasherTests extends ESTestCase {
@@ -30,6 +31,21 @@ public void testBcryptFamilySelfGenerated() throws Exception {
3031
testHasherSelfGenerated(Hasher.BCRYPT14);
3132
}
3233

34+
public void testBcryptFromExternalSources() throws Exception {
35+
check("$2b$12$0313KXrdhWp6HLREsKxW/OWJQCxy2uYprv44b8MBk6dOj3PY6WSFG", "my-password", true);
36+
check("$2b$12$0313KXrdhWp6HLREsKxW/OWJQCxy2uYprv44b8MBk6dOj3PY6WSFG", "not-the-password", false);
37+
38+
check("$2b$12$4bf6s1NIUhyA5FtLn1UrpuZTByjNCC7f0r5OFJP9ra8U2LtcpcK7C", "changeme", true);
39+
check("$2b$12$4bf6s1NIUhyA5FtLn1UrpuZTByjNCC7f0r5OFJP9ra8U2LtcpcK7C", "changed-it", false);
40+
41+
check("$2b$09$OLrfKSXQJxohtFnkU.VZCO1gKywaTSFi4KPHqhuyY3qetAbI8v6/S", "NjJmOWRmMjJmODEyZmQ1NjFhNWVmZmIwOWMwNjk4MmMK", true);
42+
check("$2b$09$OLrfKSXQJxohtFnkU.VZCO1gKywaTSFi4KPHqhuyY3qetAbI8v6/S", "nJjMowrMmJjModeYzMq1nJfHnwvMzMiWowmWnJK4mMmk", false);
43+
44+
check("$2b$14$azbTD0EotrQtoSsxFpbx/.HG8hCAojDFmN4hsD8khuevk/9j0yPlK", "python 3.9.6; bcrypt 3.2.0", true);
45+
check("$2a$06$aQVRp5ajsIn3fzx2MnXKy.KlhxFLHaCOh8jSElqCtmYFbRkmTy..C", "https://bcrypt-generator.com/", true);
46+
check("$2y$10$flKBxak./o.7Hql0il/98ejdZyob67TmPhHbRy3qOnMtCBosAVSRy", "php", true);
47+
}
48+
3349
public void testPBKDF2FamilySelfGenerated() throws Exception {
3450
testHasherSelfGenerated(Hasher.PBKDF2);
3551
testHasherSelfGenerated(Hasher.PBKDF2_1000);
@@ -184,9 +200,25 @@ public void testPbkdf2WithShortPasswordThrowsInFips() {
184200
}
185201

186202
private static void testHasherSelfGenerated(Hasher hasher) {
187-
//In FIPS 140 mode, passwords for PBKDF2 need to be at least 14 chars
203+
// In FIPS 140 mode, passwords for PBKDF2 need to be at least 14 chars
188204
SecureString passwd = new SecureString(randomAlphaOfLength(between(14, 18)).toCharArray());
189205
char[] hash = hasher.hash(passwd);
190206
assertTrue(hasher.verify(passwd, hash));
207+
208+
SecureString incorrectPasswd = randomValueOtherThan(
209+
passwd,
210+
() -> new SecureString(randomAlphaOfLength(between(14, 18)).toCharArray())
211+
);
212+
assertFalse(hasher.verify(incorrectPasswd, hash));
213+
}
214+
215+
private void check(String hash, String password, boolean shouldMatch) {
216+
char[] hashChars = hash.toCharArray();
217+
Hasher hasher = Hasher.resolveFromHash(hashChars);
218+
assertThat(
219+
"Verify " + password + " against " + hash + " using " + hasher.name(),
220+
hasher.verify(new SecureString(password.toCharArray()), hashChars),
221+
equalTo(shouldMatch)
222+
);
191223
}
192224
}

0 commit comments

Comments
 (0)