Skip to content

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 →