8000 Stop the deferred password re-hash from overwriting a password changed after validation by d53dave · Pull Request #53742 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content

Stop the deferred password re-hash from overwriting a password changed after validation - #53742

Closed
d53dave wants to merge 6 commits into
keycloak:mainfrom
d53dave:issue-53633-deferred-password-rehash
Closed

d53dave wants to merge 6 commits into
keycloak:mainfrom
d53dave:issue-53633-deferred-password-rehash

Conversation

@d53dave
@d53dave d53dave commented Oct 8, 2026
Copy link
Copy Markdown
Contributor

Closes #53633

Scope

1. The deferred re-hash no longer overwrites a password that changed after it was validated (PasswordCredentialProvider)

rehashPasswordIfRequired now passes the secret data of the credential it validated to the re-hash job. refreshPassword then works in this order:

  1. Compute the new hash.
  2. Read the credential from local storage (UserStoragePrivateUtil.userLocalStorage), not through the user cache.
  3. If the credential is gone or its secret data changed, skip the write.
  4. Otherwise update it through the user model, as before.

Step 2 loads the CredentialEntity into the job's persistence context. The update in step 4 then works on that managed entity. If a change commits between the read and the write, the @Version check on CredentialEntity rejects the update. The ModelException is already caught and logged.

Ordering is important. Comparing before computing the hash, or fetching from the user cache, will still let concurrent changes through: argon2 hashing is potentially slow, and the cached credential can be stale. In our tests that version reverted about 22% of the concurrent rounds.

2. Argon2 policyCheck uses the same iterations as encodedCredential (Argon2PasswordHashProvider)

encodedCredential uses the realm policy hashIterations(n) when it is set and ≤ 100. policyCheck always compared against the provider's configured iterations. With e.g. hashIterations(2), a hash that was just created was considered outdated, so every successful login queued a re-hash. Both methods now get the expected iterations from one helper. The previous Javadoc said the policy iterations were ignored, which encodedCredential didn't do; I updated it to describe the actual behaviour.

Change 2 alone removes the "every login" trigger, but change 1 is still needed. A hash that is legitimately outdated (after a policy or algorithm change) can still overwrite a concurrent or same-transaction password change.

Tests

I added four tests to PasswordHashingTest. They go into the existing Arquillian test because #51644 (migrating it to the new test framework) is still open. All four are deterministic and don't depend on timing.

  • testPasswordNotRehashedWithArgon2PolicyIterations: with hashIterations(2) and Argon2, a login doesn't re-hash a fresh hash (the salt is unchanged).
  • testPasswordChangedAfterValidationInSameTransactionNotReverted: with an outdated hash, isValid(old) and then updateCredential(new) run in one transaction. After the commit, the new password must be valid and the old one invalid.
  • testPasswordChangedConcurrentlyAfterValidationNotReverted: with an outdated hash, isValid(old) queues a re-hash. Then a separate transaction (standing in for a concurrent request, e.g. an admin reset) sets the new password and commits before the validating transaction does.
  • testPasswordChangedWhileRehashingNotReverted: the new password is committed by a separate transaction while the re-hash job computes the hash. This uses a test-only PasswordHashProvider in testsuite-providers (PasswordChangingHashProviderFactory): PBKDF2-SHA512 that, when configured through realm attributes, changes the user password in a separate transaction during encodedCredential. This test pins the order inside refreshPassword: hash first, then read and compare.

Results:

  • With the fix, all 17 tests in PasswordHashingTest pass, and spotless:check passes for the changed modules.
  • On stock main, all four new tests fail. The re-hash tests report "New password must be valid", and the argon2 test finds a changed salt.
  • With only the PasswordCredentialProvider change reverted, the re-hash tests still fail. The argon2 change alone doesn't prevent the revert.
  • testPasswordChangedWhileRehashingNotReverted also fails with a variant of the fix that compares, through the user cache, before hashing. That variant reads the credential from the cache, the password change commits during hashing, and the write then reverts it.

A change that commits between the jobs read and its write is not covered by a dedicated test. That window is guarded by the existing @Version optimistic locking on CredentialEntity, because the job's read loads the entity the update then uses. I saw this guard work in one of the control runs above (a variant that reads through local storage before hashing): Hibernates OptimisticLockException was converted to a ModelIllegalStateException, caught, and logged at INFO ("Error re-hashing the password in a different transaction"). The validating request didn't fail, and the new password was kept.

AI disclosure: parts of this PR (investigation, scripts to reproduce, some of the test code) were done using Claude Code.

Copilot AI balanced review requested due to automatic review settings October 8, 2026 12:45
@d53dave
d53dave requested review from a team as code owners October 8, 2026 12:45
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The overwrite race remains possible for credentials stored in federated user storage.

1 open finding
What changed in this PR

Prevents deferred password re-hashing from reverting newer passwords and aligns Argon2 iteration policy checks.

Changes:

  • Guards deferred re-hashes using current credential data.
  • Aligns Argon2 hashing and policy-check iterations.
  • Adds deterministic regression tests and a test hash provider.
File Description
PasswordHashingTest.java Adds four regression tests.
PasswordHashProviderFactory Registers the test provider.
PasswordChangingHashProviderFactory.java Simulates concurrent password changes.
PasswordCredentialProvider.java Adds stale-credential protection.
Argon2PasswordHashProvider.java Unifies expected iteration handling.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Copilot AI balanced review requested due to automatic review settings October 9, 2026 07:35
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Federated credentials retain a lost-update window between comparison and write.

2 open findings

🧠 Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 13, 2026 11:30
@d53dave
d53dave commented Oct 13, 2026
Copy link
Copy Markdown
Contributor Author

I've addressed the federated user case in e5b16a5.

FederatedUserCredentialEntity now has @Version, with a VERSION column on FED_USER_CREDENTIAL (changeset 27.0.0-53633, the same pattern as 26.2.0-26106 for CREDENTIAL). The job's read loads the entity into its persistence context, and the update works on that entity. So the write is now UPDATE ... WHERE ID = ? AND VERSION = ?. If a change commits after the read, the update matches 0 rows, and the resulting ModelException is caught and logged like for local credentials. Comparing SECRET_DATA in the UPDATE itself isn't portable AFAICT, because it's a CLOB.

I chose this over pessimistic locking for 2 reasons:

  • services only sees the UserFederatedUserCredentialStore SPI, so a locking read would need a new SPI method.
  • A lock would only protect the re-hash. The version also protects other concurrent writers of federated credentials, which is why CREDENTIAL got one in Recovery Code Validation Race Possible #26106.

New tests cover this interval for both storages: testPasswordChangedBeforeRehashCommitNotReverted and testFederatedStoragePasswordChangedBeforeRehashCommitNotReverted.

  • The log shows the 0-row update on CREDENTIAL and FED_USER_CREDENTIAL respectively
  • Manually removing @Versionmakes both fail with "New password must be valid".

Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

A concurrent credential-label update can still be overwritten by the deferred re-hash.

2 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Rehash can overwrite concurrent credential-label updates

services/​src/​main/​java/​org/​keycloak/​credential/​PasswordCredentialProvider.java:358

The guard only compares secret data, but the subsequent update still writes the label captured before hashing. If a credential-label update commits during hashing, this check passes and the re-hash reverts that newer label; copy the label from current after the comparison (or include it in the guard).

🧠 Review effort: Balanced

…d after validation

Signed-off-by: David Sere <dse@tuta.com>
…deferred password re-hash

Signed-off-by: David Sere <dse@tuta.com>
…rd re-hash cannot overwrite a password changed after its read

Signed-off-by: David Sere <dse@tuta.com>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>
@mabartos
mabartos force-pushed the issue-53633-deferred-password-rehash branch from e5b16a5 to 1613928 Compare October 15, 2026 11:21
Copilot AI balanced review requested due to automatic review settings October 15, 2026 11:21
@mabartos
mabartos commented Oct 15, 2026 •
Copy link
Copy Markdown
Member

@d53dave I've just added one commit on top of it to be safe for backports with the DB migration + keeping the metadata on the password credential.

As we want to have also the tests for the new testsuite, we can make some changes there - I think we can simplify it. I'll try to produce the new tests and let you know if it's ok to you. Thanks!

EDIT: Fixed tests in the next commit.

Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive concurrency handling and the schema migration warrant maintainer validation across supported databases.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Copilot AI balanced review requested due to automatic review settings October 15, 2026 11:45
Copilot AI left a comment
Copy link
Copy Markdown < 10000 /clipboard-copy>
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive concurrency changes and the federated credential migration warrant human validation across supported databases.

1 open finding

🧠 Review effort: Balanced

@mabartos
mabartos requested a balanced review from Copilot October 15, 2026 12:50
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive concurrent credential writes and the schema migration warrant maintainer validation across supported databases.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

@mabartos mabartos left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@ahus1 Could you please check it as you were involved in similar PRs for the passwords rehashing? Thanks!

@mabartos
mabartos requested a review from ahus1 October 15, 2026 14:10
Copilot AI balanced review requested due to automatic review settings October 16, 2026 07:47
Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive password concurrency changes and the schema migration warrant human review and validation across supported databases.

0 open findings

🧠 Review effort: Balanced

@ahus1 ahus1 left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR. This tries to fix two problems at the same time, and I appreciate it. Still I think the "avoid overwriting with a rehashed password" needs some more as it is a hack. Possibly we would need a new method updateStoredCredential(oldCred, newCred) to make this a good abstraction.

As a way forward, I suggest to have 2-3 PRs, handled one after the other:

  1. Fix the Argon2 parameter problem, which will also be backported to 26.6.x
  2. Rework the interfaces, and add the new method. It should not add the new column so it can be backported to 26.6. Instead of using optimistic locking, it can use pessimistic locking.
  3. For 27.x only, we can add the new column and change the backend from pessimistic locking to optimistic locking for the federated credentials.

1 and 2 could possibly be combined, but it makes it harder to discuss so we would actually be slower.

Please let me know your thoughts.

@ahus1 ahus1 self-assigned this Oct 16, 2026
@d53dave
d53dave commented Oct 21, 2026
Copy link
Copy Markdown
Contributor Author

Thanks for your input @ahus1

I got caught up with some other tasks this week, but I'll prepare separate PRs over the next few days and tag you.

@ahus1
ahus1 commented Nov 11, 2026
Copy link
Copy Markdown
Member

I've created a PR to handle just the Argon2 always-rehashing problem first: #55027

Closing this one for now, and keeping it as a rereference.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Argon2 policyCheck ignores realm hashIterations, so every login triggers a password re-hash

4 participants

0