devise-hashable 1.0.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: 8cc3972042af2d23dffab06688a7e06ca4cb234462604b58a50fb414ffc67c75
4
+ data.tar.gz: b43cf0a5b2224eefdb855c37f09e1970501abfa5eaddb21af6fc1c228e535260
5
+ SHA512:
6
+ metadata.gz: 599c080a854b523c0e8db52ce617e8dcf6b9b1424a0190eb36612e91772ee6ca168482fb075d0174fc544067f2976dea2a8303cf0159be58631675e73fbfefa8
7
+ data.tar.gz: 7231d7215726aa2531deca8c232051571241af2b666e5620df8ddc34ca10278229b19298652b0445be8b68bed348e7e68880a3abdd4dda228d983e231c6e2f2f
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-08-23
9
+
10
+ First public release.
11
+
12
+ ### Added
13
+
14
+ - `:password_hashable` Devise module, which transparently re-hashes a user's
15
+ password to the configured strategy on successful sign-in.
16
+ - Hashing strategies for bcrypt, PBKDF2 and Argon2id, under
17
+ `Devise::Hashable::Strategy`.
18
+ - Configuration via `Devise.hashing_strategy`, `Devise.digest_algorithm`,
19
+ `Devise.hash_iterations` and `Devise.argon2_profile`.
20
+ - `Devise::Hashable::InvalidPasswordHash`, raised when a stored hash cannot be
21
+ parsed by the configured strategy.
22
+ - Guides under `docs/` covering configuration, the migration process, each
23
+ hashing strategy, and writing a custom strategy.
24
+ - CI matrix covering Rails 7.1, 7.2, 8.0 and 8.1 against Devise 4.9 and 5.0,
25
+ on Ruby 3.3 through 4.0.
26
+
27
+ ### Requirements
28
+
29
+ - Ruby >= 3.3. Older rubies are end-of-life and are not supported.
30
+
31
+ [1.0.0]: https://github.com/MatthewKennedy/devise-hashable/releases/tag/v1.0.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Matthew Kennedy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # Devise::Hashable
2
+
3
+ [![CI](https://github.com/MatthewKennedy/devise-hashable/actions/workflows/ci.yml/badge.svg)](https://github.com/MatthewKennedy/devise-hashable/actions/workflows/ci.yml)
4
+ [![RuboCop](https://github.com/MatthewKennedy/devise-hashable/actions/workflows/rubocop.yml/badge.svg)](https://github.com/MatthewKennedy/devise-hashable/actions/workflows/rubocop.yml)
5
+ [![Gem Version](https://badge.fury.io/rb/devise-hashable.svg)](https://rubygems.org/gems/devise-hashable)
6
+
7
+ Modern password hashing for Devise, with no forced password reset.
8
+
9
+ Devise stores passwords with bcrypt. This gem adds PBKDF2 and Argon2id, and
10
+ migrates your existing users onto them one at a time, as each signs in. Nobody is
11
+ locked out, nothing is reset, and you can change your mind and move back.
12
+
13
+ Requires Ruby 3.3 or newer. Tested against Rails 7.1, 7.2, 8.0 and 8.1, on
14
+ Devise 4.9 and 5.0.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bundle add devise-hashable
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Add `:password_hashable` to your Devise model and name a strategy:
25
+
26
+ ```ruby
27
+ class User < ApplicationRecord
28
+ devise :database_authenticatable, :registerable
29
+ devise :password_hashable, hashing_strategy: :argon2
30
+ end
31
+ ```
32
+
33
+ That is the whole setup. The next time each user signs in, their password is
34
+ verified against its existing hash and then re-hashed with Argon2id.
35
+
36
+ If you would rather confirm the wiring before anything changes, start by naming
37
+ the scheme you already use. Nothing will be re-hashed:
38
+
39
+ ```ruby
40
+ devise :password_hashable, hashing_strategy: :bcrypt
41
+ ```
42
+
43
+ ## Strategies
44
+
45
+ | Strategy | Choose it when |
46
+ | --- | --- |
47
+ | [Argon2id](https://github.com/MatthewKennedy/devise-hashable/blob/main/docs/strategies/argon2.md) | You are free to pick on security grounds. Memory-hard, RFC 9106 |
48
+ | [PBKDF2](https://github.com/MatthewKennedy/devise-hashable/blob/main/docs/strategies/pbkdf2.md) | You need a FIPS-aligned KDF |
49
+ | [Bcrypt](https://github.com/MatthewKennedy/devise-hashable/blob/main/docs/strategies/bcrypt.md) | You want Devise's existing behaviour, unchanged |
50
+
51
+ ## Documentation
52
+
53
+ - [Configuration](https://github.com/MatthewKennedy/devise-hashable/blob/main/docs/configuration.md) — every setting, and where to put it
54
+ - [Migrating passwords](https://github.com/MatthewKennedy/devise-hashable/blob/main/docs/migrating-passwords.md) — how the migration runs, when it is skipped, and how to roll it out
55
+ - [Custom strategies](https://github.com/MatthewKennedy/devise-hashable/blob/main/docs/custom-strategies.md) — read hashes from a system you are migrating off
56
+
57
+ ## Development
58
+
59
+ After checking out the repo, run `bin/setup` to install dependencies. Then run
60
+ `rake test` to run the tests, or `bin/console` for an interactive prompt.
61
+
62
+ The suite is written in Minitest, deliberately, so that public Devise modules can
63
+ be imported and plugged straight into it. It is held at 100% line and branch
64
+ coverage — raise the floors in `test/test_helper.rb` when coverage improves,
65
+ never lower one to make a run pass.
66
+
67
+ `bundle exec appraisal install` sets up the Rails and Devise combinations that CI
68
+ runs; `bundle exec appraisal rake test` runs the whole matrix locally.
69
+
70
+ ## Contributing
71
+
72
+ Bug reports and pull requests are welcome on GitHub at
73
+ https://github.com/MatthewKennedy/devise-hashable/issues.
74
+
75
+ ## License
76
+
77
+ The gem is available as open source under the terms of the
78
+ [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,64 @@
1
+ # Configuration
2
+
3
+ `:password_hashable` reads four settings. Each has a global default that you can
4
+ override per model.
5
+
6
+ | Setting | Default | Used by | Meaning |
7
+ | --- | --- | --- | --- |
8
+ | `hashing_strategy` | `nil` | all | Which strategy new hashes are written with |
9
+ | `digest_algorithm` | `:sha256` | PBKDF2 | `:sha1`, `:sha256` or `:sha512` |
10
+ | `hash_iterations` | `50_000` | PBKDF2 | Iteration count, must be greater than zero |
11
+ | `argon2_profile` | `:rfc_9106_low_memory` | Argon2id | Cost profile, see [Argon2id](strategies/argon2.md) |
12
+
13
+ Bcrypt has no setting of its own — it uses Devise's existing `stretches`.
14
+
15
+ ## Where to set them
16
+
17
+ Per model, which is the usual place, since the strategy is a property of that
18
+ model's password column:
19
+
20
+ ```ruby
21
+ class User < ApplicationRecord
22
+ devise :database_authenticatable, :registerable
23
+ devise :password_hashable, hashing_strategy: :argon2
24
+ end
25
+ ```
26
+
27
+ Or globally, in `config/initializers/devise.rb`, for every model that enables the
28
+ module:
29
+
30
+ ```ruby
31
+ Devise.setup do |config|
32
+ config.hashing_strategy = :argon2
33
+ config.argon2_profile = :rfc_9106_low_memory
34
+ end
35
+ ```
36
+
37
+ A per-model value wins over the global one. Settings not named on the model fall
38
+ back to the global value, so a model can take the global strategy and override
39
+ only its cost.
40
+
41
+ ## `hashing_strategy` is required
42
+
43
+ There is no default strategy. Enabling the module without one raises on the
44
+ first password operation:
45
+
46
+ ```
47
+ You need to specify a hashing_strategy: in Devise configuration to use :password_hashable
48
+ ```
49
+
50
+ A name that does not resolve to a strategy class raises:
51
+
52
+ ```
53
+ Configured password hashing_strategy 'whatever' could not be found for :password_hashable
54
+ ```
55
+
56
+ ## Reading a hash is not configured
57
+
58
+ Configuration decides how passwords are **written**. Reading is driven by the
59
+ stored hash itself: each hash records the scheme that produced it, so existing
60
+ passwords keep verifying no matter what is configured. That is what lets you
61
+ change strategy without locking anyone out.
62
+
63
+ See [Migrating passwords](migrating-passwords.md) for how and when a stored hash
64
+ is rewritten.
@@ -0,0 +1,120 @@
1
+ # Custom strategies
2
+
3
+ A custom strategy lets you read hashes from a system you are migrating off, so
4
+ that users can sign in with their existing password and be re-hashed to a modern
5
+ scheme on the way through.
6
+
7
+ It takes two pieces: a strategy class, and a way for the model to recognise the
8
+ hashes it owns.
9
+
10
+ ## 1. The strategy class
11
+
12
+ Subclass `Devise::Hashable::Strategy::Base` and implement its four methods. The
13
+ class **must** be defined inside `Devise::Hashable::Strategy`, because that is
14
+ where the strategy is looked up by name.
15
+
16
+ ```ruby
17
+ # config/initializers/legacy_sha1_strategy.rb
18
+ require "digest"
19
+
20
+ module Devise
21
+ module Hashable
22
+ module Strategy
23
+ # Reads the "$legacy-sha1$<salt>$<hex digest>" hashes written by the old
24
+ # PHP application. Only ever used to verify; new hashes are written by
25
+ # whichever strategy is configured.
26
+ class LegacySha1 < Base
27
+ class << self
28
+ def compare(_klass, encrypted_password, password)
29
+ parts = split_password_hash(encrypted_password)
30
+
31
+ Devise.secure_compare(
32
+ parts[:checksum],
33
+ Digest::SHA1.hexdigest(parts[:salt] + password)
34
+ )
35
+ end
36
+
37
+ def digest(_klass, _password)
38
+ raise NotImplementedError, "legacy hashes are read-only"
39
+ end
40
+
41
+ # Never current, so any user on a legacy hash is re-hashed to the
42
+ # configured strategy the first time they sign in.
43
+ def password_configurations_match?(_klass, _encrypted_password)
44
+ false
45
+ end
46
+
47
+ def split_password_hash(encrypted_password)
48
+ parts = encrypted_password.split("$")
49
+
50
+ raise InvalidPasswordHash, "Invalid password hash for LegacySha1" unless parts.length == 4
51
+
52
+ _, strategy, salt, checksum = parts
53
+
54
+ raise InvalidPasswordHash, "Invalid password hash strategy for LegacySha1" unless strategy == "legacy-sha1"
55
+
56
+ { strategy: strategy, salt: salt, checksum: checksum }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ ```
64
+
65
+ Returning `false` from `password_configurations_match?` is what drives the
66
+ migration: the hash is never considered current, so it is rewritten on the next
67
+ successful sign-in.
68
+
69
+ ## 2. Teach the model to recognise it
70
+
71
+ Override the private `encrypted_password_strategy_check` and call `super` for
72
+ anything you do not handle:
73
+
74
+ ```ruby
75
+ class User < ApplicationRecord
76
+ devise :database_authenticatable
77
+ devise :password_hashable, hashing_strategy: :argon2
78
+
79
+ private
80
+
81
+ def encrypted_password_strategy_check
82
+ return :legacy_sha1 if encrypted_password&.start_with?("$legacy-sha1$")
83
+
84
+ super
85
+ end
86
+ end
87
+ ```
88
+
89
+ Returning `nil` — which `super` does for anything unrecognised — marks the hash
90
+ unknown, and verification fails without raising.
91
+
92
+ ## Naming
93
+
94
+ The symbol is turned into a class name with ActiveSupport's `classify`, which
95
+ **singularises**:
96
+
97
+ | Symbol | Class looked up |
98
+ | --- | --- |
99
+ | `:legacy_sha1` | `Devise::Hashable::Strategy::LegacySha1` |
100
+ | `:my_custom` | `Devise::Hashable::Strategy::MyCustom` |
101
+ | `:credentials` | `Devise::Hashable::Strategy::Credential` |
102
+
103
+ Avoid plural names, or the lookup will search for a class you did not define.
104
+
105
+ ## Loading
106
+
107
+ An initializer is the simplest home for the class. The four built-in strategies
108
+ are autoloaded; a custom one is not, so make sure the file is loaded before the
109
+ first sign-in. If you put it under `app/`, Rails will reload it in development
110
+ and the constant will resolve normally.
111
+
112
+ ## Checklist
113
+
114
+ - Raise `Devise::Hashable::InvalidPasswordHash` from `split_password_hash` when a
115
+ hash does not parse, rather than returning something incomplete.
116
+ - Use `Devise.secure_compare` for checksum comparison, never `==`.
117
+ - Return `false` from `password_configurations_match?` for a read-only legacy
118
+ strategy so its hashes always migrate away.
119
+ - Never make a legacy strategy the configured `hashing_strategy`, or new
120
+ passwords will be written with it.
@@ -0,0 +1,86 @@
1
+ # Migrating passwords
2
+
3
+ A password hash cannot be converted from one scheme to another — the plain-text
4
+ password is needed to produce the new hash. `:password_hashable` therefore
5
+ migrates lazily: it re-hashes each user at the moment they next sign in
6
+ successfully, because that is the only time the password is available.
7
+
8
+ No batch job, no forced reset, no downtime. Users who never return keep their old
9
+ hash indefinitely, which is why old hashes must stay readable.
10
+
11
+ ## What happens on sign-in
12
+
13
+ `valid_password?` runs this sequence:
14
+
15
+ 1. **Verify** the password against the stored hash, using the scheme that hash
16
+ records — not the configured one. A wrong password returns `false` and
17
+ nothing else happens.
18
+ 2. **Skip** the migration if `password_confirmation` is present, which means the
19
+ user is in the middle of changing their password. The new password is about
20
+ to be written with the current settings anyway.
21
+ 3. **Compare** the stored hash's parameters against the configured ones. If they
22
+ already match, stop.
23
+ 4. **Re-hash** the password with the current strategy and settings, and save.
24
+
25
+ Step 3 is why changing a cost parameter is enough to trigger a migration. Raising
26
+ `hash_iterations`, or switching Argon2 profile, re-hashes everyone on their next
27
+ sign-in without any change of strategy.
28
+
29
+ ## Migration is reversible
30
+
31
+ Nothing about this is one-way. Set `hashing_strategy` back and users migrate back
32
+ on their next sign-in. There is no separate rollback path — the same mechanism
33
+ runs in both directions, which makes it safe to try a strategy and change your
34
+ mind.
35
+
36
+ ## Rolling it out
37
+
38
+ Start by naming the strategy you already use, so that nothing re-hashes and you
39
+ can confirm the module is wired up correctly:
40
+
41
+ ```ruby
42
+ devise :password_hashable, hashing_strategy: :bcrypt
43
+ ```
44
+
45
+ Once that is deployed and sign-ins are healthy, change the strategy. Migration
46
+ begins on the next sign-in for each user.
47
+
48
+ ## Interaction with Devise's Validatable
49
+
50
+ Devise's `Validatable` gates password presence and confirmation on
51
+ `password_required?`. While a re-hash is in progress this module returns `false`
52
+ from that method, so a re-hash cannot be rejected by a validation intended for
53
+ user-supplied passwords.
54
+
55
+ That suspension lasts only for the re-hash. Once it finishes, presence and
56
+ confirmation validations apply again as normal.
57
+
58
+ ## Records with no password
59
+
60
+ A user whose `encrypted_password` is blank — an OAuth-only account, or an
61
+ invitation that has not been accepted — is never migrated and never verifies a
62
+ password. `authenticatable_salt` returns `nil` for such a record rather than
63
+ raising.
64
+
65
+ ## Unreadable hashes
66
+
67
+ If a stored hash cannot be parsed by the strategy that owns its prefix, the
68
+ strategy raises `Devise::Hashable::InvalidPasswordHash`. A hash whose scheme is
69
+ not recognised at all is treated as unknown and simply fails verification,
70
+ returning `false` rather than raising.
71
+
72
+ ```ruby
73
+ begin
74
+ user.valid_password?(params[:password])
75
+ rescue Devise::Hashable::InvalidPasswordHash => e
76
+ Rails.logger.warn("Unreadable password hash for user #{user.id}: #{e.message}")
77
+ false
78
+ end
79
+ ```
80
+
81
+ ## Session tokens
82
+
83
+ Devise builds its session token from `authenticatable_salt`. Bcrypt hashes use
84
+ Devise's own implementation. The other strategies return the salt decoded out of
85
+ the stored hash, so a re-hash changes the salt and therefore invalidates that
86
+ user's existing sessions — they sign in again as normal.
@@ -0,0 +1,75 @@
1
+ # Argon2id
2
+
3
+ Winner of the Password Hashing Competition and the scheme RFC 9106 recommends.
4
+ Unlike PBKDF2 it is memory-hard, which is what makes it expensive to attack with
5
+ GPUs and ASICs. Prefer it unless you need a FIPS-aligned KDF, in which case use
6
+ [PBKDF2](pbkdf2.md).
7
+
8
+ ```ruby
9
+ devise :password_hashable,
10
+ hashing_strategy: :argon2,
11
+ argon2_profile: :rfc_9106_low_memory
12
+ ```
13
+
14
+ Only the `argon2id` variant is supported. `argon2i` and `argon2d` hashes raise
15
+ `Devise::Hashable::InvalidPasswordHash`.
16
+
17
+ ## Profiles
18
+
19
+ Cost is chosen by naming a profile rather than by setting parameters
20
+ individually. The values come from the `argon2` gem's own profile table.
21
+
22
+ | Profile | Memory | Time | Lanes | Use |
23
+ | --- | --- | --- | --- | --- |
24
+ | `:rfc_9106_high_memory` | 2 GiB | 1 | 4 | RFC 9106 FIRST RECOMMENDED |
25
+ | `:rfc_9106_low_memory` | 64 MiB | 3 | 4 | RFC 9106 SECOND RECOMMENDED — the default |
26
+ | `:unsafe_cheapest` | 8 KiB | 1 | 1 | Test suites only. Offers no real protection |
27
+
28
+ Any other value raises `ArgumentError`.
29
+
30
+ ### Sizing
31
+
32
+ The memory figure is per hash **in flight**, not per process. `:rfc_9106_high_memory`
33
+ needs 2 GiB for every password being hashed at that moment, so ten concurrent
34
+ sign-ins want 20 GiB. It is a realistic choice for a low-traffic admin system and
35
+ an unrealistic one for a busy public site.
36
+
37
+ `:rfc_9106_low_memory` is the default because 64 MiB stays workable under
38
+ concurrency while remaining memory-hard. Size your web workers against
39
+ `64 MiB x concurrent sign-ins` before raising it.
40
+
41
+ Use `:unsafe_cheapest` in your test environment. A test suite that signs users in
42
+ repeatedly at 64 MiB a time is needlessly slow:
43
+
44
+ ```ruby
45
+ devise :password_hashable,
46
+ hashing_strategy: :argon2,
47
+ argon2_profile: Rails.env.test? ? :unsafe_cheapest : :rfc_9106_low_memory
48
+ ```
49
+
50
+ ## Stored format
51
+
52
+ ```
53
+ $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
54
+ | | | | |
55
+ | | | | checksum
56
+ | | | salt
57
+ | | cost parameters
58
+ | Argon2 version
59
+ variant
60
+ ```
61
+
62
+ Because the parameters travel inside the hash, verification does not depend on
63
+ the configured profile. Changing profile never invalidates existing passwords.
64
+
65
+ ## When a hash is considered current
66
+
67
+ The `m`, `t` and `p` recorded in the hash must equal those of the configured
68
+ profile. Those expected values are read from the `argon2` gem at comparison time
69
+ rather than being hardcoded here — a profile that the gem later revises would
70
+ otherwise stop matching its own output, and every user would be re-hashed on
71
+ every sign-in.
72
+
73
+ ## Session tokens
74
+
75
+ `authenticatable_salt` returns the salt segment of the hash.
@@ -0,0 +1,57 @@
1
+ # Bcrypt
2
+
3
+ Devise's own scheme. This strategy wraps `Devise::Encryptor`, so enabling it
4
+ changes nothing about how passwords are stored — which makes it the right value
5
+ to start with when you are only checking the module is wired up correctly.
6
+
7
+ ```ruby
8
+ devise :password_hashable, hashing_strategy: :bcrypt
9
+ ```
10
+
11
+ ## Cost
12
+
13
+ Bcrypt has no setting of its own here. It uses Devise's existing `stretches`:
14
+
15
+ ```ruby
16
+ Devise.setup do |config|
17
+ config.stretches = Rails.env.test? ? 1 : 12
18
+ end
19
+ ```
20
+
21
+ Raising `stretches` re-hashes each user on their next sign-in.
22
+
23
+ ## Stored format
24
+
25
+ ```
26
+ $2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewCUlNwjBUOSNILe
27
+ | | |
28
+ | | 22-character salt followed by a 31-character checksum
29
+ | cost
30
+ identifier
31
+ ```
32
+
33
+ ## When a hash is considered current
34
+
35
+ The cost recorded in the hash must equal the configured `stretches`, floored at
36
+ 4. Bcrypt's own minimum cost is 4, so a configuration of `stretches = 1` still
37
+ produces a hash with cost 4, and comparing against the raw setting would
38
+ re-hash every user on every sign-in forever. Comparing against the floor is what
39
+ stops that.
40
+
41
+ ## Limits
42
+
43
+ Only the `$2a$` identifier is recognised. A hash written by an implementation
44
+ that uses `$2b$` or `$2y$` raises `Devise::Hashable::InvalidPasswordHash` rather
45
+ than being silently accepted. If you are importing hashes from another system,
46
+ check the identifier first.
47
+
48
+ `stretches` of zero or less raises `ArgumentError`.
49
+
50
+ Bcrypt also truncates input at 72 bytes, which is a property of bcrypt itself
51
+ rather than of this gem. If that matters to you, use
52
+ [PBKDF2](pbkdf2.md) or [Argon2id](argon2.md), neither of which truncates.
53
+
54
+ ## Session tokens
55
+
56
+ `authenticatable_salt` falls through to Devise's own implementation, so session
57
+ handling is unchanged from a stock Devise application.
@@ -0,0 +1,58 @@
1
+ # PBKDF2
2
+
3
+ PBKDF2-HMAC, the scheme named by FIPS 140 and by NIST SP 800-132. Choose it when
4
+ you need a FIPS-aligned KDF; choose [Argon2id](argon2.md) when you are free to
5
+ pick on security grounds alone, since PBKDF2 is much cheaper to attack with
6
+ purpose-built hardware.
7
+
8
+ ```ruby
9
+ devise :password_hashable,
10
+ hashing_strategy: :pbkdf2,
11
+ digest_algorithm: :sha256,
12
+ hash_iterations: 600_000
13
+ ```
14
+
15
+ ## Settings
16
+
17
+ | Setting | Default | Accepts |
18
+ | --- | --- | --- |
19
+ | `digest_algorithm` | `:sha256` | `:sha1`, `:sha256`, `:sha512` |
20
+ | `hash_iterations` | `50_000` | any integer greater than zero |
21
+
22
+ Anything else raises `ArgumentError`.
23
+
24
+ The `50_000` default is low for current guidance. OWASP recommends 600,000
25
+ iterations for PBKDF2-HMAC-SHA256 and 210,000 for SHA-512. Set a value
26
+ deliberately rather than relying on the default.
27
+
28
+ `:sha1` exists for reading hashes produced by older systems. Do not choose it for
29
+ new hashes.
30
+
31
+ ## Stored format
32
+
33
+ Passlib's `$pbkdf2-<digest>$` format, so hashes written here can be read by
34
+ Python's `passlib` and vice versa:
35
+
36
+ ```
37
+ $pbkdf2-sha256$50000$IKHmYkjgKpM$OElxCJoQ1r3oYbLSFTsf0i/whOFimbGBEw0ZH7BfFek
38
+ | | | |
39
+ | | | checksum
40
+ | | salt
41
+ | iterations
42
+ digest
43
+ ```
44
+
45
+ Salt and checksum use passlib's Base64 variant: standard Base64 with `+`
46
+ replaced by `.` and the `=` padding removed. The salt is 16 bytes from
47
+ `Devise.friendly_token`.
48
+
49
+ ## When a hash is considered current
50
+
51
+ Both the iteration count and the digest algorithm recorded in the hash must match
52
+ the configured values. Changing either re-hashes each user on their next sign-in,
53
+ so raising iterations over time costs nothing but a deploy.
54
+
55
+ ## Session tokens
56
+
57
+ `authenticatable_salt` returns the salt decoded out of the hash, not the encoded
58
+ form stored in the column.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "argon2"
4
+
5
+ module Devise
6
+ module Hashable
7
+ module Strategy
8
+ # Argon2 password hashing
9
+ class Argon2 < Base
10
+ class << self
11
+ # Verifies a plain-text password against an Argon2id hash.
12
+ #
13
+ # @param _klass [Class] the Devise model class (unused; Argon2 encodes
14
+ # its own parameters in the hash)
15
+ # @param encrypted_password [String] the stored Argon2id hash
16
+ # @param password [String] the plain-text password to verify
17
+ # @return [Boolean] true when the password matches
18
+ def compare(_klass, encrypted_password, password)
19
+ ::Argon2::Password.verify_password(password, encrypted_password)
20
+ end
21
+
22
+ # Hashes a plain-text password with Argon2id.
23
+ #
24
+ # @param klass [Class] the Devise model class, read for +argon2_profile+
25
+ # @param password [String] the plain-text password to hash
26
+ # @return [String] the encoded Argon2id hash
27
+ # @raise [ArgumentError] when +argon2_profile+ is not a known profile
28
+ def digest(klass, password)
29
+ ::Argon2::Password.new(profile: base_configs(klass)[:argon2_profile]).create(password)
30
+ end
31
+
32
+ def password_configurations_match?(klass, encrypted_password)
33
+ current_password_configs = split_password_hash(encrypted_password)
34
+ required_password_configs = base_configs(klass)
35
+
36
+ current_password_configs[:mtp] == profile_parameters(required_password_configs[:argon2_profile])
37
+ end
38
+
39
+ def split_password_hash(encrypted_password) # rubocop:disable Metrics/MethodLength
40
+ split_digest = encrypted_password.split("$")
41
+
42
+ raise InvalidPasswordHash, "Invalid password hash for Argon2" unless split_digest.length == 6
43
+
44
+ _, strategy, version, mtp, salt, digest = split_digest
45
+
46
+ raise InvalidPasswordHash, "Invalid password hash strategy for Argon2" unless strategy == "argon2id"
47
+
48
+ {
49
+ strategy: strategy,
50
+ version: version,
51
+ mtp: mtp,
52
+ salt: salt,
53
+ digest: digest
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ # The cost parameters as Argon2 encodes them in the hash, read from the
60
+ # argon2 gem's own profile table rather than hardcoded here, so they
61
+ # cannot drift when that gem revises a profile.
62
+ def profile_parameters(profile)
63
+ costs = ::Argon2::Profiles[profile]
64
+
65
+ "m=#{2**costs[:m_cost]},t=#{costs[:t_cost]},p=#{costs[:p_cost]}"
66
+ end
67
+
68
+ def base_configs(klass)
69
+ unless %i[rfc_9106_high_memory rfc_9106_low_memory unsafe_cheapest].include?(klass.argon2_profile)
70
+ raise(ArgumentError, "argon2_profile: must be one of the following options -> :rfc_9106_high_memory, :rfc_9106_low_memory, :unsafe_cheapest")
71
+ end
72
+
73
+ {
74
+ argon2_profile: klass.argon2_profile
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Hashable
5
+ module Strategy
6
+ # The base class for each hashing strategy.
7
+ #
8
+ # Subclasses implement the four class methods below. Every strategy is
9
+ # stateless: the Devise model class is passed in so the strategy can read
10
+ # the configuration (stretches, iterations, profile) it needs.
11
+ #
12
+ # @abstract Subclass and override {compare}, {digest},
13
+ # {password_configurations_match?} and {split_password_hash}.
14
+ class Base
15
+ class << self
16
+ # Verifies a plain-text password against an existing hash.
17
+ #
18
+ # @param _klass [Class] the Devise model class
19
+ # @param _encrypted_password [String] the stored password hash
20
+ # @param _password [String] the plain-text password to verify
21
+ # @return [Boolean] true when the password matches
22
+ # @raise [NotImplementedError] always, on the base class
23
+ def compare(_klass, _encrypted_password, _password)
24
+ raise NotImplementedError, "You need to implement the 'compare' method in your custom hashing strategy"
25
+ end
26
+
27
+ # Hashes a plain-text password using the strategy's configuration.
28
+ #
29
+ # @param _klass [Class] the Devise model class
30
+ # @param _password [String] the plain-text password to hash
31
+ # @return [String] the encoded password hash
32
+ # @raise [NotImplementedError] always, on the base class
33
+ def digest(_klass, _password)
34
+ raise NotImplementedError, "You need to implement the 'digest' method in your custom hashing strategy"
35
+ end
36
+
37
+ # Whether an existing hash was produced with the currently configured
38
+ # parameters. A false result means the password needs re-hashing.
39
+ #
40
+ # @param _klass [Class] the Devise model class
41
+ # @param _encrypted_password [String] the stored password hash
42
+ # @return [Boolean] true when the hash matches the current config
43
+ # @raise [NotImplementedError] always, on the base class
44
+ def password_configurations_match?(_klass, _encrypted_password)
45
+ raise NotImplementedError, "You need to implement the 'password_configurations_match?' method in your custom hashing strategy"
46
+ end
47
+
48
+ # Splits an encoded password hash into its component parts.
49
+ #
50
+ # @param _encrypted_password [String] the stored password hash
51
+ # @return [Hash] the decoded components of the hash
52
+ # @raise [NotImplementedError] always, on the base class
53
+ def split_password_hash(_encrypted_password)
54
+ raise NotImplementedError, "You need to implement the 'split_password_hash' method in your custom hashing strategy"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Hashable
5
+ module Strategy
6
+ # Wrapper for the Devise' standard encryption strategy Bcrypt
7
+ class Bcrypt < Base
8
+ class << self
9
+ # Verifies a plain-text password against a bcrypt hash.
10
+ #
11
+ # @param klass [Class] the Devise model class
12
+ # @param encrypted_password [String] the stored bcrypt hash
13
+ # @param password [String] the plain-text password to verify
14
+ # @return [Boolean] true when the password matches
15
+ def compare(klass, encrypted_password, password)
16
+ Devise::Encryptor.compare(klass, encrypted_password, password)
17
+ end
18
+
19
+ # Hashes a plain-text password with bcrypt.
20
+ #
21
+ # @param klass [Class] the Devise model class, read for +stretches+
22
+ # @param password [String] the plain-text password to hash
23
+ # @return [String] the encoded bcrypt hash
24
+ def digest(klass, password)
25
+ Devise::Encryptor.digest(klass, password)
26
+ end
27
+
28
+ def password_configurations_match?(klass, encrypted_password)
29
+ current_password_configs = split_password_hash(encrypted_password)
30
+ required_password_configs = base_configs(klass)
31
+
32
+ return false if current_password_configs[:stretches] != [required_password_configs[:stretches], 4].max
33
+
34
+ true
35
+ end
36
+
37
+ def split_password_hash(encrypted_password)
38
+ split_digest = encrypted_password.split("$")
39
+
40
+ raise InvalidPasswordHash, "Invalid password hash for Bcrypt" unless split_digest.length == 4
41
+
42
+ _, strategy, stretches, _checksum = split_digest
43
+
44
+ raise InvalidPasswordHash, "Invalid password hash strategy for Bcrypt" unless strategy == "2a"
45
+
46
+ {
47
+ stretches: stretches.to_i
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def base_configs(klass)
54
+ raise(ArgumentError, "stretches: must be greater than zero") if klass.stretches.to_i <= 0
55
+
56
+ {
57
+ stretches: klass.stretches.to_i
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Hashable
5
+ module Strategy
6
+ # Pbkdf2 hashing
7
+ class Pbkdf2 < Base
8
+ class << self
9
+ # Verifies a plain-text password against a PBKDF2 hash.
10
+ #
11
+ # @param _klass [Class] the Devise model class (unused; the iteration
12
+ # count and digest are read from the hash itself)
13
+ # @param encrypted_password [String] the stored PBKDF2 hash
14
+ # @param password [String] the plain-text password to verify
15
+ # @return [Boolean] true when the password matches
16
+ def compare(_klass, encrypted_password, password)
17
+ split_digest = split_password_hash(encrypted_password)
18
+ value_to_test = digest_checksum(password, split_digest[:hash_iterations], split_digest[:salt], split_digest[:digest_algorithm])
19
+
20
+ Devise.secure_compare(split_digest[:checksum], value_to_test)
21
+ end
22
+
23
+ # Hashes a plain-text password with PBKDF2.
24
+ #
25
+ # @param klass [Class] the Devise model class, read for +hash_iterations+
26
+ # and +digest_algorithm+
27
+ # @param password [String] the plain-text password to hash
28
+ # @return [String] the encoded PBKDF2 hash, in passlib format
29
+ def digest(klass, password)
30
+ configs = base_configs(klass)
31
+ salt = Devise.friendly_token(16)
32
+ checksum = digest_checksum(password, configs[:hash_iterations], salt, configs[:digest_algorithm])
33
+
34
+ format_hash("pbkdf2-#{configs[:digest_algorithm]}", configs[:hash_iterations], salt, checksum)
35
+ end
36
+
37
+ def password_configurations_match?(klass, encrypted_password)
38
+ current_password_configs = split_password_hash(encrypted_password)
39
+ required_password_configs = base_configs(klass)
40
+
41
+ return false if current_password_configs[:hash_iterations] != required_password_configs[:hash_iterations]
42
+ return false if current_password_configs[:digest_algorithm] != required_password_configs[:digest_algorithm]
43
+
44
+ true
45
+ end
46
+
47
+ def split_password_hash(encrypted_password) # rubocop:disable Metrics/MethodLength
48
+ split_digest = encrypted_password.split("$")
49
+
50
+ raise InvalidPasswordHash, "Invalid password hash for PBKDF2" unless split_digest.length == 5
51
+
52
+ _, strategy, hash_iterations, salt, checksum = split_digest
53
+
54
+ raise InvalidPasswordHash, "Invalid password hash strategy for PBKDF2" unless strategy.start_with?("pbkdf2-")
55
+
56
+ split_strategy = strategy.split("-")
57
+ base_strategy, digest_algorithm = split_strategy
58
+
59
+ {
60
+ strategy: strategy,
61
+ base_strategy: base_strategy,
62
+ digest_algorithm: digest_algorithm,
63
+ hash_iterations: hash_iterations.to_i,
64
+ salt: passlib_decode64(salt),
65
+ checksum: passlib_decode64(checksum)
66
+ }
67
+ end
68
+
69
+ private
70
+
71
+ def base_configs(klass)
72
+ raise(ArgumentError, "digest_algorithm: must be one of the following options -> :sha1, :sha256, :sha512") unless %i[sha1 sha256 sha512].include?(klass.digest_algorithm)
73
+ raise(ArgumentError, "hash_iterations: must be greater than zero") if klass.hash_iterations.to_i <= 0
74
+
75
+ {
76
+ hash_iterations: klass.hash_iterations.to_i,
77
+ digest_algorithm: klass.digest_algorithm.to_s
78
+ }
79
+ end
80
+
81
+ def digest_checksum(password, hash_iterations, salt, digest_algorithm)
82
+ hash = OpenSSL::Digest.new(digest_algorithm)
83
+
84
+ pbkdf2_checksum(hash, password, hash_iterations, salt)
85
+ end
86
+
87
+ def pbkdf2_checksum(hash, password, hash_iterations, salt)
88
+ OpenSSL::KDF.pbkdf2_hmac(
89
+ password.to_s,
90
+ salt: [salt].pack("H*"),
91
+ iterations: hash_iterations,
92
+ hash: hash,
93
+ length: hash.digest_length
94
+ ).unpack1("H*")
95
+ end
96
+
97
+ def format_hash(strategy, hash_iterations, salt, checksum)
98
+ encoded_salt = passlib_encode64(salt)
99
+ encoded_checksum = passlib_encode64(checksum)
100
+
101
+ "$#{strategy}$#{hash_iterations}$#{encoded_salt}$#{encoded_checksum}"
102
+ end
103
+
104
+ # Passlib-style Base64 encoding:
105
+ # - Replaces '+' with '.'
106
+ # - Strips trailing newline and '=='
107
+ def passlib_encode64(value)
108
+ Base64.strict_encode64([value].pack("H*")).tr("+", ".").delete("=")
109
+ end
110
+
111
+ def passlib_decode64(value)
112
+ enc = value.tr(".", "+")
113
+ Base64.decode64(enc).unpack1("H*")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module Hashable
5
+ # The version of the devise-hashable gem.
6
+ VERSION = "1.0.0"
7
+ end
8
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "devise"
4
+ require_relative "hashable/version"
5
+
6
+ module Devise # :nodoc:
7
+ mattr_accessor(:hashing_strategy)
8
+ @@hashing_strategy = nil
9
+
10
+ mattr_accessor(:digest_algorithm)
11
+ @@digest_algorithm = :sha256
12
+
13
+ mattr_accessor(:hash_iterations)
14
+ @@hash_iterations = 50_000
15
+
16
+ mattr_accessor(:argon2_profile)
17
+ @@argon2_profile = :rfc_9106_low_memory
18
+
19
+ module Hashable # :nodoc:
20
+ # Raised when a stored password hash cannot be parsed by the strategy that
21
+ # was asked to read it, either because it has the wrong number of segments
22
+ # or because it names a different algorithm.
23
+ class InvalidPasswordHash < StandardError; end
24
+
25
+ module Strategy # :nodoc:
26
+ autoload(:Argon2, "devise/hashable/strategy/argon2")
27
+ autoload(:Base, "devise/hashable/strategy/base")
28
+ autoload(:Bcrypt, "devise/hashable/strategy/bcrypt")
29
+ autoload(:Pbkdf2, "devise/hashable/strategy/pbkdf2")
30
+ end
31
+ end
32
+ end
33
+
34
+ Devise.add_module(:password_hashable, model: "devise/models/password_hashable")
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("devise/strategies/database_authenticatable")
4
+
5
+ module Devise
6
+ # Namespace for the model concerns Devise mixes into your models.
7
+ module Models
8
+ # Devise module that transparently re-hashes a user's password to the
9
+ # configured strategy the next time they sign in successfully.
10
+ #
11
+ # Enable it on a model with:
12
+ #
13
+ # devise :password_hashable, hashing_strategy: :argon2
14
+ module PasswordHashable
15
+ extend(ActiveSupport::Concern)
16
+
17
+ included do
18
+ after_validation :unset_migrating_password_hashing_strategy
19
+ end
20
+
21
+ def valid_password?(password)
22
+ return false unless password_matches?(password)
23
+ return true if skip_password_migration?
24
+
25
+ migrate_password!(password)
26
+ end
27
+
28
+ # The salt used to build the Devise session token.
29
+ #
30
+ # Bcrypt hashes fall through to Devise's own implementation; the other
31
+ # strategies return the salt decoded out of the stored hash.
32
+ #
33
+ # @return [String, nil] the salt, or nil when no password is set
34
+ def authenticatable_salt
35
+ # Devise's schema declares encrypted_password as null: false, default: "",
36
+ # so a user who has never set a password holds "" rather than nil.
37
+ return if encrypted_password.blank?
38
+ return super if encrypted_password_strategy == :bcrypt
39
+
40
+ devise_rehash_class(encrypted_password_strategy).split_password_hash(encrypted_password)[:salt]
41
+ end
42
+
43
+ protected
44
+
45
+ def password_digest(password)
46
+ devise_rehash_class.digest(self.class, password)
47
+ end
48
+
49
+ # skip the password_migration when
50
+ # updating or resetting password
51
+ def skip_password_migration?
52
+ password_confirmation.present?
53
+ end
54
+
55
+ private
56
+
57
+ ##
58
+ # Provides a check list for identifying the encrypted_password hashing strategy
59
+ # to match against, the returned symbol is then used to look up the Class.
60
+ # Return nil if nothing is matched. You should override this method when adding
61
+ # a custom hashing strategy.
62
+ # Example
63
+ #
64
+ # def encrypted_password_strategy_check
65
+ # return :my_custom if encrypted_password&.start_with?("$custom-")
66
+ #
67
+ # super
68
+ # end
69
+ #
70
+ def encrypted_password_strategy_check
71
+ return :bcrypt if encrypted_password&.start_with?("$2a$")
72
+ return :pbkdf2 if encrypted_password&.start_with?("$pbkdf2-")
73
+ return :argon2 if encrypted_password&.start_with?("$argon2")
74
+
75
+ nil
76
+ end
77
+
78
+ def encrypted_password_strategy
79
+ return encrypted_password_strategy_check if encrypted_password_strategy_check.present?
80
+
81
+ :unknown
82
+ end
83
+
84
+ def password_matches?(password)
85
+ return false if encrypted_password_strategy == :unknown
86
+
87
+ devise_rehash_class(encrypted_password_strategy).compare(self.class, encrypted_password, password)
88
+ end
89
+
90
+ def migrate_password!(password)
91
+ return true if password_configurations_match?
92
+
93
+ migrating_password_hashing_strategy
94
+
95
+ begin
96
+ update_attribute(:password, password)
97
+ ensure
98
+ # update_attribute saves without validating, so after_validation does
99
+ # not fire here. Clearing the flag explicitly stops it leaking past the
100
+ # re-hash and disabling Validatable's password checks on this record.
101
+ unset_migrating_password_hashing_strategy
102
+ end
103
+ end
104
+
105
+ # Sets the instance variable @migrating_password_hashing_strategy to true while the password hashing strategy update is taking place.
106
+ # this can be used as you require to ensure a your password hashing strategy process happens smoothly without any custom validations
107
+ # interfering.
108
+ #
109
+ def migrating_password_hashing_strategy
110
+ @migrating_password_hashing_strategy = true
111
+ end
112
+
113
+ # Skip password validation when migrating the password hashing strategy if Devise Validatable is used.
114
+ # from https://github.com/heartcombo/devise/blob/bb18f4d3805be0bf5f45e21be39625c7cfd9c1d6/lib/devise/models/validatable.rb#L55
115
+ #
116
+ def password_required?
117
+ return false if @migrating_password_hashing_strategy
118
+
119
+ super
120
+ end
121
+
122
+ def unset_migrating_password_hashing_strategy
123
+ remove_instance_variable(:@migrating_password_hashing_strategy) if defined?(@migrating_password_hashing_strategy)
124
+ end
125
+
126
+ def password_configurations_match?
127
+ return false if encrypted_password_strategy != self.class.hashing_strategy
128
+
129
+ devise_rehash_class.password_configurations_match?(self.class, encrypted_password)
130
+ end
131
+
132
+ def devise_rehash_class(strategy_choice = nil)
133
+ return self.class.rehash_class if strategy_choice.nil?
134
+
135
+ Devise::Hashable::Strategy.const_get(strategy_choice.to_s.classify)
136
+ end
137
+
138
+ module ClassMethods # :nodoc:
139
+ Devise::Models.config(self, :hashing_strategy, :digest_algorithm, :hash_iterations, :argon2_profile)
140
+
141
+ # Returns the class for the configured strategy. strategy
142
+ def rehash_class
143
+ @rehash_class ||=
144
+ case hashing_strategy
145
+ when nil
146
+ raise("You need to specify a hashing_strategy: in Devise configuration to use :password_hashable")
147
+ else
148
+ Devise::Hashable::Strategy.const_get(hashing_strategy.to_s.classify)
149
+ end
150
+ rescue NameError
151
+ raise("Configured password hashing_strategy '#{hashing_strategy.to_sym}' could not be found for :password_hashable")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: devise-hashable
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Kennedy
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: argon2
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: devise
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '4.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '6.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '4.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '6.0'
46
+ description: Allows Devise passwords to be re-encrypted with various hashing strategies
47
+ and configurations.
48
+ email:
49
+ - m.kennedy@me.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - CHANGELOG.md
55
+ - LICENSE.txt
56
+ - README.md
57
+ - docs/configuration.md
58
+ - docs/custom-strategies.md
59
+ - docs/migrating-passwords.md
60
+ - docs/strategies/argon2.md
61
+ - docs/strategies/bcrypt.md
62
+ - docs/strategies/pbkdf2.md
63
+ - lib/devise/hashable.rb
64
+ - lib/devise/hashable/strategy/argon2.rb
65
+ - lib/devise/hashable/strategy/base.rb
66
+ - lib/devise/hashable/strategy/bcrypt.rb
67
+ - lib/devise/hashable/strategy/pbkdf2.rb
68
+ - lib/devise/hashable/version.rb
69
+ - lib/devise/models/password_hashable.rb
70
+ homepage: https://github.com/MatthewKennedy/devise-hashable
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/MatthewKennedy/devise-hashable
75
+ source_code_uri: https://github.com/MatthewKennedy/devise-hashable
76
+ changelog_uri: https://github.com/MatthewKennedy/devise-hashable/releases
77
+ rubygems_mfa_required: 'true'
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.3.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.6.9
93
+ specification_version: 4
94
+ summary: A password hashing tool for Devise
95
+ test_files: []