From 3697470092cc30f97ffea41c67211fba7ec2133f Mon Sep 17 00:00:00 2001 From: Patrick Favre-Bulle Date: Tue, 10 Jul 2018 00:41:59 +0200 Subject: [PATCH] Added parser and verify --- .../at/favre/lib/crypto/bcrypt/BCrypt.java | 108 ++++++++++------ .../favre/lib/crypto/bcrypt/BCryptParser.java | 68 ++++++++-- .../lib/crypto/bcrypt/BCryptProtocol.java | 18 +-- .../bcrypt/IllegalBCryptFormatException.java | 8 +- .../lib/crypto/bcrypt/BCryptParserTest.java | 118 ++++++++++++++++++ .../favre/lib/crypto/bcrypt/BcryptTest.java | 29 ++++- .../lib/crypto/bcrypt/BcryptTestEntry.java | 9 +- 7 files changed, 288 insertions(+), 70 deletions(-) create mode 100644 src/test/java/at/favre/lib/crypto/bcrypt/BCryptParserTest.java diff --git a/src/main/java/at/favre/lib/crypto/bcrypt/BCrypt.java b/src/main/java/at/favre/lib/crypto/bcrypt/BCrypt.java index b1e065e..54b1d84 100644 --- a/src/main/java/at/favre/lib/crypto/bcrypt/BCrypt.java +++ b/src/main/java/at/favre/lib/crypto/bcrypt/BCrypt.java @@ -7,6 +7,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.util.Objects; public final class BCrypt { /** @@ -19,7 +20,7 @@ public final class BCrypt { */ static final byte MAJOR_VERSION = 0x32; static final int SALT_LENGTH = 16; - static final int MAX_PW_LENGTH_BYTE = 71; + static final int MAX_PW_LENGTH_BYTE = 72; static final int MIN_COST = 4; static final int MAX_COST = 30; @@ -53,7 +54,16 @@ private BCrypt(Version version, SecureRandom secureRandom, BCryptProtocol.Encode } public byte[] hash(int cost, char[] password) { - return hash(cost, generateRandomSalt(), password); + byte[] passwordBytes = null; + try { + passwordBytes = new String(CharBuffer.allocate(password.length + 1).put(password).array()) + .getBytes(defaultCharset); + return hash(cost, generateRandomSalt(), passwordBytes); + } finally { + if (passwordBytes != null) { + Bytes.wrap(passwordBytes).mutable().secureWipe(); + } + } } private byte[] generateRandomSalt() { @@ -62,8 +72,7 @@ private byte[] generateRandomSalt() { return salt; } - public byte[] hash(int cost, byte[] salt, char[] password) { - + byte[] hash(int cost, byte[] salt, byte[] password) { if (cost > MAX_COST || cost < MIN_COST) { throw new IllegalArgumentException("cost factor must be between " + MIN_COST + " and " + MAX_COST + ", was " + cost); } @@ -76,13 +85,17 @@ public byte[] hash(int cost, byte[] salt, char[] password) { if (password == null) { throw new IllegalArgumentException("provided password must not be null"); } - if (password.length > MAX_PW_LENGTH_BYTE || password.length < 0) { - throw new IllegalArgumentException("password must be between 1 and 72 bytes encoded in utf-8, was " + password.length); + if (password.length > MAX_PW_LENGTH_BYTE) { + throw new IllegalArgumentException("password must be between 0 and 72 bytes encoded in utf-8, was " + password.length); } - byte[] hash = new BCryptProtocol.BcryptHasher().cryptRaw(cost, salt, password, defaultCharset); - - return createOutMessage(cost, salt, hash); + byte[] pwWithNullTerminator = password = Bytes.wrap(password).append((byte) 0).array(); + try { + byte[] hash = new BCryptProtocol.BcryptHasher().cryptRaw(cost, salt, password); + return createOutMessage(cost, salt, hash); + } finally { + Bytes.wrap(pwWithNullTerminator).mutable().secureWipe(); + } } private byte[] createOutMessage(int cost, byte[] salt, byte[] hash) { @@ -105,38 +118,47 @@ private byte[] createOutMessage(int cost, byte[] salt, byte[] hash) { } } - public boolean verify(char[] bcryptHash) { - return verifyWithResult(bcryptHash).verified; + public Result verifyStrict(char[] password, char[] bcryptHash) { + return verify(password, bcryptHash, true); } - public Result verifyWithResult(char[] bcryptHash) { - if (bcryptHash == null || bcryptHash.length == 0) { - throw new IllegalArgumentException("must provide non-null, non-empty hash"); - } - - byte[] hashBytes = defaultCharset.encode(CharBuffer.wrap(bcryptHash)).array(); + public Result verify(char[] password, char[] bcryptHash) { + return verify(password, bcryptHash, false); + } - if (hashBytes.length < 7) { - throw new IllegalBCryptFormatException("hash prefix meta must be at least 6 bytes long e.g. '$2a$10$'"); + private Result verify(char[] password, char[] bcryptHash, boolean strictVersion) { + byte[] passwordBytes = null; + byte[] bcryptHashBytes = null; + try { + passwordBytes = new String(CharBuffer.allocate(password.length + 1).put(password).array()).getBytes(defaultCharset); + bcryptHashBytes = new String(CharBuffer.allocate(bcryptHash.length + 1).put(bcryptHash).array()).getBytes(defaultCharset); + return verify(passwordBytes, bcryptHashBytes, strictVersion); + } finally { + if (passwordBytes != null) { + Bytes.wrap(passwordBytes).mutable().secureWipe(); + } + if (bcryptHashBytes != null) { + Bytes.wrap(bcryptHashBytes).mutable().secureWipe(); + } } + } - if (hashBytes[0] != SEPARATOR || hashBytes[1] != MAJOR_VERSION) { - throw new IllegalBCryptFormatException("hash must start with " + new String(new byte[]{SEPARATOR, MAJOR_VERSION})); - } + public Result verify(byte[] password, byte[] bcryptHash, boolean strictVersion) { + Objects.requireNonNull(bcryptHash); + + BCryptParser parser = new BCryptParser.Default(defaultCharset, encoder); + try { + BCryptParser.Parts parts = parser.parse(bcryptHash); - Version usedVersion = null; - for (Version versionToTest : Version.values()) { - if (versionToTest.test(hashBytes, true)) { - usedVersion = versionToTest; - break; + if (strictVersion && parts.version != version) { + return new Result(parts, false); } - } - if (usedVersion == null) { - throw new IllegalBCryptFormatException("unknown bcrypt version"); + byte[] refHash = BCrypt.with(parts.version).hash(parts.cost, parts.salt, password); + return new Result(parts, Bytes.wrap(refHash).equals(bcryptHash)); + } catch (IllegalBCryptFormatException e) { + return new Result(e); } - - return new Result(usedVersion, 0, null, false); } /** @@ -152,16 +174,24 @@ public byte[] upgrade(char[] currentLowercostBcryptHash, int newMinCost) { } public static final class Result { - public final Version version; - public final int cost; - public final byte[] salt; + public final BCryptParser.Parts details; + public final boolean validFormat; public final boolean verified; + public final String formatErrorMessage; + + public Result(IllegalBCryptFormatException e) { + this(null, false, false, e.getMessage()); + } + + public Result(BCryptParser.Parts details, boolean verified) { + this(details, true, verified, null); + } - public Result(Version version, int cost, byte[] salt, boolean verified) { - this.version = version; - this.cost = cost; - this.salt = salt; + private Result(BCryptParser.Parts details, boolean validFormat, boolean verified, String formatErrorMessage) { + this.details = details; + this.validFormat = validFormat; this.verified = verified; + this.formatErrorMessage = formatErrorMessage; } } diff --git a/src/main/java/at/favre/lib/crypto/bcrypt/BCryptParser.java b/src/main/java/at/favre/lib/crypto/bcrypt/BCryptParser.java index 72aa363..3b536ad 100644 --- a/src/main/java/at/favre/lib/crypto/bcrypt/BCryptParser.java +++ b/src/main/java/at/favre/lib/crypto/bcrypt/BCryptParser.java @@ -1,30 +1,36 @@ package at.favre.lib.crypto.bcrypt; +import at.favre.lib.bytes.Bytes; + import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Objects; import static at.favre.lib.crypto.bcrypt.BCrypt.MAJOR_VERSION; import static at.favre.lib.crypto.bcrypt.BCrypt.SEPARATOR; public interface BCryptParser { - Parts parse(byte[] bcryptHash); + Parts parse(byte[] bcryptHash) throws IllegalBCryptFormatException; final class Default implements BCryptParser { - public final Charset defaultCharset; + private final Charset defaultCharset; + private final BCryptProtocol.Encoder encoder; - public Default(Charset defaultCharset) { + Default(Charset defaultCharset, BCryptProtocol.Encoder encoder) { this.defaultCharset = defaultCharset; + this.encoder = encoder; } @Override - public Parts parse(byte[] bcryptHash) { + public Parts parse(byte[] bcryptHash) throws IllegalBCryptFormatException { if (bcryptHash == null || bcryptHash.length == 0) { throw new IllegalArgumentException("must provide non-null, non-empty hash"); } if (bcryptHash.length < 7) { - throw new IllegalBCryptFormatException("hash prefix meta must be at least 6 bytes long e.g. '$2a$10$'"); + throw new IllegalBCryptFormatException("hash prefix meta must be at least 7 bytes long e.g. '$2a$10$'"); } if (bcryptHash[0] != SEPARATOR || bcryptHash[1] != MAJOR_VERSION) { @@ -44,8 +50,8 @@ public Parts parse(byte[] bcryptHash) { } byte[] costBytes = new byte[2]; - costBytes[0] = bcryptHash[usedVersion.versionPrefix.length - 1]; - costBytes[1] = bcryptHash[usedVersion.versionPrefix.length]; + costBytes[0] = bcryptHash[usedVersion.versionPrefix.length]; + costBytes[1] = bcryptHash[usedVersion.versionPrefix.length + 1]; int parsedCostFactor; try { @@ -54,15 +60,23 @@ public Parts parse(byte[] bcryptHash) { throw new IllegalBCryptFormatException("cannot parse cost factor '" + new String(costBytes, defaultCharset) + "'"); } - if (bcryptHash[usedVersion.versionPrefix.length + 1] != SEPARATOR) { - throw new IllegalBCryptFormatException("expected separator " + SEPARATOR + " after cost factor"); + if (bcryptHash[usedVersion.versionPrefix.length + 2] != SEPARATOR) { + throw new IllegalBCryptFormatException("expected separator " + Bytes.from(SEPARATOR).encodeUtf8() + " after cost factor"); + } + + if (bcryptHash.length != 7 + 22 + 31) { + throw new IllegalBCryptFormatException("hash expected to be exactly 60 bytes"); } - /*for (int i = ; i <; i++) { + byte[] salt = new byte[22]; + byte[] hash = new byte[31]; - }*/ + System.arraycopy(bcryptHash, 7, salt, 0, salt.length); + System.arraycopy(bcryptHash, 7 + salt.length, hash, 0, hash.length); - return new Parts(usedVersion, parsedCostFactor, null, null); + return new Parts(usedVersion, parsedCostFactor, + encoder.decode(new String(salt), salt.length), + encoder.decode(new String(hash), hash.length)); } } @@ -78,5 +92,35 @@ final class Parts { this.salt = salt; this.hash = hash; } + + @Override + public String toString() { + return "Parts{" + + "version=" + version + + ", cost=" + cost + + ", salt=" + Bytes.wrap(salt).toString() + + ", hash=" + Bytes.wrap(hash).toString() + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Parts parts = (Parts) o; + return cost == parts.cost && + version == parts.version && + Arrays.equals(salt, parts.salt) && + Arrays.equals(hash, parts.hash); + } + + @Override + public int hashCode() { + + int result = Objects.hash(version, cost); + result = 31 * result + Arrays.hashCode(salt); + result = 31 * result + Arrays.hashCode(hash); + return result; + } } } diff --git a/src/main/java/at/favre/lib/crypto/bcrypt/BCryptProtocol.java b/src/main/java/at/favre/lib/crypto/bcrypt/BCryptProtocol.java index 0c60640..c39d127 100644 --- a/src/main/java/at/favre/lib/crypto/bcrypt/BCryptProtocol.java +++ b/src/main/java/at/favre/lib/crypto/bcrypt/BCryptProtocol.java @@ -1,9 +1,6 @@ package at.favre.lib.crypto.bcrypt; import java.io.ByteArrayOutputStream; -import java.nio.CharBuffer; -import java.nio.charset.Charset; -import java.util.Arrays; final class BCryptProtocol { @@ -105,7 +102,7 @@ private static byte char64(char x) { * byte array. Note that this is *not* compatible with * the standard MIME-base64 encoding. * - * @param s the string to decode + * @param s the string to decode * @param maxLen the maximum number of bytes to decode * @return an array containing the decoded bytes * @throws IllegalArgumentException if maxolen is invalid @@ -434,17 +431,8 @@ static final class BcryptHasher { BcryptHasher() { } - byte[] cryptRaw(int cost, byte[] salt, char[] password, Charset charset) { - byte[] passwordBytes = null; - try { - passwordBytes = new String(CharBuffer.allocate(password.length + 1).put(password).put("\000").array()) - .getBytes(charset); - return cryptRaw(cost, salt, passwordBytes, bf_crypt_ciphertext.clone()); - } finally { - if (passwordBytes != null) { - Arrays.fill(passwordBytes, (byte) 0); - } - } + byte[] cryptRaw(int cost, byte[] salt, byte[] password) { + return cryptRaw(cost, salt, password, bf_crypt_ciphertext.clone()); } /** diff --git a/src/main/java/at/favre/lib/crypto/bcrypt/IllegalBCryptFormatException.java b/src/main/java/at/favre/lib/crypto/bcrypt/IllegalBCryptFormatException.java index d92b819..ab837e7 100644 --- a/src/main/java/at/favre/lib/crypto/bcrypt/IllegalBCryptFormatException.java +++ b/src/main/java/at/favre/lib/crypto/bcrypt/IllegalBCryptFormatException.java @@ -1,6 +1,6 @@ package at.favre.lib.crypto.bcrypt; -public class IllegalBCryptFormatException extends IllegalArgumentException { +public class IllegalBCryptFormatException extends Exception { public IllegalBCryptFormatException(String s) { super(s); @@ -13,4 +13,10 @@ public IllegalBCryptFormatException(String message, Throwable cause) { public IllegalBCryptFormatException(Throwable cause) { super(cause); } + + @Override + public String getMessage() { + return super.getMessage() + " - example of expected hash format: '$2a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i'" + + " which includes 16 bytes salt and 23 bytes hash value encoded in a base64 flavor"; + } } diff --git a/src/test/java/at/favre/lib/crypto/bcrypt/BCryptParserTest.java b/src/test/java/at/favre/lib/crypto/bcrypt/BCryptParserTest.java new file mode 100644 index 0000000..a320c98 --- /dev/null +++ b/src/test/java/at/favre/lib/crypto/bcrypt/BCryptParserTest.java @@ -0,0 +1,118 @@ +package at.favre.lib.crypto.bcrypt; + +import at.favre.lib.bytes.Bytes; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class BCryptParserTest { + private BCryptParser parser; + + @Before + public void setUp() { + parser = new BCryptParser.Default(StandardCharsets.UTF_8, new BCryptProtocol.Encoder.Default()); + } + + @Test + public void parseDifferentCostFactors() throws Exception { + for (int cost = 4; cost < 10; cost++) { + byte[] salt = Bytes.random(16).array(); + byte[] hash = BCrypt.withDefaults().hash(cost, salt, "12345".getBytes()); + + BCryptParser.Parts parts = parser.parse(hash); + assertEquals(cost, parts.cost); + assertEquals(BCrypt.Version.VERSION_2A, parts.version); + assertArrayEquals(salt, parts.salt); + assertEquals(23, parts.hash.length); + + System.out.println(parts); + } + } + + @Test + public void parseDifferentVersions() throws Exception { + for (BCrypt.Version version : BCrypt.Version.values()) { + byte[] salt = Bytes.random(16).array(); + byte[] hash = BCrypt.with(version).hash(6, salt, "hs61i1oAJhdasdÄÄ".getBytes(StandardCharsets.UTF_8)); + BCryptParser.Parts parts = parser.parse(hash); + assertEquals(version, parts.version); + assertEquals(6, parts.cost); + assertArrayEquals(salt, parts.salt); + assertEquals(23, parts.hash.length); + + System.out.println(parts); + } + } + + @Test + public void parseDoubleDigitCost() throws Exception { + byte[] salt = Bytes.random(16).array(); + byte[] hash = BCrypt.with(BCrypt.Version.VERSION_2A).hash(11, salt, "i27ze8172eaidh asdhsd".getBytes(StandardCharsets.UTF_8)); + BCryptParser.Parts parts = parser.parse(hash); + assertEquals(BCrypt.Version.VERSION_2A, parts.version); + assertEquals(11, parts.cost); + assertArrayEquals(salt, parts.salt); + assertEquals(23, parts.hash.length); + + System.out.println(parts); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingVersion() throws Exception { + parser.parse("$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingLeadingZero() throws Exception { + parser.parse("$2a$6$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingSeparator() throws Exception { + parser.parse("$2a$06If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingSeparator2() throws Exception { + parser.parse("$2a06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorInvalidVersion() throws Exception { + parser.parse("$2$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorInvalidVersion2() throws Exception { + parser.parse("$3a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorInvalidVersion3() throws Exception { + parser.parse("$2l$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingSaltAndHas() throws Exception { + parser.parse("$2a$06$".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingHash() throws Exception { + parser.parse("$2a$06$If6bvum7DFjUnE9p2uDeDu".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorMissingChar() throws Exception { + parser.parse("$2a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0".getBytes()); + } + + @Test(expected = IllegalBCryptFormatException.class) + public void parseErrorTooLong() throws Exception { + parser.parse("$2a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i9".getBytes()); + } +} \ No newline at end of file diff --git a/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTest.java b/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTest.java index 11b13a8..f9da66c 100644 --- a/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTest.java +++ b/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTest.java @@ -4,6 +4,8 @@ import org.junit.Before; import org.junit.Test; +import static junit.framework.TestCase.*; + public class BcryptTest { private BcryptTestEntry[] testEntries = new BcryptTestEntry[]{ // see: https://stackoverflow.com/a/12761326/774398 @@ -33,8 +35,33 @@ public void simpleTest() { byte[] salt = new byte[]{(byte) 156, (byte) 234, 33, 0, 5, 69, 7, 18, 9, 10, 11, 0, 13, 99, 42, 16}; BCrypt bCrypt = BCrypt.withDefaults(); for (int i = 4; i < 10; i++) { - byte[] hash = bCrypt.hash(i, salt, "1234".toCharArray()); + byte[] hash = bCrypt.hash(i, salt, "1234".getBytes()); + assertEquals(60, hash.length); System.out.println(Bytes.wrap(hash).encodeUtf8()); } } + + @Test + public void verifyWithResult() { + BCrypt bCrypt = BCrypt.withDefaults(); + byte[] pw = "78PHasdhklöALÖö".getBytes(); + byte[] hash = bCrypt.hash(8, Bytes.random(16).array(), pw); + + BCrypt.Result result = BCrypt.withDefaults().verify(pw, hash, false); + assertTrue(result.verified); + assertEquals(BCrypt.Version.VERSION_2A, result.details.version); + assertEquals(8, result.details.cost); + } + + @Test + public void verifyIncorrectStrictVersion() { + BCrypt bCrypt = BCrypt.with(BCrypt.Version.VERSION_2Y); + byte[] pw = "78PHasdhklöALÖö".getBytes(); + byte[] hash = bCrypt.hash(5, Bytes.random(16).array(), pw); + + BCrypt.Result result = BCrypt.with(BCrypt.Version.VERSION_2A).verify(pw, hash, true); + assertFalse(result.verified); + assertEquals(BCrypt.Version.VERSION_2Y, result.details.version); + assertEquals(5, result.details.cost); + } } diff --git a/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTestEntry.java b/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTestEntry.java index b59c68c..c85e919 100644 --- a/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTestEntry.java +++ b/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTestEntry.java @@ -19,8 +19,13 @@ public BcryptTestEntry(String plainPw, int cost, String radix64Salt, String hash static void testEntries(BcryptTestEntry[] entries) { for (BcryptTestEntry testEntry : entries) { - byte[] hashed = BCrypt.withDefaults().hash(testEntry.cost, new BCryptProtocol.Encoder.Default().decode(testEntry.radix64Salt, 16), testEntry.plainPw.toCharArray()); - assertArrayEquals("hash does not match: \n\r" + testEntry.hash + " was \n\r" + new String(hashed, StandardCharsets.UTF_8), + byte[] hashed = BCrypt.withDefaults().hash( + testEntry.cost, + new BCryptProtocol.Encoder.Default().decode(testEntry.radix64Salt, 16), + testEntry.plainPw.getBytes(StandardCharsets.UTF_8)); + + assertArrayEquals( + "hash does not match: \n\r" + testEntry.hash + " was \n\r" + new String(hashed, StandardCharsets.UTF_8), testEntry.hash.getBytes(StandardCharsets.UTF_8), hashed); } }