Password Breach Checker — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# Breach Checker — password breach lookup helper for Have I Been Pwned's
# Pwned Passwords API, using k-anonymity: only the first 5 characters of the
# SHA-1 hash ever leave the browser.
#
# Language: Ruby (3.1+, standard library only)
# Source: CosmoDev polyglot showcase port of the Breach Checker tool,
# ported from src/lib/breach-checker.ts (the canonical TypeScript
# implementation).
# License: display source — part of CosmoDev's polyglot tool pages.
#
# This port covers the pure logic — SHA-1 hashing, the 5/35 k-anonymity
# split, and range-response parsing. The HTTP fetch itself is browser/IO
# specific and is intentionally omitted (the web tool and the Go twin perform
# the network call).
#
# API: https://api.pwnedpasswords.com/range/{PREFIX} — free, no key.
# Returns one "SUFFIX:COUNT" line per hash sharing the prefix (~800
# candidates). The suffix match happens locally.
require 'digest/sha1'
module BreachChecker
# Lookup outcome. +count+ is 0 when never seen and -1 when the lookup
# failed; +hash_prefix+ (5 chars) is the only part ever sent to the API.
BreachResult = Struct.new(:breached, :count, :hash_prefix, :hash_suffix,
:error, :candidates, keyword_init: true)
HIBP_RANGE_URL = 'https://api.pwnedpasswords.com/range/'
module_function
# SHA-1 of a UTF-8 string as uppercase hex (the format HIBP expects).
def sha1_hex(input)
Digest::SHA1.hexdigest(input).upcase
end
# Split a 40-char uppercase hash into the 5-char k-anonymity prefix and
# the 35-char suffix that is matched locally.
def split_hash(hash)
h = hash.to_s.upcase
{ prefix: h[0, 5], suffix: h[5..] }
end
# Search an HIBP range response for a hash suffix and return its breach
# count. Never raises; returns 0 when the suffix is not present. Tolerates
# LF and CRLF line endings, blank lines, and leading/trailing whitespace
# per line.
def parse_range_body(body, suffix)
return 0 unless body.is_a?(String) && !suffix.empty?
body.split(/\r?\n/).each do |line|
idx = line.index(':')
next if idx.nil?
next unless line[0...idx].strip == suffix
# Mirror parseInt: take the leading integer, ignore trailing junk,
# treat unparseable or negative values as 0.
part = line[(idx + 1)..].strip
match = /\A[+-]?\d+/.match(part)
count = match && match[0].to_i
return (count.nil? || count.negative?) ? 0 : count
end
0
end
# Count the "SUFFIX:COUNT" candidate lines in a range response.
def count_candidates(body)
return 0 unless body.is_a?(String)
body.split(/\r?\n/).count do |line|
idx = line.index(':')
!idx.nil? && !line[0...idx].strip.empty?
end
end
end
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 →