-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Auth/pm 35392/master password service foundation #7530
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
enmande
merged 29 commits into
main
from
auth/pm-35392/master-password-service-foundation
Apr 29, 2026
+2,577
−0
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
aef7ea8
feat(mp-service): Add MasterPasswordService foundation.
enmande d7c0d7e
docs(mp-service): Resolve incoming comments, document contract.
enmande 13b0ac2
feat(mp-service): Add KDF-setting helper and DI.
enmande 45813b6
test(mp-service): Add tests.
enmande 50c62a8
feat(mp-service): Add enforecement in Build delegate for stamp/valida…
enmande 86aa681
refactor(mp-service): Align validate/hash/compose/execute pattern.
enmande b2f9dd6
test(mp-service): Tighten test assertions.
enmande b7db39b
refactor(mp-service) chants: unlock and authenticate.
enmande ba43405
docs(mp-service): Re-fit some XML doc comment tags for general support.
enmande 22731cb
docs(mp-service): Address review comment feedback.
enmande 58ffe45
refactor(mp-service): Apply result.Tx handling to all OneOf returns.
enmande 7b8b669
docs(mp-service): Refine unlock vs authentication data comments.
enmande bf82f2e
refactor(mp-service): Rename for saveExistingData (too much existing).
enmande d9b98b3
docs(mp-service): Restore PM-34905 userrepository TODOs.
enmande cd459d2
Merge branch 'main' into auth/pm-35392/master-password-service-founda…
enmande 3d22e29
refactor(mp-service): Apply test naming clarification.
enmande 343ed33
refactor(mp-service): Make service internal to Core.
enmande e0ac0e3
Merge branch 'main' into auth/pm-35392/master-password-service-founda…
enmande b799072
docs(mp-service): Update method comment formats: what, use when, cons…
enmande e1314df
docs(mp-service): Update interface docs for consistency.
enmande 8ec3bfc
refactor(mp-service): Rename internal helpers to Apply, add documenta…
enmande b18c6ac
docs(mp-service): Add summary and use-when annotations to data models.
enmande 1915ca3
docs(mp-service): Add annotation preferring non-Build API verbs where…
enmande 9f42c6c
test(mp-service): Refactor data model tests into discrete files.
enmande aa5bd3e
test(mp-service): Address additional coverage cases.
enmande ab25caf
docs(mp-service): Spelling.
enmande 6cfee8b
refactor(mp-service): Extract user security stamp rotation to its own…
enmande a672bdc
docs(mp-service): Clarify authentication hash documentation.
enmande c221552
Merge branch 'main' into auth/pm-35392/master-password-service-founda…
enmande 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
54 changes: 54 additions & 0 deletions
54
src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialOrUpdateExistingPasswordData.cs
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,54 @@ | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
|
|
||
| namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; | ||
|
|
||
| /// <summary> | ||
| /// Combined input data covering both the set-initial and update-existing paths. Converts to | ||
| /// <see cref="SetInitialPasswordData"/> or <see cref="UpdateExistingPasswordData"/> via | ||
| /// <see cref="ToSetInitialData"/> or <see cref="ToUpdateExistingData"/>. | ||
| /// | ||
| /// <para> | ||
| /// Use when: constructing a call to | ||
| /// <see cref="Interfaces.IMasterPasswordService.PrepareSetInitialOrUpdateExistingMasterPasswordAsync"/>, | ||
| /// where the caller does not need to select the set-initial or update-existing path explicitly. | ||
| /// </para> | ||
| /// </summary> | ||
| public class SetInitialOrUpdateExistingPasswordData | ||
| { | ||
| public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } | ||
| public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// When <c>true</c>, runs the new password hash through the registered | ||
| /// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing. | ||
| /// Set to <c>false</c> only in flows where password policy validation has already been enforced | ||
| /// (e.g. admin-initiated recovery). Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool ValidatePassword { get; set; } = true; | ||
| /// <summary> | ||
| /// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates | ||
| /// all active sessions and authentication tokens for the user. Set to <c>false</c> only when | ||
| /// intentionally preserving existing sessions. Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool RefreshStamp { get; set; } = true; | ||
|
|
||
| public string? MasterPasswordHint { get; set; } = null; | ||
|
|
||
| public SetInitialPasswordData ToSetInitialData() => new() | ||
| { | ||
| MasterPasswordUnlock = MasterPasswordUnlock, | ||
| MasterPasswordAuthentication = MasterPasswordAuthentication, | ||
| ValidatePassword = ValidatePassword, | ||
| RefreshStamp = RefreshStamp, | ||
| MasterPasswordHint = MasterPasswordHint | ||
| }; | ||
|
|
||
| public UpdateExistingPasswordData ToUpdateExistingData() => new() | ||
| { | ||
| MasterPasswordUnlock = MasterPasswordUnlock, | ||
| MasterPasswordAuthentication = MasterPasswordAuthentication, | ||
| ValidatePassword = ValidatePassword, | ||
| RefreshStamp = RefreshStamp, | ||
| MasterPasswordHint = MasterPasswordHint | ||
| }; | ||
| } |
79 changes: 79 additions & 0 deletions
79
src/Core/Auth/UserFeatures/UserMasterPassword/Data/SetInitialPasswordData.cs
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,79 @@ | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
|
|
||
| namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; | ||
|
|
||
| /// <summary> | ||
| /// Input data for setting an initial master password on a user who has none. Carries the | ||
| /// cryptographic material, authentication credential, and control flags consumed by | ||
| /// <see cref="Interfaces.IMasterPasswordService"/>. | ||
| /// | ||
| /// <para> | ||
| /// Use when: constructing a call to | ||
| /// <see cref="Interfaces.IMasterPasswordService.PrepareSetInitialMasterPasswordAsync"/>, | ||
| /// <see cref="Interfaces.IMasterPasswordService.SaveSetInitialMasterPasswordAsync"/>, or | ||
| /// <see cref="Interfaces.IMasterPasswordService.BuildUpdateUserDelegateSetInitialMasterPassword"/>. | ||
| /// </para> | ||
| /// </summary> | ||
| public class SetInitialPasswordData | ||
| { | ||
| public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } | ||
| public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// When <c>true</c>, runs the new password hash through the registered | ||
| /// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing. | ||
| /// Set to <c>false</c> only in flows where password policy validation has already been enforced | ||
| /// (e.g. admin-initiated recovery). Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool ValidatePassword { get; set; } = true; | ||
| /// <summary> | ||
| /// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates | ||
| /// all active sessions and authentication tokens for the user. Set to <c>false</c> only when | ||
| /// intentionally preserving existing sessions. Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool RefreshStamp { get; set; } = true; | ||
|
|
||
| public string? MasterPasswordHint { get; set; } = null; | ||
|
|
||
| public void ValidateDataForUser(User user) | ||
| { | ||
| // Validate that the user does not have a master password set. | ||
| if (user.HasMasterPassword()) | ||
| { | ||
| throw new BadRequestException("User already has a master password set."); | ||
| } | ||
|
|
||
| // Validate that there is no key set since there is no master password. The key | ||
| // and MasterPassword property are siblings in that they should either both be | ||
| // present or both be null, even for all TDE/KeyConnector users. | ||
| if (user.Key != null) | ||
| { | ||
| throw new BadRequestException("User already has a key set."); | ||
| } | ||
|
|
||
| // Validate that there is no salt set. | ||
| if (user.MasterPasswordSalt != null) | ||
| { | ||
| throw new BadRequestException("User already has a master password set."); | ||
| } | ||
|
|
||
| // Once a user is in the KeyConnector state they cannot become a master password | ||
| // user ever again so we can check here to make sure that they shouldn't ever be | ||
| // setting a password | ||
| if (user.UsesKeyConnector) | ||
| { | ||
| throw new BadRequestException("Cannot set an initial password of a user with Key Connector."); | ||
| } | ||
|
|
||
| // Compatibility-window invariant: during Stage 1 of email-salt separation (PM-27044), | ||
| // the client MUST send salt == email.lower.trim on initial SET. The server cannot yet | ||
| // handle divergent salts; GetMasterPasswordSalt() falls back to email when MasterPasswordSalt | ||
| // is null, and a mismatch here would make the user un-decryptable on next login. Centralized | ||
| // here so both TDE and SSO JIT initial-SET flows enforce the same rule. This check is | ||
| // removed in Stage 3 when PM-28143 feature flag clears and independent salts are safe. | ||
| MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); | ||
| MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); | ||
| } | ||
| } |
65 changes: 65 additions & 0 deletions
65
src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs
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,65 @@ | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
|
|
||
| namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; | ||
|
|
||
| /// <summary> | ||
| /// Input data for updating a user's existing master password hash together with new KDF parameters. | ||
| /// Salt is validated as unchanged; KDF validation is intentionally skipped since KDF is being replaced. | ||
| /// | ||
| /// <para> | ||
| /// Use when: constructing a call to | ||
| /// <see cref="Interfaces.IMasterPasswordService.SaveUpdateExistingMasterPasswordAndKdfAsync"/> | ||
| /// (KDF rotation flows). For hash-only updates, use <see cref="UpdateExistingPasswordData"/> instead. | ||
| /// </para> | ||
| /// </summary> | ||
| public class UpdateExistingPasswordAndKdfData | ||
| { | ||
| public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } | ||
| public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// When <c>true</c>, runs the new password hash through the registered | ||
| /// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing. | ||
| /// Set to <c>false</c> only in flows where password policy validation has already been enforced | ||
| /// (e.g. admin-initiated recovery). Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool ValidatePassword { get; set; } = true; | ||
| /// <summary> | ||
| /// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates | ||
| /// all active sessions and authentication tokens for the user. Set to <c>false</c> only when | ||
| /// intentionally preserving existing sessions. Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool RefreshStamp { get; set; } = true; | ||
|
|
||
| public string? MasterPasswordHint { get; set; } = null; | ||
|
|
||
| public void ValidateDataForUser(User user) | ||
| { | ||
| // Validate that the user has a master password already, if not then they shouldn't be updating they should | ||
| // be setting initial. | ||
| if (!user.HasMasterPassword()) | ||
| { | ||
| throw new BadRequestException("User does not have an existing master password to update."); | ||
| } | ||
|
|
||
| // KDF parameters govern how the master password is stretched into the encryption key. | ||
| // Key Connector replaces the master password entirely — the encryption key is managed | ||
| // by an external service — so KDF rotation has no meaningful target. The existing | ||
| // ChangeKdfCommand blocks this implicitly (CheckPasswordAsync fails against a null | ||
| // master password), but this guard makes the categorical inapplicability explicit. | ||
| // Note: org owners/admins cannot be KC users (enforced at conversion time in | ||
| // UserService.CheckCanUseKeyConnector), so no role-based edge case exists. | ||
|
enmande marked this conversation as resolved.
|
||
| if (user.UsesKeyConnector) | ||
| { | ||
| throw new BadRequestException("Cannot update password of a user with Key Connector."); | ||
| } | ||
|
|
||
| // Do not validate if kdf is the same here on the user because we are changing it. | ||
|
|
||
| // Validate Salt is unchanged for user | ||
| MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); | ||
| MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); | ||
|
enmande marked this conversation as resolved.
|
||
| } | ||
| } | ||
69 changes: 69 additions & 0 deletions
69
src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs
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,69 @@ | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Exceptions; | ||
| using Bit.Core.KeyManagement.Models.Data; | ||
|
|
||
| namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; | ||
|
|
||
| /// <summary> | ||
| /// Input data for updating a user's existing master password hash without changing KDF parameters. | ||
| /// Carries the cryptographic material, authentication credential, and control flags consumed by | ||
| /// <see cref="Interfaces.IMasterPasswordService"/>. KDF and salt are validated as unchanged. | ||
| /// | ||
| /// <para> | ||
| /// Use when: constructing a call to | ||
| /// <see cref="Interfaces.IMasterPasswordService.PrepareUpdateExistingMasterPasswordAsync"/> or | ||
| /// <see cref="Interfaces.IMasterPasswordService.SaveUpdateExistingMasterPasswordAsync"/>. | ||
| /// For KDF rotation, use <see cref="UpdateExistingPasswordAndKdfData"/> instead. | ||
| /// </para> | ||
| /// </summary> | ||
| public class UpdateExistingPasswordData | ||
| { | ||
| public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } | ||
| public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// When <c>true</c>, runs the new password hash through the registered | ||
| /// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing. | ||
| /// Set to <c>false</c> only in flows where password policy validation has already been enforced | ||
| /// (e.g. admin-initiated recovery). Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool ValidatePassword { get; set; } = true; | ||
| /// <summary> | ||
| /// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates | ||
| /// all active sessions and authentication tokens for the user. Set to <c>false</c> only when | ||
| /// intentionally preserving existing sessions. Defaults to <c>true</c>. | ||
| /// </summary> | ||
| public bool RefreshStamp { get; set; } = true; | ||
|
|
||
| public string? MasterPasswordHint { get; set; } = null; | ||
|
|
||
| public void ValidateDataForUser(User user) | ||
| { | ||
| // Validate that the user has a master password already, if not then they shouldn't be updating they should | ||
| // be setting initial. | ||
| if (!user.HasMasterPassword()) | ||
| { | ||
| throw new BadRequestException("User does not have an existing master password to update."); | ||
| } | ||
|
|
||
| // Key Connector users' encryption keys are managed by an external service, replacing the | ||
| // master password entirely (MasterPassword is set to null on conversion). Master password | ||
| // operations are categorically inapplicable to these users. This guard is defense-in-depth: | ||
| // the HasMasterPassword() check above would also catch KC users, but this makes the | ||
| // rejection reason explicit. Note: org owners/admins are structurally prohibited from | ||
| // using Key Connector (enforced at conversion time in UserService.CheckCanUseKeyConnector), | ||
| // so there is no owner/admin edge case to handle here. | ||
| if (user.UsesKeyConnector) | ||
| { | ||
| throw new BadRequestException("Cannot update password of a user with Key Connector."); | ||
| } | ||
|
|
||
| // Validate KDF is unchanged for user | ||
| MasterPasswordUnlock.Kdf.ValidateUnchangedForUser(user); | ||
| MasterPasswordAuthentication.Kdf.ValidateUnchangedForUser(user); | ||
|
|
||
| // Validate Salt is unchanged for user | ||
| MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); | ||
| MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); | ||
| } | ||
| } |
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.