diff --git a/documentation-website/Writerside/topics/Adding-dependencies.md b/documentation-website/Writerside/topics/Adding-dependencies.md index e08e3f9616..8e954c8e2b 100644 --- a/documentation-website/Writerside/topics/Adding-dependencies.md +++ b/documentation-website/Writerside/topics/Adding-dependencies.md @@ -209,7 +209,7 @@ and date-time handling: | Module | Function | |--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `exposed-crypt` | Provides additional column types to store encrypted data in the database and encode/decode it on the client-side | +| `exposed-crypt` | Provides additional column types to store encrypted data in the database and encode/decode it on the client-side, as well as one-way hashed data such as passwords | | `exposed-java-time` | Date-time extensions based on the [Java 8 Time API](https://docs.oracle.com/javase/8/docs/api/java/time/package-summary.html) | | `exposed-jodatime` | Date-time extensions based on the [Joda-Time](https://www.joda.org/joda-time/) library | | `exposed-json` | JSON and JSONB data type extensions | diff --git a/exposed-crypt/api/exposed-crypt.api b/exposed-crypt/api/exposed-crypt.api index aefb0e8dca..8e197e6e71 100644 --- a/exposed-crypt/api/exposed-crypt.api +++ b/exposed-crypt/api/exposed-crypt.api @@ -6,6 +6,18 @@ public final class org/jetbrains/exposed/v1/crypt/Algorithms { public final fun TRIPLE_DES (Ljava/lang/CharSequence;)Lorg/jetbrains/exposed/v1/crypt/Encryptor; } +public final class org/jetbrains/exposed/v1/crypt/Argon2Hasher : org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher { + public fun ()V + public fun (IIIII)V + public synthetic fun (IIIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class org/jetbrains/exposed/v1/crypt/BCryptHasher : org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher { + public fun ()V + public fun (I)V + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public final class org/jetbrains/exposed/v1/crypt/ByteArrayEncryptionTransformer : org/jetbrains/exposed/v1/core/ColumnTransformer { public fun (Lorg/jetbrains/exposed/v1/crypt/Encryptor;)V public synthetic fun unwrap (Ljava/lang/Object;)Ljava/lang/Object; @@ -32,6 +44,57 @@ public final class org/jetbrains/exposed/v1/crypt/Encryptor { public final fun maxColLength (I)I } +public final class org/jetbrains/exposed/v1/crypt/Hashed { + public fun (Lorg/jetbrains/exposed/v1/crypt/Hasher;Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getEncodedValue ()Ljava/lang/String; + public fun hashCode ()I + public final fun matches (Ljava/lang/String;)Z + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/jetbrains/exposed/v1/crypt/Hasher { + public abstract fun hash (Ljava/lang/String;)Lorg/jetbrains/exposed/v1/crypt/Hashed; + public abstract fun matches (Ljava/lang/String;Ljava/lang/String;)Z +} + +public final class org/jetbrains/exposed/v1/crypt/HashingTransformer : org/jetbrains/exposed/v1/core/ColumnTransformer { + public fun (Lorg/jetbrains/exposed/v1/crypt/Hasher;)V + public final fun getHasher ()Lorg/jetbrains/exposed/v1/crypt/Hasher; + public synthetic fun unwrap (Ljava/lang/Object;)Ljava/lang/Object; + public fun unwrap (Lorg/jetbrains/exposed/v1/crypt/Hashed;)Ljava/lang/String; + public synthetic fun wrap (Ljava/lang/Object;)Ljava/lang/Object; + public fun wrap (Ljava/lang/String;)Lorg/jetbrains/exposed/v1/crypt/Hashed; +} + +public final class org/jetbrains/exposed/v1/crypt/NullableHashingTransformer : org/jetbrains/exposed/v1/core/ColumnTransformer { + public fun (Lorg/jetbrains/exposed/v1/crypt/Hasher;)V + public final fun getHasher ()Lorg/jetbrains/exposed/v1/crypt/Hasher; + public synthetic fun unwrap (Ljava/lang/Object;)Ljava/lang/Object; + public fun unwrap (Lorg/jetbrains/exposed/v1/crypt/Hashed;)Ljava/lang/String; + public synthetic fun wrap (Ljava/lang/Object;)Ljava/lang/Object; + public fun wrap (Ljava/lang/String;)Lorg/jetbrains/exposed/v1/crypt/Hashed; +} + +public class org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher : org/jetbrains/exposed/v1/crypt/Hasher { + public fun (Lorg/springframework/security/crypto/password/PasswordEncoder;)V + protected final fun getPasswordEncoder ()Lorg/springframework/security/crypto/password/PasswordEncoder; + public fun hash (Ljava/lang/String;)Lorg/jetbrains/exposed/v1/crypt/Hashed; + public fun matches (Ljava/lang/String;Ljava/lang/String;)Z +} + +public final class org/jetbrains/exposed/v1/crypt/Pbkdf2Hasher : org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher { + public fun ()V + public fun (Ljava/lang/CharSequence;IILorg/springframework/security/crypto/password/Pbkdf2PasswordEncoder$SecretKeyFactoryAlgorithm;)V + public synthetic fun (Ljava/lang/CharSequence;IILorg/springframework/security/crypto/password/Pbkdf2PasswordEncoder$SecretKeyFactoryAlgorithm;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class org/jetbrains/exposed/v1/crypt/SCryptHasher : org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher { + public fun ()V + public fun (IIIII)V + public synthetic fun (IIIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public final class org/jetbrains/exposed/v1/crypt/StringEncryptionTransformer : org/jetbrains/exposed/v1/core/ColumnTransformer { public fun (Lorg/jetbrains/exposed/v1/crypt/Encryptor;)V public synthetic fun unwrap (Ljava/lang/Object;)Ljava/lang/Object; @@ -43,5 +106,11 @@ public final class org/jetbrains/exposed/v1/crypt/StringEncryptionTransformer : public final class org/jetbrains/exposed/v1/crypt/TablesKt { public static final fun encryptedBinary (Lorg/jetbrains/exposed/v1/core/Table;Ljava/lang/String;ILorg/jetbrains/exposed/v1/crypt/Encryptor;)Lorg/jetbrains/exposed/v1/core/Column; public static final fun encryptedVarchar (Lorg/jetbrains/exposed/v1/core/Table;Ljava/lang/String;ILorg/jetbrains/exposed/v1/crypt/Encryptor;)Lorg/jetbrains/exposed/v1/core/Column; + public static final fun hash (Lorg/jetbrains/exposed/v1/core/Column;Ljava/lang/String;)Lorg/jetbrains/exposed/v1/crypt/Hashed; + public static final fun hashNullable (Lorg/jetbrains/exposed/v1/core/Column;Ljava/lang/String;)Lorg/jetbrains/exposed/v1/crypt/Hashed; + public static final fun hashed (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/crypt/Hasher;)Lorg/jetbrains/exposed/v1/core/Column; + public static synthetic fun hashed$default (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/crypt/Hasher;ILjava/lang/Object;)Lorg/jetbrains/exposed/v1/core/Column; + public static final fun hashedNullable (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/crypt/Hasher;)Lorg/jetbrains/exposed/v1/core/Column; + public static synthetic fun hashedNullable$default (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/crypt/Hasher;ILjava/lang/Object;)Lorg/jetbrains/exposed/v1/core/Column; } diff --git a/exposed-crypt/build.gradle.kts b/exposed-crypt/build.gradle.kts index 7f8e5c142c..abccdcb78b 100644 --- a/exposed-crypt/build.gradle.kts +++ b/exposed-crypt/build.gradle.kts @@ -17,6 +17,7 @@ kotlin { dependencies { api(project(":exposed-core")) api(libs.spring.security.crypto) + runtimeOnly(libs.commons.logging) testImplementation(project(":exposed-dao")) testImplementation(project(":exposed-tests")) @@ -25,6 +26,7 @@ dependencies { testRuntimeOnly(libs.junit.platform.launcher) testImplementation(kotlin("test")) testImplementation(libs.logcaptor) + testImplementation(libs.bouncycastle) } tasks.withType().configureEach { diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Argon2Hasher.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Argon2Hasher.kt new file mode 100644 index 0000000000..091e21abf0 --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Argon2Hasher.kt @@ -0,0 +1,49 @@ +package org.jetbrains.exposed.v1.crypt + +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder + +/** + * [Hasher] that uses the Argon2id algorithm, the first choice recommended by OWASP, salting each value + * individually. + * + * Encoded values follow the PHC string format and are 97 characters long with the default parameters, varying + * with [saltLength], [hashLength], and the printed length of the remaining parameters. + * + * This requires BouncyCastle (`org.bouncycastle:bcprov-jdk18on`) on the runtime classpath, which `exposed-crypt` + * does not depend on; constructing it throws an [IllegalStateException] if BouncyCastle is missing. + * + * @param saltLength Length in bytes of the randomly generated salt + * @param hashLength Length in bytes of the generated hash + * @param parallelism Number of lanes used by the algorithm + * @param memory Amount of memory in kibibytes used by the algorithm + * @param iterations Number of passes over the memory + */ +class Argon2Hasher( + saltLength: Int = DEFAULT_SALT_LENGTH, + hashLength: Int = DEFAULT_HASH_LENGTH, + parallelism: Int = DEFAULT_PARALLELISM, + memory: Int = DEFAULT_MEMORY, + iterations: Int = DEFAULT_ITERATIONS +) : PasswordEncoderHasher(newEncoder(saltLength, hashLength, parallelism, memory, iterations)) { + private companion object { + private const val DEFAULT_SALT_LENGTH = 16 + private const val DEFAULT_HASH_LENGTH = 32 + private const val DEFAULT_PARALLELISM = 1 + private const val DEFAULT_MEMORY = 16384 + private const val DEFAULT_ITERATIONS = 2 + + private const val BOUNCY_CASTLE_CLASS = "org.bouncycastle.crypto.generators.Argon2BytesGenerator" + + @Suppress("LongParameterList") + private fun newEncoder( + saltLength: Int, + hashLength: Int, + parallelism: Int, + memory: Int, + iterations: Int + ): Argon2PasswordEncoder { + requireBouncyCastle("Argon2Hasher", BOUNCY_CASTLE_CLASS) + return Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations) + } + } +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/BCryptHasher.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/BCryptHasher.kt new file mode 100644 index 0000000000..2350d9e4d1 --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/BCryptHasher.kt @@ -0,0 +1,19 @@ +package org.jetbrains.exposed.v1.crypt + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder + +/** + * [Hasher] that uses the bcrypt algorithm, salting each value individually. + * + * Encoded values are always 60 characters long. + * + * @param strength Log rounds of hashing work to perform, between 4 and 31, where each increment doubles the + * time taken. Defaults to the same value as Spring Security's `BCryptPasswordEncoder`. + */ +class BCryptHasher( + strength: Int = DEFAULT_STRENGTH +) : PasswordEncoderHasher(BCryptPasswordEncoder(strength)) { + private companion object { + private const val DEFAULT_STRENGTH = 10 + } +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Hasher.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Hasher.kt new file mode 100644 index 0000000000..a3512696f0 --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Hasher.kt @@ -0,0 +1,58 @@ +package org.jetbrains.exposed.v1.crypt + +/** + * Base class responsible for the one-way hashing of data. + * + * Unlike [Encryptor], a [Hasher] cannot recover the original value: the only operation available on a stored + * hash is checking whether some plaintext produces it. Use this for values that never need to be read back, + * such as passwords. + * + * [BCryptHasher], [Argon2Hasher], [Pbkdf2Hasher], and [SCryptHasher] cover the algorithms recommended by the + * [OWASP password storage cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). + * To hash with something else, wrap any Spring Security `PasswordEncoder` in a [PasswordEncoderHasher], or + * implement the two operations declared here: + * + * ```kotlin + * class CustomHasher : Hasher { + * override fun hash(plainText: String): Hashed = Hashed(this, customLibrary.hash(plainText)) + * + * override fun matches(plainText: String, encodedValue: String): Boolean = + * customLibrary.verify(plainText, encodedValue) + * } + * ``` + */ +interface Hasher { + /** Hashes [plainText] into the value to be stored, salting it if the algorithm supports salting. */ + fun hash(plainText: String): Hashed + + /** Returns whether [plainText] hashes to [encodedValue], which is expected to be an already hashed value. */ + fun matches(plainText: String, encodedValue: String): Boolean +} + +/** + * A [hashed] column holds these rather than strings, which is what keeps a plaintext value from being stored in + * one by accident, or compared against one in SQL. Assigning a [Hashed] read from the database back to a column + * stores it unchanged, without hashing it a second time. + * + * Constructing one directly wraps [encodedValue] as it is, without hashing it. Do that to adopt hashes produced + * elsewhere, such as when migrating existing values into a [hashed] column; to hash a plaintext value, use + * [Hasher.hash]. + */ +class Hashed( + private val hasher: Hasher, + /** The encoded hash, as stored in the database. */ + val encodedValue: String +) { + /** Returns whether [plainText] hashes to this value, as determined by the [Hasher] that produced it. */ + fun matches(plainText: String): Boolean = hasher.matches(plainText, encodedValue) + + override fun equals(other: Any?): Boolean = when { + this === other -> true + other !is Hashed -> false + else -> encodedValue == other.encodedValue + } + + override fun hashCode(): Int = encodedValue.hashCode() + + override fun toString(): String = "Hashed(***)" +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/HashingTransformer.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/HashingTransformer.kt new file mode 100644 index 0000000000..c66de3dcda --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/HashingTransformer.kt @@ -0,0 +1,32 @@ +package org.jetbrains.exposed.v1.crypt + +import org.jetbrains.exposed.v1.core.ColumnTransformer + +/** + * Transformer that stores the [Hashed.encodedValue] of a hashed value in a character column, using the provided + * [hasher] to verify the values read back out of it. + * + * The hashing itself is performed by [Hasher.hash], not by this transformer, so a value that is already stored is + * never hashed again when it is read and written back. + */ +class HashingTransformer( + /** [Hasher] this transformer hashes with, and verifies the values it reads against. */ + val hasher: Hasher +) : ColumnTransformer { + override fun unwrap(value: Hashed): String = value.encodedValue + + override fun wrap(value: String): Hashed = Hashed(hasher, value) +} + +/** + * Transformer that behaves like [HashingTransformer] but passes `null` through untouched, for use with nullable + * character columns. + */ +class NullableHashingTransformer( + /** [Hasher] this transformer hashes with, and verifies the values it reads against. */ + val hasher: Hasher +) : ColumnTransformer { + override fun unwrap(value: Hashed?): String? = value?.encodedValue + + override fun wrap(value: String?): Hashed? = value?.let { Hashed(hasher, it) } +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher.kt new file mode 100644 index 0000000000..d3fcde4718 --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher.kt @@ -0,0 +1,47 @@ +package org.jetbrains.exposed.v1.crypt + +import org.springframework.security.crypto.password.PasswordEncoder + +/** + * [Hasher] that delegates to a Spring Security [passwordEncoder]. + * + * This is the base of [BCryptHasher], [Argon2Hasher], [Pbkdf2Hasher], and [SCryptHasher], and can also be used + * directly with any other implementation, including a `DelegatingPasswordEncoder` for a column whose existing + * values were hashed by an algorithm that is being migrated away from: + * + * ```kotlin + * val hasher = PasswordEncoderHasher( + * DelegatingPasswordEncoder( + * "argon2", + * mapOf("argon2" to Argon2PasswordEncoder(...), "bcrypt" to BCryptPasswordEncoder()) + * ) + * ) + * ``` + * + * To hash with something that is not a `PasswordEncoder` at all, implement [Hasher] directly. + */ +open class PasswordEncoderHasher( + protected val passwordEncoder: PasswordEncoder +) : Hasher { + override fun hash(plainText: String): Hashed = Hashed( + this, + checkNotNull(passwordEncoder.encode(plainText)) { + "${passwordEncoder::class.simpleName} returned no hash for the given value" + } + ) + + override fun matches(plainText: String, encodedValue: String): Boolean = + passwordEncoder.matches(plainText, encodedValue) +} + +internal fun requireBouncyCastle(hasher: String, className: String) { + try { + Class.forName(className) + } catch (cause: ClassNotFoundException) { + throw IllegalStateException( + "$hasher requires BouncyCastle on the runtime classpath. " + + "Add a dependency on org.bouncycastle:bcprov-jdk18on to use it.", + cause + ) + } +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Pbkdf2Hasher.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Pbkdf2Hasher.kt new file mode 100644 index 0000000000..db5f69c69d --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Pbkdf2Hasher.kt @@ -0,0 +1,28 @@ +package org.jetbrains.exposed.v1.crypt + +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm + +/** + * [Hasher] that uses the PBKDF2 algorithm, salting each value individually. + * + * Encoded values are the hex encoded salt and hash, 96 characters long with the default parameters, varying with + * [saltLength] and the hash width of [algorithm]. + * + * @param secret Optional secret, sometimes called a pepper, mixed into every hash. Unlike the salt it is not + * stored alongside the hash, so it has to be kept and supplied identically in order to verify existing values. + * @param saltLength Length in bytes of the randomly generated salt + * @param iterations Number of hashing iterations to perform + * @param algorithm Pseudorandom function to apply on each iteration + */ +class Pbkdf2Hasher( + secret: CharSequence = "", + saltLength: Int = DEFAULT_SALT_LENGTH, + iterations: Int = DEFAULT_ITERATIONS, + algorithm: SecretKeyFactoryAlgorithm = SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256 +) : PasswordEncoderHasher(Pbkdf2PasswordEncoder(secret, saltLength, iterations, algorithm)) { + private companion object { + private const val DEFAULT_SALT_LENGTH = 16 + private const val DEFAULT_ITERATIONS = 310_000 + } +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/SCryptHasher.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/SCryptHasher.kt new file mode 100644 index 0000000000..11fdb9ac85 --- /dev/null +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/SCryptHasher.kt @@ -0,0 +1,45 @@ +package org.jetbrains.exposed.v1.crypt + +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder + +/** + * [Hasher] that uses the scrypt algorithm, salting each value individually. + * + * This requires BouncyCastle (`org.bouncycastle:bcprov-jdk18on`) on the runtime classpath, which `exposed-crypt` + * does not depend on; constructing it throws an [IllegalStateException] if BouncyCastle is missing. + * + * @param cpuCost CPU cost of the algorithm, as a power of 2 greater than 1 + * @param memoryCost Memory cost of the algorithm + * @param parallelization Parallelization of the algorithm + * @param keyLength Length in bytes of the generated key + * @param saltLength Length in bytes of the randomly generated salt + */ +class SCryptHasher( + cpuCost: Int = DEFAULT_CPU_COST, + memoryCost: Int = DEFAULT_MEMORY_COST, + parallelization: Int = DEFAULT_PARALLELIZATION, + keyLength: Int = DEFAULT_KEY_LENGTH, + saltLength: Int = DEFAULT_SALT_LENGTH +) : PasswordEncoderHasher(newEncoder(cpuCost, memoryCost, parallelization, keyLength, saltLength)) { + private companion object { + private const val DEFAULT_CPU_COST = 65536 + private const val DEFAULT_MEMORY_COST = 8 + private const val DEFAULT_PARALLELIZATION = 1 + private const val DEFAULT_KEY_LENGTH = 32 + private const val DEFAULT_SALT_LENGTH = 16 + + private const val BOUNCY_CASTLE_CLASS = "org.bouncycastle.crypto.generators.SCrypt" + + @Suppress("LongParameterList") + private fun newEncoder( + cpuCost: Int, + memoryCost: Int, + parallelization: Int, + keyLength: Int, + saltLength: Int + ): SCryptPasswordEncoder { + requireBouncyCastle("SCryptHasher", BOUNCY_CASTLE_CLASS) + return SCryptPasswordEncoder(cpuCost, memoryCost, parallelization, keyLength, saltLength) + } + } +} diff --git a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Tables.kt b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Tables.kt index 685642f6fa..b9cd68a40a 100644 --- a/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Tables.kt +++ b/exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Tables.kt @@ -1,6 +1,8 @@ package org.jetbrains.exposed.v1.crypt import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnTransformer +import org.jetbrains.exposed.v1.core.ColumnWithTransform import org.jetbrains.exposed.v1.core.Table /** @@ -24,3 +26,105 @@ fun Table.encryptedVarchar(name: String, cipherTextLength: Int, encryptor: Encry */ fun Table.encryptedBinary(name: String, cipherByteLength: Int, encryptor: Encryptor): Column = registerColumn(name, EncryptedBinaryColumnType(encryptor, cipherByteLength)) + +/** + * Transforms this character column into one that stores one-way hashed values, using the provided [hasher]. + * + * ```kotlin + * val passwordHasher = BCryptHasher() + * + * object Users : IntIdTable() { + * val password = text("password").hashed(passwordHasher) + * } + * + * Users.insert { it[password] = passwordHasher.hash("s3cret") } + * + * val user = Users.selectAll().where { Users.id eq id }.single() + * val granted = user[Users.password].matches(submittedPassword) + * ``` + * + * Leaving [hasher] out leaves nothing to hash with either, as the default is not held anywhere the calling code + * can reach. Ask the column for it instead, with [hash]: + * + * ```kotlin + * object Users : IntIdTable() { + * val password = text("password").hashed() + * } + * + * Users.insert { it[password] = Users.password.hash("s3cret") } + * ``` + * + * [hash] works whichever way the column was declared, and is worth preferring even when a hasher is at hand: + * it always hashes with the one the column verifies with, which a separately held [Hasher] is not guaranteed + * to be. + * + * A stored value can only be verified by the algorithm that produced it, so changing [hasher] on a column that + * already holds data stops the values written before the change from matching. + * + * @param hasher [Hasher] responsible for hashing values and for verifying them against stored hashes. Defaults + * to [BCryptHasher], the provided algorithm that needs nothing beyond `exposed-crypt` itself. + * @return A new column holding [Hashed] values. + */ +fun Column.hashed(hasher: Hasher = BCryptHasher()): Column = + with(table) { this@hashed.transform(HashingTransformer(hasher)) } + +/** + * Transforms this nullable character column into one that stores one-way hashed values, using the provided + * [hasher], and leaving `null` values untouched. + * + * @param hasher [Hasher] responsible for hashing values and for verifying them against stored hashes. Defaults + * to [BCryptHasher], the provided algorithm that needs nothing beyond `exposed-crypt` itself. + * @return A new nullable column holding [Hashed] values. + * @see hashed + */ +@JvmName("hashedNullable") +fun Column.hashed(hasher: Hasher = BCryptHasher()): Column = + with(table) { this@hashed.transform(NullableHashingTransformer(hasher)) } + +/** + * Hashes [plainText] with the [Hasher] this column was declared with, ready to be stored in it. + * + * Using this rather than a separately held [Hasher] rules out hashing a value with one algorithm and storing it + * in a column that verifies with another, which the column cannot detect and which makes every later + * [Hashed.matches] fail. It is the only way to hash for a column declared without an explicit hasher: + * + * ```kotlin + * object Users : IntIdTable() { + * val password = text("password").hashed() + * } + * + * Users.insert { it[password] = Users.password.hash("s3cret") } + * ``` + * + * @param plainText Value to hash + * @return The [Hashed] result of applying this column's [Hasher] to [plainText]. + */ +fun Column.hash(plainText: String): Hashed = hasher.hash(plainText) + +/** + * Hashes [plainText] with the [Hasher] this nullable column was declared with, ready to be stored in it. + * + * @param plainText Value to hash + * @return The [Hashed] result of applying this column's [Hasher] to [plainText]. + * @see hash + */ +@JvmName("hashNullable") +fun Column.hash(plainText: String): Hashed = hasher.hash(plainText) + +/** + * The [Hasher] behind a column created by [hashed]. + * + * Transforms nest, each one delegating to the column type it was applied to, so the hashing transformer is not + * necessarily the outermost one and the whole chain has to be searched. + */ +private val Column<*>.hasher: Hasher + get() = generateSequence(columnType as? ColumnWithTransform<*, *>) { it.delegate as? ColumnWithTransform<*, *> } + .firstNotNullOfOrNull { it.transformer.hasherOrNull() } + ?: error("Column $name does not hash its values. Declare it with hashed() to be able to hash for it.") + +/** The [Hasher] this transformer hashes with, or `null` if it does not hash at all. */ +private fun ColumnTransformer<*, *>.hasherOrNull(): Hasher? = when (this) { + is HashingTransformer -> hasher + is NullableHashingTransformer -> hasher + else -> null +} diff --git a/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/HashedColumnTests.kt b/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/HashedColumnTests.kt new file mode 100644 index 0000000000..6f7de33691 --- /dev/null +++ b/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/HashedColumnTests.kt @@ -0,0 +1,205 @@ +package org.jetbrains.exposed.v1.crypt + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.insertAndGetId +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.tests.DatabaseTestsBase +import org.jetbrains.exposed.v1.tests.shared.assertEquals +import org.jetbrains.exposed.v1.tests.shared.assertFalse +import org.jetbrains.exposed.v1.tests.shared.assertTrue +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HashedColumnTests : DatabaseTestsBase() { + private companion object { + const val TEST_BCRYPT_STRENGTH = 4 + const val TEST_ARGON2_MEMORY = 512 + const val TEST_ARGON2_ITERATIONS = 1 + const val TEST_PBKDF2_ITERATIONS = 1000 + const val TEST_SCRYPT_CPU_COST = 1024 + + const val PASSWORD = "s3cret" + const val WRONG_PASSWORD = "s3cr3t" + const val RECOVERY_CODE = "r3covery" + + const val TESTER_TABLE = "HashedTester" + } + + @Test + fun testColumnDeclaredWithoutAHasherHashesWithBCrypt() { + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed() + val recoveryCode = varchar("recovery_code", 60).nullable().hashed() + } + + withTables(tester) { + tester.insert { + it[password] = tester.password.hash(PASSWORD) + it[recoveryCode] = tester.recoveryCode.hash(RECOVERY_CODE) + } + + val stored = tester.selectAll().single() + assertTrue(stored[tester.password].matches(PASSWORD)) + assertFalse(stored[tester.password].matches(WRONG_PASSWORD)) + assertTrue(assertNotNull(stored[tester.recoveryCode]).matches(RECOVERY_CODE)) + + assertTrue(BCryptHasher().matches(PASSWORD, stored[tester.password].encodedValue)) + } + } + + @Test + fun testHashedColumnStoresHashAndVerifiesPlainText() { + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + val recoveryCode = varchar("recovery_code", 60).nullable().hashed(hasher) + } + + val rawTester = object : IntIdTable(TESTER_TABLE) { + val password = text("password") + val recoveryCode = varchar("recovery_code", 60).nullable() + } + + withTables(tester) { + val id = tester.insertAndGetId { + it[password] = hasher.hash(PASSWORD) + it[recoveryCode] = null + } + + val raw = rawTester.selectAll().where { rawTester.id eq id }.single() + assertNotEquals(PASSWORD, raw[rawTester.password], "The plaintext reached the database.") + assertTrue(hasher.matches(PASSWORD, raw[rawTester.password])) + assertNull(raw[rawTester.recoveryCode]) + + val stored = tester.selectAll().where { tester.id eq id }.single() + assertTrue(stored[tester.password].matches(PASSWORD)) + assertFalse(stored[tester.password].matches(WRONG_PASSWORD)) + assertNull(stored[tester.recoveryCode]) + } + } + + @Test + fun testColumnHashesWhenTheHashingTransformIsNotTheOutermostOne() { + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher).transform(wrap = { it }, unwrap = { it }) + } + + withTables(tester) { + tester.insert { it[password] = tester.password.hash(PASSWORD) } + + val stored = tester.selectAll().single()[tester.password] + assertTrue(stored.matches(PASSWORD)) + assertFalse(stored.matches(WRONG_PASSWORD)) + } + } + + @Test + fun testEachAlgorithmVerifiesItsOwnHashesAndSaltsThem() { + val hashers = arrayOf( + "BCRYPT" to BCryptHasher(strength = TEST_BCRYPT_STRENGTH), + "ARGON2" to Argon2Hasher(memory = TEST_ARGON2_MEMORY, iterations = TEST_ARGON2_ITERATIONS), + "PBKDF2" to Pbkdf2Hasher(iterations = TEST_PBKDF2_ITERATIONS), + "SCRYPT" to SCryptHasher(cpuCost = TEST_SCRYPT_CPU_COST) + ) + + for ((algorithm, hasher) in hashers) { + val hashed = hasher.hash(PASSWORD) + + assertTrue(hashed.matches(PASSWORD), "$algorithm failed to verify the value it hashed.") + assertFalse(hashed.matches(WRONG_PASSWORD), "$algorithm verified a value it did not hash.") + assertFalse(PASSWORD in hashed.encodedValue, "$algorithm leaked the plaintext into its output.") + assertNotEquals( + hashed, + hasher.hash(PASSWORD), + "$algorithm produced the same hash twice, so it does not salt each value." + ) + } + } + + @Test + fun testStoredHashIsNotHashedAgainWhenWrittenBack() { + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + } + + withTables(tester) { + val originalId = tester.insertAndGetId { it[password] = hasher.hash(PASSWORD) } + val original = tester.selectAll().where { tester.id eq originalId }.single()[tester.password] + + val copyId = tester.insertAndGetId { it[password] = original } + val copy = tester.selectAll().where { tester.id eq copyId }.single()[tester.password] + + assertEquals(original.encodedValue, copy.encodedValue) + assertTrue(copy.matches(PASSWORD)) + } + } + + @Test + fun testUpdateReplacesStoredHash() { + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + } + + withTables(tester) { + val id = tester.insertAndGetId { it[password] = hasher.hash(PASSWORD) } + + val newPassword = "even-more-s3cret" + tester.update({ tester.id eq id }) { it[password] = hasher.hash(newPassword) } + + val stored = tester.selectAll().where { tester.id eq id }.single()[tester.password] + assertTrue(stored.matches(newPassword)) + assertFalse(stored.matches(PASSWORD)) + } + } + + @Test + fun testHashedWrapsAHashProducedElsewhereWithoutRehashingIt() { + val foreignHash = BCryptHasher(strength = TEST_BCRYPT_STRENGTH + 1).hash(PASSWORD).encodedValue + + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + } + + withTables(tester) { + tester.insert { it[password] = Hashed(hasher, foreignHash) } + + val stored = tester.selectAll().single()[tester.password] + assertEquals(foreignHash, stored.encodedValue) + assertTrue(stored.matches(PASSWORD)) + } + } + + private class ReversingHasher : Hasher { + override fun hash(plainText: String): Hashed = Hashed(this, plainText.reversed()) + + override fun matches(plainText: String, encodedValue: String): Boolean = plainText.reversed() == encodedValue + } + + @Test + fun testCustomHasherIsUsableAsAColumn() { + val hasher = ReversingHasher() + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + } + + withTables(tester) { + tester.insert { it[password] = hasher.hash(PASSWORD) } + + val stored = tester.selectAll().single()[tester.password] + assertEquals(PASSWORD.reversed(), stored.encodedValue) + assertTrue(stored.matches(PASSWORD)) + assertFalse(stored.matches(WRONG_PASSWORD)) + } + } +} diff --git a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/crypt/HashedColumnTests.kt b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/crypt/HashedColumnTests.kt new file mode 100644 index 0000000000..db2922b4a9 --- /dev/null +++ b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/crypt/HashedColumnTests.kt @@ -0,0 +1,85 @@ +package org.jetbrains.exposed.v1.r2dbc.sql.tests.crypt + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.crypt.BCryptHasher +import org.jetbrains.exposed.v1.crypt.hashed +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.update +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HashedColumnTests : R2dbcDatabaseTestsBase() { + private companion object { + const val TEST_BCRYPT_STRENGTH = 4 + + const val PASSWORD = "s3cret" + const val WRONG_PASSWORD = "s3cr3t" + + const val TESTER_TABLE = "HashedTester" + } + + @Test + fun testHashedColumnStoresHashAndVerifiesPlainText() { + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + val recoveryCode = varchar("recovery_code", 60).nullable().hashed(hasher) + } + + val rawTester = object : IntIdTable(TESTER_TABLE) { + val password = text("password") + val recoveryCode = varchar("recovery_code", 60).nullable() + } + + withTables(tester) { + val id = tester.insertAndGetId { + it[password] = hasher.hash(PASSWORD) + it[recoveryCode] = null + } + + val raw = rawTester.selectAll().where { rawTester.id eq id }.single() + assertNotEquals(PASSWORD, raw[rawTester.password], "The plaintext reached the database.") + assertTrue(hasher.matches(PASSWORD, raw[rawTester.password])) + assertNull(raw[rawTester.recoveryCode]) + + val stored = tester.selectAll().where { tester.id eq id }.single() + assertTrue(stored[tester.password].matches(PASSWORD)) + assertFalse(stored[tester.password].matches(WRONG_PASSWORD)) + assertNull(stored[tester.recoveryCode]) + } + } + + @Test + fun testUpdateReplacesStoredHash() { + val hasher = BCryptHasher(strength = TEST_BCRYPT_STRENGTH) + val tester = object : IntIdTable(TESTER_TABLE) { + val password = text("password").hashed(hasher) + } + + withTables(tester) { + val id = tester.insertAndGetId { it[password] = hasher.hash(PASSWORD) } + val original = tester.selectAll().where { tester.id eq id }.single()[tester.password] + + val newPassword = "even-more-s3cret" + tester.update({ tester.id eq id }) { it[password] = hasher.hash(newPassword) } + + val stored = tester.selectAll().where { tester.id eq id }.single()[tester.password] + assertTrue(stored.matches(newPassword)) + assertFalse(stored.matches(PASSWORD)) + assertNotEquals(original.encodedValue, stored.encodedValue) + + tester.update({ tester.id eq id }) { it[password] = stored } + val rewritten = tester.selectAll().where { tester.id eq id }.single()[tester.password] + assertEquals(stored.encodedValue, rewritten.encodedValue) + assertTrue(rewritten.matches(newPassword)) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 497ffbdfcf..facb4c38d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,8 @@ springBoot3 = "3.5.8" springBoot4 = "4.1.0" spring-security-crypto = "7.0.0" +commons-logging = "1.3.5" +bouncycastle = "1.85" joda-time = "2.14.3" junit5 = "5.14.1" junit6 = "6.0.1" @@ -73,6 +75,8 @@ kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-seria slf4j = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } spring-security-crypto = { group = "org.springframework.security", name = "spring-security-crypto", version.ref = "spring-security-crypto" } +commons-logging = { group = "commons-logging", name = "commons-logging", version.ref = "commons-logging" } +bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } joda-time = { group = "joda-time", name = "joda-time", version.ref = "joda-time" } junit5 = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit5" } junit6 = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit6" }