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 53 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 53 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 63.23%. Comparing base (5358a2f) to head (b29976b).

Additional details and impacted files
@@            Coverage Diff            @@
##           main    #7723       +/-   ##
=========================================
+ Coverage      0   63.23%   +63.23%     
=========================================
  Files         0     2381     +2381     
  Lines         0   103788   +103788     
  Branches      0     9402     +9402     
=========================================
+ Hits          0    65635    +65635     
- Misses        0    35924    +35924     
- Partials      0     2229     +2229     

☔ 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 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

@ike-kottlowski ike-kottlowski 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.

Just some more questions. Otherwise looks good.

/// request because they already have one. Checks both AccountKeys (new) and Keys (legacy) so
/// the predicate is correct for the transitional period where clients may send either key shape.
/// </summary>
public bool IsTdeSetPasswordRequest()

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 a client could send either AccountKeys or Keys shouldn't the logic be || (OR) not && (AND).

If AccountKeys == null then it's V1 TDESetPassword, if Keys == null then it's V2 TDESetPassword?

But both have to be null in this case? Maybe the logic works either way?

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.

IsTdeSetPasswordRequest() is not concerned with distinguishing V1 vs. V2 encryption users. It’s simply asking “Is this a TDE user?”, which is determined by the complete absence (hence the use of &&) of a key pair of either sort on the request.

The method doesn’t need to distinguish V1 vs. V2 encryption because in a TDE set-password scenario, the user’s cryptographic state was already previously set up (whether V1 or V2), and all we are doing through the "set-password" endpoint is just setting the password via _tdeSetPasswordCommand.SetMasterPasswordAsync() (i.e. that command does not set cryptographic state; it just sets the password).

// TDE set-password (for TDE users who obtain the "manage account recovery" permission).
// _tdeSetPasswordCommand handles both V1 and V2 TDE users (it sets the master password
// without touching cryptographic state).
// 
// Note: Why not check the V2RegistrationTDEJIT flag?
// The V2RegistrationTDEJIT flag governs SSO+TDE account creation, not set-password, so
// don't reference it here (no feature-flag gate), because it isn't relevant. A TDE user
// reaching this endpoint already has keys that were set up at registration time, regardless
// of the flag.
if (model.IsTdeSetPasswordRequest())
{
	await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData());
	return;
}

This is distinct from our handling of MP JIT users, where we do need to distinguish between V1 vs. V2 encryption. The _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync() method does set cryptographic state - specifically it sets V2 encryption state via _userRepository.SetV2AccountCryptographicStateAsync() - which is why that branch must be gated by an AccountKeys presence check (not legacy Keys) as well as a feature flag check.

// V2 encryption - MP JIT.
// We require AccountKeys (the new key shape) here, not legacy Keys — otherwise
// a modern V1 MP JIT request (MPAD + MPUD + legacy Keys) would be incorrectly routed here
// when the flag is on, and `model.ToData().AccountKeys` would be null, breaking the V2 MP
// JIT command (which requires AccountKeys per FinishSsoJitProvisionMasterPasswordCommand).
if (model.AccountKeys != null &&
	_featureService.IsEnabled(FeatureFlagKeys.EnableAccountEncryptionV2JitPasswordRegistration))
{
	await _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync(user, model.ToData());
	return;
}

But if the request instead contains Keys, then we are dealing with a V1 encryption and so we fall back to SetInitialPasswordV1Async().

@rr-bw rr-bw Aug 6, 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.

@mzieniukbw Do you believe that the above is an accurate description? Especially regarding the idea that the _tdeSetPasswordCommand.SetMasterPasswordAsync() covers (sets a password for) both V1 and V2 encryption TDE users, and therefore there is no need to distinguish between V1 vs. V2 encryption for TDE users?

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 am fine with this one. We should not expect either. That being said, see my other comment, where i have concerns about gaps in validation. We should really expect one or another, never a mix of MPAD/MPUD and legacy.

Comment thread test/Api.IntegrationTest/Auth/Controllers/AccountsControllerTest.cs
Comment thread test/Api.IntegrationTest/Auth/Controllers/AccountsControllerTest.cs Outdated
Comment thread test/Api.IntegrationTest/Auth/Controllers/AccountsControllerTest.cs
{
// Arrange — modern MP JIT client: MPAD + MPUD + legacy Keys (no AccountKeys)
// Salt must match the user's email-derived salt (Stage 1 PM-27044 invariant).
var emailSalt = user.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.

👍🏻: In this test since we call GetMasterPasswordSalt the salt may or may not be an email thus adding flexibility.

Comment thread test/Api.Test/Auth/Controllers/AccountsControllerTests.cs
ike-kottlowski
ike-kottlowski previously approved these changes Aug 7, 2026
@ike-kottlowski
ike-kottlowski dismissed JaredSnider-Bitwarden’s stale review August 7, 2026 18:21

Ownership transferred. Changes have been addressed.


// If both modern and legacy fields are present for the same value, they must agree —
// reject ambiguous input rather than silently letting the modern field win via ?? fallback.
if (MasterPasswordAuthentication != null && MasterPasswordHash != null

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.

That impossible scenario. Furthermore, you should MasterPasswordAuthentication or MasterPasswordHash, not both at the same time. (Checked existing code and linked clients PR).
If you want to check for correctness, then expect there is at least one - preferably expect that either MasterPasswordAuthentication and MasterPasswordUnlock are provided (with Keys or AccountKeys to distinguish modern V1 vs V2) at the same time OR the other legacy. If you have both or none, then that's should be validation error.

@rr-bw rr-bw Aug 11, 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.

Update here: c0e2ba7 (and related tests ed33516)

  • if (hasModern && hasLegacy) - ensures that we don't have both shapes
  • After that, the if (HasAuthAndUnlockData()) check, followed by the if (string.IsNullOrEmpty(MasterPasswordHash)) and if (string.IsNullOrEmpty(Key)) checks, will validate that we have one or the other (i.e. if we receive neither, we yield "MasterPasswordHash must be supplied."

[nameof(MasterPasswordAuthentication), nameof(MasterPasswordHash)]);
}

if (MasterPasswordUnlock != null && Key != null

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.

See my other comment, this scenario does not exist in clients.

@rr-bw rr-bw Aug 11, 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.

Update here: c0e2ba7 (and related tests ed33516)

  • if (hasModern && hasLegacy) - ensures that we don't have both shapes
  • After that, the if (HasAuthAndUnlockData()) check, followed by the if (string.IsNullOrEmpty(MasterPasswordHash)) and if (string.IsNullOrEmpty(Key)) checks, will validate that we have one or the other (i.e. if we receive neither, we yield "MasterPasswordHash must be supplied."

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (IsV2Request())
if (AccountKeys != null && Keys != null)

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.

See my other comment, this scenario does not exist in clients.

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.

I would argue keep this. Even though clients don't send both, the endpoint/model technically does accept both at the same time, so this is just a defensive measure.

/// request because they already have one. Checks both AccountKeys (new) and Keys (legacy) so
/// the predicate is correct for the transitional period where clients may send either key shape.
/// </summary>
public bool IsTdeSetPasswordRequest()

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 am fine with this one. We should not expect either. That being said, see my other comment, where i have concerns about gaps in validation. We should really expect one or another, never a mix of MPAD/MPUD and legacy.

mzieniukbw
mzieniukbw previously approved these changes Aug 14, 2026
@rr-bw
rr-bw requested a review from ike-kottlowski August 14, 2026 16:25
@rr-bw rr-bw added the ai-review Request a Claude code review label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants