Skip to content

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 →