Password Breach Checker — Kotlin source
Check if a password has appeared in known data breaches using k-anonymity. Only the first 5 characters of the SHA-1 hash are sent - your full password never leaves your browser.
This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Password breach lookup via Have I Been Pwned's Pwned Passwords API, using
// k-anonymity: only the first 5 characters of the SHA-1 hash ever leave the
// process.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/breach-checker.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// inputs -> same hash split, candidate parsing and verdicts.
//
// API: https://api.pwnedpasswords.com/range/{PREFIX} — free, no key. Returns
// one "SUFFIX:COUNT" line per hash sharing the prefix (~800 candidates).
// The suffix match happens locally.
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.security.MessageDigest
/** One k-anonymity lookup outcome. */
data class BreachResult(
/** True when the exact hash suffix appeared in the API's candidate list. */
val breached: Boolean,
/** How many times the password appeared in breaches. 0 = never seen. -1 = lookup failed. */
val count: Int,
/** First 5 chars of the uppercase SHA-1 hex — the only part sent to the API. */
val hashPrefix: String,
/** Remaining 35 chars of the hash, matched locally against the response. */
val hashSuffix: String,
/** Set when the lookup failed (network error or non-200 response). */
val error: String? = null,
/** How many candidate suffixes the API returned (all checked locally). */
val candidates: Int? = null,
)
const val HIBP_RANGE_URL = "https://api.pwnedpasswords.com/range/"
/** Uppercase hex of a byte array (the format HIBP expects). */
private fun ByteArray.toUpperHex(): String =
joinToString("") { "%02X".format(it) }
/** SHA-1 of a UTF-8 string as uppercase hex. */
fun sha1Hex(input: String): String =
MessageDigest.getInstance("SHA-1").digest(input.toByteArray(Charsets.UTF_8)).toUpperHex()
/** Split a 40-char uppercase hash into the 5-char k-anonymity prefix and the 35-char suffix. */
data class HashSplit(val prefix: String, val suffix: String)
fun splitHash(hash: String): HashSplit {
val h = hash.uppercase()
return HashSplit(h.take(5), h.substring(5))
}
/**
* Search an HIBP range response for a hash suffix and return its breach count.
* Never throws; returns 0 when the suffix is not present. Tolerates LF and
* CRLF line endings, blank lines, and leading/trailing whitespace per line.
*/
fun parseRangeBody(body: String, suffix: String): Int {
if (suffix.isEmpty()) return 0
for (line in body.split(Regex("\\r?\\n"))) {
val idx = line.indexOf(':')
if (idx == -1) continue
if (line.take(idx).trim() == suffix) {
val count = line.substring(idx + 1).trim().toIntOrNull() ?: return 0
return if (count < 0) 0 else count
}
}
return 0
}
/** Count the "SUFFIX:COUNT" candidate lines in a range response. */
fun countCandidates(body: String): Int =
body.split(Regex("\\r?\\n")).count { line ->
val idx = line.indexOf(':')
idx != -1 && line.take(idx).trim().isNotEmpty()
}
/**
* The transport the lookup runs over — injectable for deterministic tests,
* exactly like the TS reference's `fetchFn` parameter.
*/
fun interface Fetcher {
/** Fetch the URL and return (status, body), or null when the request itself failed. */
fun get(url: String): Pair<Int, String>?
}
/** Default transport: the JVM's built-in HttpClient, UTF-8 body. */
private fun defaultFetcher(): Fetcher {
val client = HttpClient.newHttpClient()
return Fetcher { url ->
try {
val request = HttpRequest.newBuilder(URI.create(url)).GET().build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
response.statusCode() to response.body()
} catch (e: Exception) {
null
}
}
}
/**
* Check a password against the Pwned Passwords corpus using k-anonymity.
* Only `hashPrefix` is sent over the network; the suffix match is local.
* Never throws — a failed lookup returns { breached: false, count: -1, error }.
*/
fun checkBreach(password: String, fetcher: Fetcher = defaultFetcher()): BreachResult {
val (prefix, suffix) = splitHash(sha1Hex(password))
val base = BreachResult(breached = false, count = -1, hashPrefix = prefix, hashSuffix = suffix)
val response = try {
fetcher.get("$HIBP_RANGE_URL$prefix")
} catch (e: Exception) {
return base.copy(error = e.message ?: "Network request failed")
} ?: return base.copy(error = "Network request failed")
val (status, body) = response
if (status < 200 || status >= 300) {
return base.copy(error = "The breach database returned HTTP $status")
}
val count = parseRangeBody(body, suffix)
return base.copy(
breached = count > 0,
count = count,
candidates = countCandidates(body),
)
}
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 →