-
Notifications
You must be signed in to change notification settings - Fork 793
feat: EXPOSED-1049 Support hashing algorithms for the Crypt module #2876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
obabichevjb
wants to merge
2
commits into
main
Choose a base branch
from
obabichev/exposed-1049-hashing-algorithms
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Argon2Hasher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package org.jetbrains.exposed.v1.crypt | ||
|
|
||
| import org.springframework.security.crypto.argon2.Argon2PasswordEncoder | ||
|
|
||
| /** | ||
| * [Argon2Hasher] 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 | ||
| * @sample org.jetbrains.exposed.v1.crypt.hashed | ||
| */ | ||
| 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) | ||
| } | ||
| } | ||
| } | ||
18 changes: 18 additions & 0 deletions
18
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/BCryptHasher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package org.jetbrains.exposed.v1.crypt | ||
|
|
||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder | ||
|
|
||
| /** | ||
| * 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`. | ||
| * @sample org.jetbrains.exposed.v1.crypt.hashed | ||
| */ | ||
| class BCryptHasher( | ||
| strength: Int = DEFAULT_STRENGTH | ||
| ) : PasswordEncoderHasher(BCryptPasswordEncoder(strength)) { | ||
| private companion object { | ||
| private const val DEFAULT_STRENGTH = 10 | ||
| } | ||
| } |
58 changes: 58 additions & 0 deletions
58
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Hasher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(***)" | ||
| } |
15 changes: 15 additions & 0 deletions
15
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/HashingTransformer.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package org.jetbrains.exposed.v1.crypt | ||
|
|
||
| import org.jetbrains.exposed.v1.core.ColumnTransformer | ||
|
|
||
| class HashingTransformer(private val hasher: Hasher) : ColumnTransformer<String, Hashed> { | ||
|
obabichevjb marked this conversation as resolved.
Outdated
|
||
| override fun unwrap(value: Hashed): String = value.encodedValue | ||
|
|
||
| override fun wrap(value: String): Hashed = Hashed(hasher, value) | ||
| } | ||
|
|
||
| class NullableHashingTransformer(private val hasher: Hasher) : ColumnTransformer<String?, Hashed?> { | ||
| override fun unwrap(value: Hashed?): String? = value?.encodedValue | ||
|
|
||
| override fun wrap(value: String?): Hashed? = value?.let { Hashed(hasher, it) } | ||
| } | ||
47 changes: 47 additions & 0 deletions
47
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| private val passwordEncoder: PasswordEncoder | ||
|
obabichevjb marked this conversation as resolved.
Outdated
|
||
| ) : 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 | ||
| ) | ||
| } | ||
| } | ||
28 changes: 28 additions & 0 deletions
28
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/Pbkdf2Hasher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
45 changes: 45 additions & 0 deletions
45
exposed-crypt/src/main/kotlin/org/jetbrains/exposed/v1/crypt/SCryptHasher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.