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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/docs/configuration.md +64 -0
- data/docs/custom-strategies.md +120 -0
- data/docs/migrating-passwords.md +86 -0
- data/docs/strategies/argon2.md +75 -0
- data/docs/strategies/bcrypt.md +57 -0
- data/docs/strategies/pbkdf2.md +58 -0
- data/lib/devise/hashable/strategy/argon2.rb +81 -0
- data/lib/devise/hashable/strategy/base.rb +60 -0
- data/lib/devise/hashable/strategy/bcrypt.rb +64 -0
- data/lib/devise/hashable/strategy/pbkdf2.rb +119 -0
- data/lib/devise/hashable/version.rb +8 -0
- data/lib/devise/hashable.rb +34 -0
- data/lib/devise/models/password_hashable.rb +156 -0
- metadata +95 -0
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
|
+
[](https://github.com/MatthewKennedy/devise-hashable/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/MatthewKennedy/devise-hashable/actions/workflows/rubocop.yml)
|
|
5
|
+
[](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,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: []
|