Skip to content

Password Breach Checker — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// breach-checker — password breach lookup via Have I Been Pwned's Pwned
// Passwords API, k-anonymity style.
//
// Language: Swift 5.9+ (Foundation + CryptoKit)
// Ported from src/lib/breach-checker.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Only the first 5 characters of the SHA-1 hash ever leave the machine: the
// API is queried for the ~800 candidate suffixes sharing that prefix, and the
// suffix match happens locally. The TS reference is async because fetch is;
// URLSession is callback-based, so this port wraps the same flow in Swift
// concurrency. (SHA-1 because that is what the HIBP corpus is indexed by —
// a lookup key, not a security primitive.)

import Foundation
import CryptoKit

// MARK: - Types

/// One breach lookup's outcome.
struct BreachResult: Equatable {
    /// True when the exact hash suffix appeared in the API's candidate list.
    let breached: Bool
    /// How many times the password appeared in breaches. 0 = never seen. -1 = lookup failed.
    let count: Int
    /// First 5 chars of the uppercase SHA-1 hex — the only part sent to the API.
    let hashPrefix: String
    /// Remaining 35 chars of the hash, matched locally against the response.
    let hashSuffix: String
    /// Set when the lookup failed (network error or non-200 response).
    var error: String?
    /// How many candidate suffixes the API returned (all checked locally).
    var candidates: Int?
}

let HIBP_RANGE_URL = "https://api.pwnedpasswords.com/range/"

// MARK: - Hashing

/// SHA-1 of a UTF-8 string as uppercase hex (the format HIBP expects).
/// (Integrity/lookup only — marked `Insecure` in CryptoKit for a reason.)
func sha1Hex(_ input: String) -> String {
    let digest = Insecure.SHA1.hash(data: Data(input.utf8))
    return digest.map { String(format: "%02X", $0) }.joined()
}

/// Split a 40-char uppercase hash into the 5-char k-anonymity prefix and the
/// 35-char suffix.
func splitHash(_ hash: String) -> (prefix: String, suffix: String) {
    let h = hash.uppercased()
    return (String(h.prefix(5)), String(h.dropFirst(5)))
}

// MARK: - Range-response parsing

/// 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 per-line whitespace.
func parseRangeBody(_ body: String, suffix: String) -> Int {
    if suffix.isEmpty { return 0 }
    for line in body.split(whereSeparator: \.isNewline) {
        guard let colon = line.firstIndex(of: ":") else { continue }
        let candidate = line[..<colon].trimmingCharacters(in: .whitespaces)
        if candidate == suffix {
            let countText = line[line.index(after: colon)...]
                .trimmingCharacters(in: .whitespaces)
            let count = Int(countText, radix: 10) ?? 0
            return max(count, 0)
        }
    }
    return 0
}

/// Count the "SUFFIX:COUNT" candidate lines in a range response.
func countCandidates(_ body: String) -> Int {
    var n = 0
    for line in body.split(whereSeparator: \.isNewline) {
        if let colon = line.firstIndex(of: ":"),
            !line[..<colon].trimmingCharacters(in: .whitespaces).isEmpty
        {
            n += 1
        }
    }
    return n
}

// MARK: - Lookup

/// 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).
func checkBreach(password: String) async -> BreachResult {
    let (prefix, suffix) = splitHash(sha1Hex(password))
    let base = BreachResult(
        breached: false, count: -1, hashPrefix: prefix, hashSuffix: suffix,
        error: nil, candidates: nil)

    guard let url = URL(string: HIBP_RANGE_URL + prefix) else {
        var failed = base
        failed.error = "Network request failed"
        return failed
    }
    do {
        // The plain URLSession request injects no add-data-vector padding:
        // a 5-hex-char prefix identifies ~800 hashes, never one password.
        let (data, response) = try await URLSession.shared.data(from: url)
        guard let http = response as? HTTPURLResponse else {
            var failed = base
            failed.error = "Network request failed"
            return failed
        }
        guard (200..<300).contains(http.statusCode) else {
            var failed = base
            failed.error = "The breach database returned HTTP \(http.statusCode)"
            return failed
        }
        let body = String(decoding: data, as: UTF8.self)
        let count = parseRangeBody(body, suffix: suffix)
        return BreachResult(
            breached: count > 0, count: count, hashPrefix: prefix, hashSuffix: suffix,
            error: nil, candidates: countCandidates(body))
    } catch {
        var failed = base
        failed.error = error.localizedDescription
        return failed
    }
}

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 →