Password Breach Checker — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package breachchecker is the Go twin of CosmoDev's src/lib/breach-checker.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). It ports the lib's PURE logic only: SHA-1 hashing, the
// k-anonymity 5/35 hash split, HIBP range-response parsing/classification and
// breach-count interpretation. Network I/O (the browser fetch in the TS lib)
// stays OUT of the twin — callers fetch RangeURLFor(prefix) themselves and
// hand the response to CheckRange, or the fetch error to FailedLookup.
//
// API shape: https://api.pwnedpasswords.com/range/{PREFIX} returns one
// "SUFFIX:COUNT" line per hash sharing the prefix (~800 candidates). The
// suffix match happens locally — only the 5-char prefix ever leaves the caller.
//
// Pure + deterministic, never panics. The table-driven tests in
// breach-checker_test.go share vectors with src/lib/breach-checker.test.ts so
// the two implementations are held to the same contract.
package breachchecker
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"strings"
)
// RangeURL is the HIBP Pwned Passwords range endpoint — free, no key.
// Mirrors HIBP_RANGE_URL in src/lib/breach-checker.ts.
const RangeURL = "https://api.pwnedpasswords.com/range/"
// Result mirrors the BreachResult interface in src/lib/breach-checker.ts.
type Result struct {
// Breached is true when the exact hash suffix appeared in the API's
// candidate list.
Breached bool
// Count is how many times the password appeared in breaches.
// 0 = never seen. -1 = lookup failed.
Count int
// HashPrefix is the first 5 chars of the uppercase SHA-1 hex — the only
// part ever sent to the API.
HashPrefix string
// HashSuffix is the remaining 35 chars of the hash, matched locally
// against the response.
HashSuffix string
// Error is set when the lookup failed (network error or non-2xx
// response); "" otherwise.
Error string
// Candidates is how many candidate suffixes the API returned (all
// checked locally). 0 on failed lookups (the TS lib leaves it
// undefined there — Go's zero value plays that role).
Candidates int
}
// Sha1Hex returns the SHA-1 of a UTF-8 string as uppercase hex — the format
// HIBP expects. It is the synchronous twin of sha1Hex() (Web Crypto there).
func Sha1Hex(input string) string {
sum := sha1.Sum([]byte(input))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
// SplitHash splits a 40-char uppercase hash into the 5-char k-anonymity
// prefix and the 35-char suffix. Mirrors splitHash(): input is uppercased
// first, so lowercase hashes are normalized. A hash shorter than 5 chars
// yields itself as prefix and an empty suffix (TS slice() semantics; never
// panics).
func SplitHash(hash string) (prefix, suffix string) {
h := strings.ToUpper(hash)
if len(h) < 5 {
return h, ""
}
return h[:5], h[5:]
}
// ParseRangeBody searches an HIBP range response for a hash suffix and
// returns its breach count. Never fails; returns 0 when the suffix is not
// present. Tolerates LF and CRLF line endings, blank lines, and
// leading/trailing whitespace per line. Mirrors parseRangeBody().
func ParseRangeBody(body, suffix string) int {
if suffix == "" {
return 0
}
for line := range strings.SplitSeq(body, "\n") {
cand, count, ok := strings.Cut(line, ":")
if !ok {
continue
}
if strings.TrimSpace(cand) == suffix {
n, ok := parseInt10(strings.TrimSpace(count))
if !ok || n < 0 {
return 0
}
return n
}
}
return 0
}
// CountCandidates counts the "SUFFIX:COUNT" candidate lines in a range
// response. Lines without a colon or with an empty suffix field don't count.
// Mirrors countCandidates().
func CountCandidates(body string) int {
n := 0
for line := range strings.SplitSeq(body, "\n") {
cand, _, ok := strings.Cut(line, ":")
if ok && strings.TrimSpace(cand) != "" {
n++
}
}
return n
}
// RangeURLFor builds the range request URL for a hash prefix — the
// request-shape half of checkBreach(). The k-anonymity invariant: only the
// 5-char prefix ever appears in the URL; the suffix never leaves the caller.
func RangeURLFor(prefix string) string {
return RangeURL + prefix
}
// baseResult hashes password and returns the pre-lookup result: prefix and
// suffix filled in, Count -1, Breached false. Mirrors the `base` object
// built at the top of checkBreach().
func baseResult(password string) Result {
prefix, suffix := SplitHash(Sha1Hex(password))
return Result{Count: -1, HashPrefix: prefix, HashSuffix: suffix}
}
// FailedLookup classifies a failed fetch for password (network error). It
// mirrors checkBreach()'s catch branch: count -1, Breached false, the error's
// message. A nil err is reported as "Network request failed", matching the TS
// lib's fallback for non-Error rejections. Never panics.
func FailedLookup(password string, err error) Result {
base := baseResult(password)
if err != nil {
base.Error = err.Error()
} else {
base.Error = "Network request failed"
}
return base
}
// CheckRange classifies a fetched HIBP range response for password. It is the
// pure half of checkBreach() in src/lib/breach-checker.ts: the caller has
// already fetched RangeURLFor(base.HashPrefix) and hands the HTTP status and
// body here. A non-2xx status returns count -1 with an HTTP error message
// (the !response.ok branch); otherwise the suffix is matched locally against
// the candidate lines and the breach count interpreted.
func CheckRange(password string, status int, body string) Result {
base := baseResult(password)
if status < 200 || status > 299 {
base.Error = fmt.Sprintf("The breach database returned HTTP %d", status)
return base
}
count := ParseRangeBody(body, base.HashSuffix)
return Result{
Breached: count > 0,
Count: count,
HashPrefix: base.HashPrefix,
HashSuffix: base.HashSuffix,
Candidates: CountCandidates(body),
}
}
// parseInt10 parses a leading optionally-signed decimal integer, mirroring
// Number.parseInt(s, 10): trailing junk after the digits is ignored
// ("42abc" → 42), and a string with no digits at all reports !ok. HIBP counts
// are plain digits in practice, so fidelity beyond this is unnecessary.
func parseInt10(s string) (int, bool) {
i := 0
neg := false
if i < len(s) && (s[i] == '+' || s[i] == '-') {
neg = s[i] == '-'
i++
}
start := i
n := 0
for ; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
break
}
n = n*10 + int(s[i]-'0')
}
if i == start {
return 0, false
}
if neg {
n = -n
}
return n, true
}
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 →