1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 10:03:23 +00:00

Merge main

This commit is contained in:
Bernd Schoolmann
2026-02-09 12:56:14 +01:00
1289 changed files with 63681 additions and 17791 deletions

View File

@@ -10,7 +10,9 @@ import {
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit {
constructor(
private auditService: AuditService,
private cipherService: CipherService,
private configService: ConfigService,
private dialogService: DialogService,
private formBuilder: FormBuilder,
private i18nService: I18nService,
@@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit {
}
if (!this.email) {
throw new Error("Email is required to create master key.");
throw new Error("Email not found.");
}
// 1. Determine kdfConfig
@@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit {
this.kdfConfig = DEFAULT_KDF_CONFIG;
} else {
if (!this.userId) {
throw new Error("userId not passed down");
throw new Error("userId not found.");
}
this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
}
if (this.kdfConfig == null) {
throw new Error("KdfConfig is required to create master key.");
throw new Error("KdfConfig not found.");
}
const salt =
@@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit {
? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId))
: this.masterPasswordService.emailToSalt(this.email);
if (salt == null) {
throw new Error("Salt is required to create master key.");
throw new Error("Salt not found.");
}
// 2. Verify current password is correct (if necessary)
@@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit {
return;
}
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
);
if (newApisWithInputPasswordFlagEnabled) {
// 4. Build a PasswordInputResult object
const passwordInputResult: PasswordInputResult = {
newPassword,
kdfConfig: this.kdfConfig,
salt,
newPasswordHint,
newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143
};
if (
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = currentPassword;
}
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value;
}
// 5. Emit and return PasswordInputResult object
this.onPasswordFormSubmit.emit(passwordInputResult);
return passwordInputResult;
}
/*******************************************************************
* The following code (within this `try`) to be removed in PM-28143
*******************************************************************/
// 4. Create cryptographic keys and build a PasswordInputResult object
const newMasterKey = await this.keyService.makeMasterKey(
newPassword,

View File

@@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials.
Specifically, it does the following:
The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting
an initial password or changing an existing password. Specifically, it does the following:
1. Displays form fields in the UI
2. Validates form fields
3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`,
`newServerMasterKeyHash`, etc.)
4. Emits the generated properties to the parent component
3. Emits values to the parent component
The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our
form UI and validation logic consistent. As such, it is intended for re-use in different set/change
@@ -30,7 +28,6 @@ those values as needed.
- [The InputPasswordFlow](#the-inputpasswordflow)
- [Use Cases](#use-cases)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
@@ -44,20 +41,20 @@ those values as needed.
**Required**
- `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine
which form input elements will be displayed in the UI and which cryptographic keys will be created
and emitted. [Click here](#the-inputpasswordflow) to learn more about the different
`InputPasswordFlow` options.
which form input elements will be displayed in the UI and which values will be emitted.
[Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow`
options.
**Optional (sometimes)**
These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs`
are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that
the `email` and/or `userId` is present in certain flows, while not present in other flows.
These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are
not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the
`email` and/or `userId` is present in certain flows, while not present in other flows.
- `email` - allows the `InputPasswordComponent` to generate a master key
- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed)
- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`,
verify that a current password is correct, and perform validation prior to user key rotation on
the parent
verify that a current password is correct, and perform validation prior to user key rotation (if
selected) on the parent
**Optional**
@@ -87,8 +84,7 @@ These `@Inputs` are truly optional.
## The `InputPasswordFlow`
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component. It is important for the dev to understand when to use
each flow.
logic of the component. It is important for the dev to understand when to use each flow.
### Use Cases
@@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set
their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a
starting role that requires them to have/set their initial password
- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset
password permission ("manage account recovery") from the start, which requires them to have/set
their initial password
- A note on JIT provisioned user flows:
- Even though a JIT provisioned user is a brand-new user who was “just” created, we consider
them to be an “existing authed user” _from the perspective of the set-password flow_. This is
@@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their
initial password, their account does not yet exist in the database, and will only be created
once they set an initial password.
- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now
requires them to have/set their initial password
- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset
password persmission ("manage account recovery"), which now requires the user to have/set their
initial password
- An existing user logs in after their org admin offboarded the org from TDE, and the user must now
have/set their initial password<br /><br />
@@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti
Used in scenarios where we simply want to offer the user the ability to change their password:
- User clicks an org email invite link an logs in with their password which does not meet the org's
- User clicks an org email invite link and logs in with their password which does not meet the org's
policy requirements
- User logs in with password that does not meet the org's policy requirements
- User logs in after their password was reset via Account Recovery (and now they must change their
@@ -156,26 +154,10 @@ which form field UI elements get displayed.
<br />
### TypeScript - Credential Generation
- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`**
- These flows involve a user setting their password for the first time. Therefore on submit the
component will only generate new credentials (`newMasterKey`) and not current credentials
(`currentMasterKey`).<br /><br />
- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`**
- These flows both require the user to enter a current password along with a new password.
Therefore on submit the component will generate current credentials (`currentMasterKey`) along
with new credentials (`newMasterKey`).<br /><br />
- **`ChangePasswordDelegation`**
- This flow does not generate any credentials, but simply validates the new password and emits it
up to the parent.
<br />
### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser`
These two flows are similar in that they display the same form fields and only generate new
credentials, but we need to keep them separate for the following reasons:
These two flows are similar in that they display the same form fields, but we need to keep them
separate for the following reasons:
- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and
**thus NO active account `userId`**:
@@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons:
and **thus an active account `userId`**:
The presence or absence of an active account `userId` is important because it determines how we get
the correct `kdfConfig` prior to key generation:
the correct `kdfConfig`:
- If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG`
- If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the
@@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
checkbox)
- Checks that the new password adheres to any enforced master password policies that were
optionally passed down by the parent
2. Uses the form inputs to create cryptographic properties (`newMasterKey`,
`newServerMasterKeyHash`, etc.)
3. Emits those cryptographic properties up to the parent (along with other values defined in
`PasswordInputResult`) to be used by the parent as needed.
2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be
used by the parent as needed.
```typescript
export interface PasswordInputResult {
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint?: string;
newMasterKey?: MasterKey;
newServerMasterKeyHash?: string;
newLocalMasterKeyHash?: string;
kdfConfig?: KdfConfig;
salt?: MasterPasswordSalt;
newPasswordHint?: string;
rotateUserKey?: boolean;
}
```

View File

@@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -59,6 +60,13 @@ export default {
getAllDecrypted: () => Promise.resolve([]),
},
},
// Can remove ConfigService from component and stories in PM-28143 (if it is no longer used)
{
provide: ConfigService,
useValue: {
getFeatureFlag: () => false, // default to false since flag does not effect UI
},
},
{
provide: KdfConfigService,
useValue: {

View File

@@ -10,6 +10,20 @@ export interface PasswordInputResult {
newPasswordHint?: string;
rotateUserKey?: boolean;
/**
* Temporary property that persists the flag state through the entire set/change password process.
* This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves.
*
* The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever
* require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off.
* Flagging for those 2 flows will be done via the ConfigService in their respective services.
*
* To be removed in PM-28143
*/
newApisWithInputPasswordFlagEnabled?: boolean;
// The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143
/** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */
currentMasterKey?: MasterKey;
/** @deprecated */

View File

@@ -278,13 +278,6 @@ describe("LoginDecryptionOptionsComponent", () => {
const expectedUserKey = new SymmetricCryptoKey(new Uint8Array(mockUserKeyBytes));
// Verify keys were set
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
mockSecurityState,
mockUserId,
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
expect.objectContaining({
V2: {

View File

@@ -34,11 +34,6 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import {
SignedPublicKey,
SignedSecurityState,
WrappedSigningKey,
} from "@bitwarden/common/key-management/types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -322,23 +317,6 @@ export class LoginDecryptionOptionsComponent implements OnInit {
register_result.account_cryptographic_state,
userId,
);
// Legacy individual states
await this.keyService.setPrivateKey(
register_result.account_cryptographic_state.V2.private_key,
userId,
);
await this.keyService.setSignedPublicKey(
register_result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
userId,
);
await this.keyService.setUserSigningKey(
register_result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
userId,
);
await this.securityStateService.setAccountSecurityState(
register_result.account_cryptographic_state.V2.security_state as SignedSecurityState,
userId,
);
// TDE unlock
await this.deviceTrustService.setDeviceKey(

View File

@@ -9,7 +9,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { TwoFactorTimeoutIcon } from "@bitwarden/assets/svg";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { ButtonModule, IconModule } from "@bitwarden/components";
import { ButtonModule, SvgModule } from "@bitwarden/components";
/**
* RegistrationLinkExpiredComponentData
@@ -24,7 +24,7 @@ export interface RegistrationLinkExpiredComponentData {
@Component({
selector: "auth-registration-link-expired",
templateUrl: "./registration-link-expired.component.html",
imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule],
imports: [CommonModule, JslibModule, RouterModule, SvgModule, ButtonModule],
})
export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();

View File

@@ -20,7 +20,7 @@ import {
ButtonModule,
CheckboxModule,
FormFieldModule,
IconModule,
SvgModule,
LinkModule,
} from "@bitwarden/components";
@@ -54,7 +54,7 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record<Region, boolean> = {
CheckboxModule,
ButtonModule,
LinkModule,
IconModule,
SvgModule,
RegistrationEnvSelectorComponent,
],
})

View File

@@ -11,30 +11,30 @@
[ngSwitch]="provider.type"
class="tw-w-16 md:tw-w-20 tw-mr-2 sm:tw-mr-4"
>
<bit-icon
<bit-svg
*ngSwitchCase="TwoFactorProviderType.Authenticator"
[icon]="Icons.TwoFactorAuthAuthenticatorIcon"
></bit-icon>
<bit-icon
[content]="Icons.TwoFactorAuthAuthenticatorIcon"
></bit-svg>
<bit-svg
*ngSwitchCase="TwoFactorProviderType.Email"
[icon]="Icons.TwoFactorAuthEmailIcon"
></bit-icon>
<bit-icon
[content]="Icons.TwoFactorAuthEmailIcon"
></bit-svg>
<bit-svg
*ngSwitchCase="TwoFactorProviderType.Duo"
[icon]="Icons.TwoFactorAuthDuoIcon"
></bit-icon>
<bit-icon
[content]="Icons.TwoFactorAuthDuoIcon"
></bit-svg>
<bit-svg
*ngSwitchCase="TwoFactorProviderType.Yubikey"
[icon]="Icons.TwoFactorAuthYubicoIcon"
></bit-icon>
<bit-icon
[content]="Icons.TwoFactorAuthYubicoIcon"
></bit-svg>
<bit-svg
*ngSwitchCase="TwoFactorProviderType.OrganizationDuo"
[icon]="Icons.TwoFactorAuthDuoIcon"
></bit-icon>
<bit-icon
[content]="Icons.TwoFactorAuthDuoIcon"
></bit-svg>
<bit-svg
*ngSwitchCase="TwoFactorProviderType.WebAuthn"
[icon]="Icons.TwoFactorAuthWebAuthnIcon"
></bit-icon>
[content]="Icons.TwoFactorAuthWebAuthnIcon"
></bit-svg>
</div>
{{ provider.name }}
<ng-container slot="secondary"> {{ provider.description }} </ng-container>

View File

@@ -18,7 +18,7 @@ import {
ButtonModule,
DialogModule,
DialogService,
IconModule,
SvgModule,
ItemModule,
TypographyModule,
} from "@bitwarden/components";
@@ -39,7 +39,7 @@ export type TwoFactorOptionsDialogResult = {
ButtonModule,
TypographyModule,
ItemModule,
IconModule,
SvgModule,
],
providers: [],
})

View File

@@ -42,7 +42,7 @@
>
<div class="tw-flex tw-flex-col tw-items-center">
<div class="tw-size-16 tw-content-center tw-mb-4">
<bit-icon [icon]="Icons.UserVerificationBiometricsIcon"></bit-icon>
<bit-svg [content]="Icons.UserVerificationBiometricsIcon"></bit-svg>
</div>
<p class="tw-font-medium tw-mb-1">{{ "verifyWithBiometrics" | i18n }}</p>
<div *ngIf="!biometricsVerificationFailed">

View File

@@ -28,7 +28,7 @@ import {
CalloutModule,
FormFieldModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
} from "@bitwarden/components";
@@ -64,7 +64,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
IconModule,
SvgModule,
LinkModule,
ButtonModule,
CalloutModule,

View File

@@ -180,7 +180,10 @@ describe("AuthRequestLoginStrategy", () => {
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId);
expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled();
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
mockUserId,
);
});
it("sets keys after a successful authentication when only userKey provided in login credentials", async () => {
@@ -207,7 +210,10 @@ describe("AuthRequestLoginStrategy", () => {
mockUserId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(decUserKey, mockUserId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, mockUserId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
mockUserId,
);
// trustDeviceIfRequired should be called
expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled();

View File

@@ -120,20 +120,14 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
exportCache(): CacheData {

View File

@@ -19,7 +19,6 @@ import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
MasterKeyWrappedUserKey,
@@ -38,15 +37,12 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
PasswordStrengthServiceAbstraction,
PasswordStrengthService,
} from "@bitwarden/common/tools/password-strength";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { LoginStrategyServiceAbstraction } from "../abstractions";
@@ -109,6 +105,12 @@ export function identityTokenResponseFactory(
token_type: "Bearer",
MasterPasswordPolicy: masterPasswordPolicyResponse,
UserDecryptionOptions: userDecryptionOptions || defaultUserDecryptionOptionsServerResponse,
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: privateKey,
publicKey: "PUBLIC_KEY",
},
},
});
}
@@ -201,19 +203,7 @@ describe("LoginStrategy", () => {
});
describe("base class", () => {
const userKeyBytesLength = 64;
const masterKeyBytesLength = 64;
let userKey: UserKey;
let masterKey: MasterKey;
beforeEach(() => {
userKey = new SymmetricCryptoKey(
new Uint8Array(userKeyBytesLength).buffer as CsprngArray,
) as UserKey;
masterKey = new SymmetricCryptoKey(
new Uint8Array(masterKeyBytesLength).buffer as CsprngArray,
) as MasterKey;
const mockVaultTimeoutAction = VaultTimeoutAction.Lock;
const mockVaultTimeoutActionBSub = new BehaviorSubject<VaultTimeoutAction>(
mockVaultTimeoutAction,
@@ -335,39 +325,6 @@ describe("LoginStrategy", () => {
userId,
);
});
it("makes a new public and private key for an old account", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.privateKey = null;
keyService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]);
keyService.userKey$.mockReturnValue(new BehaviorSubject<UserKey>(userKey).asObservable());
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
await passwordLoginStrategy.logIn(credentials);
// User symmetric key must be set before the new RSA keypair is generated
expect(keyService.setUserKey).toHaveBeenCalled();
expect(keyService.makeKeyPair).toHaveBeenCalled();
expect(keyService.setUserKey.mock.invocationCallOrder[0]).toBeLessThan(
keyService.makeKeyPair.mock.invocationCallOrder[0],
);
expect(apiService.postAccountKeys).toHaveBeenCalled();
});
it("throws if userKey is CoseEncrypt0 (V2 encryption) in createKeyPairForOldAccount", async () => {
keyService.userKey$.mockReturnValue(
new BehaviorSubject<UserKey>({
inner: () => ({ type: 7 }),
} as unknown as UserKey).asObservable(),
);
await expect(passwordLoginStrategy["createKeyPairForOldAccount"](userId)).resolves.toBe(
undefined,
);
});
});
describe("Two-factor authentication", () => {

View File

@@ -25,7 +25,6 @@ import {
VaultTimeoutAction,
VaultTimeoutSettingsService,
} from "@bitwarden/common/key-management/vault-timeout";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -33,7 +32,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService, KdfConfigService } from "@bitwarden/key-management";
@@ -265,7 +263,7 @@ export abstract class LoginStrategy {
await this.setMasterKey(response, userId);
await this.setUserKey(response, userId);
await this.setPrivateKey(response, userId);
await this.setAccountCryptographicState(response, userId);
// This needs to run after the keys are set because it checks for the existence of the encrypted private key
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
@@ -283,7 +281,10 @@ export abstract class LoginStrategy {
protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void>;
// Old accounts used master key for encryption. We are forcing migrations but only need to
// check on password logins
@@ -315,28 +316,6 @@ export abstract class LoginStrategy {
return true;
}
protected async createKeyPairForOldAccount(userId: UserId) {
try {
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey === null) {
throw new Error("User key is null when creating key pair for old account");
}
if (userKey.inner().type == EncryptionType.CoseEncrypt0) {
throw new Error("Cannot create key pair for account on V2 encryption");
}
const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey);
if (!privateKey.encryptedString) {
throw new Error("Failed to create encrypted private key");
}
await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString));
return privateKey.encryptedString;
} catch (e) {
this.logService.error(e);
}
}
/**
* Handles the response from the server when a 2FA is required.
* It clears any existing 2FA token, as it's no longer valid, and sets up the necessary data for the 2FA process.

View File

@@ -216,7 +216,10 @@ describe("PasswordLoginStrategy", () => {
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
userId,
);
});
it("does not force the user to update their master password when there are no requirements", async () => {

View File

@@ -148,20 +148,14 @@ export class PasswordLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean {

View File

@@ -196,13 +196,14 @@ describe("SsoLoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.key = null;
tokenResponse.privateKey = null;
tokenResponse.accountKeysResponseModel = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(accountCryptographicStateService.setAccountCryptographicState).not.toHaveBeenCalled();
});
it("sets master key encrypted user key for existing SSO users", async () => {

View File

@@ -335,7 +335,7 @@ export class SsoLoginStrategy extends LoginStrategy {
await this.keyService.setUserKey(userKey, userId);
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
tokenResponse: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
@@ -345,20 +345,6 @@ export class SsoLoginStrategy extends LoginStrategy {
userId,
);
}
if (tokenResponse.hasMasterKeyEncryptedUserKey()) {
// User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey
// Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair
// and so we don't want them falling into the createKeyPairForOldAccount flow
await this.keyService.setPrivateKey(
tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
} else if (tokenResponse.privateKey) {
// User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey
// This is just existing TDE users or a TDE offboarder on an untrusted device
await this.keyService.setPrivateKey(tokenResponse.privateKey, userId);
}
}
exportCache(): CacheData {

View File

@@ -188,7 +188,10 @@ describe("UserApiLoginStrategy", () => {
tokenResponse.key,
userId,
);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey, userId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: tokenResponse.privateKey } },
userId,
);
});
it("gets and sets the master key if Key Connector is enabled", async () => {

View File

@@ -79,20 +79,14 @@ export class UserApiLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
// Overridden to save client ID and secret to token service

View File

@@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => {
WebAuthnPrfOption: {
EncryptedPrivateKey: mockEncPrfPrivateKey,
EncryptedUserKey: mockEncUserKey,
CredentialId: "mockCredentialId",
Transports: ["usb", "nfc"],
},
};
@@ -261,7 +263,10 @@ describe("WebAuthnLoginStrategy", () => {
mockPrfPrivateKey,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey, userId);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{ V1: { private_key: idTokenResponse.privateKey } },
userId,
);
// Master key and private key should not be set
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();

View File

@@ -72,14 +72,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
if (userDecryptionOptions?.webAuthnPrfOption) {
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
const credentials = this.cache.value.credentials;
// confirm we still have the prf key
if (!credentials.prfKey) {
return;
}
const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption;
// decrypt prf encrypted private key
const privateKey = await this.encryptService.unwrapDecapsulationKey(
webAuthnPrfOption.encryptedPrivateKey,
@@ -98,20 +99,14 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
}
}
protected override async setPrivateKey(
protected override async setAccountCryptographicState(
response: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
await this.keyService.setPrivateKey(
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
if (response.accountKeysResponseModel) {
await this.accountCryptographicStateService.setAccountCryptographicState(
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
userId,
);
}
}
exportCache(): CacheData {

View File

@@ -5,6 +5,7 @@ import { Jsonify } from "type-fest";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
/**
* Key Connector decryption options. Intended to be sent to the client for use after authentication.
@@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption {
}
}
/**
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
*/
/**
* WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
*/
export class WebAuthnPrfUserDecryptionOption {
/** The encrypted private key that can be decrypted with the PRF key. */
encryptedPrivateKey: string;
/** The encrypted user key that can be decrypted with the private key. */
encryptedUserKey: string;
/** The credential ID for this WebAuthn PRF credential. */
credentialId: string;
/** The transports supported by this credential. */
transports: string[];
/**
* Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object.
* @param response The WebAuthn PRF user decryption option response object.
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish.
*/
static fromResponse(
response: WebAuthnPrfDecryptionOptionResponse,
): WebAuthnPrfUserDecryptionOption | undefined {
if (response == null) {
return undefined;
}
if (!response.encryptedPrivateKey || !response.encryptedUserKey) {
return undefined;
}
const options = new WebAuthnPrfUserDecryptionOption();
options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString;
options.encryptedUserKey = response.encryptedUserKey.encryptedString;
options.credentialId = response.credentialId;
options.transports = response.transports || [];
return options;
}
/**
* Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object.
* @param obj JSON object to deserialize.
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish.
*/
static fromJSON(
obj: Jsonify<WebAuthnPrfUserDecryptionOption>,
): WebAuthnPrfUserDecryptionOption | undefined {
if (obj == null) {
return undefined;
}
return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj);
}
}
/**
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
@@ -104,6 +160,8 @@ export class UserDecryptionOptions {
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
/** {@link KeyConnectorUserDecryptionOption} */
keyConnectorOption?: KeyConnectorUserDecryptionOption;
/** Array of {@link WebAuthnPrfUserDecryptionOption} */
webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[];
/**
* Initializes a new instance of the UserDecryptionOptions from a response object.
@@ -134,6 +192,18 @@ export class UserDecryptionOptions {
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse(
responseOptions.keyConnectorOption,
);
// The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in
// with the same PRF passkey.
// Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array.
if (responseOptions.webAuthnPrfOption) {
const option = WebAuthnPrfUserDecryptionOption.fromResponse(
responseOptions.webAuthnPrfOption,
);
if (option) {
decryptionOptions.webAuthnPrfOptions = [option];
}
}
} else {
throw new Error(
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
@@ -158,6 +228,12 @@ export class UserDecryptionOptions {
obj?.keyConnectorOption,
);
if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) {
decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions
.map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option))
.filter((option) => option !== undefined);
}
return decryptionOptions;
}
}

View File

@@ -495,6 +495,12 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -562,6 +568,12 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -627,6 +639,12 @@ describe("LoginStrategyService", () => {
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",
@@ -690,6 +708,12 @@ describe("LoginStrategyService", () => {
KdfParallelism: 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
AccountKeys: {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
},
},
access_token: "ACCESS_TOKEN",
expires_in: 3600,
refresh_token: "REFRESH_TOKEN",