Argon2 Hash & Verify — Kotlin source
Hash passwords with Argon2id — the winner of the Password Hashing Competition. Configure memory, iterations, and parallelism. WASM-powered, client-side.
This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Argon2id password hashing - PHC parse/encode/verify logic in pure Kotlin;
// the KDF itself delegated to Bouncy Castle's Argon2BytesGenerator.
//
// Language: Kotlin 1.9+ (JVM).
// Ported from src/lib/argon2.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: the PHC
// parser, validator, encoder and verifier share its exact semantics.
//
// KDF LOADING: the TS build drives the reference C library compiled to WASM
// (argon2-browser). The JVM has no Argon2 in its standard library either, so
// the same reference algorithm arrives via Bouncy Castle — add
// `org.bouncycastle:bcprov-jdk18on` to the classpath. The KDF is injected as
// an Argon2Kdf function so tests can stay deterministic (the Ruby sibling
// snippet shells out to the argon2(1) CLI for the same reason).
//
// PHC string format (what `encoded` holds - the string you store in a DB):
// $argon2id$v=19$m=65536,t=3,p=1$<b64 salt>$<b64 digest>
// Salt and digest are unpadded standard Base64.
import java.security.SecureRandom
import java.util.Base64
/** Defaults follow the OWASP-recommended Argon2id profile (64 MiB, 3 passes). */
data class Argon2Defaults(
val memory: Int = 65_536,
val iterations: Int = 3,
val parallelism: Int = 1,
val hashLength: Int = 32,
)
val ARGON2_DEFAULTS = Argon2Defaults()
/** Random salt size in bytes (128 bits - the PHC recommendation). */
const val SALT_BYTES = 16
/** The Argon2 variant names the PHC format knows. */
val TYPE_NAMES = setOf("argon2d", "argon2i", "argon2id")
/** Hashing parameters; every field has an OWASP-profile default. */
data class Argon2Options(
/** Memory cost in KiB (default 65536 = 64 MiB). Must be >= 1024. */
val memory: Int? = null,
/** Time cost - passes over memory (default 3). Must be >= 1. */
val iterations: Int? = null,
/** Parallelism - lanes (default 1). Must be >= 1. */
val parallelism: Int? = null,
/** Digest length in bytes (default 32). Must be 16..64. */
val hashLength: Int? = null,
/** Explicit salt bytes; a random 16-byte salt is generated when null. */
val salt: ByteArray? = null,
)
/** What argon2Hash returns. */
data class Argon2Result(
/** Raw digest, lowercase hex (hashLength bytes). */
val hash: String,
/** Self-contained PHC string - store this, verify against it. */
val encoded: String,
/** Salt used, lowercase hex (16 bytes unless an explicit salt was given). */
val salt: String,
)
/** Parameters extracted from a PHC string (parseArgon2's return type). */
data class Argon2Params(
val type: String,
val version: Int,
val memory: Int,
val iterations: Int,
val parallelism: Int,
/** Salt, decoded from the embedded Base64 into lowercase hex. */
val salt: String,
/** Digest, decoded from the embedded Base64 into lowercase hex ('' if absent). */
val hash: String,
)
/** Lowercase hex of a byte array. */
private fun bytesToHex(bytes: ByteArray): String =
bytes.joinToString("") { "%02x".format(it) }
/** Unpadded standard Base64 (the PHC encoding) of a byte array. */
private fun bytesToPhcBase64(bytes: ByteArray): String =
Base64.getEncoder().withoutPadding().encodeToString(bytes)
/**
* Unpadded standard Base64 (the PHC encoding) -> bytes. Throws on any
* non-alphabet character or an impossible length (1 mod 4). The length
* checks mirror the TS byte counting; decoding is left to the platform.
*/
fun phcBase64ToBytes(b64: String): ByteArray {
if (b64.isEmpty()) throw IllegalArgumentException("Invalid Argon2 string: empty Base64 field")
if (!Regex("^[A-Za-z0-9+/]+$").matches(b64)) {
throw IllegalArgumentException("Invalid Argon2 string: non-Base64 characters")
}
if (b64.length % 4 == 1) throw IllegalArgumentException("Invalid Argon2 string: impossible Base64 length")
return try {
Base64.getDecoder().decode(b64) // accepts unpadded input of a valid length
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException("Invalid Argon2 string: non-Base64 characters")
}
}
private val PHC_RE =
Regex("^\\\$(argon2(?:d|i|id))\\\$v=(\\d+)\\\$m=(\\d+),t=(\\d+),p=(\\d+)\\\$([A-Za-z0-9+/]+)(?:\\\$([A-Za-z0-9+/]+))?$")
/**
* Parse a PHC-format Argon2 string (`$argon2id$v=19$m=65536,t=3,p=1$salt$hash`)
* into its typed parameters. Accepts argon2d / argon2i / argon2id. The digest
* segment is optional (some encoders omit it); salt and hash are returned as
* lowercase hex. Throws on any malformed input.
*/
fun parseArgon2(encoded: String): Argon2Params {
val m = PHC_RE.find(encoded.trim())
?: throw IllegalArgumentException(
"Invalid Argon2 string: expected \$argon2id\$v=19\$m=…,t=…,p=…\$salt\$hash"
)
return Argon2Params(
type = m.groupValues[1],
version = m.groupValues[2].toInt(),
memory = m.groupValues[3].toInt(),
iterations = m.groupValues[4].toInt(),
parallelism = m.groupValues[5].toInt(),
salt = bytesToHex(phcBase64ToBytes(m.groupValues[6])),
hash = if (m.groupValues[7].isNotEmpty()) bytesToHex(phcBase64ToBytes(m.groupValues[7])) else "",
)
}
/** Validate + normalise hashing parameters, throwing with a clear message. */
private fun normalizeOptions(options: Argon2Options): Argon2Defaults {
val memory = options.memory ?: ARGON2_DEFAULTS.memory
val iterations = options.iterations ?: ARGON2_DEFAULTS.iterations
val parallelism = options.parallelism ?: ARGON2_DEFAULTS.parallelism
val hashLength = options.hashLength ?: ARGON2_DEFAULTS.hashLength
if (memory < 1024) throw IllegalArgumentException("Memory must be at least 1024 KiB")
if (iterations < 1) throw IllegalArgumentException("Iterations must be at least 1")
if (parallelism < 1) throw IllegalArgumentException("Parallelism must be at least 1")
if (hashLength < 16 || hashLength > 64) {
throw IllegalArgumentException("Hash length must be between 16 and 64 bytes")
}
return Argon2Defaults(memory, iterations, parallelism, hashLength)
}
/** The KDF as an injectable function - the reference Argon2id core. */
fun interface Argon2Kdf {
/** Derive `hashLength` bytes from password + salt with the given costs. */
fun derive(
password: ByteArray,
salt: ByteArray,
memoryKib: Int,
iterations: Int,
parallelism: Int,
hashLength: Int,
): ByteArray
}
/**
* The Bouncy-Castle-backed KDF (requires org.bouncycastle:bcprov-jdk18on):
* the same reference Argon2 v1.3 the TS build loads as WASM.
*/
fun bouncyCastleKdf(): Argon2Kdf = Argon2Kdf { password, salt, memoryKib, iterations, parallelism, hashLength ->
val params = org.bouncycastle.crypto.params.Argon2Parameters.Builder(
org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_id
)
.withVersion(org.bouncycastle.crypto.params.Argon2Parameters.ARGON2_VERSION_13)
.withIterations(iterations)
.withMemoryAsKB(memoryKib)
.withParallelism(parallelism)
.withSalt(salt)
.build()
val generator = org.bouncycastle.crypto.generators.Argon2BytesGenerator()
generator.init(params)
val out = ByteArray(hashLength)
generator.generateBytes(password, out)
out
}
private fun hexToBytes(hex: String): ByteArray =
ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
/**
* Hash a password with Argon2id (hybrid of Argon2i's side-channel resistance
* and Argon2d's GPU resistance - the Password Hashing Competition winner and
* the recommended mode for password storage). Returns the digest (hex), the
* salt used (hex), and the self-contained PHC string. A fresh random 16-byte
* salt is generated per call unless `options.salt` is given.
*/
fun argon2Hash(
password: String,
options: Argon2Options = Argon2Options(),
kdf: Argon2Kdf = bouncyCastleKdf(),
): Argon2Result {
val (memory, iterations, parallelism, hashLength) = normalizeOptions(options)
val salt = options.salt ?: ByteArray(SALT_BYTES).also(SecureRandom()::nextBytes)
val digest = kdf.derive(
password.toByteArray(Charsets.UTF_8), salt, memory, iterations, parallelism, hashLength,
)
val encoded =
"\$argon2id\$v=19\$m=$memory,t=$iterations,p=$parallelism\$" +
"${bytesToPhcBase64(salt)}\$${bytesToPhcBase64(digest)}"
return Argon2Result(hash = bytesToHex(digest), encoded = encoded, salt = bytesToHex(salt))
}
/**
* Verify a password against a PHC-format encoded hash (as produced by
* argon2Hash). Returns true on match, false on mismatch; throws only on a
* malformed encoded string. Any Argon2 type (d/i/id) is accepted - the type
* is read from the string itself, and the recomputed digest is compared in
* constant time (what the C library's argon2_verify does internally).
*/
fun argon2Verify(
encoded: String,
password: String,
kdf: Argon2Kdf = bouncyCastleKdf(),
): Boolean {
val params = parseArgon2(encoded) // validate format up front
val salt = hexToBytes(params.salt)
val hashLength = if (params.hash.isEmpty()) ARGON2_DEFAULTS.hashLength else params.hash.length / 2
val recomputed = kdf.derive(
password.toByteArray(Charsets.UTF_8), salt,
params.memory, params.iterations, params.parallelism, hashLength,
)
if (params.hash.isEmpty()) {
// No embedded digest to compare against: rebuild the full PHC string.
val rebuilt =
"\$argon2${params.type.removePrefix("argon2")}\$v=${params.version}\$" +
"m=${params.memory},t=${params.iterations},p=${params.parallelism}\$" +
"${bytesToPhcBase64(salt)}\$${bytesToPhcBase64(recomputed)}"
return rebuilt == encoded.trim()
}
val expected = hexToBytes(params.hash)
var diff = 0
for (i in recomputed.indices) diff = diff or (recomputed[i].toInt() xor expected[i].toInt())
return diff == 0
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →