Skip to content

Commit c281161

Browse files
authored
feat(mp-service) [PM-35392] Add master password service (#7530)
* feat(mp-service): Add MasterPasswordService foundation. * docs(mp-service): Resolve incoming comments, document contract. * feat(mp-service): Add KDF-setting helper and DI. * test(mp-service): Add tests. * feat(mp-service): Add enforecement in Build delegate for stamp/validate pw flags, tag data update ticket. * refactor(mp-service): Align validate/hash/compose/execute pattern. * test(mp-service): Tighten test assertions. * refactor(mp-service) chants: unlock and authenticate. * docs(mp-service): Re-fit some XML doc comment tags for general support. * docs(mp-service): Address review comment feedback. * refactor(mp-service): Apply result.Tx handling to all OneOf returns. * docs(mp-service): Refine unlock vs authentication data comments. * refactor(mp-service): Rename for saveExistingData (too much existing). * docs(mp-service): Restore PM-34905 userrepository TODOs. * refactor(mp-service): Apply test naming clarification. * refactor(mp-service): Make service internal to Core. * docs(mp-service): Update method comment formats: what, use when, constraints. * docs(mp-service): Update interface docs for consistency. * refactor(mp-service): Rename internal helpers to Apply, add documentation. * docs(mp-service): Add summary and use-when annotations to data models. * docs(mp-service): Add annotation preferring non-Build API verbs where possible. * test(mp-service): Refactor data model tests into discrete files. * test(mp-service): Address additional coverage cases. * docs(mp-service): Spelling. * refactor(mp-service): Extract user security stamp rotation to its own helper. * docs(mp-service): Clarify authentication hash documentation.
1 parent 995ccbb commit c281161

13 files changed

Lines changed: 2577 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Bit.Core.KeyManagement.Models.Data;
2+
3+
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data;
4+
5+
/// <summary>
6+
/// Combined input data covering both the set-initial and update-existing paths. Converts to
7+
/// <see cref="SetInitialPasswordData"/> or <see cref="UpdateExistingPasswordData"/> via
8+
/// <see cref="ToSetInitialData"/> or <see cref="ToUpdateExistingData"/>.
9+
///
10+
/// <para>
11+
/// Use when: constructing a call to
12+
/// <see cref="Interfaces.IMasterPasswordService.PrepareSetInitialOrUpdateExistingMasterPasswordAsync"/>,
13+
/// where the caller does not need to select the set-initial or update-existing path explicitly.
14+
/// </para>
15+
/// </summary>
16+
public class SetInitialOrUpdateExistingPasswordData
17+
{
18+
public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; }
19+
public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; }
20+
21+
/// <summary>
22+
/// When <c>true</c>, runs the new password hash through the registered
23+
/// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing.
24+
/// Set to <c>false</c> only in flows where password policy validation has already been enforced
25+
/// (e.g. admin-initiated recovery). Defaults to <c>true</c>.
26+
/// </summary>
27+
public bool ValidatePassword { get; set; } = true;
28+
/// <summary>
29+
/// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates
30+
/// all active sessions and authentication tokens for the user. Set to <c>false</c> only when
31+
/// intentionally preserving existing sessions. Defaults to <c>true</c>.
32+
/// </summary>
33+
public bool RefreshStamp { get; set; } = true;
34+
35+
public string? MasterPasswordHint { get; set; } = null;
36+
37+
public SetInitialPasswordData ToSetInitialData() => new()
38+
{
39+
MasterPasswordUnlock = MasterPasswordUnlock,
40+
MasterPasswordAuthentication = MasterPasswordAuthentication,
41+
ValidatePassword = ValidatePassword,
42+
RefreshStamp = RefreshStamp,
43+
MasterPasswordHint = MasterPasswordHint
44+
};
45+
46+
public UpdateExistingPasswordData ToUpdateExistingData() => new()
47+
{
48+
MasterPasswordUnlock = MasterPasswordUnlock,
49+
MasterPasswordAuthentication = MasterPasswordAuthentication,
50+
ValidatePassword = ValidatePassword,
51+
RefreshStamp = RefreshStamp,
52+
MasterPasswordHint = MasterPasswordHint
53+
};
54+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using Bit.Core.Entities;
2+
using Bit.Core.Exceptions;
3+
using Bit.Core.KeyManagement.Models.Data;
4+
5+
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data;
6+
7+
/// <summary>
8+
/// Input data for setting an initial master password on a user who has none. Carries the
9+
/// cryptographic material, authentication credential, and control flags consumed by
10+
/// <see cref="Interfaces.IMasterPasswordService"/>.
11+
///
12+
/// <para>
13+
/// Use when: constructing a call to
14+
/// <see cref="Interfaces.IMasterPasswordService.PrepareSetInitialMasterPasswordAsync"/>,
15+
/// <see cref="Interfaces.IMasterPasswordService.SaveSetInitialMasterPasswordAsync"/>, or
16+
/// <see cref="Interfaces.IMasterPasswordService.BuildUpdateUserDelegateSetInitialMasterPassword"/>.
17+
/// </para>
18+
/// </summary>
19+
public class SetInitialPasswordData
20+
{
21+
public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; }
22+
public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; }
23+
24+
/// <summary>
25+
/// When <c>true</c>, runs the new password hash through the registered
26+
/// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing.
27+
/// Set to <c>false</c> only in flows where password policy validation has already been enforced
28+
/// (e.g. admin-initiated recovery). Defaults to <c>true</c>.
29+
/// </summary>
30+
public bool ValidatePassword { get; set; } = true;
31+
/// <summary>
32+
/// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates
33+
/// all active sessions and authentication tokens for the user. Set to <c>false</c> only when
34+
/// intentionally preserving existing sessions. Defaults to <c>true</c>.
35+
/// </summary>
36+
public bool RefreshStamp { get; set; } = true;
37+
38+
public string? MasterPasswordHint { get; set; } = null;
39+
40+
public void ValidateDataForUser(User user)
41+
{
42+
// Validate that the user does not have a master password set.
43+
if (user.HasMasterPassword())
44+
{
45+
throw new BadRequestException("User already has a master password set.");
46+
}
47+
48+
// Validate that there is no key set since there is no master password. The key
49+
// and MasterPassword property are siblings in that they should either both be
50+
// present or both be null, even for all TDE/KeyConnector users.
51+
if (user.Key != null)
52+
{
53+
throw new BadRequestException("User already has a key set.");
54+
}
55+
56+
// Validate that there is no salt set.
57+
if (user.MasterPasswordSalt != null)
58+
{
59+
throw new BadRequestException("User already has a master password set.");
60+
}
61+
62+
// Once a user is in the KeyConnector state they cannot become a master password
63+
// user ever again so we can check here to make sure that they shouldn't ever be
64+
// setting a password
65+
if (user.UsesKeyConnector)
66+
{
67+
throw new BadRequestException("Cannot set an initial password of a user with Key Connector.");
68+
}
69+
70+
// Compatibility-window invariant: during Stage 1 of email-salt separation (PM-27044),
71+
// the client MUST send salt == email.lower.trim on initial SET. The server cannot yet
72+
// handle divergent salts; GetMasterPasswordSalt() falls back to email when MasterPasswordSalt
73+
// is null, and a mismatch here would make the user un-decryptable on next login. Centralized
74+
// here so both TDE and SSO JIT initial-SET flows enforce the same rule. This check is
75+
// removed in Stage 3 when PM-28143 feature flag clears and independent salts are safe.
76+
MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
77+
MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
78+
}
79+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using Bit.Core.Entities;
2+
using Bit.Core.Exceptions;
3+
using Bit.Core.KeyManagement.Models.Data;
4+
5+
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data;
6+
7+
/// <summary>
8+
/// Input data for updating a user's existing master password hash together with new KDF parameters.
9+
/// Salt is validated as unchanged; KDF validation is intentionally skipped since KDF is being replaced.
10+
///
11+
/// <para>
12+
/// Use when: constructing a call to
13+
/// <see cref="Interfaces.IMasterPasswordService.SaveUpdateExistingMasterPasswordAndKdfAsync"/>
14+
/// (KDF rotation flows). For hash-only updates, use <see cref="UpdateExistingPasswordData"/> instead.
15+
/// </para>
16+
/// </summary>
17+
public class UpdateExistingPasswordAndKdfData
18+
{
19+
public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; }
20+
public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; }
21+
22+
/// <summary>
23+
/// When <c>true</c>, runs the new password hash through the registered
24+
/// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing.
25+
/// Set to <c>false</c> only in flows where password policy validation has already been enforced
26+
/// (e.g. admin-initiated recovery). Defaults to <c>true</c>.
27+
/// </summary>
28+
public bool ValidatePassword { get; set; } = true;
29+
/// <summary>
30+
/// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates
31+
/// all active sessions and authentication tokens for the user. Set to <c>false</c> only when
32+
/// intentionally preserving existing sessions. Defaults to <c>true</c>.
33+
/// </summary>
34+
public bool RefreshStamp { get; set; } = true;
35+
36+
public string? MasterPasswordHint { get; set; } = null;
37+
38+
public void ValidateDataForUser(User user)
39+
{
40+
// Validate that the user has a master password already, if not then they shouldn't be updating they should
41+
// be setting initial.
42+
if (!user.HasMasterPassword())
43+
{
44+
throw new BadRequestException("User does not have an existing master password to update.");
45+
}
46+
47+
// KDF parameters govern how the master password is stretched into the encryption key.
48+
// Key Connector replaces the master password entirely — the encryption key is managed
49+
// by an external service — so KDF rotation has no meaningful target. The existing
50+
// ChangeKdfCommand blocks this implicitly (CheckPasswordAsync fails against a null
51+
// master password), but this guard makes the categorical inapplicability explicit.
52+
// Note: org owners/admins cannot be KC users (enforced at conversion time in
53+
// UserService.CheckCanUseKeyConnector), so no role-based edge case exists.
54+
if (user.UsesKeyConnector)
55+
{
56+
throw new BadRequestException("Cannot update password of a user with Key Connector.");
57+
}
58+
59+
// Do not validate if kdf is the same here on the user because we are changing it.
60+
61+
// Validate Salt is unchanged for user
62+
MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
63+
MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
64+
}
65+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using Bit.Core.Entities;
2+
using Bit.Core.Exceptions;
3+
using Bit.Core.KeyManagement.Models.Data;
4+
5+
namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data;
6+
7+
/// <summary>
8+
/// Input data for updating a user's existing master password hash without changing KDF parameters.
9+
/// Carries the cryptographic material, authentication credential, and control flags consumed by
10+
/// <see cref="Interfaces.IMasterPasswordService"/>. KDF and salt are validated as unchanged.
11+
///
12+
/// <para>
13+
/// Use when: constructing a call to
14+
/// <see cref="Interfaces.IMasterPasswordService.PrepareUpdateExistingMasterPasswordAsync"/> or
15+
/// <see cref="Interfaces.IMasterPasswordService.SaveUpdateExistingMasterPasswordAsync"/>.
16+
/// For KDF rotation, use <see cref="UpdateExistingPasswordAndKdfData"/> instead.
17+
/// </para>
18+
/// </summary>
19+
public class UpdateExistingPasswordData
20+
{
21+
public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; }
22+
public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; }
23+
24+
/// <summary>
25+
/// When <c>true</c>, runs the new password hash through the registered
26+
/// <see cref="Microsoft.AspNetCore.Identity.IPasswordValidator{TUser}"/> pipeline before hashing.
27+
/// Set to <c>false</c> only in flows where password policy validation has already been enforced
28+
/// (e.g. admin-initiated recovery). Defaults to <c>true</c>.
29+
/// </summary>
30+
public bool ValidatePassword { get; set; } = true;
31+
/// <summary>
32+
/// When <c>true</c>, rotates <see cref="Bit.Core.Entities.User.SecurityStamp"/>, which invalidates
33+
/// all active sessions and authentication tokens for the user. Set to <c>false</c> only when
34+
/// intentionally preserving existing sessions. Defaults to <c>true</c>.
35+
/// </summary>
36+
public bool RefreshStamp { get; set; } = true;
37+
38+
public string? MasterPasswordHint { get; set; } = null;
39+
40+
public void ValidateDataForUser(User user)
41+
{
42+
// Validate that the user has a master password already, if not then they shouldn't be updating they should
43+
// be setting initial.
44+
if (!user.HasMasterPassword())
45+
{
46+
throw new BadRequestException("User does not have an existing master password to update.");
47+
}
48+
49+
// Key Connector users' encryption keys are managed by an external service, replacing the
50+
// master password entirely (MasterPassword is set to null on conversion). Master password
51+
// operations are categorically inapplicable to these users. This guard is defense-in-depth:
52+
// the HasMasterPassword() check above would also catch KC users, but this makes the
53+
// rejection reason explicit. Note: org owners/admins are structurally prohibited from
54+
// using Key Connector (enforced at conversion time in UserService.CheckCanUseKeyConnector),
55+
// so there is no owner/admin edge case to handle here.
56+
if (user.UsesKeyConnector)
57+
{
58+
throw new BadRequestException("Cannot update password of a user with Key Connector.");
59+
}
60+
61+
// Validate KDF is unchanged for user
62+
MasterPasswordUnlock.Kdf.ValidateUnchangedForUser(user);
63+
MasterPasswordAuthentication.Kdf.ValidateUnchangedForUser(user);
64+
65+
// Validate Salt is unchanged for user
66+
MasterPasswordUnlock.ValidateSaltUnchangedForUser(user);
67+
MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user);
68+
}
69+
}

0 commit comments

Comments
 (0)