Skip to content
Merged
Show file tree
Hide file tree
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 Apr 21, 2026
d7c0d7e
docs(mp-service): Resolve incoming comments, document contract.
enmande Apr 21, 2026
13b0ac2
feat(mp-service): Add KDF-setting helper and DI.
enmande Apr 21, 2026
45813b6
test(mp-service): Add tests.
enmande Apr 21, 2026
50c62a8
feat(mp-service): Add enforecement in Build delegate for stamp/valida…
enmande Apr 22, 2026
86aa681
refactor(mp-service): Align validate/hash/compose/execute pattern.
enmande Apr 22, 2026
b2f9dd6
test(mp-service): Tighten test assertions.
enmande Apr 22, 2026
b7db39b
refactor(mp-service) chants: unlock and authenticate.
enmande Apr 22, 2026
ba43405
docs(mp-service): Re-fit some XML doc comment tags for general support.
enmande Apr 22, 2026
22731cb
docs(mp-service): Address review comment feedback.
enmande Apr 23, 2026
58ffe45
refactor(mp-service): Apply result.Tx handling to all OneOf returns.
enmande Apr 23, 2026
7b8b669
docs(mp-service): Refine unlock vs authentication data comments.
enmande Apr 23, 2026
bf82f2e
refactor(mp-service): Rename for saveExistingData (too much existing).
enmande Apr 23, 2026
d9b98b3
docs(mp-service): Restore PM-34905 userrepository TODOs.
enmande Apr 23, 2026
cd459d2
Merge branch 'main' into auth/pm-35392/master-password-service-founda…
enmande Apr 23, 2026
3d22e29
refactor(mp-service): Apply test naming clarification.
enmande Apr 24, 2026
343ed33
refactor(mp-service): Make service internal to Core.
enmande Apr 24, 2026
e0ac0e3
Merge branch 'main' into auth/pm-35392/master-password-service-founda…
enmande Apr 24, 2026
b799072
docs(mp-service): Update method comment formats: what, use when, cons…
enmande Apr 24, 2026
e1314df
docs(mp-service): Update interface docs for consistency.
enmande Apr 27, 2026
8ec3bfc
refactor(mp-service): Rename internal helpers to Apply, add documenta…
enmande Apr 27, 2026
b18c6ac
docs(mp-service): Add summary and use-when annotations to data models.
enmande Apr 27, 2026
1915ca3
docs(mp-service): Add annotation preferring non-Build API verbs where…
enmande Apr 27, 2026
9f42c6c
test(mp-service): Refactor data model tests into discrete files.
enmande Apr 27, 2026
aa5bd3e
test(mp-service): Address additional coverage cases.
enmande Apr 27, 2026
ab25caf
docs(mp-service): Spelling.
enmande Apr 27, 2026
6cfee8b
refactor(mp-service): Extract user security stamp rotation to its own…
enmande Apr 27, 2026
a672bdc
docs(mp-service): Clarify authentication hash documentation.
enmande Apr 27, 2026
c221552
Merge branch 'main' into auth/pm-35392/master-password-service-founda…
enmande Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.
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
};
}
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);
}
}
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.
Comment thread
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);
Comment thread
enmande marked this conversation as resolved.
}
}
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);
}
}
Loading
Loading