Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
62 changes: 62 additions & 0 deletions exposed-crypt/api/exposed-crypt.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
public fun <init> (IIIII)V
public synthetic fun <init> (IIIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V
}

public final class org/jetbrains/exposed/v1/crypt/BCryptHasher : org/jetbrains/exposed/v1/crypt/PasswordEncoderHasher {
public fun <init> ()V
public fun <init> (I)V
public synthetic fun <init> (IILkotlin/jvm/internal/DefaultConstructorMarker;)V
}

public final class org/jetbrains/exposed/v1/crypt/ByteArrayEncryptionTransformer : org/jetbrains/exposed/v1/core/ColumnTransformer {
public fun <init> (Lorg/jetbrains/exposed/v1/crypt/Encryptor;)V
public synthetic fun unwrap (Ljava/lang/Object;)Ljava/lang/Object;
Expand All @@ -32,6 +44,54 @@ 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 <init> (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 <init> (Lorg/jetbrains/exposed/v1/crypt/Hasher;)V
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 <init> (Lorg/jetbrains/exposed/v1/crypt/Hasher;)V
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 <init> (Lorg/springframework/security/crypto/password/PasswordEncoder;)V
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 <init> ()V
public fun <init> (Ljava/lang/CharSequence;IILorg/springframework/security/crypto/password/Pbkdf2PasswordEncoder$SecretKeyFactoryAlgorithm;)V
public synthetic fun <init> (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 <init> ()V
public fun <init> (IIIII)V
public synthetic fun <init> (IIIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V
}

public final class org/jetbrains/exposed/v1/crypt/StringEncryptionTransformer : org/jetbrains/exposed/v1/core/ColumnTransformer {
public fun <init> (Lorg/jetbrains/exposed/v1/crypt/Encryptor;)V
public synthetic fun unwrap (Ljava/lang/Object;)Ljava/lang/Object;
Expand All @@ -43,5 +103,7 @@ 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 hashed (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/crypt/Hasher;)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;
}

2 changes: 2 additions & 0 deletions exposed-crypt/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -25,6 +26,7 @@ dependencies {
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(kotlin("test"))
testImplementation(libs.logcaptor)
testImplementation(libs.bouncycastle)
}

tasks.withType<KotlinCompile>().configureEach {
Expand Down
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`
Comment thread
obabichevjb marked this conversation as resolved.
Outdated
* 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)
}
}
}
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
}
}
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(***)"
}
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> {
Comment thread
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) }
}
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
Comment thread
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
)
}
}
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
}
}
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)
}
}
}
Loading
Loading