Skip to content

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 →