Created
November 14, 2025 15:48
-
-
Save soujiro32167/6ad9bd85eea50e9586f03b52283b19b3 to your computer and use it in GitHub Desktop.
Argon2 password hashing with bouncy castle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //> using scala 3.7 | |
| //> using dep "org.bouncycastle:bcprov-jdk18on:1.82" | |
| import java.security.SecureRandom | |
| import org.bouncycastle.crypto.params.Argon2Parameters | |
| import org.bouncycastle.crypto.generators.Argon2BytesGenerator | |
| import java.util.Base64 | |
| import scala.util.Try | |
| import BouncyCastleArgon2PasswordHasher.Config | |
| case class StoredArgon2Hash (`type`: Int, version: Int, memory: Int, iterations: Int, parallelism: Int, salt: Array[Byte], hash: Array[Byte]){ | |
| def format: String = { | |
| val stringType = `type` match { | |
| case Argon2Parameters.ARGON2_d => "argon2d" | |
| case Argon2Parameters.ARGON2_i => "argon2i" | |
| case Argon2Parameters.ARGON2_id => "argon2id" | |
| case _ => "unknown" | |
| } | |
| val versionString = s"v=$version" | |
| val mtp = s"m=$memory,t=$iterations,p=$parallelism" | |
| val saltBase64 = Base64.getEncoder.encodeToString(salt) | |
| val digestBase64 = Base64.getEncoder.encodeToString(hash) | |
| "$" + List(stringType, versionString, mtp, saltBase64, digestBase64).mkString("$") | |
| } | |
| def toParameters: Argon2Parameters.Builder = { | |
| new Argon2Parameters.Builder(`type`) | |
| .withVersion(version) | |
| .withMemoryAsKB(memory) | |
| .withIterations(iterations) | |
| .withParallelism(parallelism) | |
| .withSalt(salt) | |
| } | |
| } | |
| object StoredArgon2Hash: | |
| def fromParameters(params: Argon2Parameters, hash: Array[Byte]): StoredArgon2Hash = | |
| StoredArgon2Hash( | |
| params.getType, | |
| params.getVersion, | |
| params.getMemory, | |
| params.getIterations, | |
| params.getLanes, | |
| params.getSalt, | |
| hash | |
| ) | |
| def parse(stored: String): Either[String, StoredArgon2Hash] = { | |
| stored.split("\\$") match { | |
| case Array(_, stringType, versionPart, mtpPart, saltBase64, hashBase64) => | |
| for { | |
| intType <- stringType match { | |
| case "argon2d" => Right(Argon2Parameters.ARGON2_d) | |
| case "argon2i" => Right(Argon2Parameters.ARGON2_i) | |
| case "argon2id" => Right(Argon2Parameters.ARGON2_id) | |
| case _ => Left(s"Unknown Argon2 type: $stringType") | |
| } | |
| version <- Try(versionPart.stripPrefix("v=").toInt).toEither.left.map(_ => s"Invalid version value: $versionPart") | |
| (memory, iterations, parallelism) <- mtpPart.split(",") match { | |
| case Array(mPart, tPart, pPart) => | |
| for { | |
| m <- Try(mPart.stripPrefix("m=").toInt).toEither.left.map(_ => s"Invalid memory value: $mPart") | |
| t <- Try(tPart.stripPrefix("t=").toInt).toEither.left.map(_ => s"Invalid iterations value: $tPart") | |
| p <- Try(pPart.stripPrefix("p=").toInt).toEither.left.map(_ => s"Invalid parallelism value: $pPart") | |
| } yield (m, t, p) | |
| case _ => Left(s"Invalid memory/iterations/parallelism part: $mtpPart") | |
| } | |
| salt = Base64.getDecoder.decode(saltBase64) | |
| hash = Base64.getDecoder.decode(hashBase64) | |
| result = StoredArgon2Hash( | |
| intType, | |
| version, | |
| memory, | |
| iterations, | |
| parallelism, | |
| salt, | |
| hash | |
| ) | |
| } yield result | |
| case _ => Left(s"Invalid stored Argon2 hash format: $stored") | |
| } | |
| } | |
| trait Argon2PasswordHasher { | |
| def hashPassword(password: String): StoredArgon2Hash | |
| def verifyPassword(password: String, stored: StoredArgon2Hash): Boolean | |
| } | |
| case class BouncyCastleArgon2PasswordHasher(config: Config) extends Argon2PasswordHasher { | |
| private def generateSalt(length: Int): Array[Byte] = { | |
| val salt = new Array[Byte](length) | |
| val random = SecureRandom.getInstanceStrong() | |
| random.nextBytes(salt) | |
| salt | |
| } | |
| override def hashPassword(password: String): StoredArgon2Hash = { | |
| import config.* | |
| val salt = generateSalt(config.saltLength) | |
| val params = new Argon2Parameters.Builder(`type`) | |
| .withVersion(version) | |
| .withIterations(iterations) | |
| .withMemoryAsKB(memory) | |
| .withParallelism(parallelism) | |
| .withSalt(salt) | |
| .withSecret(pepper) | |
| .build() | |
| // hash a password | |
| val generator = new Argon2BytesGenerator() | |
| generator.init(params) | |
| val hash = new Array[Byte](hashLength) | |
| generator.generateBytes(password.getBytes, hash) | |
| StoredArgon2Hash.fromParameters(params, hash) | |
| } | |
| override def verifyPassword(password: String, stored: StoredArgon2Hash): Boolean = { | |
| val verifier = new Argon2BytesGenerator() | |
| verifier.init(stored.toParameters.withSecret(config.pepper).build()) | |
| val testHash = new Array[Byte](config.hashLength) | |
| verifier.generateBytes(password.getBytes, testHash) | |
| stored.hash.sameElements(testHash) | |
| } | |
| } | |
| object BouncyCastleArgon2PasswordHasher { | |
| case class Config(pepperBase64: String, iterations: Int, memory: Int, parallelism: Int, hashLength: Int, `type`: Int, version: Int, saltLength: Int) { | |
| val pepper: Array[Byte] = Base64.getDecoder.decode(pepperBase64) | |
| } | |
| object Config { | |
| // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html | |
| def default(pepperBase64: String): Config = Config( | |
| pepperBase64 = pepperBase64, | |
| iterations = 2, | |
| memory = 65536, | |
| parallelism = 1, | |
| hashLength = 32, | |
| `type` = Argon2Parameters.ARGON2_id, | |
| version = Argon2Parameters.ARGON2_VERSION_13, | |
| saltLength = 16 | |
| ) | |
| } | |
| } | |
| @main def run = { | |
| val password = "some properly L0ng P@ssw0rd" | |
| val pepper = Base64.getEncoder().encodeToString("le poivre".getBytes()) | |
| val hasher = BouncyCastleArgon2PasswordHasher( | |
| BouncyCastleArgon2PasswordHasher.Config.default(pepper) | |
| ) | |
| val stored = hasher.hashPassword(password) | |
| println(s"Stored hash: ${stored.format}") | |
| val restored = StoredArgon2Hash.parse(stored.format).getOrElse(throw new Exception("Failed to parse stored hash")) | |
| val verified = hasher.verifyPassword(password, restored) | |
| assert(verified, "Password verification failed") | |
| assert(restored.format == stored.format, "Hash was not restored correctly") | |
| println("Password verified successfully") | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment