pwned 1.0.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +12 -1
- data/CHANGELOG.md +20 -0
- data/Gemfile +3 -0
- data/README.md +91 -2
- data/lib/locale/en.yml +5 -0
- data/lib/pwned.rb +23 -0
- data/lib/pwned/error.rb +3 -7
- data/lib/pwned/password.rb +32 -15
- data/lib/pwned/pwned_validator.rb +41 -0
- data/lib/pwned/version.rb +3 -1
- data/pwned.gemspec +0 -2
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 502db43dbcbc77b9ea32dbac1b642d4f682f3969b810b13c2ec02b381c1b50e9
|
4
|
+
data.tar.gz: 5ef819185c54637d5544b7fd27134d1ae61c07ed2eaf464b7040e39fa8b15937
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fd00a0191f6d8a379ef7380cda0e7de42bdcc996483c45256185c535fb16d6a61b40a850d0f9383efda0e4441cde66ec5e24b73152a0e9af3d78da9b74a3a63f
|
7
|
+
data.tar.gz: bf4299bd181ff6d7b37754ed3cfefd52f9ea5fb72f9219b48a403137d5950c530be60ec85f9dfe58f412d1dbcbdb7958e0e0dc062ff8006728aa5d454899c6ae
|
data/.travis.yml
CHANGED
@@ -1,12 +1,23 @@
|
|
1
1
|
sudo: false
|
2
2
|
language: ruby
|
3
|
+
|
4
|
+
env:
|
5
|
+
matrix:
|
6
|
+
- RAILS_VERSION=4.2.0
|
7
|
+
- RAILS_VERSION=5.0.0
|
8
|
+
- RAILS_VERSION=5.1.0
|
9
|
+
- RAILS_VERSION=5.2.0.rc1
|
10
|
+
|
3
11
|
rvm:
|
4
12
|
- 2.5.0
|
5
13
|
- 2.4.0
|
6
14
|
- 2.3.0
|
7
15
|
- jruby
|
8
16
|
- ruby-head
|
17
|
+
|
9
18
|
before_install: gem install bundler -v 1.16.1
|
19
|
+
|
10
20
|
matrix:
|
11
21
|
allow_failures:
|
12
|
-
- rvm: ruby-head
|
22
|
+
- rvm: ruby-head
|
23
|
+
- env: RAILS_VERSION=5.2.0.rc1
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# Changelog for `Pwned`
|
2
|
+
|
3
|
+
## Ongoing [☰](https://github.com/philnash/pwned/compare/v1.1.0...master)
|
4
|
+
|
5
|
+
## 1.1.0 (March 12, 2018) [☰](https://github.com/philnash/pwned/commits/v1.1.0)
|
6
|
+
|
7
|
+
* Major updates
|
8
|
+
* Refactors exception handling with built in Ruby method ([PR #1](https://github.com/philnash/pwned/pull/1) thanks [@kpumuk](https://github.com/kpumuk))
|
9
|
+
* Passwords must be strings, the initializer will raise a `TypeError` unless `password.is_a? String`. ([dbf7697](https://github.com/philnash/pwned/commit/dbf7697e878d87ac74aed1e715cee19b73473369))
|
10
|
+
* Added Ruby on Rails validator ([PR #3](https://github.com/philnash/pwned/pull/3) & [PR #6](https://github.com/philnash/pwned/pull/6))
|
11
|
+
* Added simplified accessors `Pwned.pwned?` and `Pwned.pwned_count` ([PR #4](https://github.com/philnash/pwned/pull/4))
|
12
|
+
|
13
|
+
* Minor updates
|
14
|
+
* SHA1 is only calculated once
|
15
|
+
* Frozen string literal to make sure Ruby does not copy strings over and over again
|
16
|
+
* Removal of `@match_data`, since we only use it to retrieve the counter. Caching the counter instead (all [PR #2](https://github.com/philnash/pwned/pull/2) thanks [@kpumuk](https://github.com/kpumuk))
|
17
|
+
|
18
|
+
## 1.0.0 (March 6, 2018) [☰](https://github.com/philnash/pwned/commits/v1.0.0)
|
19
|
+
|
20
|
+
Initial release. Includes basic features for checking passwords and their count from the Pwned Passwords API. Allows setting of request headers and other options for open-uri.
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
# Pwned
|
2
2
|
|
3
|
+
An easy, Ruby way to use the Pwned Passwords API.
|
4
|
+
|
5
|
+
[![Build Status](https://travis-ci.org/philnash/pwned.svg?branch=master)](https://travis-ci.org/philnash/pwned)
|
6
|
+
|
3
7
|
Troy Hunt's [Pwned Passwords API V2](https://haveibeenpwned.com/API/v2#PwnedPasswords) allows you to check if a password has been found in any of the huge data breaches.
|
4
8
|
|
5
9
|
`Pwned` is a Ruby library to use the Pwned Passwords API's [k-Anonymity model](https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/#cloudflareprivacyandkanonymity) to test a password against the API without sending the entire password to the service.
|
@@ -30,6 +34,8 @@ To test a password against the API, instantiate a `Pwned::Password` object and t
|
|
30
34
|
password = Pwned::Password.new("password")
|
31
35
|
password.pwned?
|
32
36
|
#=> true
|
37
|
+
password.pwned_count
|
38
|
+
#=> 3303003
|
33
39
|
```
|
34
40
|
|
35
41
|
You can also check how many times the password appears in the dataset.
|
@@ -51,7 +57,16 @@ rescue Pwned::Error => e
|
|
51
57
|
end
|
52
58
|
```
|
53
59
|
|
54
|
-
|
60
|
+
Most of the times you only care if the password has been pwned before or not. You can use simplified accessors to check whether the password has been pwned, or how many times it was pwned:
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
Pwned.pwned?("password")
|
64
|
+
#=> true
|
65
|
+
Pwned.pwned_count("password")
|
66
|
+
#=> 3303003
|
67
|
+
```
|
68
|
+
|
69
|
+
#### Advanced
|
55
70
|
|
56
71
|
You can set options and headers to be used with `open-uri` when making the request to the API. HTTP headers must be string keys and the [other options are available in the `OpenURI::OpenRead` module](https://ruby-doc.org/stdlib-2.5.0/libdoc/open-uri/rdoc/OpenURI/OpenRead.html#method-i-open).
|
57
72
|
|
@@ -59,9 +74,83 @@ You can set options and headers to be used with `open-uri` when making the reque
|
|
59
74
|
password = Pwned::Password.new("password", { 'User-Agent' => 'Super fun new user agent' })
|
60
75
|
```
|
61
76
|
|
77
|
+
### ActiveRecord Validator
|
78
|
+
|
79
|
+
There is a custom validator available for your ActiveRecord models:
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
class User < ApplicationRecord
|
83
|
+
validates :password, pwned: true
|
84
|
+
# or
|
85
|
+
validates :password, pwned: { message: "has been pwned %{count} times" }
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
#### I18n
|
90
|
+
|
91
|
+
You can change the error message using I18n (use `%{count}` to interpolate the number of times the password was seen in the data breaches):
|
92
|
+
|
93
|
+
```yaml
|
94
|
+
en:
|
95
|
+
errors:
|
96
|
+
messages:
|
97
|
+
pwned: has been pwned %{count} times
|
98
|
+
pwned_error: might be pwned
|
99
|
+
```
|
100
|
+
|
101
|
+
#### Threshold
|
102
|
+
|
103
|
+
If you are ok with the password appearing a certain number of times before you decide it is invalid, you can set a threshold. The validator will check whether the `pwned_count` is greater than the threshold.
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
class User < ApplicationRecord
|
107
|
+
# The record is marked as valid if the password has been used once in the breached data
|
108
|
+
validates :password, pwned: { threshold: 1 }
|
109
|
+
end
|
110
|
+
```
|
111
|
+
|
112
|
+
#### Network Errors Handling
|
113
|
+
|
114
|
+
By default the record will be treated as valid when we cannot reach the [haveibeenpwned.com](https://haveibeenpwned.com/) servers. This can be changed with the `:on_error` validator parameter:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
class User < ApplicationRecord
|
118
|
+
# The record is marked as valid on network errors.
|
119
|
+
validates :password, pwned: true
|
120
|
+
validates :password, pwned: { on_error: :valid }
|
121
|
+
|
122
|
+
# The record is marked as invalid on network errors
|
123
|
+
# (error message "could not be verified against the past data breaches".)
|
124
|
+
validates :password, pwned: { on_error: :invalid }
|
125
|
+
|
126
|
+
# The record is marked as invalid on network errors with custom error.
|
127
|
+
validates :password, pwned: { on_error: :invalid, error_message: "might be pwned" }
|
128
|
+
|
129
|
+
# We will raise an error on network errors.
|
130
|
+
# This means that `record.valid?` will raise `Pwned::Error`.
|
131
|
+
# Not recommended to use in production.
|
132
|
+
validates :password, pwned: { on_error: :raise_error }
|
133
|
+
|
134
|
+
# Call custom proc on error. For example, capture errors in Sentry,
|
135
|
+
# but do not mark the record as invalid.
|
136
|
+
validates :password, pwned: {
|
137
|
+
on_error: ->(record, error) { Raven.capture_exception(error) }
|
138
|
+
}
|
139
|
+
end
|
140
|
+
```
|
141
|
+
|
142
|
+
#### Custom Request Options
|
143
|
+
|
144
|
+
You can configure network requests made from the validator using `:request_options` (see [OpenURI::OpenRead#open](http://ruby-doc.org/stdlib-2.5.0/libdoc/open-uri/rdoc/OpenURI/OpenRead.html#method-i-open) for the list of available options, string keys represent custom network request headers, e.g. `"User-Agent"`):
|
145
|
+
|
146
|
+
```ruby
|
147
|
+
validates :password, pwned: {
|
148
|
+
request_options: { read_timeout: 5, open_timeout: 1, "User-Agent" => "Super fun user agent" }
|
149
|
+
}
|
150
|
+
```
|
151
|
+
|
62
152
|
## TODO
|
63
153
|
|
64
|
-
- [ ] Rails validator
|
65
154
|
- [ ] Devise plugin
|
66
155
|
|
67
156
|
## Development
|
data/lib/locale/en.yml
ADDED
data/lib/pwned.rb
CHANGED
@@ -1,6 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "pwned/version"
|
2
4
|
require "pwned/error"
|
3
5
|
require "pwned/password"
|
4
6
|
|
7
|
+
begin
|
8
|
+
# Load Rails and our custom validator
|
9
|
+
require "active_model"
|
10
|
+
require "pwned/pwned_validator"
|
11
|
+
|
12
|
+
# Initialize I18n (validation error message)
|
13
|
+
require "active_support/i18n"
|
14
|
+
I18n.load_path.concat Dir[File.expand_path('locale/*.yml', __dir__)]
|
15
|
+
rescue LoadError
|
16
|
+
# Not a Rails project, no need to do anything
|
17
|
+
end
|
18
|
+
|
5
19
|
module Pwned
|
20
|
+
# Returns true when the password has been pwned.
|
21
|
+
def self.pwned?(password, request_options={})
|
22
|
+
Pwned::Password.new(password, request_options).pwned?
|
23
|
+
end
|
24
|
+
|
25
|
+
# Returns number of times the password has been pwned.
|
26
|
+
def self.pwned_count(password, request_options={})
|
27
|
+
Pwned::Password.new(password, request_options).pwned_count
|
28
|
+
end
|
6
29
|
end
|
data/lib/pwned/error.rb
CHANGED
@@ -1,13 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Pwned
|
2
4
|
class Error < StandardError
|
3
|
-
attr_reader :original_error
|
4
|
-
|
5
|
-
def initialize(message, original_error)
|
6
|
-
@original_error = original_error
|
7
|
-
super(message)
|
8
|
-
end
|
9
5
|
end
|
10
6
|
|
11
7
|
class TimeoutError < Error
|
12
8
|
end
|
13
|
-
end
|
9
|
+
end
|
data/lib/pwned/password.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "digest"
|
2
4
|
require "open-uri"
|
3
5
|
|
@@ -5,51 +7,66 @@ module Pwned
|
|
5
7
|
class Password
|
6
8
|
API_URL = "https://api.pwnedpasswords.com/range/"
|
7
9
|
HASH_PREFIX_LENGTH = 5
|
10
|
+
SHA1_LENGTH = 40
|
8
11
|
DEFAULT_REQUEST_OPTIONS = {
|
9
12
|
"User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}"
|
10
|
-
}
|
13
|
+
}.freeze
|
11
14
|
|
12
15
|
attr_reader :password
|
13
16
|
|
14
17
|
def initialize(password, request_options={})
|
18
|
+
raise TypeError, "password must be of type String" unless password.is_a? String
|
15
19
|
@password = password
|
16
20
|
@request_options = DEFAULT_REQUEST_OPTIONS.merge(request_options)
|
17
21
|
end
|
18
22
|
|
23
|
+
# Returns the full SHA1 hash of the given password.
|
19
24
|
def hashed_password
|
20
|
-
Digest::SHA1.hexdigest(password).upcase
|
25
|
+
@hashed_password ||= Digest::SHA1.hexdigest(password).upcase
|
21
26
|
end
|
22
27
|
|
28
|
+
# Returns true when the password has been pwned.
|
23
29
|
def pwned?
|
24
|
-
|
30
|
+
pwned_count > 0
|
25
31
|
end
|
26
32
|
|
33
|
+
# Returns number of times the password has been pwned.
|
27
34
|
def pwned_count
|
28
|
-
|
35
|
+
@pwned_count ||= fetch_pwned_count
|
29
36
|
end
|
30
37
|
|
31
38
|
private
|
32
39
|
|
33
|
-
def
|
34
|
-
|
40
|
+
def fetch_pwned_count
|
41
|
+
suffix = hashed_password_suffix
|
42
|
+
for_each_response_line do |line|
|
43
|
+
next unless line.start_with?(suffix)
|
44
|
+
# Count starts after the suffix, followed by a colon
|
45
|
+
return line[(SHA1_LENGTH-HASH_PREFIX_LENGTH+1)..-1].to_i
|
46
|
+
end
|
47
|
+
|
48
|
+
# The hash was not found, we can assume the password is not pwned [yet]
|
49
|
+
0
|
35
50
|
end
|
36
51
|
|
37
|
-
def
|
52
|
+
def for_each_response_line(&block)
|
38
53
|
begin
|
39
|
-
open("#{API_URL}#{
|
40
|
-
|
54
|
+
open("#{API_URL}#{hashed_password_prefix}", @request_options) do |io|
|
55
|
+
io.each_line(&block)
|
41
56
|
end
|
42
|
-
@hashes
|
43
57
|
rescue Timeout::Error => e
|
44
|
-
raise Pwned::TimeoutError
|
58
|
+
raise Pwned::TimeoutError, e.message
|
45
59
|
rescue => e
|
46
|
-
raise Pwned::Error
|
60
|
+
raise Pwned::Error, e.message
|
47
61
|
end
|
48
62
|
end
|
49
63
|
|
50
|
-
def
|
51
|
-
|
64
|
+
def hashed_password_prefix
|
65
|
+
hashed_password[0...HASH_PREFIX_LENGTH]
|
52
66
|
end
|
53
67
|
|
68
|
+
def hashed_password_suffix
|
69
|
+
hashed_password[HASH_PREFIX_LENGTH..-1]
|
70
|
+
end
|
54
71
|
end
|
55
|
-
end
|
72
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class PwnedValidator < ActiveModel::EachValidator
|
4
|
+
# We do not want to break customer sign-up process when the service is down.
|
5
|
+
DEFAULT_ON_ERROR = :valid
|
6
|
+
DEFAULT_THRESHOLD = 0
|
7
|
+
|
8
|
+
def validate_each(record, attribute, value)
|
9
|
+
begin
|
10
|
+
pwned_check = Pwned::Password.new(value, request_options)
|
11
|
+
if pwned_check.pwned_count > threshold
|
12
|
+
record.errors.add(attribute, :pwned, options.merge(count: pwned_check.pwned_count))
|
13
|
+
end
|
14
|
+
rescue Pwned::Error => error
|
15
|
+
case on_error
|
16
|
+
when :invalid
|
17
|
+
record.errors.add(attribute, :pwned_error, options.merge(message: options[:error_message]))
|
18
|
+
when :valid
|
19
|
+
# Do nothing, consider the record valid
|
20
|
+
when Proc
|
21
|
+
on_error.call(record, error)
|
22
|
+
else
|
23
|
+
raise
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def on_error
|
29
|
+
options[:on_error] || DEFAULT_ON_ERROR
|
30
|
+
end
|
31
|
+
|
32
|
+
def request_options
|
33
|
+
options[:request_options] || {}
|
34
|
+
end
|
35
|
+
|
36
|
+
def threshold
|
37
|
+
threshold = options[:threshold] || DEFAULT_THRESHOLD
|
38
|
+
raise TypeError, "PwnedValidator option 'threshold' must be of type Integer" unless threshold.is_a? Integer
|
39
|
+
threshold
|
40
|
+
end
|
41
|
+
end
|
data/lib/pwned/version.rb
CHANGED
data/pwned.gemspec
CHANGED
@@ -16,8 +16,6 @@ Gem::Specification.new do |spec|
|
|
16
16
|
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
17
|
f.match(%r{^(test|spec|features)/})
|
18
18
|
end
|
19
|
-
spec.bindir = "exe"
|
20
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
19
|
spec.require_paths = ["lib"]
|
22
20
|
|
23
21
|
spec.add_development_dependency "bundler", "~> 1.16"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pwned
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Phil Nash
|
8
8
|
autorequire:
|
9
|
-
bindir:
|
9
|
+
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-03-
|
11
|
+
date: 2018-03-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -76,6 +76,7 @@ files:
|
|
76
76
|
- ".gitignore"
|
77
77
|
- ".rspec"
|
78
78
|
- ".travis.yml"
|
79
|
+
- CHANGELOG.md
|
79
80
|
- CODE_OF_CONDUCT.md
|
80
81
|
- Gemfile
|
81
82
|
- LICENSE.txt
|
@@ -83,9 +84,11 @@ files:
|
|
83
84
|
- Rakefile
|
84
85
|
- bin/console
|
85
86
|
- bin/setup
|
87
|
+
- lib/locale/en.yml
|
86
88
|
- lib/pwned.rb
|
87
89
|
- lib/pwned/error.rb
|
88
90
|
- lib/pwned/password.rb
|
91
|
+
- lib/pwned/pwned_validator.rb
|
89
92
|
- lib/pwned/version.rb
|
90
93
|
- pwned.gemspec
|
91
94
|
homepage: https://github.com/philnash/pwned
|