Lab note · from Canary
Check a password against Have I Been Pwned without sending it
To check a password against Have I Been Pwned without revealing it, hash it with SHA-1 on the device and send only the first five hex characters to https://api.pwnedpasswords.com/range/{prefix}. Then look for the remaining 35 characters in the list that comes back. The service learns which of 1,048,576 possible prefixes you asked about, never the password or its full hash. Canary, a macOS menu bar breach monitor, does this in HIBPClient.checkPassword. It doesn't yet send the Add-Padding: true header, which stops the response size hinting at the prefix. And its "zero the password after hashing" step wipes a copy, not the password.
Symptoms
Leaking a password to a checking service doesn't break anything, so nothing tells you it happened.
- Sending the full hash is nearly as bad as sending the password. An unsalted SHA-1 of a password anyone has used before works as a lookup key. The SHA-1 of the throwaway
P@ssw0rdis21BD12DC183F740EE76F27B78EB39C8AD972A757, and on 2026-09-28 its suffix came back from HIBP's own range endpoint with a count of 6,421,042. - A review of Canary's version found two quieter gaps. Requests go out without padding. And
PasswordHasher.sha1Hashzeroes a byte array after hashing, which reads like wiping the password but only wipes a copy of it.
Why it happens
How the range API keeps the password private
HIBP's API documentation accepts "the first 5 characters of either a SHA-1 or an NTLM hash (not case-sensitive)". It responds with "the suffix of every hash beginning with the specified prefix, followed by a count". The stated purpose is "to protect the value of the source password being searched for". The client does the final comparison, so the server can't tell which of the returned hashes, if any, was the one being checked.
The same page says a range "typically returns approximately 800 hash suffixes, although this number will differ depending on the hash prefix being searched for and will increase as more passwords are added." It has increased. On 2026-09-28, 25 randomly chosen prefixes returned between 1,910 and 2,046 suffixes each (median 1,971). Lines end in CRLF.
No key is involved: "The Pwned Passwords API is freely accessible without the need for a subscription and API key", and "There is no rate limit on the Pwned Passwords API." A user agent is required: "A missing user agent will result in an HTTP 403 response."
Response size can hint at the prefix
TLS hides the URL path but not how many bytes come back. HIBP added padding "such that anyone able to intercept encrypted responses to the API cannot reasonably determine which hash prefix was searched for by observing the response size." The header is Add-Padding: true, and "Padded entries always have a password count of 0 and can be discarded once received." The documentation says padding brings responses to "between 800 and 1,000" records. Real ranges are now past that, so in the same 25-prefix test the header added between 52 and 180 zero-count rows per response.
Zeroing a copy
Array(password.utf8) allocates a new buffer and copies the bytes into it. The defer in Canary's hasher zeroes that buffer. The String passed in is untouched, and so are the other places the plaintext lives: the @State string behind the SecureField, and, for password-manager CSV imports, an array of Strings. AddPasswordView does set password = "" after adding. That gives the property a new value; nothing in the code overwrites the old one's bytes.
The fix
Canary's hasher, from CanaryEngine/Crypto/PasswordHasher.swift:
public static func sha1Hash(of password: String) -> String {
var bytes = Array(password.utf8) // a new buffer
defer {
for i in bytes.indices { bytes[i] = 0 } // zeroes that buffer only
}
let digest = Insecure.SHA1.hash(data: bytes)
return digest.map { String(format: "%02X", $0) }.joined()
}
The range check, from CanaryEngine/API/HIBPClient.swift, trimmed, with the one line it is missing:
public func checkPassword(sha1Hash: String) async throws -> Int {
let prefix = String(sha1Hash.prefix(5))
let suffix = String(sha1Hash.dropFirst(5))
guard let url = URL(string: "\(passwordURL)/range/\(prefix)") else { throw HIBPError.invalidURL }
var request = URLRequest(url: url)
request.setValue("Canary", forHTTPHeaderField: "user-agent")
request.setValue("true", forHTTPHeaderField: "Add-Padding") // not in Canary yet
let (data, response) = try await session.data(for: request)
try validateStatus(response)
let responseString = String(data: data, encoding: .utf8) ?? ""
for line in responseString.split(separator: "\r\n") {
let parts = line.split(separator: ":")
if parts.count == 2, parts[0].uppercased() == suffix.uppercased() {
return Int(parts[1]) ?? 0
}
}
return 0
}
Why it works:
- Only
prefixis in the request. The full hash is used in exactly one place, the comparison against each returned suffix, and that happens after the response arrives. - The padding line needs no parser change. Padded rows carry a count of 0, so if a padded row ever matched, the function would return 0, the same as "not found". A scratch copy of Canary's client with the header added returned the same live counts as without it: 6,421,042 for
P@ssw0rdand 391 forcorrect horse battery staple. - The zeroing does what it says, narrowly. Compiled with
swiftc -O(Swift 6.4, arm64), the specializedsha1Hashstill calls_bzero, so the optimizer didn't remove the wipe of the dead buffer. It doesn't reach the caller'sString. Treat it as removing one extra copy, not as clearing the password from memory.
How to check you've fixed it
- Compare against the API by hand, with a throwaway password:
H=$(printf '%s' 'P@ssw0rd' | shasum -a 1 | awk '{print toupper($1)}')
curl -s -A "your-app" "https://api.pwnedpasswords.com/range/${H:0:5}" | tr -d '\r' | grep "^${H:5}:"
Your client should report the same count.
- Confirm padding is on: send the same request with
-H "Add-Padding: true"and count lines ending in:0. Without the header there were none. With it, the21BD1range had 135. - Run the unit tests.
HIBPClientTestsregisters a mock response forhttps://api.pwnedpasswords.com/range/\(prefix), and the mock answers any other URL with a 404, so a request that carried more than the prefix would fail the test.swift testinCanaryPackageran 22 tests in 5 suites, all passing. - To see whether a wipe survives optimization, build with
swiftc -O -emit-assemblyand look for the zeroing (_bzero, or zero stores) in the function.
Caveats
- In Canary, only passwords get k-anonymity. Email checks send the full address: to HIBP's
breachedaccountandpasteaccountendpoints, which need an HIBP API key, and to XposedOrNot'sbreach-analytics?email=. HIBP does offer a k-anonymity email search, "sending the first 6 characters" of the address's SHA-1, on subscriptions that include it. - Canary stores the full unsalted SHA-1 so it can re-check on a schedule. For a weak password that hash is as good as the password, so it depends on the store's protection: an in-memory SQLite database written to disk as an AES-256-GCM blob, with the key in the Keychain.
- DNS monitoring baselines records on the first scan and diffs later ones (
Engine.scanDomain), but record capture isn't reliable yet. "A" is whichever address a TCP connection to port 443 reaches. MX, NS and TXT go throughDNSServiceQueryRecord, whose callback frees its context on the first answer.dns_sd.hsayskDNSServiceFlagsMoreComingmeans "at least one more result is queued". In a standalone test, every multi-record answer crashed the process: gmail.com MX, NS and TXT, and example.com NS and TXT. In the one we traced, the fault was at the read of the freed context. - Versions: macOS 14 deployment target,
swift-tools-version5.10, Swift 5 language mode. The tests and harnesses above ran with Swift 6.4.
More lab notes
- Anthropic API key in an iOS app binary: move it to the KeychainA Swift string literal ships in the app binary, readable with strings. Store a user-supplied key in the Keychain with a ThisDeviceOnly class instead.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Claude Code signed out by another app: refresh token rotationAnthropic rotates the refresh token on every refresh, so another app that refreshes Claude Code's login signs it out. Only Claude Code should refresh it.
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.