nacl_password 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cab426388ecd1f11ff9f5832d6b5fe75a56e8e249eaec25c136c86bba89c6dc5
4
+ data.tar.gz: 9aa167457870e442f30550f1811979a8a78140979c7a01f1d86c50e3312219da
5
+ SHA512:
6
+ metadata.gz: 544e6b5af9ca3af49950858ba5844b08ec64784794a8988ddbc5709827a6a54b5b86f67845ff1bf350c10aa41864355f71d59b41bb9b9532ab4146e09dbacb22
7
+ data.tar.gz: c2080b635da0588db6206fb4f5922c6f2f32ddb9c71d80e202640d0599aa92d4085d4f4a40e652b06e68501291fe9e5acda9cda15a6ca87aff907acece00c604
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2020 Sampson Crowley
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # NaClPassword
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'nacl_password'
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install nacl_password
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,190 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'coerce_boolean'
5
+
6
+ module NaClPassword
7
+ INVALID_OPTION_MESSAGE = "predefined options are :min, :interactive, :moderate, :sensitive, and :max"
8
+ UNCOERCEABLE_OPTION_MESSAGE = "value must be coercable to an integer or one of the predefined options (:min | :interactive | :moderate | :sensitive | :max)"
9
+
10
+ # Load rbnacl gem only when nacl_password is used. This is to avoid
11
+ # the entire app using this gem being dependent on a binary library.
12
+ def self.load_gem
13
+ require "rbnacl"
14
+ rescue LoadError, NameError
15
+ $stderr.puts <<-ERROR
16
+ You don't have rbnacl installed in your application.
17
+ Please add it to your Gemfile and run bundle install
18
+ ERROR
19
+
20
+ raise
21
+ end
22
+
23
+ # The Argon2 hash function can handle maximum 2^32 bytes, but system available
24
+ # memory probably cannot.
25
+ # A password of length 1024 bytes is way more than any user would ever need.
26
+ # Put a restriction on password length that keeps memory usage in the available
27
+ # range, but is more than anyone would ever need.
28
+ # other defaults are tested ranges of functional values for libsodium that
29
+ # allow maximum possible security without crashing the system
30
+ def self.setup
31
+ load_gem
32
+ ::NaClPassword::Argon2 ||= RbNaCl::PasswordHash::Argon2
33
+ ::NaClPassword::MAX_PASSWORD_LENGTH ||= 1024
34
+ ::NaClPassword::OPS_LIMIT_RANGE ||= 3..20
35
+ ::NaClPassword::MEM_LIMIT_RANGE ||= (2**25)..(2**32) #32 MB - 4 GB
36
+ ::NaClPassword::DIGEST_SIZE_RANGE ||= 64..512
37
+ self
38
+ end
39
+
40
+ def self.const_missing(name)
41
+ NaClPassword.setup
42
+ if const_defined?(name)
43
+ const_get(name)
44
+ else
45
+ super
46
+ end
47
+ end
48
+
49
+ class << self
50
+ attr_reader :ops_limit # :nodoc:
51
+ attr_reader :mem_limit # :nodoc:
52
+ attr_reader :digest_size # :nodoc:
53
+
54
+ def ops_limit=(value)
55
+ @ops_limit = get_ops_limit(value)
56
+ end
57
+
58
+ def mem_limit=(value)
59
+ @mem_limit = get_mem_limit(value)
60
+ end
61
+
62
+ def digest_size=(value)
63
+ @digest_size = get_digest_size(value)
64
+ digest_size
65
+ end
66
+
67
+ def generate(password)
68
+ salt = RbNaCl::Random.random_bytes(Argon2::SALTBYTES)
69
+ ops = get_ops_limit
70
+ mem = get_mem_limit
71
+ size = get_digest_size
72
+ "#{
73
+ Base64.strict_encode64(encrypt(password, salt, ops, mem, size))
74
+ }.#{
75
+ Base64.strict_encode64(salt)
76
+ }.#{
77
+ ops
78
+ }.#{
79
+ mem
80
+ }.#{
81
+ size
82
+ }"
83
+ end
84
+
85
+ def authenticate(encoded, password)
86
+ digest, *metadata = decode(encoded)
87
+ RbNaCl::PasswordHash.argon2id(password, *metadata) == digest
88
+ end
89
+
90
+ def self.const_missing(name)
91
+ NaClPassword.const_missing(name)
92
+ end
93
+
94
+ private
95
+ def encrypt(...)
96
+ RbNaCl::PasswordHash.argon2id(...)
97
+ end
98
+
99
+ def decode(encoded)
100
+ digest_64, salt_64, ops, mem, size = encoded.split(".")
101
+ digest = Base64.strict_decode64(digest_64)
102
+ salt = Base64.strict_decode64(salt_64)
103
+ ops = get_ops_limit(ops)
104
+ mem = get_mem_limit(mem)
105
+ size = get_digest_size(size)
106
+ [ digest, salt, ops, mem, size ]
107
+ end
108
+
109
+ def get_ops_limit(ops = NaClPassword.ops_limit)
110
+ ops = :moderate unless CoerceBoolean.from(ops)
111
+
112
+ case ops
113
+ when :min then OPS_LIMIT_RANGE.min
114
+ when :interactive then 5
115
+ when :moderate then 10
116
+ when :sensitive then 15
117
+ when :max then OPS_LIMIT_RANGE.max
118
+ when Symbol
119
+ raise ArgumentError, INVALID_OPTION_MESSAGE
120
+ else
121
+ case ops = ops.to_i
122
+ when OPS_LIMIT_RANGE then ops
123
+ else
124
+ raise \
125
+ ArgumentError,
126
+ "ops_limit must be within the range #{OPS_LIMIT_RANGE}"
127
+ end
128
+ end
129
+ rescue NoMethodError
130
+ raise ArgumentError, UNCOERCEABLE_OPTION_MESSAGE
131
+ end
132
+
133
+ def get_mem_limit(mem = NaClPassword.mem_limit)
134
+ mem = :moderate unless CoerceBoolean.from(mem)
135
+
136
+ case mem
137
+ when :min then MEM_LIMIT_RANGE.min # 32mb
138
+ when :interactive then (2**26) # 64mb
139
+ when :moderate then (2**28) # 256mb
140
+ when :sensitive then (2**30) # 1024mb
141
+ when :max then MEM_LIMIT_RANGE.max # 4096mb
142
+ when Symbol
143
+ raise ArgumentError, INVALID_OPTION_MESSAGE
144
+ else
145
+ case mem = mem.to_i
146
+ when MEM_LIMIT_RANGE then mem
147
+ else
148
+ raise \
149
+ ArgumentError,
150
+ "mem_limit must be within the range #{MEM_LIMIT_RANGE}"
151
+ end
152
+ end
153
+ rescue NoMethodError
154
+ raise ArgumentError, UNCOERCEABLE_OPTION_MESSAGE
155
+ end
156
+
157
+ def get_digest_size(size = NaClPassword.digest_size)
158
+ size = :moderate unless CoerceBoolean.from(size)
159
+
160
+ case size
161
+ when :min then DIGEST_SIZE_RANGE.min
162
+ when :interactive then DIGEST_SIZE_RANGE.min * 2
163
+ when :moderate then DIGEST_SIZE_RANGE.min * 4
164
+ when :sensitive then DIGEST_SIZE_RANGE.min * 8
165
+ when :max then DIGEST_SIZE_RANGE.max
166
+ when Symbol
167
+ raise ArgumentError, INVALID_OPTION_MESSAGE
168
+ else
169
+ case size = size.to_i
170
+ when DIGEST_SIZE_RANGE
171
+ unless size % 64 == 0
172
+ raise ArgumentError, "digest_size must be a multiple of 64"
173
+ end
174
+
175
+ size
176
+ else
177
+ raise ArgumentError, "digest_size must be within the range #{DIGEST_SIZE_RANGE}"
178
+ end
179
+ end
180
+ rescue NoMethodError
181
+ raise ArgumentError, UNCOERCEABLE_OPTION_MESSAGE
182
+ end
183
+ end
184
+
185
+ self.ops_limit = :moderate
186
+ self.mem_limit = :moderate
187
+ self.digest_size = :moderate
188
+ end
189
+
190
+ require "nacl_password/railtie" if defined? Rails
@@ -0,0 +1,190 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'nacl_password'
5
+
6
+ module NaClPassword
7
+ module Concern
8
+ extend ActiveSupport::Concern
9
+
10
+ module ClassMethods
11
+ # Adds methods to set and authenticate against a Argon2 password.
12
+ # This mechanism requires you to have a +XXX_digest+ attribute.
13
+ # Where +XXX+ is the attribute name of your desired password.
14
+ # the +digest+ attribute to use can be set by passing
15
+ # `digest_attribute: non_standard_attribute` to `nacl_password`
16
+ #
17
+ # The following validations are added automatically:
18
+ # * Password must be present on creation
19
+ # * Password length should be less than or equal to 1024 bytes
20
+ # * Confirmation of password (using a +XXX_confirmation+ attribute)
21
+ #
22
+ # If confirmation validation is not needed, simply leave out the
23
+ # value for +XXX_confirmation+ (i.e. don't provide a form field for
24
+ # it). When this attribute has a +nil+ value, the validation will not be
25
+ # triggered.
26
+ #
27
+ # It is also possible to suppress the default validations completely by
28
+ # passing skip_validations: true as an argument.
29
+ #
30
+ # Add rbnacl (~> 7.1) to Gemfile to use #nacl_password:
31
+ #
32
+ # gem "rbnacl", "~> 7.1"
33
+ #
34
+ # Example:
35
+ #
36
+ # # Schema: User(name:string, password_digest:string, recovery_password_digest:string)
37
+ # class User < ActiveRecord::Base
38
+ # include NaClPassword::Concern
39
+ # nacl_password
40
+ # nacl_password :recovery_password, validations: false
41
+ # end
42
+ #
43
+ # user = User.new(name: 'david', password: '', password_confirmation: 'nomatch')
44
+ # user.save # => false, password required
45
+ # user.password = 'mUc3m00RsqyRe'
46
+ # user.save # => false, confirmation doesn't match
47
+ # user.password_confirmation = 'mUc3m00RsqyRe'
48
+ # user.save # => true
49
+ # user.recovery_password = "42password"
50
+ # user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uXkTG65HR.qpW.bNhEjFP3ftli3o5DQC"
51
+ # user.save # => true
52
+ # user.authenticate('notright') # => false
53
+ # user.authenticate('mUc3m00RsqyRe') # => user
54
+ # user.authenticate_recovery_password('42password') # => user
55
+ # User.find_by(name: 'david')&.authenticate('notright') # => false
56
+ # User.find_by(name: 'david')&.authenticate('mUc3m00RsqyRe') # => user
57
+ def nacl_password(attribute = :password, digest_attribute: nil, **opts)
58
+ NaClPassword.setup
59
+
60
+ digest_attribute ||= "#{attribute}_digest"
61
+
62
+ attribute = attribute.to_sym
63
+ digest_attribute = digest_attribute.to_sym
64
+
65
+ if digest_attribute.to_s == attribute.to_s
66
+ raise ArgumentError, "Digest Attribute Name can't be the same as Password Attribute Name"
67
+ end
68
+
69
+ skip_validations =
70
+ CoerceBoolean.from(opts[:skip_validations]) &&
71
+ (opts[:skip_validations] != :blank)
72
+
73
+ length_options =
74
+ skip_validations ? {} :
75
+ { maximum: NaClPassword::MAX_PASSWORD_LENGTH }.
76
+ merge(
77
+ opts[:min_length] == :none \
78
+ ? {} \
79
+ : { minimum: opts[:min_length].presence&.to_i || 8 }
80
+ )
81
+
82
+
83
+ include InstanceMethodsOnActivation.new(attribute.to_sym, digest_attribute, **length_options)
84
+
85
+ unless skip_validations
86
+ include ActiveModel::Validations
87
+
88
+ # This ensures the model has a password by checking whether the password_digest
89
+ # is present, so that this works with both new and existing records. However,
90
+ # when there is an error, the message is added to the password attribute instead
91
+ # so that the error message will make sense to the end-user.
92
+ unless opts[:skip_validations] == :blank
93
+ validate do |record|
94
+ unless record.__send__(digest_attribute).present?
95
+ record.errors.add(attribute, :blank)
96
+ end
97
+ end
98
+ end
99
+
100
+ validates_length_of attribute, **length_options, allow_blank: true
101
+
102
+ validates_confirmation_of attribute, allow_blank: true
103
+ end
104
+ end
105
+ end
106
+
107
+ class InstanceMethodsOnActivation < Module
108
+ def initialize(attribute, digest_attribute, **attribute_opts)
109
+ # == Constants ============================================================
110
+ confirmation_var = "@#{attribute}_confirmation"
111
+
112
+ # == Attributes ===========================================================
113
+ attr_reader attribute
114
+
115
+ define_method("#{attribute}=") do |given_password|
116
+ instance_variable_set("@#{attribute}", given_password.presence)
117
+
118
+ if given_password.nil? || given_password.empty?
119
+ self.__send__("#{digest_attribute}=", nil)
120
+ else
121
+ self.__send__(
122
+ "#{digest_attribute}=",
123
+ NaClPassword.generate(given_password)
124
+ )
125
+ end
126
+ end
127
+
128
+ define_method("#{attribute}_confirmation=") do |given_password|
129
+ instance_variable_set(confirmation_var, given_password)
130
+ end
131
+
132
+ # == Boolean Methods ======================================================
133
+ define_method("#{attribute}_length_valid?") do
134
+ value = self.__send__(attribute)
135
+ invalid = false
136
+
137
+ if attribute_opts[:maximum].present?
138
+ invalid ||= (value.length > attribute_opts[:maximum])
139
+ end
140
+
141
+ if attribute_opts[:minimum].present?
142
+ invalid ||= (value.length < attribute_opts[:minimum])
143
+ end
144
+
145
+ !invalid
146
+ end
147
+
148
+ define_method("#{attribute}_confirmed?") do
149
+ self.__send__(attribute) == self.instance_variable_get(confirmation_var)
150
+ end
151
+
152
+ define_method("#{attribute}_ready?") do |require_confirmation = false|
153
+ value = self.__send__(attribute)
154
+ if value.nil? || value.empty?
155
+ self.__send__("#{digest_attribute}").present?
156
+ else
157
+ confirmation = self.instance_variable_get(confirmation_var)
158
+ if !require_confirmation && (confirmation.nil? || confirmation.empty?)
159
+ self.__send__("#{attribute}_length_valid?")
160
+ else
161
+ self.__send__("#{attribute}_confirmed?") \
162
+ && self.__send__("#{attribute}_length_valid?")
163
+ end
164
+ end
165
+ end
166
+
167
+ # == Instance Methods =====================================================
168
+
169
+ # Returns +self+ if the password is correct, otherwise +nil+.
170
+ #
171
+ # class User < ActiveRecord::Base
172
+ # nacl_password validations: false
173
+ # end
174
+ #
175
+ # user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
176
+ # user.save
177
+ # user.authenticate_password('mUc3m00RsqyRe') # => user
178
+ # user.authenticate_password('notright') # => nil
179
+ define_method("authenticate_#{attribute}") do |given_password|
180
+ return nil unless attribute_digest = __send__(digest_attribute)
181
+ if NaClPassword.authenticate(attribute_digest, given_password)
182
+ self
183
+ end
184
+ end
185
+
186
+ alias_method :authenticate, :authenticate_password if attribute == :password
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,10 @@
1
+ module NaClPassword
2
+ class Railtie < ::Rails::Railtie
3
+ initializer 'nacl_password.include_concern' do
4
+ ActiveSupport.on_load(:active_record) do
5
+ require 'nacl_password/concern'
6
+ ActiveRecord::Base.send :include, NaClPassword::Concern
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ module NaClPassword
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :nacl_password do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nacl_password
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sampson Crowley
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-04-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: coerce_boolean
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rbnacl
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '6.0'
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 6.0.2.2
51
+ type: :development
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - "~>"
56
+ - !ruby/object:Gem::Version
57
+ version: '6.0'
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 6.0.2.2
61
+ - !ruby/object:Gem::Dependency
62
+ name: sqlite3
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ description: |2
76
+ Easier encryption and decryption with libsodium while remaining configurable
77
+ with validated options. Also includes a concern for a "has_secure_password"
78
+ style one-line setup
79
+ email:
80
+ - sampsonsprojects@gmail.com
81
+ executables: []
82
+ extensions: []
83
+ extra_rdoc_files: []
84
+ files:
85
+ - MIT-LICENSE
86
+ - README.md
87
+ - lib/nacl_password.rb
88
+ - lib/nacl_password/concern.rb
89
+ - lib/nacl_password/railtie.rb
90
+ - lib/nacl_password/version.rb
91
+ - lib/tasks/nacl_password_tasks.rake
92
+ homepage: https://github.com/SampsonCrowley/nacl_password
93
+ licenses:
94
+ - MIT
95
+ metadata: {}
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.1.2
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: RbNaCl on Rails
115
+ test_files: []