argon2
This commit is contained in:
parent
5d85d6e6ad
commit
447ebfd255
4 changed files with 467 additions and 0 deletions
158
modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala
Normal file
158
modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.effect.Sync
|
||||
import cats.syntax.all.*
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||
|
||||
/** Argon2 hashing support based on the Bouncy Castle library.
|
||||
*
|
||||
* @param config
|
||||
* Algorithm and configuration parameters.
|
||||
* @param secret
|
||||
* The secret used to generate hashes.
|
||||
* @param randomByteProvider
|
||||
* Used to generate random bytes.
|
||||
*/
|
||||
final class Argon2[F[_]: Sync](
|
||||
val config: Argon2.Config,
|
||||
val secret: Argon2Secret,
|
||||
val randomByteProvider: RandomByteProvider[F]
|
||||
):
|
||||
|
||||
/** Calculate a new hash for some input.
|
||||
*
|
||||
* @param input
|
||||
* The input string to hash.
|
||||
* @return
|
||||
* The calculated hash.
|
||||
*/
|
||||
def calculateHash(input: String): F[Argon2Hash] =
|
||||
randomSalt().map { salt =>
|
||||
val ap = buildAlgorithmParams(salt)
|
||||
val a2 = new Argon2BytesGenerator()
|
||||
val _ = a2.init(ap)
|
||||
val bytes = new Array[Byte](config.hashLengthInBytes)
|
||||
val _ = a2.generateBytes(input.getBytes(StandardCharsets.UTF_8), bytes)
|
||||
new Argon2Hash(
|
||||
algorithmVersion = config.algorithmVersion,
|
||||
algorithmType = config.algorithmType,
|
||||
iterations = config.iterations,
|
||||
parallelism = config.parallelism,
|
||||
memoryInKb = config.memoryInKb,
|
||||
salt = salt,
|
||||
hash = bytes
|
||||
)
|
||||
}
|
||||
|
||||
/** Given some input, determine if that input matches a stored hash.
|
||||
*
|
||||
* @param input
|
||||
* The input to compare.
|
||||
* @param stored
|
||||
* The stored hash.
|
||||
* @return
|
||||
* True if the input matches the hash, false otherwise.
|
||||
*/
|
||||
def doesInputMatch(
|
||||
input: String,
|
||||
stored: Argon2Hash
|
||||
): F[Boolean] =
|
||||
Sync[F].delay {
|
||||
// Use the stored hash as the parameter source.
|
||||
val ap = buildAlgorithmParams(stored)
|
||||
|
||||
// Hash the input using the stored parameters.
|
||||
val a2 = new Argon2BytesGenerator()
|
||||
val _ = a2.init(ap)
|
||||
val bytes = new Array[Byte](stored.hash.length)
|
||||
val _ = a2.generateBytes(input.getBytes(StandardCharsets.UTF_8), bytes)
|
||||
|
||||
// Compare the bytes to determine if the input matches the stored hash.
|
||||
bytes.sameElements(stored.hash)
|
||||
}
|
||||
|
||||
private def randomSalt(): F[Array[Byte]] =
|
||||
randomByteProvider.generateBytes(config.saltLengthInBytes)
|
||||
|
||||
private def buildAlgorithmParams(salt: Array[Byte]): Argon2Parameters =
|
||||
new Argon2Parameters.Builder(config.algorithmType)
|
||||
.withIterations(config.iterations)
|
||||
.withMemoryAsKB(config.memoryInKb)
|
||||
.withParallelism(config.parallelism)
|
||||
.withSalt(salt)
|
||||
.withSecret(secret.bytes)
|
||||
.withVersion(config.algorithmVersion)
|
||||
.build()
|
||||
|
||||
private def buildAlgorithmParams(hash: Argon2Hash): Argon2Parameters =
|
||||
new Argon2Parameters.Builder(hash.algorithmType)
|
||||
.withVersion(hash.algorithmVersion)
|
||||
.withIterations(hash.iterations)
|
||||
.withParallelism(hash.parallelism)
|
||||
.withMemoryAsKB(hash.memoryInKb)
|
||||
.withSalt(hash.salt)
|
||||
.withSecret(secret.bytes)
|
||||
.build()
|
||||
|
||||
object Argon2:
|
||||
val Algorithm: Int = Argon2Parameters.ARGON2_id
|
||||
|
||||
/** Includes all parameters needed to support Argon2 for Smolban. Includes all
|
||||
* standard algorithm parameters, along with additional information such as
|
||||
* the number of bytes that Smolban uses to produce hashes.
|
||||
*
|
||||
* @param algorithmVersion
|
||||
* The algorithm version used to hash bytes.
|
||||
* @param algorithmType
|
||||
* The Argon2 variant used to hash bytes.
|
||||
* @param iterations
|
||||
* Number of algorithm iterations.
|
||||
* @param parallelism
|
||||
* Amount of algorithm parallelism.
|
||||
* @param memoryInKb
|
||||
* Memory, in KB, used by the algorithm.
|
||||
* @param saltLengthInBytes
|
||||
* Number of bytes Smolban uses for salts.
|
||||
* @param hashLengthInBytes
|
||||
* Number of bytes Smolban uses for output hashes.
|
||||
*/
|
||||
case class Config(
|
||||
algorithmVersion: Int,
|
||||
algorithmType: Int,
|
||||
iterations: Int,
|
||||
parallelism: Int,
|
||||
memoryInKb: Int,
|
||||
saltLengthInBytes: Int,
|
||||
hashLengthInBytes: Int
|
||||
)
|
||||
|
||||
/** @return
|
||||
* [[Argon2.Params]] with default settings. Suitable for most cases.
|
||||
*/
|
||||
def defaultConfig(): Config =
|
||||
Config(
|
||||
algorithmVersion = Defaults.AlgorithmVersion,
|
||||
algorithmType = Defaults.AlgorithmType,
|
||||
iterations = Defaults.Iterations,
|
||||
parallelism = Defaults.Parallelism,
|
||||
memoryInKb = Defaults.MemoryInKB,
|
||||
saltLengthInBytes = Defaults.SaltLengthInBytes,
|
||||
hashLengthInBytes = Defaults.HashLengthInBytes
|
||||
)
|
||||
|
||||
object Defaults:
|
||||
|
||||
val AlgorithmVersion: Int = Argon2Parameters.ARGON2_VERSION_13
|
||||
val AlgorithmType: Int = Argon2Parameters.ARGON2_id
|
||||
val Iterations: Int = 3
|
||||
val Parallelism: Int = 2
|
||||
val SaltLengthInBytes: Int = 16
|
||||
val MemoryInKB: Int = 1024
|
||||
val HashLengthInBytes: Int = 32
|
||||
val KeyLengthInBytes: Int = 32
|
||||
|
||||
end Defaults
|
||||
|
||||
end Argon2
|
||||
145
modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala
Normal file
145
modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import java.util.Base64
|
||||
import scala.util.Try
|
||||
|
||||
/** Represents an Argon2 hash packed with the parameters that produced it.
|
||||
*
|
||||
* @param algorithmVersion
|
||||
* The Argon2 algorithm version.
|
||||
* @param algorithmType
|
||||
* The Argon2 type.
|
||||
* @param iterations
|
||||
* The Argon2 number of iterations.
|
||||
* @param parallelism
|
||||
* The Argon2 parallelism factor.
|
||||
* @param memoryInKb
|
||||
* The Argon2 memory, expressed in KB.
|
||||
* @param salt
|
||||
* The unencoded salt.
|
||||
* @param hash
|
||||
* The unencoded hash.
|
||||
*/
|
||||
final class Argon2Hash(
|
||||
val algorithmVersion: Int,
|
||||
val algorithmType: Int,
|
||||
val iterations: Int,
|
||||
val parallelism: Int,
|
||||
val memoryInKb: Int,
|
||||
val salt: Array[Byte],
|
||||
val hash: Array[Byte]
|
||||
):
|
||||
|
||||
/** Encode this hash as a '$' delimited string that includes all parameters.
|
||||
* This string can be parsed by using the `decode` function.
|
||||
*
|
||||
* @return
|
||||
* The encoded hash string.
|
||||
*/
|
||||
def encode(): String =
|
||||
s"v=$algorithmVersion$$t=$algorithmType$$i=$iterations$$p=$parallelism$$m=$memoryInKb}$$${encodedSalt()}$$${encodedHash()}"
|
||||
|
||||
private def encodedSalt(): String =
|
||||
Base64.getEncoder().encodeToString(salt)
|
||||
|
||||
private def encodedHash(): String =
|
||||
Base64.getEncoder().encodeToString(hash)
|
||||
|
||||
object Argon2Hash:
|
||||
|
||||
/** Decode a string produced by the [[Argon2Hash]] encode function.
|
||||
*
|
||||
* @param input
|
||||
* The encoded string.
|
||||
* @return
|
||||
* The parsed hash, or `None` if the input is not a valid hash.
|
||||
*/
|
||||
def decode(input: String): Option[Argon2Hash] =
|
||||
val parts = input.split("\\$")
|
||||
if parts.length != 7 then None
|
||||
else
|
||||
for
|
||||
v <- parseInt(parts(0))
|
||||
t <- parseInt(parts(1))
|
||||
i <- parseInt(parts(2))
|
||||
p <- parseInt(parts(3))
|
||||
m <- parseInt(parts(4))
|
||||
s <- parseBytes(parts(5))
|
||||
h <- parseBytes(parts(6))
|
||||
out <- new Builder()
|
||||
.withAlgorithmVersion(v)
|
||||
.withAlgorithmType(t)
|
||||
.withIterations(i)
|
||||
.withParallelism(p)
|
||||
.withMemoryInKb(m)
|
||||
.withSalt(s)
|
||||
.withHash(h)
|
||||
.build()
|
||||
yield out
|
||||
|
||||
private def parseInt(input: String): Option[Int] =
|
||||
val parts = input.split("\\=")
|
||||
if parts.length != 2 then None
|
||||
else parts(1).toIntOption
|
||||
|
||||
private def parseBytes(input: String): Option[Array[Byte]] =
|
||||
Try(Base64.getDecoder().decode(input)).toOption
|
||||
|
||||
private class Builder(
|
||||
var algorithmVersion: Option[Int] = None,
|
||||
var algorithmType: Option[Int] = None,
|
||||
var iterations: Option[Int] = None,
|
||||
var parallelism: Option[Int] = None,
|
||||
var memoryInKb: Option[Int] = None,
|
||||
var salt: Option[Array[Byte]] = None,
|
||||
var hash: Option[Array[Byte]] = None
|
||||
):
|
||||
|
||||
def withAlgorithmVersion(input: Int): Builder =
|
||||
algorithmVersion = Some(input)
|
||||
this
|
||||
|
||||
def withAlgorithmType(input: Int): Builder =
|
||||
algorithmType = Some(input)
|
||||
this
|
||||
|
||||
def withIterations(input: Int): Builder =
|
||||
iterations = Some(input)
|
||||
this
|
||||
|
||||
def withParallelism(input: Int): Builder =
|
||||
parallelism = Some(input)
|
||||
this
|
||||
|
||||
def withMemoryInKb(input: Int): Builder =
|
||||
memoryInKb = Some(input)
|
||||
this
|
||||
|
||||
def withSalt(input: Array[Byte]): Builder =
|
||||
salt = Some(input)
|
||||
this
|
||||
|
||||
def withHash(input: Array[Byte]): Builder =
|
||||
hash = Some(input)
|
||||
this
|
||||
|
||||
def build(): Option[Argon2Hash] =
|
||||
for
|
||||
v <- algorithmVersion
|
||||
t <- algorithmType
|
||||
i <- iterations
|
||||
p <- parallelism
|
||||
m <- memoryInKb
|
||||
s <- salt
|
||||
h <- hash
|
||||
yield new Argon2Hash(
|
||||
algorithmVersion = v,
|
||||
algorithmType = t,
|
||||
iterations = i,
|
||||
parallelism = p,
|
||||
memoryInKb = m,
|
||||
salt = s,
|
||||
hash = h
|
||||
)
|
||||
|
||||
end Argon2Hash
|
||||
107
modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala
Normal file
107
modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.Applicative
|
||||
import cats.effect.Sync
|
||||
import cats.syntax.all.*
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.Base64
|
||||
|
||||
/** Secret intended for use with [[Argon2]]. Random array of bytes.
|
||||
*
|
||||
* @param bytes
|
||||
* The underlying bytes that represent the secret.
|
||||
*/
|
||||
final class Argon2Secret(val bytes: Array[Byte]):
|
||||
|
||||
override def equals(obj: Any): Boolean =
|
||||
obj match
|
||||
case other: Argon2Secret => bytes.sameElements(other.bytes)
|
||||
case _ => false
|
||||
|
||||
override def hashCode(): Int = bytes.hashCode()
|
||||
|
||||
/** @return
|
||||
* The value of this secret, encoded using Base64.
|
||||
*/
|
||||
def encodeBase64(): String = Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
/** Write this secret to a local file.
|
||||
*
|
||||
* @param path
|
||||
* The path to write. Any existing file will be truncated.
|
||||
* @return
|
||||
* An effect which writes the secret.
|
||||
*/
|
||||
def writeToLocalFile[F[_]: Sync](path: Path): F[Unit] =
|
||||
Argon2Secret.writeToLocalFile[F](path, this)
|
||||
|
||||
object Argon2Secret:
|
||||
|
||||
/** Decode the given Base64 string as an [[Argon2Secret]].
|
||||
*
|
||||
* @param input
|
||||
* The string that houses encoded bytes.
|
||||
* @return
|
||||
* The new [[Argon2Secret]] instance, backed by the input bytes.
|
||||
*/
|
||||
def decode(input: String): Argon2Secret =
|
||||
new Argon2Secret(Base64.getDecoder().decode(input))
|
||||
|
||||
/** Load a secret from a local file. The following assumptions are made:
|
||||
*
|
||||
* - The file contains nothing but the base64-encoded bytes.
|
||||
* - The file has no newlines.
|
||||
*
|
||||
* @param path
|
||||
* The path to the local file.
|
||||
* @return
|
||||
* The loaded secret.
|
||||
*/
|
||||
def loadFromLocalFile[F[_]: Sync](path: Path): F[Argon2Secret] =
|
||||
Sync[F]
|
||||
.delay(
|
||||
Files.readString(path, StandardCharsets.UTF_8)
|
||||
)
|
||||
.map(decode)
|
||||
|
||||
/** Write a secret to a local file. The resultant file will contain the
|
||||
* base64-encoded bytes of the given secret.
|
||||
*
|
||||
* @param path
|
||||
* The path for the local file. This file will be truncated if it exists.
|
||||
* @param secret
|
||||
* The secret to write.
|
||||
* @return
|
||||
* An effect which writes the secret.
|
||||
*/
|
||||
def writeToLocalFile[F[_]: Sync](
|
||||
path: Path,
|
||||
secret: Argon2Secret
|
||||
): F[Unit] =
|
||||
Sync[F].delay(
|
||||
Files.writeString(
|
||||
path,
|
||||
secret.encodeBase64(),
|
||||
StandardOpenOption.TRUNCATE_EXISTING
|
||||
)
|
||||
)
|
||||
|
||||
/** Generate a new random secret.
|
||||
*
|
||||
* @param size
|
||||
* The number of bytes in the secret value.
|
||||
* @param randomByteProvider
|
||||
* The [[RandomByteProvider]].
|
||||
* @return
|
||||
* The new [[Argon2Secret]].
|
||||
*/
|
||||
def generate[F[_]: Applicative](
|
||||
size: Int,
|
||||
randomByteProvider: RandomByteProvider[F]
|
||||
): F[Argon2Secret] =
|
||||
randomByteProvider.generateBytes(size).map(new Argon2Secret(_))
|
||||
|
||||
end Argon2Secret
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.effect.Sync
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** Utility which produces random bytes. Used for cryptographic support.
|
||||
*/
|
||||
trait RandomByteProvider[F[_]]:
|
||||
/** Generate the specified number of random bytes.
|
||||
*
|
||||
* @param numberOfBytes
|
||||
* The number of bytes to generate.
|
||||
* @return
|
||||
* New array with the specified number of random bytes.
|
||||
*/
|
||||
def generateBytes(numberOfBytes: Int): F[Array[Byte]]
|
||||
|
||||
object RandomByteProvider:
|
||||
|
||||
/** Default random number generator.
|
||||
*/
|
||||
lazy val RNG: SecureRandom = new SecureRandom
|
||||
|
||||
/** New [[RandomByteProvider]] that uses the default (secure) random number
|
||||
* generator to produce bytes.
|
||||
*
|
||||
* @return
|
||||
* The new provider instance.
|
||||
*/
|
||||
def secureRandom[F[_]: Sync]: RandomByteProvider[F] =
|
||||
new SecureRandomProvider[F](RNG)
|
||||
|
||||
/** New [[RandomByteProvider]] that uses the given `SecureRandom` instance to
|
||||
* produce bytes.
|
||||
*
|
||||
* @param random
|
||||
* The `SecureRandom` instance.
|
||||
* @return
|
||||
* The new provider.
|
||||
*/
|
||||
def secureRandom[F[_]: Sync](random: SecureRandom): RandomByteProvider[F] =
|
||||
new SecureRandomProvider[F](random)
|
||||
|
||||
final class SecureRandomProvider[F[_]: Sync](
|
||||
val random: SecureRandom
|
||||
) extends RandomByteProvider[F]:
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def generateBytes(numberOfBytes: Int): F[Array[Byte]] =
|
||||
Sync[F].delay {
|
||||
val b = new Array[Byte](numberOfBytes)
|
||||
random.nextBytes(b)
|
||||
b
|
||||
}
|
||||
|
||||
end RandomByteProvider
|
||||
Loading…
Add table
Reference in a new issue