From 447ebfd255c8092978ab5c0ed82f3f9232c9b27b Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Fri, 30 Jan 2026 17:02:57 -0600 Subject: [PATCH] argon2 --- .../main/scala/gs/smolban/auth/Argon2.scala | 158 ++++++++++++++++++ .../scala/gs/smolban/auth/Argon2Hash.scala | 145 ++++++++++++++++ .../scala/gs/smolban/auth/Argon2Secret.scala | 107 ++++++++++++ .../gs/smolban/auth/RandomByteProvider.scala | 57 +++++++ 4 files changed, 467 insertions(+) create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/RandomByteProvider.scala diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala b/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala new file mode 100644 index 0000000..e91205e --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Argon2.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala b/modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala new file mode 100644 index 0000000..787be73 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Argon2Hash.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala b/modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala new file mode 100644 index 0000000..57105a5 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Argon2Secret.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/RandomByteProvider.scala b/modules/auth/src/main/scala/gs/smolban/auth/RandomByteProvider.scala new file mode 100644 index 0000000..f75cb98 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/RandomByteProvider.scala @@ -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