This commit is contained in:
Pat Garrity 2026-01-30 17:02:57 -06:00
parent 5d85d6e6ad
commit 447ebfd255
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
4 changed files with 467 additions and 0 deletions

View 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

View 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

View 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

View file

@ -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