diff --git a/CHANGELOG b/CHANGELOG index 1a02896..70a42f2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,9 @@ # Releases +## v0.3.0 + +* support 24byte hash out and make null terminator optional (BC style) #3 + ## v0.2.0 * add String API 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 d7960b9..43062fc 100644 --- a/src/main/java/at/favre/lib/crypto/bcrypt/BCrypt.java +++ b/src/main/java/at/favre/lib/crypto/bcrypt/BCrypt.java @@ -264,14 +264,22 @@ public HashData hashRaw(int cost, byte[] salt, byte[] password) { if (password == null) { throw new IllegalArgumentException("provided password must not be null"); } - if (password.length > MAX_PW_LENGTH_BYTE) { + + if (!version.appendNullTerminator && password.length == 0) { + throw new IllegalArgumentException("provided password must at least be length 1 if no null terminator is appended"); + } + + if (password.length > MAX_PW_LENGTH_BYTE + (version.appendNullTerminator ? 0 : 1)) { password = longPasswordStrategy.derive(password); } - byte[] pwWithNullTerminator = Bytes.wrap(password).append((byte) 0).array(); + byte[] pwWithNullTerminator = version.appendNullTerminator ? Bytes.wrap(password).append((byte) 0).array() : Bytes.wrap(password).copy().array(); try { byte[] hash = new BCryptOpenBSDProtocol().cryptRaw(1 << cost, salt, pwWithNullTerminator); - return new HashData(cost, version, salt, Bytes.wrap(hash).resize(HASH_OUT_LENGTH, BytesTransformer.ResizeTransformer.Mode.RESIZE_KEEP_FROM_ZERO_INDEX).array()); + return new HashData(cost, version, salt, version.useOnly23bytesForHash ? + Bytes.wrap(hash).resize(HASH_OUT_LENGTH, BytesTransformer.ResizeTransformer.Mode.RESIZE_KEEP_FROM_ZERO_INDEX).array() : + hash + ); } finally { Bytes.wrap(pwWithNullTerminator).mutable().secureWipe(); } @@ -304,7 +312,7 @@ public HashData(int cost, Version version, byte[] rawSalt, byte[] rawHash) { Objects.requireNonNull(rawSalt); Objects.requireNonNull(version); if (!Bytes.wrap(rawSalt).validate(BytesValidators.exactLength(16)) || - !Bytes.wrap(rawHash).validate(BytesValidators.or(BytesValidators.exactLength(23)))) { + !Bytes.wrap(rawHash).validate(BytesValidators.or(BytesValidators.exactLength(23), BytesValidators.exactLength(24)))) { throw new IllegalArgumentException("salt must be exactly 16 bytes and hash 23 bytes long"); } this.cost = cost; @@ -621,6 +629,12 @@ public static final class Version { */ public static final Version VERSION_2Y = new Version(new byte[]{MAJOR_VERSION, 0x79}, DEFAULT_FORMATTER, DEFAULT_PARSER); + /** + * This mirrors how Bouncy Castle creates bcrypt hashes: with 24 byte out and without null-terminator. Gets a fake + * version descriptor. + */ + public static final Version VERSION_BC = new Version(new byte[]{MAJOR_VERSION, 0x63}, false, false, DEFAULT_FORMATTER, DEFAULT_PARSER); + /** * List of supported versions */ @@ -630,6 +644,19 @@ public static final class Version { * Version identifier byte array, eg.{0x32, 0x61} for '2a' */ public final byte[] versionIdentifier; + + /** + * Due to a bug the OpenBSD implemenation only uses 23 bytes (184 bit) of the possible 24 byte output from + * blowfish. Set this to false if you want the full 24 byte out (which makes it incompatible with most other impl) + */ + public final boolean useOnly23bytesForHash; + + /** + * Since OpenBSD bcrypt version $2a$ a null-terminator byte must be append to the hash. This flag decides if + * that will be done during hashing. + */ + public final boolean appendNullTerminator; + /** * The formatter for the bcrypt message digest */ @@ -640,15 +667,23 @@ public static final class Version { */ public final BCryptParser parser; + private Version(byte[] versionIdentifier, BCryptFormatter formatter, BCryptParser parser) { + this(versionIdentifier, true, true, formatter, parser); + } + /** * Create a new version. Only use this if you are know what you are doing, most common versions are already available with * {@link Version#VERSION_2A}, {@link Version#VERSION_2Y} etc. * - * @param versionIdentifier version as UTF-8 encoded byte array, e.g. '2a' = new byte[]{0x32, 0x61}, do not included the separator '$' - * @param formatter the formatter responsible for formatting the out hash message digest + * @param versionIdentifier version as UTF-8 encoded byte array, e.g. '2a' = new byte[]{0x32, 0x61}, do not included the separator '$' + * @param useOnly23bytesForHash set to false if you want the full 24 byte out for the hash (otherwise will be truncated to 23 byte according to OpenBSD impl) + * @param appendNullTerminator as defined in $2a$+ a null terminator is appended to the password, pass false if you want avoid this + * @param formatter the formatter responsible for formatting the out hash message digest */ - public Version(byte[] versionIdentifier, BCryptFormatter formatter, BCryptParser parser) { + public Version(byte[] versionIdentifier, boolean useOnly23bytesForHash, boolean appendNullTerminator, BCryptFormatter formatter, BCryptParser parser) { this.versionIdentifier = versionIdentifier; + this.useOnly23bytesForHash = useOnly23bytesForHash; + this.appendNullTerminator = appendNullTerminator; this.formatter = formatter; this.parser = parser; } @@ -658,12 +693,17 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Version version = (Version) o; - return Arrays.equals(versionIdentifier, version.versionIdentifier); + return useOnly23bytesForHash == version.useOnly23bytesForHash && + appendNullTerminator == version.appendNullTerminator && + Arrays.equals(versionIdentifier, version.versionIdentifier); } @Override public int hashCode() { - return Arrays.hashCode(versionIdentifier); + + int result = Objects.hash(useOnly23bytesForHash, appendNullTerminator); + result = 31 * result + Arrays.hashCode(versionIdentifier); + return result; } @Override diff --git a/src/test/java/at/favre/lib/crypto/bcrypt/BcBcryptTestCases.java b/src/test/java/at/favre/lib/crypto/bcrypt/BcBcryptTestCases.java index 546db41..dbb1ed2 100644 --- a/src/test/java/at/favre/lib/crypto/bcrypt/BcBcryptTestCases.java +++ b/src/test/java/at/favre/lib/crypto/bcrypt/BcBcryptTestCases.java @@ -1,13 +1,11 @@ package at.favre.lib.crypto.bcrypt; import at.favre.lib.bytes.Bytes; -import at.favre.lib.bytes.BytesTransformer; import at.favre.lib.crypto.bcrypt.misc.Repeat; import at.favre.lib.crypto.bcrypt.misc.RepeatRule; import org.junit.Rule; import org.junit.Test; -import java.nio.charset.StandardCharsets; import java.util.Random; import static at.favre.lib.crypto.bcrypt.BcryptTest.UTF_8; @@ -22,19 +20,41 @@ public class BcBcryptTestCases { @Rule public RepeatRule repeatRule = new RepeatRule(); + // see: https://www.programcreek.com/java-api-examples/?code=ttt43ttt/gwt-crypto/gwt-crypto-master/src/test/java/org/bouncycastle/crypto/test/BCryptTest.java + private static final Object[][] testVectors = { + {"00", "144b3d691a7b4ecf39cf735c7fa7a79c", 6, "557e94f34bf286e8719a26be94ac1e16d95ef9f819dee092"}, + {"00", "26c63033c04f8bcba2fe24b574db6274", 8, "56701b26164d8f1bc15225f46234ac8ac79bf5bc16bf48ba"}, + {"00", "9b7c9d2ada0fd07091c915d1517701d6", 10, "7b2e03106a43c9753821db688b5cc7590b18fdf9ba544632"}, + {"6100", "a3612d8c9a37dac2f99d94da03bd4521", 6, "e6d53831f82060dc08a2e8489ce850ce48fbf976978738f3"}, + {"6100", "7a17b15dfe1c4be10ec6a3ab47818386", 8, "a9f3469a61cbff0a0f1a1445dfe023587f38b2c9c40570e1"}, + {"6100", "9bef4d04e1f8f92f3de57323f8179190", 10, "5169fd39606d630524285147734b4c981def0ee512c3ace1"}, + {"61626300", "2a1f1dc70a3d147956a46febe3016017", 6, "d9a275b493bcbe1024b0ff80d330253cfdca34687d8f69e5"}, + {"61626300", "4ead845a142c9bc79918c8797f470ef5", 8, "8d4131a723bfbbac8a67f2e035cae08cc33b69f37331ea91"}, + {"61626300", "631c554493327c32f9c26d9be7d18e4c", 10, "8cd0b863c3ff0860e31a2b42427974e0283b3af7142969a6"}}; + @Test @Repeat(10) - public void testRandomAgainstJBcrypt() throws IllegalBCryptFormatException { + public void testRandomAgainstJBcrypt() { int cost = new Random().nextInt(3) + 4; String pw = Bytes.random(8 + new Random().nextInt(24)).encodeBase64(); byte[] salt = Bytes.random(16).array(); //BC will only return the hash without the salt, cost factor and version identifier and does not add a null terminator - byte[] bcryptHashOnly = org.bouncycastle.crypto.generators.BCrypt.generate(Bytes.from(pw).append((byte) 0).array(), salt, cost); - - byte[] hash = BCrypt.with(BCrypt.Version.VERSION_2A).hash(cost, salt, pw.getBytes(UTF_8)); - BCrypt.HashData parts = new BCryptParser.Default(new Radix64Encoder.Default(), StandardCharsets.UTF_8).parse(hash); + byte[] bcryptHashOnly = org.bouncycastle.crypto.generators.BCrypt.generate(Bytes.from(pw).array(), salt, cost); + BCrypt.HashData hash = BCrypt.with(BCrypt.Version.VERSION_BC).hashRaw(cost, salt, pw.getBytes(UTF_8)); + assertArrayEquals(hash.rawHash, bcryptHashOnly); + } - assertArrayEquals(parts.rawHash, Bytes.wrap(bcryptHashOnly).resize(23, BytesTransformer.ResizeTransformer.Mode.RESIZE_KEEP_FROM_ZERO_INDEX).array()); + @Test + public void testBcRefVectors() { + for (Object[] testVector : testVectors) { + byte[] pw = Bytes.parseHex((String) testVector[0]).array(); + byte[] salt = Bytes.parseHex((String) testVector[1]).array(); + int cost = (int) testVector[2]; + byte[] refHash = Bytes.parseHex((String) testVector[3]).array(); + + BCrypt.HashData hash = BCrypt.with(BCrypt.Version.VERSION_BC).hashRaw(cost, salt, pw); + assertArrayEquals(refHash, hash.rawHash); + } } } 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 337cfb2..f9f7866 100644 --- a/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTest.java +++ b/src/test/java/at/favre/lib/crypto/bcrypt/BcryptTest.java @@ -130,6 +130,13 @@ public void hashRandomByteArrays() { System.out.println(Bytes.wrap(hash).encodeUtf8()); } + @Test + public void testEmptyPw() { + byte[] hash = BCrypt.with(BCrypt.Version.VERSION_2A).hash(4, new byte[0]); + assertTrue(BCrypt.verifyer().verify(new byte[0], hash).verified); + System.out.println(Bytes.wrap(hash).encodeUtf8()); + } + @Test(expected = IllegalArgumentException.class) public void createHashCostTooSmall() { BCrypt.withDefaults().hash(3, "123".toCharArray()); @@ -160,6 +167,11 @@ public void createHashWithPwNull() { BCrypt.withDefaults().hash(6, new byte[16], null); } + @Test(expected = IllegalArgumentException.class) + public void createHashWithPwEmptyNoNullTerm() { + BCrypt.with(BCrypt.Version.VERSION_BC).hash(6, new byte[16], new byte[0]); + } + @Test(expected = IllegalArgumentException.class) public void createHashWithCharPwNull() { BCrypt.withDefaults().hash(6, (char[]) null); @@ -344,14 +356,14 @@ public void testHashDataWipe() { @Test public void testVersionPojoMethods() { assertEquals(BCrypt.Version.VERSION_2A, BCrypt.Version.VERSION_2A); - assertEquals(BCrypt.Version.VERSION_2A, new BCrypt.Version(new byte[]{MAJOR_VERSION, 0x61}, null, null)); - assertEquals(BCrypt.Version.VERSION_2Y, new BCrypt.Version(new byte[]{MAJOR_VERSION, 0x79}, null, null)); + assertEquals(BCrypt.Version.VERSION_2A, new BCrypt.Version(new byte[]{MAJOR_VERSION, 0x61}, true, true, null, null)); + assertEquals(BCrypt.Version.VERSION_2Y, new BCrypt.Version(new byte[]{MAJOR_VERSION, 0x79}, true, true, null, null)); assertNotEquals(BCrypt.Version.VERSION_2Y, BCrypt.Version.VERSION_2A); assertNotEquals(BCrypt.Version.VERSION_2A, BCrypt.Version.VERSION_2B); assertNotEquals(BCrypt.Version.VERSION_2X, BCrypt.Version.VERSION_2Y); assertEquals(BCrypt.Version.VERSION_2A.hashCode(), BCrypt.Version.VERSION_2A.hashCode()); - assertEquals(BCrypt.Version.VERSION_2A.hashCode(), new BCrypt.Version(new byte[]{MAJOR_VERSION, 0x61}, null, null).hashCode()); + assertEquals(BCrypt.Version.VERSION_2A.hashCode(), new BCrypt.Version(new byte[]{MAJOR_VERSION, 0x61}, true, true, null, null).hashCode()); assertNotEquals(BCrypt.Version.VERSION_2Y.hashCode(), BCrypt.Version.VERSION_2A.hashCode()); assertNotEquals(BCrypt.Version.VERSION_2A.hashCode(), BCrypt.Version.VERSION_2B.hashCode());