Skip to content

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 →