lib/pwned/password_base.rb
# frozen_string_literal: true
require "digest"
require "net/http"
module Pwned
##
# This class represents a password. It does all the work of talking to the
# Pwned Passwords API to find out if the password has been pwned.
# @see https://haveibeenpwned.com/API/v2#PwnedPasswords
module PasswordBase
##
# The base URL for the Pwned Passwords API
API_URL = "https://api.pwnedpasswords.com/range/"
##
# The number of characters from the start of the hash of the password that
# are used to search for the range of passwords.
HASH_PREFIX_LENGTH = 5
##
# The total length of a SHA1 hash
SHA1_LENGTH = 40
##
# The default request headers that are used to make HTTP requests to the
# API. A user agent is provided as requested in the documentation.
# @see https://haveibeenpwned.com/API/v2#UserAgent
DEFAULT_REQUEST_HEADERS = {
"User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}"
}.freeze
##
# @example
# password = Pwned::Password.new("password")
# password.pwned? #=> true
# password.pwned? #=> true
#
# @return [Boolean] +true+ when the password has been pwned.
# @raise [Pwned::Error] if there are errors with the HTTP request.
# @raise [Pwned::TimeoutError] if the HTTP request times out.
# @since 1.0.0
def pwned?
pwned_count > 0
end
##
# @example
# password = Pwned::Password.new("password")
# password.pwned_count #=> 3303003
#
# @return [Integer] the number of times the password has been pwned.
# @raise [Pwned::Error] if there are errors with the HTTP request.
# @raise [Pwned::TimeoutError] if the HTTP request times out.
# @since 1.0.0
def pwned_count
@pwned_count ||= fetch_pwned_count
end
##
# Returns the full SHA1 hash of the given password in uppercase.
# @return [String] The full SHA1 hash of the given password.
# @since 1.0.0
attr_reader :hashed_password
private
attr_reader :request_options, :request_headers, :request_proxy, :ignore_env_proxy
def fetch_pwned_count
for_each_response_line do |line|
next unless line.start_with?(hashed_password_suffix)
# Count starts after the suffix, followed by a colon
return line[(SHA1_LENGTH-HASH_PREFIX_LENGTH+1)..-1].to_i
end
# The hash was not found, we can assume the password is not pwned [yet]
0
end
def for_each_response_line(&block)
begin
with_http_response "#{API_URL}#{hashed_password_prefix}" do |response|
response.value # raise if request was unsuccessful
stream_response_lines(response, &block)
end
rescue Timeout::Error => e
raise Pwned::TimeoutError, e.message
rescue => e
raise Pwned::Error, e.message
end
end
def hashed_password_prefix
@hashed_password[0...HASH_PREFIX_LENGTH]
end
def hashed_password_suffix
@hashed_password[HASH_PREFIX_LENGTH..-1]
end
# Make a HTTP GET request given the URL and headers.
# Yields a `Net::HTTPResponse`.
def with_http_response(url, &block)
uri = URI(url)
request = Net::HTTP::Get.new(uri)
request.initialize_http_header(request_headers)
request_options[:use_ssl] = true
environment_proxy = ignore_env_proxy ? nil : :ENV
Net::HTTP.start(
uri.host,
uri.port,
request_proxy&.host || environment_proxy,
request_proxy&.port,
request_proxy&.user,
request_proxy&.password,
request_options
) do |http|
http.request(request, &block)
end
end
# Stream a Net::HTTPResponse by line, handling lines that cross chunks.
def stream_response_lines(response, &block)
last_line = ""
response.read_body do |chunk|
chunk_lines = (last_line + chunk).lines
# This could end with half a line, so save it for next time. If
# chunk_lines is empty, pop returns nil, so this also ensures last_line
# is always a string.
last_line = chunk_lines.pop || ""
chunk_lines.each(&block)
end
yield last_line unless last_line.empty?
end
end
end