Skip to content

fix: Replace bcrypt with Argon2id, upgrading legacy hashes on login - #901

Open
serendipty01 wants to merge 4 commits into
roostorg:mainfrom
serendipty01:bcrypt-work-factor-12
Open

fix: Replace bcrypt with Argon2id, upgrading legacy hashes on login#901
serendipty01 wants to merge 4 commits into
roostorg:mainfrom
serendipty01:bcrypt-work-factor-12

Conversation

@serendipty01

@serendipty01 serendipty01 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Context

Closes #900.

hashPassword was minting bcrypt at cost factor 5. This PR originally raised it to 12; per review discussion below, it now moves straight to Argon2id so the hash format only churns once.

Argon2id at OWASP's recommended minimum: 19 MiB of memory (m=19456 KiB), t=2, p=1. The parameters are encoded in every hash, so a future bump needs no flag day.

Re-hashing requires the plaintext password, which we only hold for the instant of a login — so there is no migration, only an opportunistic upgrade:

  • New hashes (signup, password change/reset, create-org) are Argon2id.
  • Existing bcrypt hashes keep verifying. bcryptjs therefore stays a permanent dependency: an account that never logs in again is never re-hashed.
  • Rehash-on-login: after a successful password check, a stale hash is transparently re-minted and persisted. Best-effort — a failed write is logged, never fails the login, and the row is retried on the next login.

For reviewers

  1. The rehash write is compare-and-swap on the verified hash, not an update by user id. If a concurrent password change lands between verification and the write, zero rows match and the stale rehash is dropped instead of resurrecting the old password.

  2. It is not the transactional path. changePassword / resetPasswordForToken also purge the user's other sessions ((auth) Invalidate sessions on password change #778). A rehash is not a password change; reusing that path would log users out of their other sessions on their first login after deploy.

  3. passwordNeedsRehash compares v/m/t/p for strict equality, so hashes converge on exactly one configuration rather than leaving a tail of stronger-but-different ones. This mirrors needsRehash in the reference argon2 package — except that one doesn't check the algorithm variant, so an argon2i hash with matching parameters slips past it. Ours is anchored on $argon2id$. Any bcrypt hash is flagged regardless of cost: cost is no longer the criterion, bcrypt is.

  4. Corrupt input: one path resolves false, the other surfaces as an error — both logged. argon2Verify is inconsistent on corrupt input — some malformed digests resolve false (an ordinary wrong-password result), others throw. A throw is not special-cased into "incorrect password": it propagates to verifyEmailPasswordCredentials's existing catch-all, which logs it via the tracer and returns a generic internal-server error instead (per review discussion — no handling was added for a case nobody could explain how to reach). Without that log, a total Argon2 failure (the 19 MiB allocation failing under memory pressure affects every login) would be indistinguishable from a flood of users mistyping their passwords.

Dependency: @node-rs/argon2 (MIT) — approval requested

argon2 is the more obvious pick, but it is node-gyp native and our server image (node:24.14.1-bullseye-slim) installs only git — no python3/make/g++ — so it would depend entirely on a prebuilt binary resolving, with no fallback. @node-rs/argon2 ships per-platform binaries as optional deps and has no install script. Same Argon2id, same parameters, interchangeable hashes.

Verified rather than assumed: docker compose build backendnpm ci succeeds in the image and the binding loads and hashes inside it; the lockfile carries all 15 platform binaries, including linux-x64-gnu for CI.

Trade-off: it exports no needsRehash, so the ~12 lines that parse the encoded parameters are ours.

Node 24 also ships an experimental crypto.argon2 — worth dropping the dependency for once it stabilises.

Tests

15 unit tests across server/services/userManagementService/utils.test.ts (parameters, round-trip, legacy bcrypt verification, corrupt-input handling, passwordNeedsRehash across bcrypt / parameter drift / wrong variant) and server/graphql/datasources/userApiCredentials.test.ts (rehash persisted and CAS-scoped, no write when current, no write on wrong password, corrupt hash surfaces a generic error and logs, failed rehash write still succeeds the login).

Manual, on a local instance against real Postgres:

Scenario Result
create-org admin user minted $argon2id$v=19$m=19456,t=2,p=1$
Login against a legacy $2a$05$ hash succeeds; row upgraded to Argon2id
Login against a $2a$12$ hash succeeds; also upgraded
Second login on a current Argon2id hash succeeds; no rewrite (hash and updated_at unchanged)
Wrong password LoginIncorrectPasswordError, no write
Corrupt hash generic internal-server error, logged, no write

Rollout

Nothing required: no migration, no new env vars, no API/UI change. public.users.password is varchar(255) and an encoded Argon2id hash is 97 characters. Legacy hashes upgrade lazily as users log in.

Expected side effect: the password hashing paths (login, signup, password change) get deliberately slower — that is the point, and it affects only those endpoints.

Checklist

Only check items that apply to this PR; leave the rest unchecked.

  • If you changed anything user-facing (i.e. user interface or APIs):
    Did you update the CHANGELOG.md and related docs?

  • If you changed server/models/**/{ContentTypeModel,ActionModel,RuleModel,PolicyModel}.ts:
    Did you update the corresponding history tables and their triggers?

  • If you changed db/src/scripts/** and used CREATE TABLE, ADD COLUMN, or ALTER COLUMN:
    Are as many columns marked NOT NULL as possible? If some columns can sometimes be null depending on other columns, are there CHECK constraints capturing those relationships, and are these also reflected using unions in the associated Kysely types?

  • If you added a new signal in server/services/signalsService/signals/**:
    Did you classify every error case as a permanent error (SignalPermanentError, no retry) or a normal error (retryable)? Any case where the signal can't determine a score should be a SignalPermanentError.

Summary by CodeRabbit

  • New Features
    • New passwords are now hashed with Argon2id.
    • Existing bcrypt passwords continue to work during sign-in.
    • After a successful login, outdated password hashes are opportunistically upgraded.
  • Bug Fixes
    • Password upgrades only run after verification succeeds and the stored password is still the expected legacy value.
    • Wrong passwords no longer trigger any upgrade attempts.
    • If an upgrade write fails, sign-in still succeeds while the failure is recorded.
    • Unverifiable stored hashes are handled safely (“fails closed”).
  • Tests
    • Added Jest coverage for hashing, verification (including legacy support), rehash decisioning, and login upgrade scenarios.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Password hashing now uses Argon2id while retaining legacy bcrypt verification. Successful logins conditionally rehash outdated passwords with compare-and-swap persistence, and failures during hash evaluation or persistence are logged without allowing persistence errors to reject login.

Changes

Password security

Layer / File(s) Summary
Argon2id hashing and detection
server/services/userManagementService/utils.ts, server/services/userManagementService/index.ts, server/services/userManagementService/utils.test.ts, server/package.json
Password hashing uses configured Argon2id parameters, verification retains bcrypt compatibility, and passwordNeedsRehash detects parameter drift, non-Argon2id hashes, and malformed hashes.
Login rehash persistence
server/graphql/datasources/userApiCredentials.ts
Successful login conditionally rehashes outdated passwords and persists them using a compare-and-swap condition; verification and persistence failures are logged appropriately.
Login rehash validation
server/graphql/datasources/userApiCredentials.test.ts
Tests cover legacy-hash upgrades, current-hash skips, wrong-password rejection, invalid-hash handling, persisted hash validation, and failed-write handling.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant verifyEmailPasswordCredentials
  participant PasswordUtils
  participant UserDatabase
  participant Tracer
  Client->>verifyEmailPasswordCredentials: submit email and password
  verifyEmailPasswordCredentials->>PasswordUtils: verify password
  PasswordUtils-->>verifyEmailPasswordCredentials: successful match
  verifyEmailPasswordCredentials->>PasswordUtils: check passwordNeedsRehash
  PasswordUtils-->>verifyEmailPasswordCredentials: rehash required
  verifyEmailPasswordCredentials->>PasswordUtils: hashPassword
  PasswordUtils-->>verifyEmailPasswordCredentials: Argon2id hash
  verifyEmailPasswordCredentials->>UserDatabase: persist updated hash
  UserDatabase-->>verifyEmailPasswordCredentials: success or failure
  UserDatabase->>Tracer: log failed persistence
Loading

Possibly related PRs

  • roostorg/coop#462: Introduces the login credential verification helper extended by this change.

Suggested labels: dependencies, javascript

Suggested reviewers: cassidyjames

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: replacing bcrypt with Argon2id and upgrading legacy hashes on login.
Description check ✅ Passed The description follows the required sections and includes context, tests, rollout notes, and checklist items.
Linked Issues check ✅ Passed The changes satisfy #900 by switching new hashes to Argon2id, preserving bcrypt verification, and lazily upgrading legacy hashes on login.
Out of Scope Changes check ✅ Passed The code changes and added tests are all directly related to password hashing, verification, rehashing, and the new Argon2 dependency.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@serendipty01 serendipty01 changed the title Increase bcrypt work factor from 5 to 12 with rehash-on-login fix: Increase bcrypt work factor from 5 to 12 with rehash-on-login Jul 13, 2026
@serendipty01
serendipty01 marked this pull request as ready for review July 13, 2026 08:17
Copilot AI review requested due to automatic review settings July 13, 2026 08:17
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

Pull request overview

This PR strengthens password storage by raising bcrypt’s work factor from 5 to 12 and introducing an opportunistic rehash-on-login flow to upgrade legacy hashes without requiring a database migration.

Changes:

  • Increased new password hash cost to 12 via a single TARGET_BCRYPT_COST constant and added passwordNeedsRehash.
  • Added best-effort rehash-on-login that upgrades legacy hashes after a successful password verification.
  • Added unit tests covering hashing/verification, rehash detection, and rehash-on-login persistence + failure swallowing.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
server/services/userManagementService/utils.ts Raises bcrypt cost to 12 and adds passwordNeedsRehash.
server/services/userManagementService/utils.test.ts Adds tests for cost-12 hashing, legacy verification, and rehash detection behavior.
server/services/userManagementService/index.ts Re-exports passwordNeedsRehash for downstream use.
server/graphql/datasources/userApiCredentials.ts Adds rehash-on-login hook + helper to persist upgraded hashes.
server/graphql/datasources/userApiCredentials.test.ts Adds tests asserting rehash persistence, no-op when current, no write on wrong password, and swallow-on-failure behavior.

Comment thread server/graphql/datasources/userApiCredentials.ts
Comment thread server/graphql/datasources/userApiCredentials.ts

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
server/graphql/datasources/userApiCredentials.ts (1)

104-107: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider deferring the rehash write to avoid adding ~250ms latency to legacy-user logins.

await rehashPasswordOnLogin(...) runs synchronously in the login response path. Bcrypt at cost 12 takes ~250ms on typical hardware, meaning every login for a user with a legacy hash pays that latency until their hash is upgraded. If the rehash were fire-and-forget (with proper error handling to avoid unhandled rejections per coding guidelines), login would return immediately and the upgrade would happen in the background.

This is a design trade-off: the current approach guarantees the rehash completes before the response, while fire-and-forget risks losing the upgrade if the process exits. Given the best-effort nature of the rehash, deferring may be acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/graphql/datasources/userApiCredentials.ts` around lines 104 - 107,
Defer rehashPasswordOnLogin from the synchronous login response path when
passwordNeedsRehash(user.password) is true, allowing authentication to return
without awaiting the bcrypt work. Trigger the operation in the background and
attach explicit error handling so failures do not become unhandled promise
rejections; preserve the existing best-effort rehash behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/graphql/datasources/userApiCredentials.ts`:
- Around line 32-46: Update rehashPasswordOnLogin to use a compare-and-swap when
persisting the rehashed password: retain the password hash that was verified for
this login and only update user.id when the stored password still matches that
hash. Reuse the existing conditional-update/data-access mechanism, ensuring
concurrent changePassword or resetPasswordForToken updates are not overwritten
while preserving the current error logging.

---

Nitpick comments:
In `@server/graphql/datasources/userApiCredentials.ts`:
- Around line 104-107: Defer rehashPasswordOnLogin from the synchronous login
response path when passwordNeedsRehash(user.password) is true, allowing
authentication to return without awaiting the bcrypt work. Trigger the operation
in the background and attach explicit error handling so failures do not become
unhandled promise rejections; preserve the existing best-effort rehash behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 720da2f6-487f-4a04-8fc2-40c9ce056f9f

📥 Commits

Reviewing files that changed from the base of the PR and between 2428fba and 43724f4.

📒 Files selected for processing (5)
  • server/graphql/datasources/userApiCredentials.test.ts
  • server/graphql/datasources/userApiCredentials.ts
  • server/services/userManagementService/index.ts
  • server/services/userManagementService/utils.test.ts
  • server/services/userManagementService/utils.ts

Comment thread server/graphql/datasources/userApiCredentials.ts
serendipty01 added a commit to serendipty01/coop that referenced this pull request Jul 13, 2026
…ied hash

Addresses PR roostorg#901 review (Copilot and CodeRabbit converged on the same
finding): the rehash write was keyed on user id alone, so a password
change committing between verification and the write could be overwritten
by a rehash of the just-verified OLD plaintext - silently resurrecting
the old password. The write now also matches the exact stored hash that
was verified (WHERE id = ? AND password = ?); zero matched rows means the
row changed underneath us and the stale rehash is dropped, preserving the
best-effort semantics.

Also removes the now-unused kyselyUserUpdate import (that helper cannot
express the guard - review comment 2) and pins the CAS query shape in the
rehash test.

Generated with AI

Co-Authored-By: AI <ai@example.com>

@taobojlen taobojlen 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.

thanks for this!

i think the rehash-on-login approach is sensible. but it's a good question about argon2. i think that if we're going to change our password hashes, it makes most sense to move to the OWASP recommendation of

Use Argon2id with a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism.

otherwise we'll have to maintain several different hash formats all at once; seems we might as well do it in one go! what do you think?

@serendipty01

Copy link
Copy Markdown
Contributor Author

hey — agreed, let's do it in one go. i'll add the argon2id changes to this PR rather than opening a new one.

I was thinking to use @node-rs/argon2 (MIT)

Reasoning from Claude regarding this:

the more obvious pick is argon2, but it's a node-gyp native module and our server image (node:24.14.1-bullseye-slim) only installs git — no python3/make/g++ — so it'd be relying on a prebuilt binary resolving for linux/glibc, with no fallback if one doesn't. @node-rs/argon2 ships per-platform prebuilts as optional deps and skips node-gyp entirely, so it avoids that failure mode. same argon2id under the hood, same OWASP params.

let me know if you'd rather use something else

node also has crypto.argon2 now, but it's still experimental, probably worth dropping the dependency for it once it stabilises.

i was also thinking about adding a pepper, and will raise a separate issue to discuss that.

marking this as draft for now; i'll ping you once it's done and tested on my end.

@serendipty01
serendipty01 marked this pull request as draft July 13, 2026 19:51
serendipty01 added a commit to serendipty01/coop that referenced this pull request Jul 13, 2026
…ied hash

Addresses PR roostorg#901 review (Copilot and CodeRabbit converged on the same
finding): the rehash write was keyed on user id alone, so a password
change committing between verification and the write could be overwritten
by a rehash of the just-verified OLD plaintext - silently resurrecting
the old password. The write now also matches the exact stored hash that
was verified (WHERE id = ? AND password = ?); zero matched rows means the
row changed underneath us and the stale rehash is dropped, preserving the
best-effort semantics.

Also removes the now-unused kyselyUserUpdate import (that helper cannot
express the guard - review comment 2) and pins the CAS query shape in the
rehash test.

Generated with AI

Co-Authored-By: AI <ai@example.com>
@serendipty01
serendipty01 force-pushed the bcrypt-work-factor-12 branch from acbd76d to adb896c Compare July 13, 2026 20:19
@serendipty01 serendipty01 changed the title fix: Increase bcrypt work factor from 5 to 12 with rehash-on-login fix: Replace bcrypt with Argon2id, upgrading legacy hashes on login Jul 13, 2026
@serendipty01

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • server/package-lock.json: Generated file

Comment thread server/package.json
Comment thread server/services/userManagementService/utils.ts Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/services/userManagementService/utils.ts`:
- Around line 49-55: Update the catch block in passwordMatchesHash to log the
caught malformed-hash error at debug level before returning false. Preserve the
fail-closed non-match behavior and include enough context to identify the
password-hash validation failure without exposing sensitive values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ec519b23-1464-40bb-bd26-e29210b2aa56

📥 Commits

Reviewing files that changed from the base of the PR and between 43724f4 and adb896c.

⛔ Files ignored due to path filters (1)
  • server/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • server/graphql/datasources/userApiCredentials.test.ts
  • server/graphql/datasources/userApiCredentials.ts
  • server/package.json
  • server/services/userManagementService/index.ts
  • server/services/userManagementService/utils.test.ts
  • server/services/userManagementService/utils.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/services/userManagementService/index.ts
  • server/graphql/datasources/userApiCredentials.test.ts

Comment thread server/services/userManagementService/utils.ts Outdated

@taobojlen taobojlen 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.

Thank you, this looks great!

I'd like to see us simplify this a little bit -- LLMs love to write overly-defensive code, but IMO it just complicates things and often isn't necessary. But this is very close!

Comment thread server/graphql/datasources/userApiCredentials.test.ts Outdated
expect(updateTable).not.toHaveBeenCalled();
});

it('still succeeds the login when the rehash write fails (#778 regression guard)', async () => {

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.

i don't really understand this test!

under what circumstances would the rewrite throw an exception, and how is that related to a user changing their password?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed the comment. The test covers: the rehash-on-login write can fail for any transient reason (DB connection drop, timeout, etc.), and the login must still succeed when that happens.

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.

the rehash-on-login write can fail for any transient reason (DB connection drop, timeout, etc.), and the login must still succeed when that happens.

i think i fundamentally disagree with this! in all of these above cases, the login should fail.

).rejects.toThrow();

await expect(
passwordMatchesHash(PASSWORD, '$argon2id$v=19$corrupt'),

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.

i think this should probably throw, too? i can't see why we'd want to handle this case elegantly; it should not come up in practice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This test exists only to pin the library's current behavior, so a future @node-rs/argon2 upgrade that changes it trips a test instead of silently drifting. Happy to delete it if you'd rather not carry that insurance — but there's no handling on our side to remove.

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.

let's remove it. this behaviour actually would cause the silent drift, we want to fail loudly if our fundamental assumptions get violated!

Comment thread server/graphql/datasources/userApiCredentials.ts Outdated
.where('password', '=', verifiedHash)
.execute();
} catch (e) {
deps.tracer.logActiveSpanFailedIfAny(e);

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.

Under what circumstances would we hit this?

I am generally against swallowing errors unless they're actually expected.

@serendipty01 serendipty01 Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Some scenarios: the UPDATE hitting a connection-pool limit, a timeout, or a deadlock, or hashPassword failing under memory pressure (Argon2 needs a 19 MiB allocation per call; if the process is memory-starved that can throw).

They should not fail the login and will only log them.

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.

all of those things should make the login fail! the user can just retry if it's truly transient but let's not have all sorts of silent behaviors in our code.

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.

i.e. let's remove the try/catch here.

Comment thread server/graphql/datasources/userApiCredentials.ts Outdated
Copilot AI review requested due to automatic review settings July 21, 2026 15:30
@serendipty01
serendipty01 force-pushed the bcrypt-work-factor-12 branch from a55c8fd to cf56118 Compare July 21, 2026 15:30
serendipty01 added a commit to serendipty01/coop that referenced this pull request Jul 21, 2026
…ied hash

Addresses PR roostorg#901 review (Copilot and CodeRabbit converged on the same
finding): the rehash write was keyed on user id alone, so a password
change committing between verification and the write could be overwritten
by a rehash of the just-verified OLD plaintext - silently resurrecting
the old password. The write now also matches the exact stored hash that
was verified (WHERE id = ? AND password = ?); zero matched rows means the
row changed underneath us and the stale rehash is dropped, preserving the
best-effort semantics.

Also removes the now-unused kyselyUserUpdate import (that helper cannot
express the guard - review comment 2) and pins the CAS query shape in the
rehash test.

Generated with AI

Co-Authored-By: AI <ai@example.com>
serendipty01 added a commit to serendipty01/coop that referenced this pull request Jul 21, 2026
- Trim rehashPasswordOnLogin's JSDoc to the load-bearing parts, dropping
  the changePassword/session-purge tangent and Argon2-variant mention.
- Explain exactly which failures the rehash write's catch block is
  expected to swallow (transient DB/infra failures), rather than
  swallowing everything unjustified.
- Drop the try/catch that disguised a passwordMatchesHash throw as
  "wrong password"; let it propagate to the existing outer catch-all,
  which already logs and returns a generic error.
- Rename/reclarify the "rehash write fails" test: it was mislabeled as
  a roostorg#778 regression guard, but roostorg#778 is about the changePassword
  session-purge, an unrelated concern.
- Clarify utils.test.ts's malformed-hash test: the throw-vs-resolve-false
  split is @node-rs/argon2's own behavior, not something our code
  special-cases; the test pins it against a silent library upgrade.
- Apply the trivial comment-wording suggestion at test line 105.
serendipty01 and others added 3 commits July 21, 2026 21:02
Closes roostorg#900.

- hashPassword now hashes at cost 12 (was 5; OWASP minimum is 10).
  Existing rows stay valid - bcrypt embeds the cost factor in the hash
  string, so legacy cost-5 hashes keep verifying with no migration.
- passwordNeedsRehash flags hashes below the target cost; bcrypt.getRounds
  returns NaN for non-bcrypt strings, which safely falls out as
  "no rehash".
- Rehash-on-login: after a successful password check against a legacy
  hash, verifyEmailPasswordCredentials transparently re-hashes the
  plaintext (already in memory from the login attempt) at cost 12 and
  persists it as a plain column update - deliberately NOT the
  session-purging changePassword/resetPasswordForToken path (see roostorg#778),
  so the upgrade never logs anyone out. Best-effort: a failed write is
  logged and never fails the login.
- Tests: utils.test.ts (target cost, round-trip, legacy verify,
  passwordNeedsRehash edge cases), userApiCredentials.test.ts (rehash
  persists, current hash untouched, wrong password never writes, login
  survives a failed rehash write).

Co-Authored-By: Claude <noreply@anthropic.com>
…ied hash

Addresses PR roostorg#901 review (Copilot and CodeRabbit converged on the same
finding): the rehash write was keyed on user id alone, so a password
change committing between verification and the write could be overwritten
by a rehash of the just-verified OLD plaintext - silently resurrecting
the old password. The write now also matches the exact stored hash that
was verified (WHERE id = ? AND password = ?); zero matched rows means the
row changed underneath us and the stale rehash is dropped, preserving the
best-effort semantics.

Also removes the now-unused kyselyUserUpdate import (that helper cannot
express the guard - review comment 2) and pins the CAS query shape in the
rehash test.

Co-Authored-By: Claude <noreply@anthropic.com>
…login

Co-Authored-By: Claude <noreply@anthropic.com>

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
server/services/userManagementService/utils.ts (1)

33-39: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant promisify wrapper. bcryptjs.compare already returns a Promise when no callback is passed, so bcryptCompare can be dropped and passwordMatchesHash can call bcrypt.compare(...) directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/services/userManagementService/utils.ts` around lines 33 - 39, Remove
the promisify-based bcryptCompare declaration and update passwordMatchesHash to
call bcrypt.compare directly without a callback. Preserve the existing
bcrypt-hash dispatch and verification behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/services/userManagementService/utils.ts`:
- Around line 33-39: Remove the promisify-based bcryptCompare declaration and
update passwordMatchesHash to call bcrypt.compare directly without a callback.
Preserve the existing bcrypt-hash dispatch and verification behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fe1495ec-a0d7-4636-8fec-0c98e2527d06

📥 Commits

Reviewing files that changed from the base of the PR and between a55c8fd and cf56118.

⛔ Files ignored due to path filters (1)
  • server/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • server/graphql/datasources/userApiCredentials.test.ts
  • server/graphql/datasources/userApiCredentials.ts
  • server/package.json
  • server/services/userManagementService/index.ts
  • server/services/userManagementService/utils.test.ts
  • server/services/userManagementService/utils.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • server/services/userManagementService/index.ts
  • server/package.json
  • server/graphql/datasources/userApiCredentials.ts
  • server/services/userManagementService/utils.test.ts
  • server/graphql/datasources/userApiCredentials.test.ts

serendipty01 added a commit to serendipty01/coop that referenced this pull request Jul 21, 2026
- Trim rehashPasswordOnLogin's JSDoc to the load-bearing parts, dropping
  the changePassword/session-purge tangent and Argon2-variant mention.
- Explain exactly which failures the rehash write's catch block is
  expected to swallow (transient DB/infra failures), rather than
  swallowing everything unjustified.
- Drop the try/catch that disguised a passwordMatchesHash throw as
  "wrong password"; let it propagate to the existing outer catch-all,
  which already logs and returns a generic error.
- Rename/reclarify the "rehash write fails" test: it was mislabeled as
  a roostorg#778 regression guard, but roostorg#778 is about the changePassword
  session-purge, an unrelated concern.
- Clarify utils.test.ts's malformed-hash test: the throw-vs-resolve-false
  split is @node-rs/argon2's own behavior, not something our code
  special-cases; the test pins it against a silent library upgrade.
- Apply the trivial comment-wording suggestion at test line 105.

Co-Authored-By: Claude <noreply@anthropic.com>
@serendipty01
serendipty01 force-pushed the bcrypt-work-factor-12 branch from cf56118 to d032b7f Compare July 21, 2026 15:33

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.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • server/package-lock.json: Generated file
Comments suppressed due to low confidence (1)

server/package.json:46

  • This PR introduces a new production dependency (@node-rs/argon2). Per repo policy, dependency additions/upgrades require explicit human approval (and a quick license/CVE sanity check) before merge.
    "@graphql-tools/merge": "^8.2.10",
    "@graphql-tools/schema": "^8.5.1",
    "@graphql-tools/utils": "^9.2.1",
    "@node-rs/argon2": "^2.0.2",
    "@node-saml/passport-saml": "^5.1.0",
    "@opentelemetry/api": "^1.8.0",
    "@opentelemetry/semantic-conventions": "^1.22.0",

Comment thread server/services/userManagementService/utils.test.ts Outdated
Copilot AI review requested due to automatic review settings July 21, 2026 15: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.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • server/package-lock.json: Generated file
Comments suppressed due to low confidence (1)

server/package.json:44

  • This PR introduces a new production dependency (@node-rs/argon2). Per repo policy, adding or upgrading dependencies (including transitive additions from lockfile changes) requires explicit human approval and a quick license/CVE check before merge.
    "@graphql-tools/merge": "^8.2.10",
    "@graphql-tools/schema": "^8.5.1",
    "@graphql-tools/utils": "^9.2.1",
    "@node-rs/argon2": "^2.0.2",
    "@node-saml/passport-saml": "^5.1.0",

Comment on lines +114 to +117
const passwordMatches = await passwordMatchesHash(password, user.password);
if (!passwordMatches) {
throw makeLoginIncorrectPasswordError({ shouldErrorSpan: true });
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional, not an oversight — see taobojlen's comment above, which asked to drop the special-casing that disguised this as "incorrect password." Going with that: a corrupt/unevaluable hash now surfaces as a generic internal-server error via the existing outer catch-all in verifyEmailPasswordCredentials, and is still logged via the tracer either way. I've updated the PR description (point 4) and the manual-test table to reflect this instead of the old "fail closed as non-match" language.

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.

yeah this is a bad suggestion from copilot!

serendipty01 added a commit to serendipty01/coop that referenced this pull request Jul 21, 2026
- Trim rehashPasswordOnLogin's JSDoc to the load-bearing parts, dropping
  the changePassword/session-purge tangent and Argon2-variant mention.
- Explain exactly which failures the rehash write's catch block is
  expected to swallow (transient DB/infra failures), rather than
  swallowing everything unjustified.
- Drop the try/catch that disguised a passwordMatchesHash throw as
  "wrong password"; let it propagate to the existing outer catch-all,
  which already logs and returns a generic error.
- Rename/reclarify the "rehash write fails" test: it was mislabeled as
  a roostorg#778 regression guard, but roostorg#778 is about the changePassword
  session-purge, an unrelated concern.
- Clarify utils.test.ts's malformed-hash test: the throw-vs-resolve-false
  split is @node-rs/argon2's own behavior, not something our code
  special-cases; the test pins it against a silent library upgrade.
- Apply the trivial comment-wording suggestion at test line 105.
- Address a follow-up Copilot comment: stop computing a real cost-12
  bcrypt hash at runtime in the "regardless of cost factor" test
  (bcryptjs is pure JS and cost 12 is noticeably slow); passwordNeedsRehash
  never parses the cost out of a bcrypt string, so a string that merely
  looks like a cost-12 hash proves the same thing for free.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 21, 2026 16:53
@serendipty01
serendipty01 force-pushed the bcrypt-work-factor-12 branch from d032b7f to f4cfcd2 Compare July 21, 2026 16:53
- Trim rehashPasswordOnLogin's JSDoc to the load-bearing parts, dropping
  the changePassword/session-purge tangent and Argon2-variant mention.
- Explain exactly which failures the rehash write's catch block is
  expected to swallow (transient DB/infra failures), rather than
  swallowing everything unjustified.
- Drop the try/catch that disguised a passwordMatchesHash throw as
  "wrong password"; let it propagate to the existing outer catch-all,
  which already logs and returns a generic error.
- Rename/reclarify the "rehash write fails" test: it was mislabeled as
  a roostorg#778 regression guard, but roostorg#778 is about the changePassword
  session-purge, an unrelated concern.
- Clarify utils.test.ts's malformed-hash test: the throw-vs-resolve-false
  split is @node-rs/argon2's own behavior, not something our code
  special-cases; the test pins it against a silent library upgrade.
- Apply the trivial comment-wording suggestion at test line 105.
- Address a follow-up Copilot comment: stop computing a real cost-12
  bcrypt hash at runtime in the "regardless of cost factor" test
  (bcryptjs is pure JS and cost 12 is noticeably slow); passwordNeedsRehash
  never parses the cost out of a bcrypt string, so a string that merely
  looks like a cost-12 hash proves the same thing for free.
- Tighten the corrupt-hash test to assert the specific error name
  (InternalServerError, not LoginIncorrectPasswordError): the previous
  generic `rejects.toThrow()` passed identically under the old
  disguised-as-wrong-password behavior and the new one, so it wouldn't
  have caught a regression back to special-casing this case.

Co-Authored-By: Claude <noreply@anthropic.com>
@serendipty01
serendipty01 force-pushed the bcrypt-work-factor-12 branch from f4cfcd2 to 3c07c94 Compare July 21, 2026 16:56

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.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • server/package-lock.json: Generated file

Copilot AI review requested due to automatic review settings July 21, 2026 16:57

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.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • server/package-lock.json: Generated file

@serendipty01
serendipty01 requested a review from taobojlen July 21, 2026 17:06
});

it('surfaces a generic internal-server error and logs when the stored hash cannot be evaluated', async () => {
// A corrupt row, or Argon2 failing operationally (the 19 MiB allocation

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.

if we don't have 19MiB of memory available then we have bigger problems than users not being able to log in!

});
const { deps, logActiveSpanFailedIfAny } = makeDeps(kyselyPg);

// `rehashPasswordOnLogin` swallows any error from the write — e.g. a

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.

let's simplify this. we can just fail loudly in this case. no need for the added complexity!

.where('password', '=', verifiedHash)
.execute();
} catch (e) {
deps.tracer.logActiveSpanFailedIfAny(e);

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.

i.e. let's remove the try/catch here.

Comment on lines +114 to +117
const passwordMatches = await passwordMatchesHash(password, user.password);
if (!passwordMatches) {
throw makeLoginIncorrectPasswordError({ shouldErrorSpan: true });
}

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.

yeah this is a bad suggestion from copilot!

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.

Passwords are hashed with bcrypt at cost factor 5, below the OWASP minimum

3 participants