-
Notifications
You must be signed in to change notification settings - Fork 18
[#8844] Upgrade devise-two-factor prior to Rails 8 upgrade #6721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| ### | ||
| # Copyright Green River Data Group, Inc. | ||
| # | ||
| # License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md | ||
| ### | ||
|
|
||
| # frozen_string_literal: true | ||
|
|
||
| # devise-two-factor 6.x stores the TOTP secret in a Rails-encrypted `otp_secret` | ||
| # column. Existing secrets remain in encrypted_otp_secret/_iv/_salt and are read via | ||
| # User#legacy_otp_secret; this nullable column holds new and re-enrolled secrets. | ||
| class AddOtpSecretToUsers < ActiveRecord::Migration[7.2] | ||
| def change | ||
| add_column :users, :otp_secret, :string | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| ### | ||
| # Copyright Green River Data Group, Inc. | ||
| # | ||
| # License detail: https://github.com/greenriver/hmis-warehouse/blob/production/LICENSE.md | ||
| ### | ||
|
|
||
| # frozen_string_literal: true | ||
|
|
||
| require 'rails_helper' | ||
|
|
||
| # devise-two-factor 6.x stores new OTP secrets in a Rails-encrypted `otp_secret` column, | ||
| # but existing users' secrets remain in the legacy attr_encrypted columns | ||
| # (encrypted_otp_secret/_iv/_salt). User#legacy_otp_secret must decrypt those so existing | ||
| # 2FA keeps working with no data migration. | ||
| RSpec.describe 'User OTP secret legacy bridge', type: :model do | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion for AI review: Changes to Legacy writer fixture
New examples added
Assertion tightening
diff --git a/spec/models/user_otp_secret_legacy_bridge_spec.rb b/spec/models/user_otp_secret_legacy_bridge_spec.rb
index dfaa250e6d..c72596c75a 100644
--- a/spec/models/user_otp_secret_legacy_bridge_spec.rb
+++ b/spec/models/user_otp_secret_legacy_bridge_spec.rb
@@ -13,12 +13,31 @@ require 'rails_helper'
# (encrypted_otp_secret/_iv/_salt). User#legacy_otp_secret must decrypt those so existing
# 2FA keeps working with no data migration.
RSpec.describe 'User OTP secret legacy bridge', type: :model do
- # Writes a secret into the legacy encrypted_otp_secret* columns exactly the way
- # devise-two-factor <= 4.x did (attr_encrypted, per-attribute iv+salt, default
- # aes-256-gcm) — i.e. how production secrets are currently stored.
+ # Writes a secret into the legacy encrypted_otp_secret* columns the way
+ # devise-two-factor <= 4.x did (attr_encrypted, per-attribute iv+salt, aes-256-gcm),
+ # i.e. how production secrets are currently stored.
+ #
+ # Every crypto option is pinned explicitly rather than left to attr_encrypted's
+ # defaults, so the fixture cannot silently drift out of production's format when the
+ # gem's defaults change:
+ # - algorithm: aes-256-gcm — must match the concern's OpenSSL::Cipher('aes-256-gcm')
+ # - encode*/base64 on all three — REQUIRED, not stylistic: encrypted_otp_secret* are
+ # `character varying` columns (see db/structure.sql). attr_encrypted's default
+ # `encode: false` emits raw AES-GCM bytes, which PostgreSQL rejects on write with
+ # `PG::CharacterNotInRepertoire (invalid byte sequence for encoding "UTF8")`. So the
+ # only format that can physically live in these columns — and therefore the only
+ # format production can hold — is Base64. The `stored legacy columns are Base64` test
+ # below asserts this so the fixture can't drift to an unstorable (non-production) shape.
+ #
+ # NOTE: this reproduces production's *format*, but the bytes are still generated by
+ # attr_encrypted here rather than captured from production. The strongest possible
+ # version of this test is a golden known-answer vector: a real (encrypted_otp_secret,
+ # _iv, _salt) triple captured from a production/staging user asserted to decrypt to that
+ # user's known secret. Add one when such a sample is available.
+ #
# Virtual attr `legacy_secret` is mapped to the encrypted_otp_secret* columns (via
# `attribute:`) so it does not collide with the real `otp_secret` column that
- # devise-two-factor 6.x adds. This reproduces production's storage format exactly.
+ # devise-two-factor 6.x adds.
let(:legacy_writer_class) do
Class.new(ActiveRecord::Base) do
self.table_name = 'users'
@@ -26,6 +45,7 @@ RSpec.describe 'User OTP secret legacy bridge', type: :model do
attr_encrypted :legacy_secret,
key: ENV['ENCRYPTION_KEY'],
mode: :per_attribute_iv_and_salt,
+ algorithm: 'aes-256-gcm',
encode: true,
encode_iv: true,
encode_salt: true,
@@ -50,11 +70,77 @@ RSpec.describe 'User OTP secret legacy bridge', type: :model do
expect(user.otp_secret).to eq(plaintext_secret)
end
+ # Guards fixture fidelity: the concern decrypts by Base64.decode64'ing all three
+ # columns, and these `character varying` columns can only physically hold a UTF-8
+ # (Base64) string — raw AES-GCM bytes are rejected by PostgreSQL on write. If a future
+ # change made the fixture store some other shape, otp_secret would no longer reflect how
+ # production data is actually stored; this catches that drift at the source.
+ it 'stores the legacy columns in the Base64 format the concern decodes' do
+ user = user_with_legacy_secret(plaintext_secret)
+ raw = User.connection.select_one(
+ 'SELECT encrypted_otp_secret, encrypted_otp_secret_iv, encrypted_otp_secret_salt ' \
+ "FROM users WHERE id = #{user.id}",
+ )
+
+ # None of the columns are raw binary — PostgreSQL could not store that in these
+ # `character varying` columns, and the concern's Base64.decode64 would mangle it.
+ raw.each_value do |column|
+ expect(column.dup.force_encoding('UTF-8').valid_encoding?).to be(true)
+ end
+ # The ciphertext body is genuinely Base64: re-encoding the decoded bytes reproduces
+ # the stored value (modulo attr_encrypted's line wrapping). Arbitrary non-Base64 text
+ # would not survive this round-trip.
+ body = raw['encrypted_otp_secret']
+ expect(Base64.strict_encode64(Base64.decode64(body))).to eq(body.delete("\n"))
+ end
+
it 'validates an OTP generated from the legacy secret' do
user = user_with_legacy_secret(plaintext_secret)
code = ROTP::TOTP.new(plaintext_secret).now
- expect(user.validate_and_consume_otp!(code)).to be_truthy
+ expect(user.validate_and_consume_otp!(code)).to be(true)
+ end
+
+ # During the migration window a user can hold BOTH a legacy secret and a new
+ # Rails-encrypted secret — e.g. an existing user re-enrolls after the upgrade. The new
+ # secret must win. If precedence were inverted, a freshly re-enrolled authenticator
+ # would silently validate against the stale legacy secret.
+ it 'prefers the new Rails-encrypted secret over the legacy secret when both exist' do
+ user = user_with_legacy_secret(plaintext_secret)
+ new_secret = User.generate_otp_secret
+ user.update!(otp_secret: new_secret)
+ user.reload
+
+ expect(user.encrypted_otp_secret).to be_present # legacy secret is still stored...
+ expect(user.otp_secret).to eq(new_secret) # ...but the new secret takes precedence
+ # ...and with BOTH columns populated, the functional login path resolves to the new
+ # secret (unique to this test — the fresh-secret test only ever has the new column).
+ expect(user.validate_and_consume_otp!(ROTP::TOTP.new(new_secret).now)).to be(true)
+ end
+
+ # A user with no 2FA secret at all (new user, or one who never enrolled) must read back
+ # as nil, not raise. This exercises the `return nil unless self[:encrypted_otp_secret]`
+ # guard directly; without it, Base64.decode64(nil) would blow up on every such read.
+ it 'returns nil when the user has no OTP secret at all' do
+ user = create(:user)
+
+ expect(user[:otp_secret]).to be_nil
+ expect(user.otp_secret).to be_nil
+ end
+
+ # When the legacy secret cannot be decrypted — the realistic case being a rotated
+ # ENCRYPTION_KEY, which fails GCM authentication — the bridge raises rather than
+ # returning a wrong or empty secret. This pins the current fail-loud behavior: if the
+ # team later wants graceful degradation (return nil so the user is prompted to re-enroll
+ # rather than 500'ing on login), that becomes a deliberate change that flips this test.
+ it 'raises when the legacy secret cannot be decrypted' do
+ user = user_with_legacy_secret(plaintext_secret)
+ # Keep valid Base64 but swap in ciphertext that fails the GCM auth tag check — the same
+ # failure mode a wrong/rotated key produces.
+ user.update_columns(encrypted_otp_secret: Base64.encode64('not the real ciphertext'))
+ user.reload
+
+ expect { user.otp_secret }.to raise_error(OpenSSL::Cipher::CipherError)
end
# New/re-enrolled secrets use the Rails-encrypted otp_secret column; this also proves
@@ -72,6 +158,6 @@ RSpec.describe 'User OTP secret legacy bridge', type: :model do
expect(raw).not_to eq(user.otp_secret) # stored encrypted at rest
code = ROTP::TOTP.new(user.otp_secret).now
- expect(user.validate_and_consume_otp!(code)).to be_truthy
+ expect(user.validate_and_consume_otp!(code)).to be(true)
end
end |
||
| # Writes a secret into the legacy encrypted_otp_secret* columns the way | ||
| # devise-two-factor <= 4.x did (attr_encrypted, per-attribute iv+salt, aes-256-gcm), | ||
| # i.e. how production secrets are currently stored. | ||
| # | ||
| # Every crypto option is pinned explicitly rather than left to attr_encrypted's | ||
| # defaults, so the fixture cannot silently drift out of production's format when the | ||
| # gem's defaults change: | ||
| # - algorithm: aes-256-gcm — must match the concern's OpenSSL::Cipher('aes-256-gcm') | ||
| # - encode*/base64 on all three — REQUIRED, not stylistic: encrypted_otp_secret* are | ||
| # `character varying` columns (see db/structure.sql). attr_encrypted's default | ||
| # `encode: false` emits raw AES-GCM bytes, which PostgreSQL rejects on write with | ||
| # `PG::CharacterNotInRepertoire (invalid byte sequence for encoding "UTF8")`. So the | ||
| # only format that can physically live in these columns — and therefore the only | ||
| # format production can hold — is Base64. The `stored legacy columns are Base64` test | ||
| # below asserts this so the fixture can't drift to an unstorable (non-production) shape. | ||
| # | ||
| # NOTE: this reproduces production's *format*, but the bytes are still generated by | ||
| # attr_encrypted here rather than captured from production. The strongest possible | ||
| # version of this test is a golden known-answer vector: a real (encrypted_otp_secret, | ||
| # _iv, _salt) triple captured from a production/staging user asserted to decrypt to that | ||
| # user's known secret. Add one when such a sample is available. | ||
| # | ||
| # Virtual attr `legacy_secret` is mapped to the encrypted_otp_secret* columns (via | ||
| # `attribute:`) so it does not collide with the real `otp_secret` column that | ||
| # devise-two-factor 6.x adds. | ||
| let(:legacy_writer_class) do | ||
| Class.new(ActiveRecord::Base) do | ||
| self.table_name = 'users' | ||
| extend AttrEncrypted | ||
| attr_encrypted :legacy_secret, | ||
| key: ENV['ENCRYPTION_KEY'], | ||
| mode: :per_attribute_iv_and_salt, | ||
| algorithm: 'aes-256-gcm', | ||
| encode: true, | ||
| encode_iv: true, | ||
| encode_salt: true, | ||
| attribute: 'encrypted_otp_secret' | ||
| end | ||
| end | ||
|
|
||
| let(:plaintext_secret) { User.generate_otp_secret } | ||
|
|
||
| def user_with_legacy_secret(secret) | ||
| user = create(:user) | ||
| writer = legacy_writer_class.find(user.id) | ||
| writer.legacy_secret = secret | ||
| writer.save!(validate: false) | ||
| user.reload | ||
| end | ||
|
|
||
| it 'reads a legacy-encrypted secret through otp_secret' do | ||
| user = user_with_legacy_secret(plaintext_secret) | ||
|
|
||
| expect(user[:otp_secret]).to be_nil # nothing in the new Rails-encrypted column | ||
| expect(user.otp_secret).to eq(plaintext_secret) | ||
| end | ||
|
|
||
| # Guards fixture fidelity: the concern decrypts by Base64.decode64'ing all three | ||
| # columns, and these `character varying` columns can only physically hold a UTF-8 | ||
| # (Base64) string — raw AES-GCM bytes are rejected by PostgreSQL on write. If a future | ||
| # change made the fixture store some other shape, otp_secret would no longer reflect how | ||
| # production data is actually stored; this catches that drift at the source. | ||
| it 'stores the legacy columns in the Base64 format the concern decodes' do | ||
| user = user_with_legacy_secret(plaintext_secret) | ||
| raw = User.connection.select_one( | ||
| 'SELECT encrypted_otp_secret, encrypted_otp_secret_iv, encrypted_otp_secret_salt ' \ | ||
| "FROM users WHERE id = #{user.id}", | ||
| ) | ||
|
|
||
| # None of the columns are raw binary — PostgreSQL could not store that in these | ||
| # `character varying` columns, and the concern's Base64.decode64 would mangle it. | ||
| raw.each_value do |column| | ||
| expect(column.dup.force_encoding('UTF-8').valid_encoding?).to be(true) | ||
| end | ||
| # The ciphertext body is genuinely Base64: re-encoding the decoded bytes reproduces | ||
| # the stored value (modulo attr_encrypted's line wrapping). Arbitrary non-Base64 text | ||
| # would not survive this round-trip. | ||
| body = raw['encrypted_otp_secret'] | ||
| expect(Base64.strict_encode64(Base64.decode64(body))).to eq(body.delete("\n")) | ||
| end | ||
|
|
||
| it 'validates an OTP generated from the legacy secret' do | ||
| user = user_with_legacy_secret(plaintext_secret) | ||
|
|
||
| code = ROTP::TOTP.new(plaintext_secret).now | ||
| expect(user.validate_and_consume_otp!(code)).to be(true) | ||
| end | ||
|
|
||
| # During the migration window a user can hold BOTH a legacy secret and a new | ||
| # Rails-encrypted secret — e.g. an existing user re-enrolls after the upgrade. The new | ||
| # secret must win. If precedence were inverted, a freshly re-enrolled authenticator | ||
| # would silently validate against the stale legacy secret. | ||
| it 'prefers the new Rails-encrypted secret over the legacy secret when both exist' do | ||
| user = user_with_legacy_secret(plaintext_secret) | ||
| new_secret = User.generate_otp_secret | ||
| user.update!(otp_secret: new_secret) | ||
| user.reload | ||
|
|
||
| expect(user.encrypted_otp_secret).to be_present # legacy secret is still stored... | ||
| expect(user.otp_secret).to eq(new_secret) # ...but the new secret takes precedence | ||
| # ...and with BOTH columns populated, the functional login path resolves to the new | ||
| # secret (unique to this test — the fresh-secret test only ever has the new column). | ||
| expect(user.validate_and_consume_otp!(ROTP::TOTP.new(new_secret).now)).to be(true) | ||
| end | ||
|
|
||
| # A user with no 2FA secret at all (new user, or one who never enrolled) must read back | ||
| # as nil, not raise. This exercises the `return nil unless self[:encrypted_otp_secret]` | ||
| # guard directly; without it, Base64.decode64(nil) would blow up on every such read. | ||
| it 'returns nil when the user has no OTP secret at all' do | ||
| user = create(:user) | ||
|
|
||
| expect(user[:otp_secret]).to be_nil | ||
| expect(user.otp_secret).to be_nil | ||
| end | ||
|
|
||
| # When the legacy secret cannot be decrypted — the realistic case being a rotated | ||
| # ENCRYPTION_KEY, which fails GCM authentication — the bridge raises rather than | ||
| # returning a wrong or empty secret. This pins the current fail-loud behavior: if the | ||
| # team later wants graceful degradation (return nil so the user is prompted to re-enroll | ||
| # rather than 500'ing on login), that becomes a deliberate change that flips this test. | ||
| it 'raises when the legacy secret cannot be decrypted' do | ||
| user = user_with_legacy_secret(plaintext_secret) | ||
| # Keep valid Base64 but swap in ciphertext that fails the GCM auth tag check — the same | ||
| # failure mode a wrong/rotated key produces. | ||
| user.update_columns(encrypted_otp_secret: Base64.encode64('not the real ciphertext')) | ||
| user.reload | ||
|
|
||
| expect { user.otp_secret }.to raise_error(OpenSSL::Cipher::CipherError) | ||
| end | ||
|
|
||
| # New/re-enrolled secrets use the Rails-encrypted otp_secret column; this also proves | ||
| # the ActiveRecord encryption config (derived from ENCRYPTION_KEY) works end to end. | ||
| it 'stores a newly generated secret in the Rails-encrypted otp_secret column' do | ||
| user = create(:user) | ||
| user.set_initial_two_factor_secret! | ||
| user.reload | ||
|
|
||
| expect(user.encrypted_otp_secret).to be_nil # not in the legacy columns | ||
| expect(user.otp_secret).to be_present | ||
|
|
||
| raw = User.connection.select_value("SELECT otp_secret FROM users WHERE id = #{user.id}") | ||
| expect(raw).to be_present | ||
| expect(raw).not_to eq(user.otp_secret) # stored encrypted at rest | ||
|
|
||
| code = ROTP::TOTP.new(user.otp_secret).now | ||
| expect(user.validate_and_consume_otp!(code)).to be(true) | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ttoomey let me know if this should live somewhere else. I'm not seeing the IdP concerns so I think that hasn't been merged to main yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll take a look - let's huddle about this upgrade and the interaction with the IDP work
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will cause a small merge conflict with IDP but not a big deal. Needs to be relocated to
app/models/concerns/devise_user.rb. We will figure it out when we bring the IDP integration branch in