Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
69 changes: 69 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,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 <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 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 <init> (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 <init> (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 <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 +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;
}

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,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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
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,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<String, Hashed> {
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<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(
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
)
}
}
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