Skip to content

[PM-35599] Update set-password endpoint to handle V1 encryption when given Auth and Unlock data - #7723

Open
rr-bw wants to merge 42 commits into
mainfrom
auth/pm-35599/update-set-password-endpoint
Open

[PM-35599] Update set-password endpoint to handle V1 encryption when given Auth and Unlock data#7723
rr-bw wants to merge 42 commits into
mainfrom
auth/pm-35599/update-set-password-endpoint

Conversation

@rr-bw

@rr-bw rr-bw commented May 26, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-35599

Client-side changes:

📔 Objective

Update the accounts/set-password endpoint to be able to handle a SetInitialPasswordRequest that contains MasterPasswordAuthenticationData + MasterPasswordAuthentication + legacy KeysRequest. In other words, update the endpoint to allow a client to pass MPAD + MPUD but still do V1 encryption. This update is necessary for the corresponding client-side changes in bitwarden/clients#20643

Routing matrix

# Scenario MPAD/MPUD Keys AccountKeys V2 MP JIT flag Branch Outcome
1 Modern TDE (V1 or V2 TDE user) null null any Modern TDE _tdeSetPasswordCommand.SetMasterPasswordAsync (no cryptographic-state change)
2 Modern V1 MP JIT set null OFF V1 ValidateSaltUnchangedForUser then _setInitialMasterPasswordCommandV1; Keys.ToUser() sets keypair
3 Modern V1 MP JIT set null ON V1 Same as row 2 — V2 MP JIT branch requires AccountKeys != null, falls through
4 V2 MP JIT (SDK) null set ON V2 MP JIT _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync
5 V2 MP JIT shape, flag off null set OFF V1 guard 400 BadRequest — defensive guard in SetInitialPasswordV1Async
6 Old MP JIT (pre-PM-35599 client) set null any V1 _setInitialMasterPasswordCommandV1; ToUser() reads legacy KDF fields; keypair set
7 Old TDE (pre-PM-35599 client) null null any V1 _setInitialMasterPasswordCommandV1; legacy fields; no keypair mutation
8 Partial: MPAD only OR MPUD only partial any any any Validator 400 — HasAuthAndUnlockData() is false; legacy validation demands legacy fields too
9 MPAD + MPUD with mismatched KDF/salt any any any Validator 400 — KdfSettingsValidator.ValidateAuthenticationAndUnlockData rejects
10 Both AccountKeys and Keys set ✓/✗ set set any Validator 400 — mutual-exclusion rule in Validate()
11 MPAD hash ≠ legacy MasterPasswordHash any any any Validator 400 — cross-shape conflict check in Validate()
12 MPUD key ≠ legacy Key any any any Validator 400 — cross-shape conflict check in Validate()
13 Modern V1 MP JIT with divergent salt set null any V1 salt check 400 — ValidateSaltUnchangedForUser rejects (Stage 1 PM-27044 invariant)

📸 Screenshots

Regression testing existing paths (tested with clients pointing at main)

(Note: the new path (MPAD + MPUD + legacy Keys) will be tested as part of client-side changes in bitwarden/clients#20643)

MP JIT with legacy properties

A MP decryption org invites a user. The user JIT provisions. Upon setting initial password, the request sends legacy properties: masterPasswordHash + key + keys.

mp-jit-legacy-properties.mov

MP JIT with new properties and feature flag on

In this video, the "enable-account-encryption-v2-jit-password-registration" feature flag on (proven by a breakpoint in the video).

A MP decryption org invites a user. The user JIT provisions. Upon setting initial password, the request sends MPAD + MPUD + accountKeys (via SDK).

mp-jit-new-properties.mov

TDE JIT with Manage Account Recovery Permission

A TDE org invites a user with the "manage account recovery" permission. The user JIT provisions. Upon setting initial password, the request sends legacy properties: masterPasswordHash + key + NO keypair.

tde-jit-legacy-properties.mov

@rr-bw
rr-bw force-pushed the auth/pm-35599/update-set-password-endpoint branch 2 times, most recently from dbd5a89 to 0f724cf Compare June 2, 2026 21:02
Comment thread src/Api/Auth/Controllers/AccountsController.cs Fixed
@rr-bw
rr-bw force-pushed the auth/pm-35599/update-set-password-endpoint branch from 0f724cf to 9fb3fd8 Compare June 4, 2026 22:36
Comment thread src/Api/Auth/Controllers/AccountsController.cs Fixed
Comment thread src/Api/Auth/Controllers/AccountsController.cs Fixed
@codecov

codecov Bot commented Jun 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.88%. Comparing base (32342d4) to head (fedcfb5).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7723      +/-   ##
==========================================
+ Coverage   62.87%   62.88%   +0.01%     
==========================================
  Files        2301     2301              
  Lines      100212   100243      +31     
  Branches     9019     9034      +15     
==========================================
+ Hits        63008    63038      +30     
- Misses      35020    35021       +1     
  Partials     2184     2184              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rr-bw
rr-bw force-pushed the auth/pm-35599/update-set-password-endpoint branch from 17985b4 to 98d982a Compare June 7, 2026 04:34
@rr-bw
rr-bw marked this pull request as ready for review June 8, 2026 20:16
@rr-bw
rr-bw requested a review from a team as a code owner June 8, 2026 20:16
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR retightens accounts/set-password routing so a modern-shape request (MasterPasswordAuthentication + MasterPasswordUnlock) carrying legacy Keys is handled by the V1 command instead of being misrouted to the TDE or V2 MP JIT commands, and extracts that path into SetInitialPasswordV1Async. I re-verified the zero-knowledge and salt invariants: ValidateSaltUnchangedForUser now runs on both MPUD and MPAD before ToUser, KdfSettingsValidator.ValidateAuthenticationAndUnlockData still enforces KDF/salt parity, KeysRequestModel.ToUser still refuses to replace an existing keypair, and the new MasterPasswordSalt write resolves to the same email-derived value the client uses at login. The previously raised finding (client-supplied MPUD salt persisted without the email-salt invariant check) is confirmed resolved by 4e168d919, and 673e07214 adds conflict guards for AccountKeys/Keys, MasterPasswordAuthenticationHash/MasterPasswordHash, and MasterKeyWrappedUserKey/Key. Unit and integration coverage matches the routing matrix in the description, including the flag-off AccountKeys guard, the flag-on modern-V1 fall-through, and the divergent-salt rejection.

Code Review Details

No findings at or above the reporting threshold.

Considered and not raised:

  • ToUser() uses MasterPasswordUnlock?.Kdf.Memory ?? KdfMemory (and the same for Parallelism), so a request that sets MPUD with PBKDF2 and legacy KdfMemory would persist a mixed KDF config. Validate() guards conflicting hash/key but not KDF fields. No conforming client produces this shape, and the persisted value is inert for PBKDF2 — not actionable.
  • The new _featureService gate on the V2 MP JIT branch is a deliberate design outcome of the thread on AccountsController.cs:314, and the flag-off AccountKeys case is handled by the explicit 400 guard.
  • The open github-code-quality generic-catch comment targets a catch (Exception e) block that predates this PR and is only relocated by the refactor.

@JaredSnider-Bitwarden JaredSnider-Bitwarden 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.

Partial review but leaving this here:

{
await _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync(user, model.ToData());
return;
}

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.

Per discussion with @Thomas-Avery , if we get a V2 payload when the flag is off, we cannot process the request. That is an error state that shouldn't happen unless a BW client is out of sync w/ the FF being turned off, but we should still handle it.

@rr-bw rr-bw Jun 23, 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.

If what you're getting at here is...

  • If the request contains MPAD + MPUD + AccountKeys, but the EnableAccountEncryptionV2JitPasswordRegistration flag is OFF on the server, then throw.

...then that error state / thrown exception is handled at the top of SetInitialPasswordV1Async():

private async Task SetInitialPasswordV1Async(User user, SetInitialPasswordRequestModel model)
{
  // Defensive: V1 cannot consume AccountKeys (the new key shape). If a request carries
  // AccountKeys we'd silently drop the keypair, so fail loudly. This can only happen if
  // the V2 MP JIT flag is off (otherwise the V2 branch above would have handled it) — i.e.,
  // a client/server flag-state mismatch or a non-Angular caller.
  if (model.AccountKeys != null)
  {
    throw new BadRequestException(
       "Request includes V2 AccountKeys but V2 encryption is not enabled.");
  }
  // ...

if (model.IsTdeSetPasswordRequest())
// V2 encryption - TDE user with "manage account recovery" permission
if (model.IsTdeSetPasswordRequest() &&
_featureService.IsEnabled(FeatureFlagKeys.V2RegistrationTDEJIT))

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.

⚠️ : we cannot leverage a feature flag here for this particular scenario because, if an issue is discovered with the V2RegistrationTDEJIT flag where it is turned off, TDE users without MPs (who can have v1 or v2 encryption for their user key) can still be given the manage account recovery permission and they must be able to set a password.

V2RegistrationTDEJIT just governs SSO + TDE account creation - this process of setting an initial password happens later.

@rr-bw rr-bw Jun 23, 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.

17dfee5 - includes comments with your note about V2RegistrationTDEJIT governing SSO + TDE account creation. Might help avoid confusion with the asymmetry (i.e. not checking a flag for TDE, but checking a flag for MP JIT).

public KeysRequestModel? Keys { get; set; }

[Obsolete("Use MasterPasswordAuthentication instead")]
[Obsolete("Use MasterPasswordUnlock instead")]

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.

❓ : why the change?

@rr-bw rr-bw Jun 23, 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.

No major reason. "Unlock" data is just more consistent with what the ToUser() method uses after the changes on this PR.

Comment on lines +308 to +312
catch (Exception e)
{
ModelState.AddModelError(string.Empty, e.Message);
throw new BadRequestException(ModelState);
}
@rr-bw
rr-bw marked this pull request as draft June 23, 2026 07:46
@sonarqubecloud

Copy link
Copy Markdown

@rr-bw rr-bw added the ai-review Request a Claude code review label Jul 31, 2026
// MasterPasswordSalt column must never be null/empty after a successful password-set
// operation. Modern clients send an explicit salt via MPUD; older clients don't send one,
// so we fall back to the email-derived V1 salt via GetMasterPasswordSalt().
existingUser.MasterPasswordSalt = MasterPasswordUnlock?.Salt ?? existingUser.GetMasterPasswordSalt();

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.

⚠️ IMPORTANT: Client-supplied MPUD salt is persisted on the V1 path without the email-salt invariant check that every other master-password path enforces.

Details and fix

Every other initial-set / KDF-change / rotate path funnels through SetInitialPasswordData.ValidateDataForUser (or ChangeKdfCommand), which calls MasterPasswordUnlockData.ValidateSaltUnchangedForUser(user) — the Stage 1 email-salt invariant (salt == GetMasterPasswordSalt()), documented in src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs:70-77 as necessary because "the server cannot yet handle divergent salts... a mismatch here would make the user un-decryptable on next login."

The new modern-V1 route has no equivalent check. POST /accounts/set-password with masterPasswordUnlock.salt set to anything other than the email-derived salt, plus legacy keys, with EnableAccountEncryptionV2JitPasswordRegistration off, reaches SetInitialPasswordV1AsyncToUser() persists that salt → SetInitialMasterPasswordCommandV1 calls ReplaceAsync. Since PasswordPreloginResponseModel.Salt is still annotated "Not used yet", clients re-derive from email on the next login and the stored authentication hash can never match, leaving the account permanently un-loginable.

This is also a behavior change rather than a pre-existing gap: before this PR, the MPAD + MPUD + legacy Keys shape was routed to _tdeSetPasswordCommand, which did enforce the invariant.

Suggested fix in SetInitialPasswordV1Async, before model.ToUser(user):

model.MasterPasswordUnlock?.ToData().ValidateSaltUnchangedForUser(user);
model.MasterPasswordAuthentication?.ToData().ValidateSaltUnchangedForUser(user);

Both throw BadRequestException, matching the surrounding error handling.

@rr-bw rr-bw Jul 31, 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.

Addressed in: 4e168d9

@rr-bw
rr-bw marked this pull request as ready for review July 31, 2026 23:36
@rr-bw
rr-bw requested a review from ike-kottlowski July 31, 2026 23:36
@rr-bw rr-bw added t:feature Change Type - Feature Development and removed ai-review Request a Claude code review labels Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants