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 →